diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index f035842f..94d3859b 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -1653,5 +1653,79 @@ "dashboardCardPostsColumnDescription": "Featured Posts", "dashboardCardSocialColumnDescription": "Friends & Notifications", "dashboardCardChatsColumnDescription": "Recent Chats", - "searchAccountsHint": "Search across the Solar Network and fediverse network." -} \ No newline at end of file + "searchAccountsHint": "Search across the Solar Network and fediverse network.", + "fitnessActivity": "Fitness Activity", + "loadingFitnessData": "Loading fitness data...", + "fitnessDataNotAvailable": "Fitness Data Not Available", + "fitnessDataNotAvailableDescription": "Fitness data is only available on iOS and Android devices.", + "fitnessPermissionRequired": "Permission Required", + "fitnessPermissionRequiredDescription": "To access your fitness data, we need permission to read your health information.", + "requestPermission": "Request Permission", + "noFitnessData": "No Fitness Data", + "noFitnessDataDescription": "No fitness data found. Start tracking your workouts to see them here.", + "totalWorkouts": "Total Workouts", + "totalDuration": "Total Duration", + "totalCalories": "Total Calories", + "noWorkoutsOnDate": "No workouts on {}", + "noWorkoutsOnDateDescription": "No workouts found for this date.", + "workoutsOnDate": "Workouts on {}", + "refresh": "Refresh", + "fitnessWorkoutTypeBadminton": "Badminton", + "fitnessWorkoutTypeBaseball": "Baseball", + "fitnessWorkoutTypeBasketball": "Basketball", + "fitnessWorkoutTypeBiking": "Biking", + "fitnessWorkoutTypeCalisthenics": "Calisthenics", + "fitnessWorkoutTypeCricket": "Cricket", + "fitnessWorkoutTypeDancing": "Dancing", + "fitnessWorkoutTypeElliptical": "Elliptical", + "fitnessWorkoutTypeFencing": "Fencing", + "fitnessWorkoutTypeFrisbeeDisc": "Frisbee Disc", + "fitnessWorkoutTypeGolf": "Golf", + "fitnessWorkoutTypeGymnastics": "Gymnastics", + "fitnessWorkoutTypeHiking": "Hiking", + "fitnessWorkoutTypeHockey": "Hockey", + "fitnessWorkoutTypeJumpRope": "Jump Rope", + "fitnessWorkoutTypeKickboxing": "Kickboxing", + "fitnessWorkoutTypeLacrosse": "Lacrosse", + "fitnessWorkoutTypeMartialArts": "Martial Arts", + "fitnessWorkoutTypeMindAndBody": "Mind and Body", + "fitnessWorkoutTypePilates": "Pilates", + "fitnessWorkoutTypeRowing": "Rowing", + "fitnessWorkoutTypeRowingMachine": "Rowing Machine", + "fitnessWorkoutTypeRugby": "Rugby", + "fitnessWorkoutTypeRunning": "Running", + "fitnessWorkoutTypeSailing": "Sailing", + "fitnessWorkoutTypeSkiing": "Skiing", + "fitnessWorkoutTypeSnowSports": "Snow Sports", + "fitnessWorkoutTypeSoftball": "Softball", + "fitnessWorkoutTypeStairClimbing": "Stair Climbing", + "fitnessWorkoutTypeStairClimbingMachine": "Stair Climbing Machine", + "fitnessWorkoutTypeStepTraining": "Step Training", + "fitnessWorkoutTypeSurfing": "Surfing", + "fitnessWorkoutTypeSwimming": "Swimming", + "fitnessWorkoutTypeTableTennis": "Table Tennis", + "fitnessWorkoutTypeTennis": "Tennis", + "fitnessWorkoutTypeCrossTraining": "Cross Training", + "fitnessWorkoutTypeCurling": "Curling", + "fitnessWorkoutTypeCrossCountrySkiing": "Cross Country Skiing", + "fitnessWorkoutTypeEquestrianSports": "Equestrian Sports", + "fitnessWorkoutTypeFishing": "Fishing", + "fitnessWorkoutTypeFunctionalStrengthTraining": "Functional Strength Training", + "fitnessWorkoutTypeHandCycling": "Hand Cycling", + "fitnessWorkoutTypeMixedCardio": "Mixed Cardio", + "fitnessWorkoutTypeOther": "Other", + "fitnessWorkoutTypePaddleSports": "Paddle Sports", + "fitnessWorkoutTypePickleball": "Pickleball", + "fitnessWorkoutTypeRacquetball": "Racquetball", + "fitnessWorkoutTypeRockClimbing": "Rock Climbing", + "fitnessWorkoutTypeSkating": "Skating", + "fitnessWorkoutTypeSnowboarding": "Snowboarding", + "fitnessWorkoutTypeSoccer": "Soccer", + "fitnessWorkoutTypeSquash": "Squash", + "fitnessWorkoutTypeStrengthTraining": "Strength Training", + "fitnessWorkoutTypeVolleyball": "Volleyball", + "fitnessWorkoutTypeWalking": "Walking", + "fitnessWorkoutTypeWeightlifting": "Weightlifting", + "fitnessWorkoutTypeYoga": "Yoga", + "fitnessWorkoutTypeDefault": "Fitness" +} diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 27e9143b..fbe822fd 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,5 +1,5 @@ PODS: - - Alamofire (5.11.0) + - Alamofire (5.11.1) - audio_session (0.0.1): - Flutter - connectivity_plus (0.0.1): @@ -213,6 +213,8 @@ PODS: - GoogleUtilities/UserDefaults (8.1.0): - GoogleUtilities/Logger - GoogleUtilities/Privacy + - health (13.1.4): + - Flutter - image_picker_ios (0.0.1): - Flutter - in_app_review (2.0.0): @@ -354,6 +356,7 @@ DEPENDENCIES: - flutter_udid (from `.symlinks/plugins/flutter_udid/ios`) - flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`) - gal (from `.symlinks/plugins/gal/darwin`) + - health (from `.symlinks/plugins/health/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - in_app_review (from `.symlinks/plugins/in_app_review/ios`) - irondash_engine_context (from `.symlinks/plugins/irondash_engine_context/ios`) @@ -460,6 +463,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_webrtc/ios" gal: :path: ".symlinks/plugins/gal/darwin" + health: + :path: ".symlinks/plugins/health/ios" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" in_app_review: @@ -518,7 +523,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/wakelock_plus/ios" SPEC CHECKSUMS: - Alamofire: bd5e7b23a1a750975288482c1831d71e74415f86 + Alamofire: eec6cd8f73b242b59e34153a606a909eb9864b14 audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd croppy: 979e8ddc254f4642bffe7d52dc7193354b27ba30 @@ -556,6 +561,7 @@ SPEC CHECKSUMS: GoogleAppMeasurement: 72c9a682fec6290327ea5e3c4b829b247fcb2c17 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 + health: 32d2fbc7f26f9a2388d1a514ce168adbfa5bda65 image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 in_app_review: 7dd1ea365263f834b8464673f9df72c80c17c937 irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index fd6b4408..270da7d5 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ @@ -222,8 +222,6 @@ }; 7310A7D52EB10962002C0FD3 /* Solian Watch App */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = "Solian Watch App"; sourceTree = ""; }; @@ -771,10 +769,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; @@ -832,10 +834,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -886,10 +892,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Solian Watch App/Pods-Solian Watch App-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Solian Watch App/Pods-Solian Watch App-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Solian Watch App/Pods-Solian Watch App-frameworks.sh\"\n"; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 3ac3a031..a6c0cd61 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -1,122 +1,127 @@ + + AppGroupId + $(CUSTOM_GROUP_ID) + BUNDLE_ID + dev.solsynth.solian + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Solian + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + solian + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER) + + + + CFBundleTypeRole + Editor + CFBundleURLName + + CFBundleURLSchemes + + solian + + + + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + CLIENT_ID + 961776991058-stt7et4qvn3cpscl4r61gl1hnlatqkig.apps.googleusercontent.com + ITSAppUsesNonExemptEncryption + + LSRequiresIPhoneOS + + NSAppIntentsConfiguration - AppGroupId - $(CUSTOM_GROUP_ID) - BUNDLE_ID + NSAppIntentsPackage dev.solsynth.solian - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Solian - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - solian - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleURLTypes - - - CFBundleTypeRole - Editor - CFBundleURLSchemes - - ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER) - - - - CFBundleTypeRole - Editor - CFBundleURLName - - CFBundleURLSchemes - - solian - - - - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - CLIENT_ID - 961776991058-stt7et4qvn3cpscl4r61gl1hnlatqkig.apps.googleusercontent.com - ITSAppUsesNonExemptEncryption - - LSRequiresIPhoneOS - - NSCalendarsUsageDescription - Grant access to Calander help us to shows Solar Calander with your own events. - NSCameraUsageDescription - Grant access to Camera will allow Solian take photo or video for your post. - NSFaceIDUsageDescription - Allow the Solar Network verify your ownership of the logged in account and continue your action quickly. - NSMicrophoneUsageDescription - Grant access to Microphone will allow Solian record audio for your post. - NSSpeechRecognitionUsageDescription - Solian uses speech recognition for Siri integration - NSAppIntentsConfiguration - - NSAppIntentsPackage - dev.solsynth.solian - - NSAppIntentsMetadata - - NSAppIntentsSupported - - - NSPhotoLibraryAddUsageDescription - Grant access to Photo Library will allow Solian download photo to album for you. - NSPhotoLibraryUsageDescription - Grant access to Photo Library will allow Solian upload photo or video for your post. - NSUserActivityTypes - - INStartCallIntent - INSendMessageIntent - - PLIST_VERSION - 1 - REVERSED_CLIENT_ID - com.googleusercontent.apps.961776991058-stt7et4qvn3cpscl4r61gl1hnlatqkig - UIApplicationSupportsIndirectInputEvents - - UIBackgroundModes - - fetch - audio - remote-notification - voip - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UIStatusBarHidden - - UISupportedInterfaceOrientations - - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - UIInterfaceOrientationPortrait - - WKCompanionAppBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - + NSAppIntentsMetadata + + NSAppIntentsSupported + + + NSCalendarsUsageDescription + Grant access to Calander help us to shows Solar Calander with your own events. + NSCameraUsageDescription + Grant access to Camera will allow Solian take photo or video for your post. + NSFaceIDUsageDescription + Allow the Solar Network verify your ownership of the logged in account and continue + your action quickly. + NSHealthShareUsageDescription + Allow us to share your fitness data with your friends. + NSHealthUpdateUsageDescription + Allow us to update your fitness data with your friends. + NSMicrophoneUsageDescription + Grant access to Microphone will allow Solian record audio for your post. + NSPhotoLibraryAddUsageDescription + Grant access to Photo Library will allow Solian download photo to album for you. + NSPhotoLibraryUsageDescription + Grant access to Photo Library will allow Solian upload photo or video for your post. + NSSpeechRecognitionUsageDescription + Solian uses speech recognition for Siri integration + NSUserActivityTypes + + INStartCallIntent + INSendMessageIntent + + PLIST_VERSION + 1 + REVERSED_CLIENT_ID + com.googleusercontent.apps.961776991058-stt7et4qvn3cpscl4r61gl1hnlatqkig + UIApplicationSupportsIndirectInputEvents + + UIBackgroundModes + + fetch + audio + remote-notification + voip + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIStatusBarHidden + + UISupportedInterfaceOrientations + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + WKCompanionAppBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements index e6d6864c..48465854 100644 --- a/ios/Runner/Runner.entitlements +++ b/ios/Runner/Runner.entitlements @@ -15,6 +15,8 @@ com.apple.developer.device-information.user-assigned-device-name + com.apple.developer.healthkit + com.apple.developer.usernotifications.communication com.apple.security.application-groups diff --git a/lib/route.dart b/lib/route.dart index b87723bb..83ae518f 100644 --- a/lib/route.dart +++ b/lib/route.dart @@ -47,6 +47,7 @@ import 'package:island/screens/creators/poll/poll_list.dart'; import 'package:island/screens/creators/sites/site_detail.dart'; import 'package:island/screens/creators/sites/site_list.dart'; import 'package:island/screens/creators/webfeed/webfeed_list.dart'; +import 'package:island/screens/fitness_activity.dart'; import 'package:island/screens/posts/compose.dart'; import 'package:island/screens/posts/compose_article.dart'; import 'package:island/screens/posts/post_detail.dart'; @@ -437,6 +438,11 @@ final routerProvider = Provider((ref) { return AbuseReportDetailScreen(reportId: id); }, ), + GoRoute( + name: 'fitnessActivity', + path: '/account/fitness', + builder: (context, state) => const FitnessActivityScreen(), + ), ], ), diff --git a/lib/screens/account.dart b/lib/screens/account.dart index b8cc83dd..7ea9bac8 100644 --- a/lib/screens/account.dart +++ b/lib/screens/account.dart @@ -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) { diff --git a/lib/screens/fitness_activity.dart b/lib/screens/fitness_activity.dart new file mode 100644 index 00000000..22acbe1a --- /dev/null +++ b/lib/screens/fitness_activity.dart @@ -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>([]); + 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 selectedDate, + ValueNotifier> workouts, + ValueNotifier hasPermission, + ValueNotifier isDataAvailable, + ValueChanged 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 selectedDate, + List workouts, + ValueChanged onDateSelected, + ) { + // Create a map of dates with workout data + final workoutData = >{}; + 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( + 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 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 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 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 _loadData( + FitnessService fitnessService, + DateTime date, + ValueNotifier> workouts, + ValueNotifier isLoading, + ValueNotifier hasPermission, + ValueNotifier 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 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; + } +} diff --git a/lib/services/fitness_data.dart b/lib/services/fitness_data.dart new file mode 100644 index 00000000..80488997 --- /dev/null +++ b/lib/services/fitness_data.dart @@ -0,0 +1,195 @@ +import 'package:health/health.dart'; +import 'package:easy_localization/easy_localization.dart'; + +/// Represents a fitness workout with structured data +class FitnessWorkout { + final DateTime startTime; + final DateTime endTime; + final HealthWorkoutActivityType workoutType; + final double? totalEnergyBurned; + final HealthDataUnit? totalEnergyBurnedUnit; + final double? totalDistance; + final HealthDataUnit? totalDistanceUnit; + final double? totalSteps; + final HealthDataUnit? totalStepsUnit; + + FitnessWorkout({ + required this.startTime, + required this.endTime, + required this.workoutType, + this.totalEnergyBurned, + this.totalEnergyBurnedUnit, + this.totalDistance, + this.totalDistanceUnit, + this.totalSteps, + this.totalStepsUnit, + }); + + /// Convert workout type to human-readable string + String get workoutTypeString { + switch (workoutType) { + case HealthWorkoutActivityType.BADMINTON: + return 'fitnessWorkoutTypeBadminton'.tr(); + case HealthWorkoutActivityType.BASEBALL: + return 'fitnessWorkoutTypeBaseball'.tr(); + case HealthWorkoutActivityType.BASKETBALL: + return 'fitnessWorkoutTypeBasketball'.tr(); + case HealthWorkoutActivityType.BIKING: + return 'fitnessWorkoutTypeBiking'.tr(); + case HealthWorkoutActivityType.CALISTHENICS: + return 'fitnessWorkoutTypeCalisthenics'.tr(); + case HealthWorkoutActivityType.CRICKET: + return 'fitnessWorkoutTypeCricket'.tr(); + case HealthWorkoutActivityType.DANCING: + return 'fitnessWorkoutTypeDancing'.tr(); + case HealthWorkoutActivityType.ELLIPTICAL: + return 'fitnessWorkoutTypeElliptical'.tr(); + case HealthWorkoutActivityType.FENCING: + return 'fitnessWorkoutTypeFencing'.tr(); + case HealthWorkoutActivityType.FRISBEE_DISC: + return 'fitnessWorkoutTypeFrisbeeDisc'.tr(); + case HealthWorkoutActivityType.GOLF: + return 'fitnessWorkoutTypeGolf'.tr(); + case HealthWorkoutActivityType.GYMNASTICS: + return 'fitnessWorkoutTypeGymnastics'.tr(); + case HealthWorkoutActivityType.HIKING: + return 'fitnessWorkoutTypeHiking'.tr(); + case HealthWorkoutActivityType.HOCKEY: + return 'fitnessWorkoutTypeHockey'.tr(); + case HealthWorkoutActivityType.JUMP_ROPE: + return 'fitnessWorkoutTypeJumpRope'.tr(); + case HealthWorkoutActivityType.KICKBOXING: + return 'fitnessWorkoutTypeKickboxing'.tr(); + case HealthWorkoutActivityType.LACROSSE: + return 'fitnessWorkoutTypeLacrosse'.tr(); + case HealthWorkoutActivityType.MARTIAL_ARTS: + return 'fitnessWorkoutTypeMartialArts'.tr(); + case HealthWorkoutActivityType.MIND_AND_BODY: + return 'fitnessWorkoutTypeMindAndBody'.tr(); + case HealthWorkoutActivityType.PILATES: + return 'fitnessWorkoutTypePilates'.tr(); + case HealthWorkoutActivityType.ROWING: + return 'fitnessWorkoutTypeRowing'.tr(); + case HealthWorkoutActivityType.ROWING_MACHINE: + return 'fitnessWorkoutTypeRowingMachine'.tr(); + case HealthWorkoutActivityType.RUGBY: + return 'fitnessWorkoutTypeRugby'.tr(); + case HealthWorkoutActivityType.RUNNING: + return 'fitnessWorkoutTypeRunning'.tr(); + case HealthWorkoutActivityType.SAILING: + return 'fitnessWorkoutTypeSailing'.tr(); + case HealthWorkoutActivityType.SKIING: + return 'fitnessWorkoutTypeSkiing'.tr(); + case HealthWorkoutActivityType.SNOW_SPORTS: + return 'fitnessWorkoutTypeSnowSports'.tr(); + case HealthWorkoutActivityType.SOFTBALL: + return 'fitnessWorkoutTypeSoftball'.tr(); + case HealthWorkoutActivityType.STAIR_CLIMBING: + return 'fitnessWorkoutTypeStairClimbing'.tr(); + case HealthWorkoutActivityType.STAIR_CLIMBING_MACHINE: + return 'fitnessWorkoutTypeStairClimbingMachine'.tr(); + case HealthWorkoutActivityType.STEP_TRAINING: + return 'fitnessWorkoutTypeStepTraining'.tr(); + case HealthWorkoutActivityType.SURFING: + return 'fitnessWorkoutTypeSurfing'.tr(); + case HealthWorkoutActivityType.SWIMMING: + return 'fitnessWorkoutTypeSwimming'.tr(); + case HealthWorkoutActivityType.TABLE_TENNIS: + return 'fitnessWorkoutTypeTableTennis'.tr(); + case HealthWorkoutActivityType.TENNIS: + return 'fitnessWorkoutTypeTennis'.tr(); + case HealthWorkoutActivityType.CROSS_TRAINING: + return 'fitnessWorkoutTypeCrossTraining'.tr(); + case HealthWorkoutActivityType.CURLING: + return 'fitnessWorkoutTypeCurling'.tr(); + case HealthWorkoutActivityType.CROSS_COUNTRY_SKIING: + return 'fitnessWorkoutTypeCrossCountrySkiing'.tr(); + case HealthWorkoutActivityType.EQUESTRIAN_SPORTS: + return 'fitnessWorkoutTypeEquestrianSports'.tr(); + case HealthWorkoutActivityType.FISHING: + return 'fitnessWorkoutTypeFishing'.tr(); + case HealthWorkoutActivityType.FUNCTIONAL_STRENGTH_TRAINING: + return 'fitnessWorkoutTypeFunctionalStrengthTraining'.tr(); + case HealthWorkoutActivityType.HAND_CYCLING: + return 'fitnessWorkoutTypeHandCycling'.tr(); + case HealthWorkoutActivityType.MIXED_CARDIO: + return 'fitnessWorkoutTypeMixedCardio'.tr(); + case HealthWorkoutActivityType.OTHER: + return 'fitnessWorkoutTypeOther'.tr(); + case HealthWorkoutActivityType.PADDLE_SPORTS: + return 'fitnessWorkoutTypePaddleSports'.tr(); + case HealthWorkoutActivityType.PICKLEBALL: + return 'fitnessWorkoutTypePickleball'.tr(); + case HealthWorkoutActivityType.RACQUETBALL: + return 'fitnessWorkoutTypeRacquetball'.tr(); + case HealthWorkoutActivityType.ROCK_CLIMBING: + return 'fitnessWorkoutTypeRockClimbing'.tr(); + case HealthWorkoutActivityType.SKATING: + return 'fitnessWorkoutTypeSkating'.tr(); + case HealthWorkoutActivityType.SNOWBOARDING: + return 'fitnessWorkoutTypeSnowboarding'.tr(); + case HealthWorkoutActivityType.SOCCER: + return 'fitnessWorkoutTypeSoccer'.tr(); + case HealthWorkoutActivityType.SQUASH: + return 'fitnessWorkoutTypeSquash'.tr(); + case HealthWorkoutActivityType.STRENGTH_TRAINING: + return 'fitnessWorkoutTypeStrengthTraining'.tr(); + case HealthWorkoutActivityType.VOLLEYBALL: + return 'fitnessWorkoutTypeVolleyball'.tr(); + case HealthWorkoutActivityType.WALKING: + return 'fitnessWorkoutTypeWalking'.tr(); + case HealthWorkoutActivityType.WEIGHTLIFTING: + return 'fitnessWorkoutTypeWeightlifting'.tr(); + case HealthWorkoutActivityType.YOGA: + return 'fitnessWorkoutTypeYoga'.tr(); + default: + return 'fitnessWorkoutTypeDefault'.tr(); + } + } + + /// Get formatted duration string + String get durationString { + final duration = endTime.difference(startTime); + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60); + final seconds = duration.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 energy burned string + String get energyBurnedString { + if (totalEnergyBurned == null || totalEnergyBurnedUnit == null) { + return ''; + } + return '${totalEnergyBurned!.toStringAsFixed(2)} ${totalEnergyBurnedUnit!.name}'; + } + + /// Get formatted distance string + String get distanceString { + if (totalDistance == null || totalDistanceUnit == null) { + return ''; + } + return '${totalDistance!.toStringAsFixed(2)} ${totalDistanceUnit!.name}'; + } + + /// Get formatted steps string + String get stepsString { + if (totalSteps == null || totalStepsUnit == null) { + return ''; + } + return '${totalSteps!.toStringAsFixed(0)} ${totalStepsUnit!.name}'; + } +} + +/// Enum for different types of fitness data +enum FitnessDataType { workout, steps, distance, calories, heartRate, sleep } + +/// Enum for permission status +enum FitnessPermissionStatus { granted, denied, restricted, notDetermined } diff --git a/lib/services/fitness_service.dart b/lib/services/fitness_service.dart new file mode 100644 index 00000000..0563f334 --- /dev/null +++ b/lib/services/fitness_service.dart @@ -0,0 +1,353 @@ +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((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 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 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> 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 = []; + + 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> 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> 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> 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> 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> 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> 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 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 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 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(); + } +} diff --git a/lib/widgets/debug_sheet.dart b/lib/widgets/debug_sheet.dart index 3268757b..0a2a217f 100644 --- a/lib/widgets/debug_sheet.dart +++ b/lib/widgets/debug_sheet.dart @@ -5,6 +5,8 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/pods/message.dart'; import 'package:island/pods/network.dart'; +import 'package:island/services/fitness_data.dart'; +import 'package:island/services/fitness_service.dart'; import 'package:island/services/update_service.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/content/network_status_sheet.dart'; @@ -196,6 +198,66 @@ class DebugSheet extends HookConsumerWidget { DefaultCacheManager().emptyCache(); }, ), + const Divider(height: 8), + ListTile( + minTileHeight: 48, + leading: const Icon(Symbols.fitness_center), + trailing: const Icon(Symbols.chevron_right), + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + title: const Text('Load Last 7 Days Workouts'), + onTap: () async { + try { + final fitnessService = ref.read(fitnessServiceProvider); + + // Check if platform is supported + if (!fitnessService.isPlatformSupported) { + showErrorAlert('Fitness data is only available on iOS and Android devices.'); + return; + } + + // Check permissions first + final permissionStatus = await fitnessService.getPermissionStatus(); + if (permissionStatus != FitnessPermissionStatus.granted) { + final granted = await fitnessService.requestPermissions(); + if (!granted) { + showErrorAlert('Permission to access fitness data was denied. Please enable it in your device settings.'); + return; + } + } + + // Get workouts from the last 7 days + final workouts = await fitnessService.getWorkoutsLast7Days(); + + if (workouts.isEmpty) { + showInfoAlert('No workout data found for the last 7 days.', 'No Data'); + return; + } + + // Format the workout data for display + StringBuffer sb = StringBuffer(); + for (final workout in workouts) { + final dateStr = '${workout.startTime.day}/${workout.startTime.month}'; + final energyStr = workout.energyBurnedString.isNotEmpty + ? ' • ${workout.energyBurnedString}' + : ''; + final distanceStr = workout.distanceString.isNotEmpty + ? ' • ${workout.distanceString}' + : ''; + final stepsStr = workout.stepsString.isNotEmpty + ? ' • ${workout.stepsString} steps' + : ''; + + sb.write( + '${workout.workoutTypeString} • $dateStr • ${workout.durationString}$energyStr$distanceStr$stepsStr\n', + ); + } + + showInfoAlert(sb.toString(), 'Workout Data Retrieved'); + } catch (e) { + showErrorAlert(e); + } + }, + ), ], ), ), diff --git a/pubspec.lock b/pubspec.lock index 4e55ccbf..399bba74 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -201,6 +201,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + carp_serializable: + dependency: transitive + description: + name: carp_serializable + sha256: f039f8ea22e9437aef13fe7e9743c3761c76d401288dcb702eadd273c3e4dcef + url: "https://pub.dev" + source: hosted + version: "2.0.1" cassowary: dependency: transitive description: @@ -1269,6 +1277,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.3.4" + health: + dependency: "direct main" + description: + name: health + sha256: "320633022fb2423178baa66508001c4ca5aee5806ffa2c913e66488081e9fd47" + url: "https://pub.dev" + source: hosted + version: "13.1.4" highlight: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 70cef074..170c0bd4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -174,6 +174,7 @@ dependencies: video_thumbnail: ^0.5.6 just_audio: ^0.10.5 audio_session: ^0.2.2 + health: ^13.1.4 dev_dependencies: flutter_test: