Files
Tracker/lib/widgets/cycle_ring.dart
Sterlen b4b2bfe749 feat: Implement husband features and fix iOS Safari web startup
Implement initial features for husband's companion app, including mock data
service and husband notes screen. Refactor scripture and cycle services
for improved stability and testability. Address iOS Safari web app
startup issue by removing deprecated initialization.

- Implemented MockDataService and HusbandNotesScreen.
- Converted _DashboardTab and DevotionalScreen to StatefulWidgets for robust
  scripture provider initialization.
- Refactored CycleService to use immutable CycleInfo class, reducing UI rebuilds.
- Removed deprecated window.flutterConfiguration from index.html, resolving
  Flutter web app startup failure on iOS Safari.
- Updated and fixed related tests.
2025-12-26 22:40:52 -06:00

267 lines
8.3 KiB
Dart

import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'dart:math' as math;
import '../theme/app_theme.dart';
import '../models/cycle_entry.dart';
class CycleRing extends StatefulWidget {
final int dayOfCycle;
final int totalDays;
final CyclePhase phase;
const CycleRing({
super.key,
required this.dayOfCycle,
required this.totalDays,
required this.phase,
});
@override
State<CycleRing> createState() => _CycleRingState();
}
class _CycleRingState extends State<CycleRing>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
);
_animation = CurvedAnimation(
parent: _controller,
curve: Curves.easeOutCubic,
);
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final targetProgress = widget.dayOfCycle / widget.totalDays;
final daysUntilNextPeriod = widget.totalDays - widget.dayOfCycle;
final isDark = Theme.of(context).brightness == Brightness.dark;
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
final currentProgress = targetProgress * _animation.value;
return SizedBox(
width: 220,
height: 220,
child: Stack(
alignment: Alignment.center,
children: [
// Background ring
CustomPaint(
size: const Size(220, 220),
painter: _CycleRingPainter(
progress: currentProgress,
phase: widget.phase,
isDark: isDark,
),
),
// Center content with scale and fade animation
Transform.scale(
scale: 0.8 + (0.2 * _animation.value),
child: Opacity(
opacity: _animation.value,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Day ${widget.dayOfCycle}',
style: Theme.of(context)
.textTheme
.displayMedium
?.copyWith(
fontSize: 32,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: _getPhaseColor(widget.phase)
.withOpacity(isDark ? 0.3 : 0.2),
borderRadius: BorderRadius.circular(20),
border: isDark
? Border.all(
color: _getPhaseColor(widget.phase)
.withOpacity(0.5))
: null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.phase.emoji,
style: const TextStyle(fontSize: 14),
),
const SizedBox(width: 6),
Text(
widget.phase.label,
style: GoogleFonts.outfit(
fontSize: 14,
fontWeight: FontWeight.w500,
color: isDark
? Colors.white
: _getPhaseColor(widget.phase),
),
),
],
),
),
const SizedBox(height: 8),
Text(
daysUntilNextPeriod > 0
? '$daysUntilNextPeriod days until period'
: 'Period expected',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontSize: 12,
color:
Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
),
],
),
);
},
);
}
Color _getPhaseColor(CyclePhase phase) {
switch (phase) {
case CyclePhase.menstrual:
return AppColors.menstrualPhase;
case CyclePhase.follicular:
return AppColors.follicularPhase;
case CyclePhase.ovulation:
return AppColors.ovulationPhase;
case CyclePhase.luteal:
return AppColors.lutealPhase;
}
}
}
class _CycleRingPainter extends CustomPainter {
final double progress;
final CyclePhase phase;
final bool isDark;
_CycleRingPainter({
required this.progress,
required this.phase,
required this.isDark,
});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2 - 15;
const strokeWidth = 12.0;
// Background arc
final bgPaint = Paint()
..color =
(isDark ? Colors.white : AppColors.lightGray).withOpacity(isDark ? 0.05 : 0.1)
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round;
canvas.drawCircle(center, radius, bgPaint);
// Progress arc
final progressPaint = Paint()
..shader = SweepGradient(
startAngle: -math.pi / 2,
endAngle: math.pi * 1.5,
colors: _getGradientColors(phase),
stops: const [0.0, 0.5, 1.0],
).createShader(Rect.fromCircle(center: center, radius: radius))
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-math.pi / 2,
2 * math.pi * progress,
false,
progressPaint,
);
// Dot at current position
final dotAngle = -math.pi / 2 + 2 * math.pi * progress;
final dotX = center.dx + radius * math.cos(dotAngle);
final dotY = center.dy + radius * math.sin(dotAngle);
final dotPaint = Paint()
..color = isDark ? const Color(0xFF1E1E1E) : Colors.white
..style = PaintingStyle.fill;
final dotBorderPaint = Paint()
..color = _getPhaseColor(phase)
..style = PaintingStyle.stroke
..strokeWidth = 3;
canvas.drawCircle(Offset(dotX, dotY), 8, dotPaint);
canvas.drawCircle(Offset(dotX, dotY), 8, dotBorderPaint);
}
List<Color> _getGradientColors(CyclePhase phase) {
switch (phase) {
case CyclePhase.menstrual:
return [AppColors.rose, AppColors.menstrualPhase, AppColors.blushPink];
case CyclePhase.follicular:
return [
AppColors.sageGreen,
AppColors.follicularPhase,
AppColors.sageGreen.withOpacity(0.7)
];
case CyclePhase.ovulation:
return [AppColors.lavender, AppColors.ovulationPhase, AppColors.rose];
case CyclePhase.luteal:
return [
AppColors.lutealPhase,
AppColors.lavender,
AppColors.blushPink
];
}
}
Color _getPhaseColor(CyclePhase phase) {
switch (phase) {
case CyclePhase.menstrual:
return AppColors.menstrualPhase;
case CyclePhase.follicular:
return AppColors.follicularPhase;
case CyclePhase.ovulation:
return AppColors.ovulationPhase;
case CyclePhase.luteal:
return AppColors.lutealPhase;
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}