🔀 Merge branch 'features/fitness' into v3

This commit is contained in:
2026-02-05 20:59:32 +08:00
13 changed files with 1636 additions and 122 deletions

View File

@@ -419,6 +419,13 @@ class AccountScreen extends HookConsumerWidget {
context.pushNamed('reportList');
},
},
{
'icon': Symbols.fitness_center,
'title': 'fitnessActivity',
'onTap': () {
context.pushNamed('fitnessActivity');
},
},
];
return Column(
children: menuItems.map((item) {

View File

@@ -0,0 +1,777 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:health/health.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/services/fitness_service.dart';
import 'package:island/services/fitness_data.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:table_calendar/table_calendar.dart';
import 'dart:math';
class FitnessActivityScreen extends HookConsumerWidget {
const FitnessActivityScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final fitnessService = ref.watch(fitnessServiceProvider);
final selectedDate = useState(DateTime.now());
final workouts = useState<List<FitnessWorkout>>([]);
final isLoading = useState(false);
final hasPermission = useState(false);
final isDataAvailable = useState(false);
// Load initial data
useEffect(() {
_loadData(
fitnessService,
selectedDate.value,
workouts,
isLoading,
hasPermission,
isDataAvailable,
);
return null;
}, []);
return AppScaffold(
appBar: AppBar(
title: Text('fitnessActivity').tr(),
actions: [
IconButton(
icon: const Icon(Symbols.refresh),
onPressed: () {
_loadData(
fitnessService,
selectedDate.value,
workouts,
isLoading,
hasPermission,
isDataAvailable,
);
},
tooltip: 'refresh'.tr(),
),
],
),
body: isLoading.value
? _buildLoadingView()
: _buildMainContent(
context,
fitnessService,
selectedDate,
workouts,
hasPermission,
isDataAvailable,
(date) {
selectedDate.value = date;
_loadData(
fitnessService,
date,
workouts,
isLoading,
hasPermission,
isDataAvailable,
);
},
),
);
}
Widget _buildLoadingView() {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('loadingFitnessData').tr(),
],
),
);
}
Widget _buildMainContent(
BuildContext context,
FitnessService fitnessService,
ValueNotifier<DateTime> selectedDate,
ValueNotifier<List<FitnessWorkout>> workouts,
ValueNotifier<bool> hasPermission,
ValueNotifier<bool> isDataAvailable,
ValueChanged<DateTime> onDateSelected,
) {
if (!fitnessService.isPlatformSupported) {
return _buildUnsupportedPlatformView();
}
if (!hasPermission.value) {
return _buildPermissionDeniedView(fitnessService);
}
if (!isDataAvailable.value) {
return _buildNoDataView();
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Calendar View
_buildCalendarView(
context,
selectedDate,
workouts.value,
onDateSelected,
),
const SizedBox(height: 24),
// Summary Cards
_buildSummaryCards(workouts.value),
const SizedBox(height: 24),
// Selected Date Workouts
_buildSelectedDateWorkouts(
selectedDate.value,
workouts.value,
context,
),
],
),
);
}
Widget _buildUnsupportedPlatformView() {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Symbols.fitness_center, size: 64, color: Colors.grey[600]),
const SizedBox(height: 16),
Text(
'fitnessDataNotAvailable',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
).tr(),
const SizedBox(height: 8),
Text(
'fitnessDataNotAvailableDescription',
textAlign: TextAlign.center,
).tr(),
],
),
);
}
Widget _buildPermissionDeniedView(FitnessService fitnessService) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Symbols.block, size: 64, color: Colors.red[600]),
const SizedBox(height: 16),
Text(
'fitnessPermissionRequired',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
).tr(),
const SizedBox(height: 8),
Text(
'fitnessPermissionRequiredDescription',
textAlign: TextAlign.center,
).tr(),
const SizedBox(height: 24),
ElevatedButton.icon(
icon: const Icon(Symbols.settings),
label: Text('requestPermission').tr(),
onPressed: () async {
final granted = await fitnessService.requestPermissions();
if (granted) {
// Reload data
// This would need to be handled by the parent widget
}
},
),
],
),
);
}
Widget _buildNoDataView() {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Symbols.fitness_center, size: 64, color: Colors.grey[600]),
const SizedBox(height: 16),
Text(
'noFitnessData',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
).tr(),
const SizedBox(height: 8),
Text('noFitnessDataDescription', textAlign: TextAlign.center).tr(),
],
),
);
}
Widget _buildCalendarView(
BuildContext context,
ValueNotifier<DateTime> selectedDate,
List<FitnessWorkout> workouts,
ValueChanged<DateTime> onDateSelected,
) {
// Create a map of dates with workout data
final workoutData = <DateTime, List<FitnessWorkout>>{};
for (final workout in workouts) {
final date = DateTime(
workout.startTime.year,
workout.startTime.month,
workout.startTime.day,
);
if (!workoutData.containsKey(date)) {
workoutData[date] = [];
}
workoutData[date]!.add(workout);
}
return Card(
child: TableCalendar<FitnessWorkout>(
firstDay: DateTime(2020),
lastDay: DateTime.now(),
focusedDay: selectedDate.value,
selectedDayPredicate: (day) {
return isSameDay(day, selectedDate.value);
},
onDaySelected: (selectedDay, focusedDay) {
onDateSelected(selectedDay);
},
calendarBuilders: CalendarBuilders(
markerBuilder: (context, date, events) {
if (workoutData.containsKey(date)) {
final dailyWorkouts = workoutData[date]!;
return Container(
margin: const EdgeInsets.only(bottom: 4),
child: _buildFitnessMarker(dailyWorkouts),
);
}
return null;
},
todayBuilder: (context, date, _) {
return Container(
margin: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
),
child: Center(
child: Text(
date.day.toString(),
style: const TextStyle(color: Colors.white),
),
),
);
},
),
calendarStyle: CalendarStyle(
selectedDecoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
),
todayDecoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondary,
shape: BoxShape.circle,
),
),
),
);
}
Widget _buildSummaryCards(List<FitnessWorkout> workouts) {
if (workouts.isEmpty) {
return const SizedBox.shrink();
}
// Calculate summary statistics
final totalWorkouts = workouts.length;
final totalDuration = workouts.fold(
Duration.zero,
(sum, workout) => sum + workout.endTime.difference(workout.startTime),
);
final totalCalories = workouts.fold(
0.0,
(sum, workout) => sum + (workout.totalEnergyBurned ?? 0),
);
final totalDistance = workouts.fold(
0.0,
(sum, workout) => sum + (workout.totalDistance ?? 0),
);
final totalSteps = workouts.fold(
0.0,
(sum, workout) => sum + (workout.totalSteps ?? 0),
);
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
_buildSummaryCard(
icon: Symbols.fitness_center,
title: 'totalWorkouts',
value: totalWorkouts.toString(),
color: Colors.blue,
),
_buildSummaryCard(
icon: Symbols.timer,
title: 'totalDuration',
value: _formatDuration(totalDuration),
color: Colors.green,
),
_buildSummaryCard(
icon: Symbols.local_fire_department,
title: 'totalCalories',
value: '${totalCalories.toInt()} kcal',
color: Colors.orange,
),
],
);
}
Widget _buildSummaryCard({
required IconData icon,
required String title,
required String value,
required Color color,
}) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, color: color, size: 24),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold),
).tr(),
],
),
const SizedBox(height: 8),
Text(
value,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
],
),
),
);
}
Widget _buildSelectedDateWorkouts(
DateTime selectedDate,
List<FitnessWorkout> workouts,
BuildContext context,
) {
// Filter workouts for selected date
final dateWorkouts = workouts.where((workout) {
final workoutDate = DateTime(
workout.startTime.year,
workout.startTime.month,
workout.startTime.day,
);
return isSameDay(workoutDate, selectedDate);
}).toList();
if (dateWorkouts.isEmpty) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Icon(Symbols.calendar_month, size: 48, color: Colors.grey[600]),
const SizedBox(height: 8),
Text(
'noWorkoutsOnDate',
style: const TextStyle(fontWeight: FontWeight.bold),
).tr(args: [DateFormat('MMM d, yyyy').format(selectedDate)]),
const SizedBox(height: 8),
Text('noWorkoutsOnDateDescription').tr(),
],
),
),
);
}
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'workoutsOnDate',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
).tr(args: [DateFormat('MMM d, yyyy').format(selectedDate)]),
),
const Divider(height: 1),
ListView.separated(
padding: EdgeInsets.zero,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: dateWorkouts.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final workout = dateWorkouts[index];
return ListTile(
leading: _getWorkoutIcon(workout.workoutType),
title: Text(workout.workoutTypeString),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${DateFormat('HH:mm').format(workout.startTime)} - ${DateFormat('HH:mm').format(workout.endTime)}',
),
const SizedBox(height: 4),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
if (workout.energyBurnedString.isNotEmpty)
_buildStatChip(
icon: Symbols.local_fire_department,
text: workout.energyBurnedString,
color: Colors.orange,
),
if (workout.distanceString.isNotEmpty)
_buildStatChip(
icon: Symbols.straighten,
text: workout.distanceString,
color: Colors.blue,
),
if (workout.stepsString.isNotEmpty)
_buildStatChip(
icon: Symbols.directions_walk,
text: '${workout.stepsString} steps',
color: Colors.green,
),
],
),
),
],
),
trailing: Text(workout.durationString),
);
},
),
],
),
);
}
Widget _buildStatChip({
required IconData icon,
required String text,
required Color color,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
margin: const EdgeInsets.only(right: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 4),
Text(text, style: TextStyle(fontSize: 12, color: color)),
],
),
);
}
Widget _getWorkoutIcon(HealthWorkoutActivityType type) {
switch (type) {
case HealthWorkoutActivityType.RUNNING:
return const Icon(Symbols.directions_run, color: Colors.blue);
case HealthWorkoutActivityType.WALKING:
return const Icon(Symbols.directions_walk, color: Colors.green);
case HealthWorkoutActivityType.BIKING:
return const Icon(Symbols.directions_bike, color: Colors.orange);
case HealthWorkoutActivityType.SWIMMING:
return const Icon(Symbols.pool, color: Colors.cyan);
case HealthWorkoutActivityType.STRENGTH_TRAINING:
case HealthWorkoutActivityType.WEIGHTLIFTING:
return const Icon(Symbols.fitness_center, color: Colors.red);
case HealthWorkoutActivityType.YOGA:
return const Icon(Symbols.self_improvement, color: Colors.purple);
default:
return const Icon(Symbols.fitness_center, color: Colors.grey);
}
}
String _formatDuration(Duration duration) {
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
if (hours > 0) {
return '${hours}h ${minutes}m';
} else {
return '${minutes}m';
}
}
Widget _buildFitnessMarker(List<FitnessWorkout> workouts) {
// If no workouts, show nothing
if (workouts.isEmpty) {
return const SizedBox.shrink();
}
// For better visibility, we'll use a simple colored dot with workout count
// This is more visible than the complex ring for small calendar cells
return Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.8),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black26,
blurRadius: 2,
offset: Offset(0, 1),
),
],
),
child: Center(
child: Text(
workouts.length.toString(),
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
);
}
}
Future<void> _loadData(
FitnessService fitnessService,
DateTime date,
ValueNotifier<List<FitnessWorkout>> workouts,
ValueNotifier<bool> isLoading,
ValueNotifier<bool> hasPermission,
ValueNotifier<bool> isDataAvailable,
) async {
isLoading.value = true;
try {
// Check platform support
if (!fitnessService.isPlatformSupported) {
hasPermission.value = false;
isDataAvailable.value = false;
workouts.value = [];
return;
}
// Check permissions
final permissionStatus = await fitnessService.getPermissionStatus();
hasPermission.value = permissionStatus == FitnessPermissionStatus.granted;
if (!hasPermission.value) {
isDataAvailable.value = false;
workouts.value = [];
return;
}
// Get workouts for the last 30 days to populate calendar
final allWorkouts = await fitnessService.getWorkoutsLast30Days();
workouts.value = allWorkouts;
isDataAvailable.value = allWorkouts.isNotEmpty;
} catch (e) {
showErrorAlert(e);
workouts.value = [];
isDataAvailable.value = false;
} finally {
isLoading.value = false;
}
}
/// A fitness ring marker that shows workout progress for a specific day
class FitnessRingMarker extends StatelessWidget {
final List<FitnessWorkout> workouts;
final double size;
const FitnessRingMarker({super.key, required this.workouts, this.size = 24});
@override
Widget build(BuildContext context) {
// Calculate total stats for the day
final totalDuration = workouts.fold(
Duration.zero,
(sum, workout) => sum + workout.endTime.difference(workout.startTime),
);
final totalCalories = workouts.fold(
0.0,
(sum, workout) => sum + (workout.totalEnergyBurned ?? 0),
);
final totalDistance = workouts.fold(
0.0,
(sum, workout) => sum + (workout.totalDistance ?? 0),
);
final totalSteps = workouts.fold(
0.0,
(sum, workout) => sum + (workout.totalSteps ?? 0),
);
// Calculate progress percentages (with some reasonable goals)
final durationProgress = _calculateDurationProgress(totalDuration);
final caloriesProgress = _calculateCaloriesProgress(totalCalories);
final stepsProgress = _calculateStepsProgress(totalSteps);
return Stack(
alignment: Alignment.center,
children: [
// Background ring (Steps)
_buildRing(
progress: stepsProgress,
color: Colors.green,
width: 3,
size: size,
isBackground: true,
),
// Middle ring (Calories)
_buildRing(
progress: caloriesProgress,
color: Colors.orange,
width: 3,
size: size - 4,
isBackground: true,
),
// Inner ring (Duration)
_buildRing(
progress: durationProgress,
color: Colors.blue,
width: 3,
size: size - 8,
isBackground: false,
),
// Center indicator
_buildCenterIndicator(workouts.length),
],
);
}
Widget _buildRing({
required double progress,
required Color color,
required double width,
required double size,
required bool isBackground,
}) {
return CustomPaint(
size: Size(size, size),
painter: FitnessRingPainter(
progress: progress,
color: color,
width: width,
isBackground: isBackground,
),
);
}
Widget _buildCenterIndicator(int workoutCount) {
return Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(color: Colors.black26, blurRadius: 2, offset: Offset(0, 1)),
],
),
child: Center(
child: Text(
workoutCount.toString(),
style: TextStyle(
fontSize: 8,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
),
);
}
double _calculateDurationProgress(Duration duration) {
// Goal: 30 minutes of exercise per day
final goalMinutes = 30.0;
final actualMinutes = duration.inMinutes.toDouble();
return (actualMinutes / goalMinutes).clamp(0.0, 1.0);
}
double _calculateCaloriesProgress(double calories) {
// Goal: 250 calories burned per day
final goalCalories = 250.0;
return (calories / goalCalories).clamp(0.0, 1.0);
}
double _calculateStepsProgress(double steps) {
// Goal: 5000 steps per day
final goalSteps = 5000.0;
return (steps / goalSteps).clamp(0.0, 1.0);
}
}
/// Custom painter for drawing fitness rings
class FitnessRingPainter extends CustomPainter {
final double progress;
final Color color;
final double width;
final bool isBackground;
FitnessRingPainter({
required this.progress,
required this.color,
required this.width,
required this.isBackground,
});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = min(size.width, size.height) / 2 - width / 2;
final paint = Paint()
..color = isBackground ? color.withOpacity(0.2) : color
..style = PaintingStyle.stroke
..strokeWidth = width
..strokeCap = StrokeCap.round;
if (isBackground) {
// Draw full background ring
canvas.drawCircle(center, radius, paint);
} else {
// Draw progress arc
final sweepAngle = 2 * pi * progress;
final startAngle = -pi / 2; // Start from top
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
startAngle,
sweepAngle,
false,
paint,
);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return true;
}
}