Files
Solian/lib/services/fitness_service.dart

354 lines
11 KiB
Dart

import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:health/health.dart';
import 'package:island/talker.dart';
import 'package:island/services/fitness_data.dart';
final fitnessServiceProvider = Provider<FitnessService>((ref) {
return FitnessService();
});
class FitnessService {
final Health _health = Health();
/// Check if the platform supports fitness data
bool get isPlatformSupported {
return !kIsWeb && (Platform.isIOS || Platform.isAndroid);
}
/// Request permissions for fitness data
Future<bool> requestPermissions() async {
if (!isPlatformSupported) {
talker.warning('[Fitness] Platform not supported for fitness data');
return false;
}
try {
final permissions = [
HealthDataType.WORKOUT,
HealthDataType.STEPS,
HealthDataType.DISTANCE_WALKING_RUNNING,
HealthDataType.BASAL_ENERGY_BURNED,
HealthDataType.ACTIVE_ENERGY_BURNED,
];
final granted = await _health.requestAuthorization(permissions);
talker.info('[Fitness] Permission request result: $granted');
return granted;
} catch (e) {
talker.error('[Fitness] Error requesting permissions: $e');
return false;
}
}
/// Check if permissions are granted for fitness data
Future<FitnessPermissionStatus> getPermissionStatus() async {
if (!isPlatformSupported) {
return FitnessPermissionStatus.notDetermined;
}
try {
final permissions = [
HealthDataType.WORKOUT,
HealthDataType.STEPS,
HealthDataType.DISTANCE_WALKING_RUNNING,
HealthDataType.BASAL_ENERGY_BURNED,
HealthDataType.ACTIVE_ENERGY_BURNED,
];
final granted = await _health.hasPermissions(permissions) ?? true;
talker.info('[Fitness] Permission check result: $granted');
if (granted) {
return FitnessPermissionStatus.granted;
} else {
// Try to check if permissions are denied or restricted
try {
await _health.requestAuthorization(permissions);
return FitnessPermissionStatus.notDetermined;
} catch (e) {
// If request fails, permissions are likely denied
return FitnessPermissionStatus.denied;
}
}
} catch (e) {
talker.error('[Fitness] Error checking permissions: $e');
return FitnessPermissionStatus.notDetermined;
}
}
/// Get workouts for a specific date range
Future<List<FitnessWorkout>> getWorkouts({
required DateTime startTime,
required DateTime endTime,
}) async {
if (!isPlatformSupported) {
throw Exception('Fitness data is only available on iOS and Android');
}
try {
final healthData = await _health.getHealthDataFromTypes(
startTime: startTime,
endTime: endTime,
types: [HealthDataType.WORKOUT],
);
final workouts = <FitnessWorkout>[];
for (final data in healthData) {
if (data.value is WorkoutHealthValue) {
final workoutValue = data.value as WorkoutHealthValue;
final workout = FitnessWorkout(
startTime: data.dateFrom,
endTime: data.dateTo,
workoutType: workoutValue.workoutActivityType,
totalEnergyBurned: workoutValue.totalEnergyBurned?.toDouble(),
totalEnergyBurnedUnit: workoutValue.totalEnergyBurnedUnit,
totalDistance: workoutValue.totalDistance?.toDouble(),
totalDistanceUnit: workoutValue.totalDistanceUnit,
totalSteps: workoutValue.totalSteps?.toDouble(),
totalStepsUnit: workoutValue.totalStepsUnit,
);
workouts.add(workout);
}
}
// Sort by start time (newest first)
workouts.sort((a, b) => b.startTime.compareTo(a.startTime));
talker.info('[Fitness] Retrieved ${workouts.length} workouts');
return workouts;
} catch (e) {
talker.error('[Fitness] Error retrieving workouts: $e');
rethrow;
}
}
/// Get workouts from the last 7 days
Future<List<FitnessWorkout>> getWorkoutsLast7Days() async {
final now = DateTime.now();
final startTime = now.subtract(const Duration(days: 7));
return getWorkouts(startTime: startTime, endTime: now);
}
/// Get workouts from the last 30 days
Future<List<FitnessWorkout>> getWorkoutsLast30Days() async {
final now = DateTime.now();
final startTime = now.subtract(const Duration(days: 30));
return getWorkouts(startTime: startTime, endTime: now);
}
/// Get workouts for today
Future<List<FitnessWorkout>> getWorkoutsToday() async {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final tomorrow = today.add(const Duration(days: 1));
return getWorkouts(startTime: today, endTime: tomorrow);
}
/// Get workouts for this week (Monday to Sunday)
Future<List<FitnessWorkout>> getWorkoutsThisWeek() async {
final now = DateTime.now();
// Calculate Monday of current week
final daysSinceMonday = now.weekday - 1; // Monday is 1, Sunday is 7
final monday = now.subtract(Duration(days: daysSinceMonday));
final startOfWeek = DateTime(monday.year, monday.month, monday.day);
final endOfWeek = startOfWeek.add(const Duration(days: 7));
return getWorkouts(startTime: startOfWeek, endTime: endOfWeek);
}
/// Get workouts for this month
Future<List<FitnessWorkout>> getWorkoutsThisMonth() async {
final now = DateTime.now();
final startOfMonth = DateTime(now.year, now.month, 1);
final startOfNextMonth = startOfMonth.add(const Duration(days: 32));
final endOfMonth = DateTime(
startOfNextMonth.year,
startOfNextMonth.month,
1,
);
return getWorkouts(startTime: startOfMonth, endTime: endOfMonth);
}
/// Get all available workout types from the device
Future<Set<HealthWorkoutActivityType>> getAvailableWorkoutTypes({
DateTime? startTime,
DateTime? endTime,
}) async {
if (!isPlatformSupported) {
return {};
}
try {
final workouts = await getWorkouts(
startTime: startTime ?? DateTime(2020, 1, 1),
endTime: endTime ?? DateTime.now(),
);
return workouts.map((w) => w.workoutType).toSet();
} catch (e) {
talker.error('[Fitness] Error getting available workout types: $e');
return {};
}
}
/// Get summary statistics for workouts
Future<FitnessSummary> getWorkoutSummary({
required DateTime startTime,
required DateTime endTime,
}) async {
final workouts = await getWorkouts(startTime: startTime, endTime: endTime);
double totalCalories = 0;
double totalDistance = 0;
int totalSteps = 0;
int totalWorkouts = workouts.length;
Duration totalDuration = Duration.zero;
for (final workout in workouts) {
// Calculate total duration
totalDuration += workout.endTime.difference(workout.startTime);
// Add calories (prefer active energy, fallback to basal)
if (workout.totalEnergyBurned != null) {
totalCalories += workout.totalEnergyBurned!;
}
// Add distance
if (workout.totalDistance != null) {
totalDistance += workout.totalDistance!;
}
// Add steps
if (workout.totalSteps != null) {
totalSteps += workout.totalSteps!.toInt();
}
}
return FitnessSummary(
totalWorkouts: totalWorkouts,
totalDuration: totalDuration,
totalCalories: totalCalories,
totalDistance: totalDistance,
totalSteps: totalSteps,
averageDuration: totalWorkouts > 0
? Duration(
milliseconds: (totalDuration.inMilliseconds / totalWorkouts)
.round(),
)
: Duration.zero,
averageCalories: totalWorkouts > 0 ? totalCalories / totalWorkouts : 0,
);
}
/// Get summary for last 7 days
Future<FitnessSummary> getWorkoutSummaryLast7Days() async {
final now = DateTime.now();
final startTime = now.subtract(const Duration(days: 7));
return getWorkoutSummary(startTime: startTime, endTime: now);
}
/// Check if fitness data is available
Future<bool> isDataAvailable() async {
if (!isPlatformSupported) {
return false;
}
try {
final permissionStatus = await getPermissionStatus();
if (permissionStatus != FitnessPermissionStatus.granted) {
return false;
}
// Try to get a small sample of data to verify it's available
final now = DateTime.now();
final yesterday = now.subtract(const Duration(days: 1));
final workouts = await getWorkouts(startTime: yesterday, endTime: now);
return workouts.isNotEmpty;
} catch (e) {
talker.warning('[Fitness] Error checking data availability: $e');
return false;
}
}
}
/// Summary statistics for fitness data
class FitnessSummary {
final int totalWorkouts;
final Duration totalDuration;
final double totalCalories;
final double totalDistance;
final int totalSteps;
final Duration averageDuration;
final double averageCalories;
FitnessSummary({
required this.totalWorkouts,
required this.totalDuration,
required this.totalCalories,
required this.totalDistance,
required this.totalSteps,
required this.averageDuration,
required this.averageCalories,
});
/// Get formatted total duration string
String get totalDurationString {
final hours = totalDuration.inHours;
final minutes = totalDuration.inMinutes.remainder(60);
final seconds = totalDuration.inSeconds.remainder(60);
if (hours > 0) {
return '${hours}h ${minutes}m ${seconds}s';
} else if (minutes > 0) {
return '${minutes}m ${seconds}s';
} else {
return '${seconds}s';
}
}
/// Get formatted average duration string
String get averageDurationString {
final hours = averageDuration.inHours;
final minutes = averageDuration.inMinutes.remainder(60);
final seconds = averageDuration.inSeconds.remainder(60);
if (hours > 0) {
return '${hours}h ${minutes}m ${seconds}s';
} else if (minutes > 0) {
return '${minutes}m ${seconds}s';
} else {
return '${seconds}s';
}
}
/// Get formatted total calories string
String get totalCaloriesString {
return '${totalCalories.toStringAsFixed(0)} kcal';
}
/// Get formatted average calories string
String get averageCaloriesString {
return '${averageCalories.toStringAsFixed(0)} kcal';
}
/// Get formatted total distance string
String get totalDistanceString {
if (totalDistance >= 1000) {
return '${(totalDistance / 1000).toStringAsFixed(2)} km';
} else {
return '${totalDistance.toStringAsFixed(0)} m';
}
}
/// Get formatted total steps string
String get totalStepsString {
return totalSteps.toString();
}
}