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.
267 lines
8.3 KiB
Dart
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;
|
|
}
|