From c5f5a798078b9cc487d8a2fabfe4f9c40784aff1 Mon Sep 17 00:00:00 2001 From: Turtlepaw <81275769+Turtlepaw@users.noreply.github.com> Date: Wed, 30 Oct 2024 01:26:49 -0400 Subject: [PATCH] feat: add water (hydration), distance, and calories --- android/app/src/main/AndroidManifest.xml | 3 + lib/constants.dart | 8 +- lib/main.dart | 40 ++- lib/routes/challenge.dart | 2 +- lib/routes/challenges/bingo.dart | 42 +++- lib/routes/create.dart | 6 +- lib/routes/settings.dart | 305 +++++++++++++---------- lib/utils/bingo/data.dart | 24 +- lib/utils/bingo/manager.dart | 103 ++++++-- lib/utils/common.dart | 6 +- lib/utils/health.dart | 75 +++++- pubspec.lock | 9 + pubspec.yaml | 3 + 13 files changed, 440 insertions(+), 186 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1c8c968..22e65c0 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -10,6 +10,9 @@ + + + diff --git a/lib/constants.dart b/lib/constants.dart index 877ecf7..9a77fe8 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -5,11 +5,11 @@ const String apiUrl = "https://fitnesschallenges.webredirect.org"; const types = [ HealthDataType.STEPS, HealthDataType.HEART_RATE, + HealthDataType.DISTANCE_DELTA, + HealthDataType.WATER, + HealthDataType.TOTAL_CALORIES_BURNED, ]; -const permissions = [ - HealthDataAccess.READ, - HealthDataAccess.READ, -]; +var permissions = types.map((type) => HealthDataAccess.READ).toList(); final pbDateFormat = DateFormat('yyyy-MM-dd HH:mm:ss'); \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 8638ad1..0b1ceef 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -173,7 +173,7 @@ void callbackDispatcher() { final pb = await initializePocketbase(); if (!pb.authStore.isValid) return Future.value(false); final type = await HealthTypeManager().getHealthType(); - if(type == null) { + if (type == null) { logger.debug("Health type not set, canceling work"); return Future.value(false); } @@ -318,7 +318,7 @@ void main() async { manager.init(); healthManager.checkConnectionState(); Future.delayed(const Duration(seconds: 1), () { - healthManager.fetchHealthData(); + healthManager.fetchHealthData(); }); Health().configure(useHealthConnectIfAvailable: true); final wearManager = WearManager(pb).sendAuthentication(logger); @@ -554,6 +554,7 @@ class _HomePageState extends State { final challengeProvider = Provider.of(context, listen: true); final mediaQuery = MediaQuery.of(context); + final theme = Theme.of(context); // This method is rerun every time setState is called, for instance as done // by the _incrementCounter method above. // @@ -592,13 +593,42 @@ class _HomePageState extends State { ); }) else - const Center( + Center( + child: Container( + padding: + const EdgeInsets.symmetric(vertical: 25, horizontal: 30), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: theme.colorScheme.surfaceContainerHigh, + width: 1.1, + style: BorderStyle.solid, + strokeAlign: BorderSide.strokeAlignCenter, + )), child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, - children: [Text("No challenges...")], + children: [ + Icon( + Symbols.travel_explore_rounded, + size: 50, + color: theme.colorScheme.primary, + ), + const SizedBox(height: 15), + Text("You aren't in any challenges yet.", + style: theme.textTheme.titleLarge), + Text("Create or join a challenge to get started.", + style: theme.textTheme.bodyLarge), + const SizedBox(height: 5), + FilledButton.tonalIcon( + onPressed: () => _showBottomSheet(context), + icon: const Icon(Symbols.stylus_note_rounded), + label: const Text("Create or join"), + ) + ], ), - ), + )), const SizedBox(height: 25) ], ), diff --git a/lib/routes/challenge.dart b/lib/routes/challenge.dart index 141e0a8..b7d4fe1 100644 --- a/lib/routes/challenge.dart +++ b/lib/routes/challenge.dart @@ -643,7 +643,7 @@ class _ChallengeDialogState extends State { crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( - "${formatNumber(data['totalValue'] as int)} steps", + "${formatInt(data['totalValue'] as int)} steps", style: theme.textTheme.titleLarge?.copyWith( color: theme.colorScheme.onPrimary), ), diff --git a/lib/routes/challenges/bingo.dart b/lib/routes/challenges/bingo.dart index eac8666..df308b1 100644 --- a/lib/routes/challenges/bingo.dart +++ b/lib/routes/challenges/bingo.dart @@ -2,6 +2,7 @@ import 'dart:math'; import 'package:confetti/confetti.dart'; // Import the confetti package import 'package:dynamic_color/dynamic_color.dart'; +import 'package:fitness_challenges/utils/health.dart'; import 'package:flutter/material.dart'; import 'package:flutter_advanced_avatar/flutter_advanced_avatar.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -9,6 +10,7 @@ import 'package:pocketbase/pocketbase.dart'; import 'package:provider/provider.dart'; import '../../utils/bingo/data.dart'; +import '../../utils/common.dart'; class BingoCardWidget extends StatefulWidget { final Widget Function(BuildContext) buildTopDetails; @@ -111,6 +113,7 @@ class _BingoCardWidgetState extends State { @override Widget build(BuildContext context) { var theme = Theme.of(context); + final health = Provider.of(context); return LayoutBuilder( builder: (context, constraints) { @@ -233,7 +236,7 @@ class _BingoCardWidgetState extends State { ), const SizedBox(height: 10), Text( - activity.amount.toString(), + formatNumber(activity.amount), textAlign: TextAlign.center, style: theme.textTheme.labelLarge?.copyWith( color: theme.colorScheme.onPrimary, @@ -270,6 +273,25 @@ class _BingoCardWidgetState extends State { ), ), + Padding( + padding: const EdgeInsets.only(left: 8), + child: GridView( + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 5, + crossAxisSpacing: 5.0, + mainAxisSpacing: 5.0, + ), + shrinkWrap: true, + children: [ + _buildDataBlock( + health.steps + ), + + ], + ), + ), + // Horizontal scroll of other users' bingo cards _buildOtherUsersCards(manager), @@ -281,6 +303,24 @@ class _BingoCardWidgetState extends State { ); } + Widget _buildDataBlock(int? value){ + var theme = Theme.of(context); + + return Row( + children: [ + const Icon( + Symbols.steps_rounded, + size: 15, + ), + const SizedBox(width: 8), + Text( + formatNumber(value ?? 0), + style: theme.textTheme.bodyLarge, + ) + ], + ); + } + Widget _buildOtherUsersCards(BingoDataManager manager) { var theme = Theme.of(context); diff --git a/lib/routes/create.dart b/lib/routes/create.dart index 351037c..009d38b 100644 --- a/lib/routes/create.dart +++ b/lib/routes/create.dart @@ -75,12 +75,12 @@ class _CreateDialogState extends State { ReactiveFormConsumer(builder: (context, form, widget) => _isCreating ? const Padding( - padding: EdgeInsets.symmetric(horizontal: 15), + padding: EdgeInsets.only(right: 25), child: SizedBox( width: 15, height: 15, child: CircularProgressIndicator( - strokeCap: StrokeCap.round), + strokeCap: StrokeCap.round, strokeWidth: 3.2,), ) ) : TextButton( @@ -287,7 +287,7 @@ class CreateWidget extends StatelessWidget { .control(type) .value == index, ); - }).toList(), + }), ], ), // const SizedBox( diff --git a/lib/routes/settings.dart b/lib/routes/settings.dart index 98b8d72..3e77a8a 100644 --- a/lib/routes/settings.dart +++ b/lib/routes/settings.dart @@ -1,6 +1,7 @@ import 'package:fitness_challenges/components/loader.dart'; import 'package:fitness_challenges/constants.dart'; import 'package:fitness_challenges/routes/profile.dart'; +import 'package:fitness_challenges/utils/bingo/data.dart'; import 'package:fitness_challenges/utils/common.dart'; import 'package:fitness_challenges/utils/health.dart'; import 'package:flutter/material.dart'; @@ -72,8 +73,8 @@ class _SettingsPageState extends State { } Future _connectSystemHealthPlatform(bool _) async { - final result = await Health().requestAuthorization(types, - permissions: [HealthDataAccess.READ, HealthDataAccess.READ]); + final result = + await Health().requestAuthorization(types, permissions: permissions); if (result == true) { if (mounted) { @@ -81,16 +82,17 @@ class _SettingsPageState extends State { await HealthTypeManager().setHealthType(HealthType.systemManaged); setState(() { - healthType = HealthType.systemManaged; + //healthType = HealthType.systemManaged; _isRefreshing = true; }); Future.delayed(const Duration(seconds: 1)); - await Provider.of(context, listen: false) + var result = await Provider.of(context, listen: false) .fetchHealthData(context: context); setState(() { + if (result == true) healthType = HealthType.systemManaged; _isRefreshing = false; }); } else { @@ -107,7 +109,8 @@ class _SettingsPageState extends State { _isLoading = true; }); - final result = await Health().hasPermissions(types); + final result = + await Health().hasPermissions(types, permissions: permissions); if (result == true) { setState(() { @@ -192,7 +195,8 @@ class _SettingsPageState extends State { ], ), // Health panel or loading box - SizedBox( // Replace Expanded with SizedBox + SizedBox( + // Replace Expanded with SizedBox child: AnimatedSwitcher( duration: const Duration(milliseconds: 150), transitionBuilder: (Widget child, Animation animation) { @@ -200,144 +204,179 @@ class _SettingsPageState extends State { }, child: _isLoading ? LayoutBuilder(builder: (context, constraints) { - final width = getWidth(constraints); - return SizedBox( - width: width, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 14, vertical: 6), - child: LoadingBox( - height: 195, - width: MediaQuery.of(context).size.width, - radius: 12, + final width = getWidth(constraints); + return SizedBox( + width: width, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 6), + child: LoadingBox( + height: 195, + width: MediaQuery.of(context).size.width, + radius: 12, + ), + ) + ], ), - ) - ], - ), - ); - }) + ); + }) : buildCard([ - Text( - _isAvailable - ? (healthType != null - ? "Health connected via ${HealthTypeManager.formatType(healthType)}" - : "Connect a health platform") - : "Health unavailable", - style: theme.textTheme.titleLarge, - textAlign: TextAlign.center, - ), - const SizedBox(height: 5), - if (healthType == null) - const Text( - "Connect a health platform to create and join challenges", - textAlign: TextAlign.center, - ) - else if (health.steps != null) - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Symbols.steps_rounded, - color: theme.colorScheme.primary, - ), - const SizedBox(width: 8), - Text("${formatNumber(health.steps as int)} steps") - ], - ) - else - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Symbols.refresh_rounded, - color: theme.colorScheme.error, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 15).copyWith(bottom: 5), + child: Text( + _isAvailable + ? (healthType != null + ? "Health connected via ${HealthTypeManager.formatType(healthType)}" + : "Connect a health platform") + : "Health unavailable", + style: theme.textTheme.titleLarge, + textAlign: TextAlign.center, + ), ), - const SizedBox(width: 8), - Text( - getErrorText(), - style: theme.textTheme.bodyLarge - ?.copyWith(color: theme.colorScheme.error), - ) - ], - ), - const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - FilterChip( - onDeleted: healthType != null - ? () { - setState(() { - _isLoading = true; - }); - HealthTypeManager().clearHealthType(); - setState(() { - _isLoading = false; - healthType = null; - }); - } - : null, - onSelected: (_isAvailable - ? _connectSystemHealthPlatform - : null), - label: Text(health.capabilities - .contains(HealthType.systemManaged) - ? ((health.isConnected && - healthType == HealthType.systemManaged) - ? "Connected" - : HealthTypeManager.formatType( - HealthType.systemManaged)) - : "Unavailable"), - selected: healthType == HealthType.systemManaged, - avatar: _isSysHealthLoading && - health.capabilities - .contains(HealthType.systemManaged) - ? const CircularProgressIndicator( - strokeWidth: 3, - strokeCap: StrokeCap.round, - ) - : null, - showCheckmark: !_isSysHealthLoading, - ), - const SizedBox(width: 5), - Tooltip( - message: "Refresh", - child: IconButton.outlined( - onPressed: () async { - setState(() { - _isRefreshing = true; - }); - await health.fetchHealthData(); - await health.checkConnectionState(); - setState(() { - _isRefreshing = false; - }); - }, - icon: _isRefreshing - ? const SizedBox( - width: 15, - height: 15, - child: CircularProgressIndicator( - strokeWidth: 2, - strokeCap: StrokeCap.round, + const SizedBox(height: 5), + if (healthType == null) + const Text( + "Connect a health platform to create and join challenges", + textAlign: TextAlign.center, + ) + else if (health.steps != null) + Wrap( + spacing: 1, // Space between items horizontally + runSpacing: 5, // Set this to 0 to minimize space between rows + alignment: WrapAlignment.start, // Align items at the start + children: [ + buildDataBlock(BingoDataType.steps, health.steps!, theme), + buildDataBlock(BingoDataType.calories, health.calories!, theme), + buildDataBlock(BingoDataType.distance, health.distance!, theme), + buildDataBlock(BingoDataType.water, health.water!, theme), + ], + ) + else + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Symbols.refresh_rounded, + color: theme.colorScheme.error, ), + const SizedBox(width: 8), + Text( + getErrorText(), + style: theme.textTheme.bodyLarge + ?.copyWith(color: theme.colorScheme.error), + ) + ], + ), + const SizedBox(height: 5), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FilterChip( + onDeleted: healthType != null + ? () { + setState(() { + _isLoading = true; + }); + HealthTypeManager().clearHealthType(); + setState(() { + _isLoading = false; + healthType = null; + }); + } + : null, + onSelected: (_isAvailable + ? _connectSystemHealthPlatform + : null), + label: Text(health.capabilities + .contains(HealthType.systemManaged) + ? ((health.isConnected && + healthType == HealthType.systemManaged) + ? "Connected" + : HealthTypeManager.formatType( + HealthType.systemManaged)) + : "Unavailable"), + selected: healthType == HealthType.systemManaged, + avatar: _isSysHealthLoading && + health.capabilities + .contains(HealthType.systemManaged) + ? const CircularProgressIndicator( + strokeWidth: 3, + strokeCap: StrokeCap.round, + ) + : null, + showCheckmark: !_isSysHealthLoading, + ), + const SizedBox(width: 5), + Tooltip( + message: "Refresh", + child: IconButton.outlined( + onPressed: () async { + setState(() { + _isRefreshing = true; + }); + await health.fetchHealthData(); + await health.checkConnectionState(); + setState(() { + _isRefreshing = false; + }); + }, + icon: _isRefreshing + ? const SizedBox( + width: 15, + height: 15, + child: CircularProgressIndicator( + strokeWidth: 2, + strokeCap: StrokeCap.round, + ), + ) + : Icon( + Symbols.refresh_rounded, + color: theme.colorScheme.onSurface, + )), ) - : Icon( - Symbols.refresh_rounded, - color: theme.colorScheme.onSurface, - )), - ) - ], - ) - ], height: 195), + ], + ) + ], height: 195), ), ), ], ), ); + } + Widget buildDataBlock(BingoDataType type, num amount, ThemeData theme) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 5), // Minimal padding + margin: const EdgeInsets.all(2), // Minimal margin + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: theme.colorScheme.surfaceContainerHigh, + width: 1.1, + style: BorderStyle.solid, + strokeAlign: BorderSide.strokeAlignCenter, + ) + ), + child: Row( + mainAxisSize: MainAxisSize.min, // Minimize row size + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + type.asIcon(), + color: theme.colorScheme.primary, + ), + const SizedBox(width: 4), // Smaller space between icon and text + Text( + "${formatNumber(amount)} ${type.asString()}", + textAlign: TextAlign.center, // Center the text + ), + ], + ), + ); } String getErrorText() { diff --git a/lib/utils/bingo/data.dart b/lib/utils/bingo/data.dart index d094583..acb89f7 100644 --- a/lib/utils/bingo/data.dart +++ b/lib/utils/bingo/data.dart @@ -5,10 +5,10 @@ import '../../types/challenges.dart'; import '../manager.dart'; import 'manager.dart'; -enum BingoDataType { filled, steps, distance, azm } +enum BingoDataType { filled, steps, distance, azm, calories, water } extension BingoDataTypeExtension on BingoDataType { - BingoDataActivity toActivity(int amount) { + BingoDataActivity toActivity(num amount) { return BingoDataActivity(type: this, amount: amount); } @@ -17,15 +17,29 @@ extension BingoDataTypeExtension on BingoDataType { BingoDataType.steps => Symbols.steps_rounded, BingoDataType.distance => Symbols.distance_rounded, BingoDataType.azm => Symbols.azm_rounded, - BingoDataType.filled => Symbols.check_rounded, + BingoDataType.filled => Symbols.check_rounded, + BingoDataType.calories => Symbols.local_fire_department_rounded, + BingoDataType.water => Symbols.water_full_rounded, _ => Symbols.indeterminate_question_box_rounded }; } + + String asString() { + return switch (this) { + BingoDataType.steps => "Steps", + BingoDataType.distance => "Distance", + BingoDataType.azm => "Active Minutes", + BingoDataType.filled => "Filled", + BingoDataType.calories => "Calories", + BingoDataType.water => "Water", + _ => "Unknown" + }; + } } class BingoDataActivity { final BingoDataType type; - final int amount; + final num amount; BingoDataActivity({required this.type, required this.amount}); @@ -33,7 +47,7 @@ class BingoDataActivity { factory BingoDataActivity.fromJson(Map json) { return BingoDataActivity( type: BingoDataType.values[json['type'] as int], - amount: json['amount'] as int, + amount: json['amount'], // No need to cast to int here ); } diff --git a/lib/utils/bingo/manager.dart b/lib/utils/bingo/manager.dart index e70c61b..698b1ff 100644 --- a/lib/utils/bingo/manager.dart +++ b/lib/utils/bingo/manager.dart @@ -10,23 +10,44 @@ class Bingo { final stepsRange = _getStepsRange(difficulty); final distanceRange = _getDistanceRange(difficulty); final activeMinutesRange = _getActiveMinutesRange(difficulty); + final caloriesRange = _getCaloriesRange(difficulty); + final waterRange = _getWaterRange(difficulty); final List activities = List.generate(25, (index) { - if (index % 3 == 0) { - final steps = ((random.nextInt(stepsRange[1] - stepsRange[0] + 1) + stepsRange[0]) - .roundToNearest(5)) - .clamp(stepsRange[0], stepsRange[1]); - return BingoDataType.steps.toActivity(steps); - } else if (index % 3 == 1) { - final distance = ((random.nextInt(distanceRange[1] - distanceRange[0] + 1) + distanceRange[0]) - .roundToNearest(5)) - .clamp(distanceRange[0], distanceRange[1]); - return BingoDataType.distance.toActivity(distance); - } else { - final activeMinutes = ((random.nextInt(activeMinutesRange[1] - activeMinutesRange[0] + 1) + activeMinutesRange[0]) - .roundToNearest(5)) - .clamp(activeMinutesRange[0], activeMinutesRange[1]); - return BingoDataType.azm.toActivity(activeMinutes); + switch (index % 5) { + case 0: + final steps = (random.nextInt(stepsRange[1] - stepsRange[0] + 1) + stepsRange[0]) + .roundToNearest(50) + .clamp(stepsRange[0], stepsRange[1]); + return BingoDataType.steps.toActivity(steps); + + case 1: + final distance = ((random.nextDouble() * (distanceRange[1] - distanceRange[0]) + distanceRange[0]) + .roundToNearestNum(0.5)) // Use as double to keep precision + .clamp(distanceRange[0], distanceRange[1]); + return BingoDataType.distance.toActivity(distance); // Convert to int for compatibility + + case 2: + final activeMinutes = (random.nextInt(activeMinutesRange[1] - activeMinutesRange[0] + 1) + activeMinutesRange[0]) + .roundToNearest(5) + .clamp(activeMinutesRange[0], activeMinutesRange[1]); + return BingoDataType.azm.toActivity(activeMinutes); + + case 3: + final calories = (random.nextInt(caloriesRange[1] - caloriesRange[0] + 1) + caloriesRange[0]) + .roundToNearest(10) + .clamp(caloriesRange[0], caloriesRange[1]); + return BingoDataType.calories.toActivity(calories); + + case 4: + // Only include water intake if the user is comfortable with it + final water = (random.nextInt(waterRange[1] - waterRange[0] + 1) + waterRange[0]) + .roundToNearest(250) + .clamp(waterRange[0], waterRange[1]); + return BingoDataType.water.toActivity(water); + + default: + return BingoDataType.steps.toActivity(stepsRange[0]); } }); @@ -37,26 +58,26 @@ class Bingo { List _getStepsRange(Difficulty difficulty) { switch (difficulty) { case Difficulty.easy: - return [1000, 5000]; + return [500, 3000]; case Difficulty.medium: - return [5000, 10000]; + return [3000, 8000]; case Difficulty.hard: - return [10000, 20000]; + return [8000, 15000]; default: - return [1000, 5000]; + return [500, 3000]; } } - List _getDistanceRange(Difficulty difficulty) { + List _getDistanceRange(Difficulty difficulty) { switch (difficulty) { case Difficulty.easy: - return [1, 5]; + return [0.5, 3.0]; case Difficulty.medium: - return [5, 10]; + return [3.0, 8.0]; case Difficulty.hard: - return [10, 20]; + return [8.0, 12.0]; default: - return [1, 5]; + return [0.5, 3.0]; } } @@ -67,15 +88,45 @@ class Bingo { case Difficulty.medium: return [30, 60]; case Difficulty.hard: - return [60, 120]; + return [60, 90]; default: return [10, 30]; } } + + List _getCaloriesRange(Difficulty difficulty) { + switch (difficulty) { + case Difficulty.easy: + return [50, 150]; + case Difficulty.medium: + return [150, 300]; + case Difficulty.hard: + return [300, 600]; + default: + return [50, 150]; + } + } + + List _getWaterRange(Difficulty difficulty) { + switch (difficulty) { + case Difficulty.easy: + return [500, 1000]; + case Difficulty.medium: + return [1000, 1500]; + case Difficulty.hard: + return [1500, 2000]; + default: + return [500, 1000]; + } + } } extension on num { int roundToNearest(int n) { return (this / n).round() * n; } -} + + double roundToNearestNum(num n) { // Return a double to preserve precision + return (this / n).roundToDouble() * n; // Use roundToDouble() to avoid potential errors + } +} \ No newline at end of file diff --git a/lib/utils/common.dart b/lib/utils/common.dart index 651aa2b..cc5c0a5 100644 --- a/lib/utils/common.dart +++ b/lib/utils/common.dart @@ -1,5 +1,9 @@ import 'package:intl/intl.dart'; -String formatNumber(int number) { +String formatInt(int number) { + return NumberFormat.decimalPattern().format(number); +} + +String formatNumber(num number) { return NumberFormat.decimalPattern().format(number); } diff --git a/lib/utils/health.dart b/lib/utils/health.dart index c427a96..3d42a79 100644 --- a/lib/utils/health.dart +++ b/lib/utils/health.dart @@ -7,7 +7,8 @@ import 'package:fitness_challenges/types/collections.dart'; import 'package:fitness_challenges/utils/data_source_manager.dart'; import 'package:fitness_challenges/utils/sharedLogger.dart'; import 'package:fitness_challenges/utils/steps/data.dart'; -import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_health_connect/flutter_health_connect.dart'; import 'package:flutter_wear_os_connectivity/flutter_wear_os_connectivity.dart'; import 'package:health/health.dart'; import 'package:pocketbase/pocketbase.dart'; @@ -30,6 +31,18 @@ class HealthManager with ChangeNotifier { int? get activeMinutes => _azm; + num? _calories; + + num? get calories => _calories; + + num? _water; + + num? get water => _water; + + num? _distance; + + num? get distance => _distance; + bool _isConnected = false; bool get isConnected => _isConnected; @@ -77,14 +90,33 @@ class HealthManager with ChangeNotifier { notifyListeners(); } + Future getDataFromType(DateTime start, HealthDataType type) async { + var points = await Health().getHealthDataFromTypes( + startTime: start, endTime: DateTime.now(), types: [type]); + final data = Health().removeDuplicates(points).map((it) { + final value = it.value; + if (value is NumericHealthValue) { + return value.numericValue; + } else { + return 0; + } + }); + + if (data.isEmpty) { + return 0; // Return 0 if data is empty + } else { + return data.reduce((value, element) => value + element); + } + } + /// Attempts to fetch health data if all permissions are granted - Future fetchHealthData({BuildContext? context}) async { + Future fetchHealthData({BuildContext? context}) async { final health = Health(); final type = await HealthTypeManager().getHealthType(); if (!pb.authStore.isValid) { logger.debug("Pocketbase auth store not valid"); - return; + return false; } var userId = pb.authStore.model?.id; @@ -96,7 +128,15 @@ class HealthManager with ChangeNotifier { .every((item) => item == true); if (!isAvailable) { logger.debug("Missing some health types"); - return; + HealthTypeManager().clearHealthType(); + if(context != null && context.mounted){ + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Sync failed: Missing data types'), + ), + ); + } + return false; } final hasPermissions = await Future.wait(types.map((type) async { @@ -111,12 +151,28 @@ class HealthManager with ChangeNotifier { if (hasPermissions == true) { var now = DateTime.now(); var midnight = DateTime(now.year, now.month, now.day); + final startTime = midnight; _steps = await Health().getTotalStepsInInterval(midnight, now); + _calories = await getDataFromType(startTime, HealthDataType.TOTAL_CALORIES_BURNED); + _water = await getDataFromType(startTime, HealthDataType.WATER); + _distance = await getDataFromType(startTime, HealthDataType.DISTANCE_DELTA); _isConnected = true; notifyListeners(); // Notify listeners about the change - logger.debug("Successfully synced $_steps steps"); + logger.debug("Successfully synced:\n\nšŸ‘Ÿ Steps: $steps\nšŸ”„ Calories: $calories\nšŸ’¦ Water: $water\nšŸƒ Distance: $distance"); } else { logger.debug("No health permissions, sync failed"); + HealthTypeManager().clearHealthType(); + if(context != null && context.mounted){ + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Sync failed: Missing permissions'), + action: SnackBarAction(label: "Fix", onPressed: () async { + await HealthConnectFactory.openHealthConnectSettings(); + }), + ), + ); + } + return false; } } else if (type == HealthType.watch && Platform.isAndroid) { try { @@ -127,7 +183,7 @@ class HealthManager with ChangeNotifier { var devices = await flutterWearOsConnectivity.getConnectedDevices(); if (devices.isEmpty) { logger.debug("No connected devices"); - return; + return false; } for (var device in devices) { @@ -149,7 +205,8 @@ class HealthManager with ChangeNotifier { if (devices.isNotEmpty) _isConnected = true; if (data.isEmpty) { - return debugPrint("No steps from today"); + logger.debug("No data from Wear OS client"); + return false; } if (data?.first?.mapData[id] != null) { @@ -163,11 +220,13 @@ class HealthManager with ChangeNotifier { logger.debug("Synced $_steps steps from Wear OS client"); } else { logger.debug("No steps from today using Wear OS client"); + return false; } } } catch (e, stacktrace) { logger.error("Error fetching health data from Wear OS: $e"); logger.error(stacktrace.toString()); + return false; } } @@ -210,6 +269,8 @@ class HealthManager with ChangeNotifier { await Future.delayed(const Duration(seconds: 2)); await challengeProvider.reloadChallenges(context); } + + return true; } DataSource getSource(HealthType type) { diff --git a/pubspec.lock b/pubspec.lock index a10f2ee..9277dc0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -269,6 +269,15 @@ packages: url: "https://github.com/Turtlepaw/flutter_emoji_feedback.git" source: git version: "0.3.2" + flutter_health_connect: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: fcba88afe7afcc887519d3bbb2d965419bdf48a2 + url: "https://github.com/quentinleguennec/flutter_health_connect.git" + source: git + version: "4.1.0" flutter_image_stack: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index fec04ee..3dacebe 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -89,6 +89,9 @@ dependencies: pair: ^0.1.2 receive_intent: ^0.2.5 flutter_statusbarcolor_ns: ^0.6.0 + flutter_health_connect: + git: + url: https://github.com/quentinleguennec/flutter_health_connect.git #share_plus: 10.0.0 dev_dependencies: