diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 734e270..e3132f0 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -6,6 +6,7 @@ import { useData, useDatabaseChangeNotifier, } from "@/assets/src/calendar-storage"; +import { getFlowTypeString } from "@/constants/Flow"; import { useTheme, Text, Button } from "react-native-paper"; import FadeInView from "@/components/animations/FadeInView"; import { useState, useCallback } from "react"; @@ -93,12 +94,15 @@ export default function HomeScreen() { day: "numeric", }); + const flowType = getFlowTypeString(day.flow_intensity ?? 0); + const backgroundColor = flowType ? FlowColors[flowType] : FlowColors.white; + return ( {weekday} diff --git a/components/FlowChart.tsx b/components/FlowChart.tsx index bb75234..b638fc1 100644 --- a/components/FlowChart.tsx +++ b/components/FlowChart.tsx @@ -1,13 +1,27 @@ import { View } from "react-native"; import { Dimensions } from "react-native"; -import Svg, { Circle, Text, TSpan, Path } from "react-native-svg"; -import { useEffect, useRef } from "react"; -import { FlowColors } from "@/constants/Colors"; +import Svg, { + Circle, + Text, + TSpan, + Path, + Defs, + LinearGradient, + Stop, +} from "react-native-svg"; +import { useEffect, useRef, useMemo } from "react"; +import { FlowColors, FlowType } from "@/constants/Colors"; import { useTheme } from "react-native-paper"; import { useData, useFlowData } from "@/assets/src/calendar-storage"; import { useFocusEffect } from "@react-navigation/native"; import React from "react"; import { useFetchFlowData } from "@/hooks/useFetchFlowData"; +import { + getFlowTypeString, + MAX_FLOW_LENGTH, + FLOW_TAIL_PERCENT, + FLOW_TAIL_COLOR, +} from "@/constants/Flow"; import Animated, { Easing, useAnimatedProps, @@ -17,6 +31,7 @@ import Animated, { } from "react-native-reanimated"; const AnimatedCircle = Animated.createAnimatedComponent(Circle); +const AnimatedPath = Animated.createAnimatedComponent(Path); const { height } = Dimensions.get("window"); export default function FlowChart() { @@ -41,33 +56,6 @@ export default function FlowChart() { const lastDayOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0); const numberOfDaysInMonth = lastDayOfMonth.getDate(); - const renderMarks = () => { - return flowDataForCurrentMonth.map((data, index) => { - // Convert date to day of the month - const dayNumber = new Date(data.date + "T00:00:00Z").getUTCDate(); - - let angle = 8 + ((dayNumber - 1) * (352 - 8)) / (numberOfDaysInMonth - 1); - angle = (angle + startingPoint) % 360; - - const x = centerX + circleRadius * Math.cos((angle * Math.PI) / 180); - const y = centerY + circleRadius * Math.sin((angle * Math.PI) / 180); - - const markColor = FlowColors[data.flow_intensity ?? 0]; - - return ( - - ); // - }); - }; - // Calculate "today" circle angle and position const todayNumber = today.getDate(); let todayAngle = @@ -90,25 +78,20 @@ export default function FlowChart() { }; // Animated props for today circle - const animatedProps = useAnimatedProps(() => { + const animatedCircleProps = useAnimatedProps(() => { const todayX = centerX + circleRadius * Math.cos((position.value * Math.PI) / 180); const todayY = centerY + circleRadius * Math.sin((position.value * Math.PI) / 180); - return { - cx: todayX, - cy: todayY, - }; + return { cx: todayX, cy: todayY }; }); - // Create a circular path for chart const arcstartX = 55.7; const arcstartY = 5.5; const arcendX = 44.3; const arcendY = 5.5; - const arcPath = `M ${arcstartX},${arcstartY} A 45,45 0 0,1 95,50 A 45,45 0 0,1 50,95 A 45,45 0 0,1 5,50 A 45,45 0 0,1 ${arcendX},${arcendY}`; + const arcPath = `M ${arcstartX},${arcstartY} A 45,45 0 0,1 95,50 A 45,45 0 0,1 50,95 A 45,45 0 0,1 5,50 A 45,45 0 0,1 ${arcendX},${arcendY}`; - // Format the current month and today's date const todayFormatted = { year: today.toLocaleString("default", { year: "numeric" }), month: today.toLocaleString("default", { month: "long" }), @@ -119,7 +102,6 @@ export default function FlowChart() { const todayMonthDayFormatted = `${todayFormatted.month} ${todayFormatted.day},`; const todayWeekdayFormattedWithComma = `${todayFormatted.weekday},`; - // Use refs to satisfy lint const fetchFlowDataRef = useRef(fetchFlowData); const positionRef = useRef(position); const triggerAnimationRef = useRef(triggerAnimation); @@ -127,21 +109,19 @@ export default function FlowChart() { positionRef.current = position; triggerAnimationRef.current = triggerAnimation; - // useFocusEffect to fetch flow data and run animation when the screen is focused useFocusEffect( React.useCallback(() => { fetchFlowDataRef.current(); runOnJS(() => { - positionRef.current.value = initialPosition.current; // Reset position to initial value + positionRef.current.value = initialPosition.current; })(); setTimeout(() => { - triggerAnimationRef.current(); // Run animation after the position is set + triggerAnimationRef.current(); }, 10); }, []), ); - // Filter flow data for dates within the current month const filterFlowDataForCurrentMonth = (flowData: any[]) => { if (flowData.length === 0) { setFlowDataForCurrentMonth([]); @@ -154,28 +134,159 @@ export default function FlowChart() { return ( dayDateString >= firstDayString && dayDateString <= lastDayString && - day.flow_intensity + day.flow_intensity && + day.flow_intensity > 0 // Explicitly exclude "None" (0) ); }); setFlowDataForCurrentMonth(filteredData); } }; - // Updates the current month's data when there are changes in flowData useEffect(() => { filterFlowDataForCurrentMonth(flowData); }, [flowData]); // eslint-disable-line react-hooks/exhaustive-deps + // ===== Gradient + Progress Logic ===== + const flowDays = flowDataForCurrentMonth.length || 0; + const progress = Math.min(flowDays / MAX_FLOW_LENGTH, 1); + const C = 2 * Math.PI * circleRadius; + + const visible = C * progress; + const dashOffset = 0; // makes arc grow right -> left + + const tailLen = Math.min(C * FLOW_TAIL_PERCENT, visible); + const tailOffset = C - visible; + + const animatedDashProps = useAnimatedProps(() => { + // strokeDasharray accepts string or number array + const dashArray: string | number[] = [visible, C]; + return { + strokeDasharray: dashArray, + strokeDashoffset: dashOffset, + }; + }); + + // ===== Dynamic Gradient Based on Actual Flow States ===== + // Get unique flow states in chronological order + const flowStatesInOrder = useMemo(() => { + if (flowDataForCurrentMonth.length === 0) return []; + + // Sort by date to get chronological order + const sortedData = [...flowDataForCurrentMonth].sort((a, b) => { + const dateA = new Date(a.date + "T00:00:00Z").getTime(); + const dateB = new Date(b.date + "T00:00:00Z").getTime(); + return dateA - dateB; + }); + + // Extract unique flow types in order of first appearance + // Explicitly exclude "None" (flow_intensity 0) from gradient + const seen = new Set(); + const uniqueFlowTypes: FlowType[] = []; + + for (const data of sortedData) { + // Skip "None" (flow_intensity 0) - it should not affect the gradient + if (!data.flow_intensity || data.flow_intensity === 0) { + continue; + } + const flowType = getFlowTypeString(data.flow_intensity); + if (flowType && !seen.has(flowType)) { + seen.add(flowType); + uniqueFlowTypes.push(flowType); + } + } + + return uniqueFlowTypes; + }, [flowDataForCurrentMonth]); + + // Create gradient stops based on actual flow states + // Scale to 0-90% to leave room for tail fade at 95% + const gradientStops = useMemo((): React.ReactElement[] => { + const maxOffset = 90; // Leave room for tail fade + + let stops: React.ReactElement[] = []; + + if (flowStatesInOrder.length === 0) { + // Default gradient if no flow data + stops = [ + , + , + ]; + } else if (flowStatesInOrder.length === 1) { + // Single flow state - solid color + const color = FlowColors[flowStatesInOrder[0]]; + stops = [ + , + , + ]; + } else { + // Multiple flow states - create gradient with proportional stops + stops = flowStatesInOrder.map((flowType, index) => { + const offset = (index / (flowStatesInOrder.length - 1)) * maxOffset; + const color = FlowColors[flowType]; + return ; + }); + } + + // Add tail fade stop at 95% to create smooth transition to purple + stops.push(); + + return stops; + }, [flowStatesInOrder]); + + // ===================================== + return ( + + {/* Main flow gradient - dynamically generated based on actual flow states */} + + {gradientStops} + + + {/* Fade-out tail mask */} + + + + + + + {/* Base purple ring (always visible) */} + + {/* Gradient progress ring overlay */} + {flowDays > 0 && ( + <> + {/* Main gradient arc */} + + {/* Soft fade overlay */} + + + )} + + {/* Inner circle and date text */} - {renderMarks()} + + {/* Animated circle marker */} ); -} +} \ No newline at end of file diff --git a/components/dayView/FlowAccordion.tsx b/components/dayView/FlowAccordion.tsx index 4604bde..a554491 100644 --- a/components/dayView/FlowAccordion.tsx +++ b/components/dayView/FlowAccordion.tsx @@ -1,10 +1,28 @@ import { View, StyleSheet } from "react-native"; import { List, useTheme, Text, Chip } from "react-native-paper"; -import SingleChipSelection from "./SingleChipSelection"; import { ThemedView } from "../ThemedView"; +import { FlowColors } from "@/constants/Colors"; const flowOptions = ["None", "Spotting", "Light", "Medium", "Heavy"]; +// Map flow options to their corresponding colors +const getFlowColor = (option: string): string => { + switch (option.toLowerCase()) { + case "none": + return FlowColors.white; + case "spotting": + return FlowColors.spotting; + case "light": + return FlowColors.light; + case "medium": + return FlowColors.medium; + case "heavy": + return FlowColors.heavy; + default: + return FlowColors.white; + } +}; + const styles = StyleSheet.create({ chipContainer: { paddingLeft: 16, @@ -26,18 +44,48 @@ function FlowChips({ selectedOption: number; setSelectedOption: (option: number) => void; }) { + const theme = useTheme(); + const selectedValue = flowOptions[selectedOption]; + return ( - { - if (value !== null) { - setSelectedOption(flowOptions.indexOf(value)); - } - }} - label="Select Flow Intensity" - /> + + {flowOptions.map((option) => { + const isSelected = selectedValue === option; + const flowColor = getFlowColor(option); + // Use darker text color for better contrast on light backgrounds + const textColor = option === "None" ? "#000000" : "#ffffff"; + + return ( + { + const newIndex = flowOptions.indexOf(option); + setSelectedOption(isSelected ? 0 : newIndex); + }} + style={{ + backgroundColor: flowColor, + margin: 4, + borderRadius: 20, + height: 36, + justifyContent: "center", + borderWidth: isSelected ? 2 : 0, + borderColor: theme.colors.onSecondaryContainer, + }} + textStyle={{ + color: textColor, + fontWeight: isSelected ? "bold" : "normal", + }} + > + {option} + + ); + })} + ); } diff --git a/constants/Colors.ts b/constants/Colors.ts index a5d7b80..e315075 100644 --- a/constants/Colors.ts +++ b/constants/Colors.ts @@ -1,11 +1,12 @@ // cool gradient tool: https://cssgradient.io/ -export const FlowColors = [ - "#ffffff", - "#ffafaf", - "#ff7272", - "#e62929", - "#990000", -]; +export type FlowType = "spotting" | "light" | "medium" | "heavy" ; +export const FlowColors: Record = { + white: "#ffffff", + spotting: "#ffafaf", + light: "#ff7272", + medium: "#e62929", + heavy: "#990000", +}; const warmGold = "#E6B657"; const peachOrange = "#FFA770"; diff --git a/constants/Flow.ts b/constants/Flow.ts new file mode 100644 index 0000000..7e0f98d --- /dev/null +++ b/constants/Flow.ts @@ -0,0 +1,20 @@ +import { FlowType } from "@/constants/Colors"; +export const MAX_FLOW_LENGTH = 7; +export const FLOW_TAIL_PERCENT = 0.08; +export const FLOW_TAIL_COLOR = "#c6a4dbff"; + +export function getFlowTypeString(intensity: number): FlowType | undefined { + switch (intensity) { + case 1: + return "spotting"; + case 2: + return "light"; + case 3: + return "medium"; + case 4: + return "heavy"; + default: + // If 0 or any other number, treat as no flow/undefined + return undefined; + } +} \ No newline at end of file