277 lines
9.8 KiB
Dart
277 lines
9.8 KiB
Dart
import 'package:flutter/material.dart';
|
|
import '../models/user_profile.dart';
|
|
import '../models/cycle_entry.dart';
|
|
|
|
class CycleInfo {
|
|
final CyclePhase phase;
|
|
final int dayOfCycle;
|
|
final int daysUntilPeriod;
|
|
final bool isPeriodExpected;
|
|
|
|
CycleInfo({
|
|
required this.phase,
|
|
required this.dayOfCycle,
|
|
required this.daysUntilPeriod,
|
|
required this.isPeriodExpected,
|
|
});
|
|
|
|
@override
|
|
bool operator ==(Object other) =>
|
|
identical(this, other) ||
|
|
other is CycleInfo &&
|
|
runtimeType == other.runtimeType &&
|
|
phase == other.phase &&
|
|
dayOfCycle == other.dayOfCycle &&
|
|
daysUntilPeriod == other.daysUntilPeriod &&
|
|
isPeriodExpected == other.isPeriodExpected;
|
|
|
|
@override
|
|
int get hashCode =>
|
|
phase.hashCode ^
|
|
dayOfCycle.hashCode ^
|
|
daysUntilPeriod.hashCode ^
|
|
isPeriodExpected.hashCode;
|
|
}
|
|
|
|
class CycleService {
|
|
/// Calculates the current cycle information based on user profile
|
|
/// Calculates the current cycle information based on user profile and cycle entries
|
|
static CycleInfo calculateCycleInfo(
|
|
UserProfile? user, List<CycleEntry> entries) {
|
|
if (user == null) {
|
|
return CycleInfo(
|
|
phase: CyclePhase.follicular,
|
|
dayOfCycle: 1,
|
|
daysUntilPeriod: 28,
|
|
isPeriodExpected: false,
|
|
);
|
|
}
|
|
|
|
DateTime? lastPeriodStart = user.lastPeriodStartDate;
|
|
|
|
// Find the most recent period start from entries if available and more recent
|
|
// We look for a sequence of period days and take the first one
|
|
if (entries.isNotEmpty) {
|
|
final sortedEntries = List<CycleEntry>.from(entries)
|
|
..sort((a, b) => b.date.compareTo(a.date));
|
|
|
|
for (var entry in sortedEntries) {
|
|
if (entry.isPeriodDay) {
|
|
// Check if this is a "start" of a period (previous day was not period or no entry)
|
|
// Simplified logic: Just take the most recent period day found and assume it's part of the current/last period
|
|
// A better approach for "Day 1" is to find the First day of the contiguous block.
|
|
|
|
// However, for basic calculation, if we find a period day at Date X,
|
|
// and today is Date Y.
|
|
// If X is very recent, we are in the period.
|
|
|
|
// Correct logic: Identify the START DATE of the last period group.
|
|
// 1. Find the latest period entry.
|
|
// 2. Look backwards from there as long as there are consecutive period days.
|
|
|
|
// Check if we have a period day "tomorrow" relative to this entry? No, we are iterating backwards (descending).
|
|
// So if we found a period day, we need to check if the NEXT entry (which is earlier in time) is also a period day.
|
|
// If so, that earlier day is the better candidate for "Start".
|
|
|
|
// Let's iterate linearly.
|
|
// Since we sorted DESC, `entry` is the LATEST period day.
|
|
// We need to see if there are consecutive period days before it.
|
|
|
|
// But wait, the user might have logged Day 1, Day 2, Day 3.
|
|
// `entry` will be Day 3.
|
|
// We want Day 1.
|
|
|
|
// Let's try a different approach:
|
|
// Get all period days sorted DESC.
|
|
final periodDays = sortedEntries.where((e) => e.isPeriodDay).toList();
|
|
|
|
if (periodDays.isNotEmpty) {
|
|
// Take the latest block
|
|
DateTime latestParams = periodDays.first.date;
|
|
|
|
// Now find the "start" of this block
|
|
// We iterate backwards from the *latest* date found
|
|
|
|
DateTime currentSearch = latestParams;
|
|
DateTime startOfBlock = latestParams;
|
|
|
|
// Check if we have an entry for the day before
|
|
bool foundPrevious = true;
|
|
while (foundPrevious) {
|
|
final dayBefore = currentSearch.subtract(const Duration(days: 1));
|
|
final hasDayBefore =
|
|
periodDays.any((e) => DateUtils.isSameDay(e.date, dayBefore));
|
|
if (hasDayBefore) {
|
|
currentSearch = dayBefore;
|
|
startOfBlock = dayBefore;
|
|
} else {
|
|
foundPrevious = false;
|
|
}
|
|
}
|
|
|
|
// If this calculated start is more recent than the user profile one, use it
|
|
if (lastPeriodStart == null ||
|
|
startOfBlock.isAfter(lastPeriodStart)) {
|
|
lastPeriodStart = startOfBlock;
|
|
}
|
|
}
|
|
break; // We only care about the most recent period block
|
|
}
|
|
}
|
|
}
|
|
|
|
if (lastPeriodStart == null) {
|
|
return CycleInfo(
|
|
phase: CyclePhase.follicular,
|
|
dayOfCycle: 1,
|
|
daysUntilPeriod: user.averageCycleLength,
|
|
isPeriodExpected: false,
|
|
);
|
|
}
|
|
|
|
// Check if the calculated last period is in the future (invalid state validation)
|
|
if (lastPeriodStart.isAfter(DateTime.now())) {
|
|
// Fallback to today if data is weird, or just use it (maybe user logged future?)
|
|
// Let's stick to standard logic:
|
|
}
|
|
|
|
final cycleLength = user.averageCycleLength;
|
|
final now = DateTime.now();
|
|
|
|
// Normalize dates to midnight for accurate day counting
|
|
final startOfToday = DateTime(now.year, now.month, now.day);
|
|
final startOfCycle = DateTime(
|
|
lastPeriodStart.year, lastPeriodStart.month, lastPeriodStart.day);
|
|
|
|
final daysSinceLastPeriod =
|
|
startOfToday.difference(startOfCycle).inDays + 1;
|
|
|
|
// If negative (future date), handle gracefully
|
|
if (daysSinceLastPeriod < 1) {
|
|
return CycleInfo(
|
|
phase: CyclePhase.follicular,
|
|
dayOfCycle: 1,
|
|
daysUntilPeriod: cycleLength,
|
|
isPeriodExpected: false,
|
|
);
|
|
}
|
|
|
|
// Handle cases where last period was long ago (more than one cycle)
|
|
final int calculatedDayOfCycle =
|
|
((daysSinceLastPeriod - 1) % cycleLength) + 1;
|
|
|
|
// Check if we are in the predicted menstrual phase but no period is logged
|
|
bool isPeriodLoggedToday =
|
|
entries.any((e) => DateUtils.isSameDay(e.date, now) && e.isPeriodDay);
|
|
|
|
CyclePhase phase;
|
|
int dayOfCycle = calculatedDayOfCycle;
|
|
|
|
if (calculatedDayOfCycle <= user.averagePeriodLength) {
|
|
if (isPeriodLoggedToday) {
|
|
phase = CyclePhase.menstrual;
|
|
} else {
|
|
// No period logged today, but we are in the predicted window.
|
|
// Stay in Luteal and extend the day count.
|
|
phase = CyclePhase.luteal;
|
|
dayOfCycle = daysSinceLastPeriod;
|
|
}
|
|
} else if (calculatedDayOfCycle <= 13) {
|
|
phase = CyclePhase.follicular;
|
|
} else if (calculatedDayOfCycle <= 16) {
|
|
phase = CyclePhase.ovulation;
|
|
} else {
|
|
phase = CyclePhase.luteal;
|
|
}
|
|
|
|
final daysUntilPeriod =
|
|
dayOfCycle >= cycleLength ? 0 : cycleLength - dayOfCycle;
|
|
|
|
return CycleInfo(
|
|
phase: phase,
|
|
dayOfCycle: dayOfCycle,
|
|
daysUntilPeriod: daysUntilPeriod,
|
|
isPeriodExpected: daysUntilPeriod <= 0 || calculatedDayOfCycle <= 5,
|
|
);
|
|
}
|
|
|
|
/// Calculates the cycle phase for a specific date (past or future)
|
|
static CyclePhase? getPhaseForDate(DateTime date, UserProfile? user) {
|
|
if (user == null || user.lastPeriodStartDate == null) return null;
|
|
|
|
final lastPeriodStart = user.lastPeriodStartDate!;
|
|
|
|
// Normalize dates
|
|
final checkDate = DateTime(date.year, date.month, date.day);
|
|
final startCycle = DateTime(
|
|
lastPeriodStart.year, lastPeriodStart.month, lastPeriodStart.day);
|
|
|
|
final daysDifference = checkDate.difference(startCycle).inDays;
|
|
|
|
// If date is before the last known period, we can't reliably predict using this simple logic
|
|
// (though in reality we could project backwards, but let's stick to forward/current)
|
|
if (daysDifference < 0) return null;
|
|
|
|
final cycleLength = user.averageCycleLength;
|
|
final dayOfCycle = (daysDifference % cycleLength) + 1;
|
|
|
|
if (dayOfCycle <= user.averagePeriodLength) return CyclePhase.menstrual;
|
|
if (dayOfCycle <= 13) return CyclePhase.follicular;
|
|
if (dayOfCycle <= 16) return CyclePhase.ovulation;
|
|
return CyclePhase.luteal;
|
|
}
|
|
|
|
/// Predicts period days for the next [months] months
|
|
static List<DateTime> predictNextPeriodDays(UserProfile? user,
|
|
{int months = 12}) {
|
|
if (user == null || user.lastPeriodStartDate == null) return [];
|
|
|
|
final predictedDays = <DateTime>[];
|
|
final lastPeriodStart = user.lastPeriodStartDate!;
|
|
final cycleLength = user.averageCycleLength;
|
|
final periodLength = user.averagePeriodLength;
|
|
|
|
// Start predicting from the NEXT cycle if the current one is finished,
|
|
// or just project out from the last start date.
|
|
// We want to list all future period days.
|
|
|
|
DateTime currentCycleStart = lastPeriodStart;
|
|
|
|
// Project forward for roughly 'months' months
|
|
// A safe upper bound for loop is months * 30 days
|
|
final limitDate = DateTime.now().add(Duration(days: months * 30));
|
|
|
|
while (currentCycleStart.isBefore(limitDate)) {
|
|
// Add period days for this cycle
|
|
for (int i = 0; i < periodLength; i++) {
|
|
final periodDay = currentCycleStart.add(Duration(days: i));
|
|
if (periodDay.isAfter(DateTime.now())) {
|
|
predictedDays.add(periodDay);
|
|
}
|
|
}
|
|
|
|
// Move to next cycle
|
|
currentCycleStart = currentCycleStart.add(Duration(days: cycleLength));
|
|
}
|
|
|
|
return predictedDays;
|
|
}
|
|
|
|
/// Format cycle day for display
|
|
static String getDayOfCycleDisplay(int day) => 'Day $day';
|
|
|
|
/// Get phase description
|
|
static String getPhaseDescription(CyclePhase phase) {
|
|
switch (phase) {
|
|
case CyclePhase.menstrual:
|
|
return 'Your body is resting and clearing. Be gentle with yourself.';
|
|
case CyclePhase.follicular:
|
|
return 'Energy and optimism are rising. A great time for new projects.';
|
|
case CyclePhase.ovulation:
|
|
return 'You are at your peak energy and fertility.';
|
|
case CyclePhase.luteal:
|
|
return 'Progesterone is rising. You may feel more introverted or sensitive.';
|
|
}
|
|
}
|
|
}
|