From a11a7a6a84b705267f9d6798af2c37aa45bd53b4 Mon Sep 17 00:00:00 2001 From: garrismi Date: Tue, 23 Apr 2024 16:49:16 -0400 Subject: [PATCH] Umich Notification Foundation --- android/app/src/main/AndroidManifest.xml | 7 + lib/main.dart | 22 ++- lib/providers/nutrition.dart | 174 +++++++++++++++++++---- pubspec.yaml | 22 +-- 4 files changed, 182 insertions(+), 43 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 34614bd61..8a5018d86 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -8,6 +8,10 @@ + + + + @@ -62,5 +66,8 @@ + + + \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 9c45f0b87..9f2e81bc8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -50,6 +50,8 @@ import 'package:wger/screens/workout_plans_screen.dart'; import 'package:wger/theme/theme.dart'; import 'package:wger/widgets/core/about.dart'; import 'package:wger/widgets/core/settings.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'providers/auth.dart'; @@ -61,8 +63,22 @@ void main() async { // Locator to initialize exerciseDB await ServiceLocator().configure(); + // Application runApp(MyApp()); + + // Request notification permission + await _requestNotificationPermission(); +} + +Future _requestNotificationPermission() async { + // Request notification permission + var status = await Permission.notification.request(); + if (status.isGranted) { + print('Notification permission granted'); + } else { + print('Notification permission denied'); + } } class MyApp extends StatelessWidget { @@ -91,11 +107,11 @@ class MyApp extends StatelessWidget { ), ChangeNotifierProxyProvider( create: (context) => NutritionPlansProvider( - WgerBaseProvider(Provider.of(context, listen: false)), + context, WgerBaseProvider(Provider.of(context, listen: false)), [], ), update: (context, auth, previous) => - previous ?? NutritionPlansProvider(WgerBaseProvider(auth), []), + previous ?? NutritionPlansProvider(context, WgerBaseProvider(auth), []), ), ChangeNotifierProxyProvider( create: (context) => MeasurementProvider( @@ -155,7 +171,7 @@ class MyApp extends StatelessWidget { HomeTabsScreen.routeName: (ctx) => HomeTabsScreen(), MeasurementCategoriesScreen.routeName: (ctx) => MeasurementCategoriesScreen(), MeasurementEntriesScreen.routeName: (ctx) => MeasurementEntriesScreen(), - NutritionalPlansScreen.routeName: (ctx) => NutritionalPlansScreen(), + NutritionScreen.routeName: (ctx) => NutritionScreen(), NutritionalDiaryScreen.routeName: (ctx) => NutritionalDiaryScreen(), NutritionalPlanScreen.routeName: (ctx) => NutritionalPlanScreen(), WeightScreen.routeName: (ctx) => WeightScreen(), diff --git a/lib/providers/nutrition.dart b/lib/providers/nutrition.dart index fc8ed2373..61b60e66a 100644 --- a/lib/providers/nutrition.dart +++ b/lib/providers/nutrition.dart @@ -19,7 +19,9 @@ import 'dart:convert'; import 'dart:developer'; +import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:wger/exceptions/http_exception.dart'; import 'package:wger/exceptions/no_such_entry_exception.dart'; @@ -32,6 +34,10 @@ import 'package:wger/models/nutrition/meal.dart'; import 'package:wger/models/nutrition/meal_item.dart'; import 'package:wger/models/nutrition/nutritional_plan.dart'; import 'package:wger/providers/base_provider.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:timezone/data/latest.dart' as tz; +import 'package:timezone/timezone.dart' as tz; +import 'package:wger/screens/nutritional_plans_screen.dart'; class NutritionPlansProvider with ChangeNotifier { static const _nutritionalPlansPath = 'nutritionplan'; @@ -44,10 +50,33 @@ class NutritionPlansProvider with ChangeNotifier { static const _ingredientImagePath = 'ingredient-image'; final WgerBaseProvider baseProvider; + late BuildContext context; List _plans = []; List _ingredients = []; - NutritionPlansProvider(this.baseProvider, List entries) : _plans = entries; + FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = + FlutterLocalNotificationsPlugin(); + + NutritionPlansProvider(this.context, this.baseProvider, + List entries) + : _plans = entries { + // Initialize the local notifications plugin + var initializationSettingsAndroid = + AndroidInitializationSettings('@mipmap/ic_launcher'); + var initializationSettings = InitializationSettings( + android: initializationSettingsAndroid); + flutterLocalNotificationsPlugin.initialize(initializationSettings); + + // Initialize time zone + tz.initializeTimeZones(); + + // Initialize notification system + _initializeNotifications(); + } + + void navigateToNutritionalPlanScreen() { + Navigator.of(context).pushNamed(NutritionScreen.routeName); + } List get items { return [..._plans]; @@ -74,7 +103,7 @@ class NutritionPlansProvider with ChangeNotifier { NutritionalPlan findById(int id) { return _plans.firstWhere( - (plan) => plan.id == id, + (plan) => plan.id == id, orElse: () => throw NoSuchEntryException(), ); } @@ -93,7 +122,8 @@ class NutritionPlansProvider with ChangeNotifier { /// object itself and no child attributes Future fetchAndSetAllPlansSparse() async { final data = await baseProvider - .fetchPaginated(baseProvider.makeUrl(_nutritionalPlansPath, query: {'limit': '1000'})); + .fetchPaginated( + baseProvider.makeUrl(_nutritionalPlansPath, query: {'limit': '1000'})); _plans = []; for (final planData in data) { final plan = NutritionalPlan.fromJson(planData); @@ -105,8 +135,11 @@ class NutritionPlansProvider with ChangeNotifier { /// Fetches and sets all plans fully, i.e. with all corresponding child objects Future fetchAndSetAllPlansFull() async { - final data = await baseProvider.fetchPaginated(baseProvider.makeUrl(_nutritionalPlansPath)); - await Future.wait(data.map((e) => fetchAndSetPlanFull(e['id'])).toList()); + final data = await baseProvider.fetchPaginated( + baseProvider.makeUrl(_nutritionalPlansPath)); + for (final entry in data) { + await fetchAndSetPlanFull(entry['id']); + } } /// Fetches and sets the given nutritional plan @@ -151,7 +184,7 @@ class NutritionPlansProvider with ChangeNotifier { final image = IngredientImage.fromJson(mealItemData['image']); ingredient.image = image; } - mealItem.ingredient = ingredient; + mealItem.ingredientObj = ingredient; mealItems.add(mealItem); } meal.mealItems = mealItems; @@ -161,9 +194,6 @@ class NutritionPlansProvider with ChangeNotifier { // Logs await fetchAndSetLogs(plan); - for (final meal in meals) { - meal.diaryEntries = plan.diaryEntries.where((e) => e.mealId == meal.id).toList(); - } // ... and done notifyListeners(); @@ -196,7 +226,8 @@ class NutritionPlansProvider with ChangeNotifier { _plans.removeAt(existingPlanIndex); notifyListeners(); - final response = await baseProvider.deleteRequest(_nutritionalPlansPath, id); + final response = await baseProvider.deleteRequest( + _nutritionalPlansPath, id); if (response.statusCode >= 400) { _plans.insert(existingPlanIndex, existingPlan); @@ -208,6 +239,7 @@ class NutritionPlansProvider with ChangeNotifier { /// Adds a meal to a plan Future addMeal(Meal meal, int planId) async { + print("Adding meal..."); final plan = findById(planId); final data = await baseProvider.post( meal.toJson(), @@ -218,6 +250,9 @@ class NutritionPlansProvider with ChangeNotifier { plan.meals.add(meal); notifyListeners(); + // Schedule meal's notification + _initializeNotifications(); + return meal; } @@ -253,10 +288,11 @@ class NutritionPlansProvider with ChangeNotifier { /// Adds a meal item to a meal Future addMealItem(MealItem mealItem, Meal meal) async { - final data = await baseProvider.post(mealItem.toJson(), baseProvider.makeUrl(_mealItemPath)); + final data = await baseProvider.post( + mealItem.toJson(), baseProvider.makeUrl(_mealItemPath)); mealItem = MealItem.fromJson(data); - mealItem.ingredient = await fetchIngredient(mealItem.ingredientId); + mealItem.ingredientObj = await fetchIngredient(mealItem.ingredientId); meal.mealItems.add(mealItem); notifyListeners(); @@ -273,7 +309,8 @@ class NutritionPlansProvider with ChangeNotifier { notifyListeners(); // Try to delete - final response = await baseProvider.deleteRequest(_mealItemPath, mealItem.id!); + final response = await baseProvider.deleteRequest( + _mealItemPath, mealItem.id!); if (response.statusCode >= 400) { meal.mealItems.insert(mealItemIndex, existingMealItem); notifyListeners(); @@ -316,8 +353,10 @@ class NutritionPlansProvider with ChangeNotifier { if (prefs.containsKey(PREFS_INGREDIENTS)) { final ingredientData = json.decode(prefs.getString(PREFS_INGREDIENTS)!); if (DateTime.parse(ingredientData['expiresIn']).isAfter(DateTime.now())) { - ingredientData['ingredients'].forEach((e) => _ingredients.add(Ingredient.fromJson(e))); - log("Read ${ingredientData['ingredients'].length} ingredients from cache. Valid till ${ingredientData['expiresIn']}"); + ingredientData['ingredients'].forEach((e) => + _ingredients.add(Ingredient.fromJson(e))); + log("Read ${ingredientData['ingredients'] + .length} ingredients from cache. Valid till ${ingredientData['expiresIn']}"); return; } } @@ -325,7 +364,9 @@ class NutritionPlansProvider with ChangeNotifier { // Initialise an empty cache final ingredientData = { 'date': DateTime.now().toIso8601String(), - 'expiresIn': DateTime.now().add(const Duration(days: DAYS_TO_CACHE)).toIso8601String(), + 'expiresIn': DateTime.now() + .add(const Duration(days: DAYS_TO_CACHE)) + .toIso8601String(), 'ingredients': [] }; prefs.setString(PREFS_INGREDIENTS, json.encode(ingredientData)); @@ -333,8 +374,7 @@ class NutritionPlansProvider with ChangeNotifier { } /// Searches for an ingredient - Future> searchIngredient( - String name, { + Future> searchIngredient(String name, { String languageCode = 'en', bool searchEnglish = false, }) async { @@ -350,11 +390,14 @@ class NutritionPlansProvider with ChangeNotifier { // Send the request final response = await baseProvider.fetch( baseProvider - .makeUrl(_ingredientSearchPath, query: {'term': name, 'language': languages.join(',')}), + .makeUrl(_ingredientSearchPath, + query: {'term': name, 'language': languages.join(',')}), ); // Process the response - return IngredientApiSearch.fromJson(response).suggestions; + return IngredientApiSearch + .fromJson(response) + .suggestions; } /// Searches for an ingredient with code @@ -386,20 +429,22 @@ class NutritionPlansProvider with ChangeNotifier { baseProvider.makeUrl(_nutritionDiaryPath), ); log.id = data['id']; - plan.diaryEntries.add(log); + plan.logs.add(log); } notifyListeners(); } /// Log custom ingredient to nutrition diary - Future logIngredientToDiary(MealItem mealItem, int planId, [DateTime? dateTime]) async { + Future logIngredientToDiary(MealItem mealItem, int planId, + [DateTime? dateTime]) async { final plan = findById(planId); - mealItem.ingredient = await fetchIngredient(mealItem.ingredientId); + mealItem.ingredientObj = await fetchIngredient(mealItem.ingredientId); final Log log = Log.fromMealItem(mealItem, plan.id!, null, dateTime); - final data = await baseProvider.post(log.toJson(), baseProvider.makeUrl(_nutritionDiaryPath)); + final data = await baseProvider.post( + log.toJson(), baseProvider.makeUrl(_nutritionDiaryPath)); log.id = data['id']; - plan.diaryEntries.add(log); + plan.logs.add(log); notifyListeners(); } @@ -408,7 +453,7 @@ class NutritionPlansProvider with ChangeNotifier { await baseProvider.deleteRequest(_nutritionDiaryPath, logId); final plan = findById(planId); - plan.diaryEntries.removeWhere((element) => element.id == logId); + plan.logs.removeWhere((element) => element.id == logId); notifyListeners(); } @@ -417,17 +462,86 @@ class NutritionPlansProvider with ChangeNotifier { final data = await baseProvider.fetchPaginated( baseProvider.makeUrl( _nutritionDiaryPath, - query: {'plan': plan.id.toString(), 'limit': '999', 'ordering': 'datetime'}, + query: { + 'plan': plan.id.toString(), + 'limit': '999', + 'ordering': 'datetime' + }, ), ); - plan.diaryEntries = []; + plan.logs = []; for (final logData in data) { final log = Log.fromJson(logData); final ingredient = await fetchIngredient(log.ingredientId); - log.ingredient = ingredient; - plan.diaryEntries.add(log); + log.ingredientObj = ingredient; + plan.logs.add(log); } notifyListeners(); } + + /// NEW: Notification Manager for meals + /// Schedule notifications for all meals in the plans + Future _initializeNotifications() async { + var androidPlatformChannelSpecifics = + AndroidInitializationSettings('@mipmap/ic_launcher'); + var initializationSettings = + InitializationSettings(android: androidPlatformChannelSpecifics); + await flutterLocalNotificationsPlugin.initialize(initializationSettings); + _scheduleNotifications(); + } + + Future _scheduleNotifications() async { + await flutterLocalNotificationsPlugin.cancelAll(); + await _scheduleSingleNotification(); + } + + Future _scheduleSingleNotification() async { + var androidPlatformChannelSpecifics = AndroidNotificationDetails( + 'wger_channel', + 'wger_channel', + 'Channel for wger notifications', + importance: Importance.max, + priority: Priority.high, + ticker: 'ticker', + ); + + var platformChannelSpecifics = NotificationDetails( + android: androidPlatformChannelSpecifics, + ); + + // Convert DateTime to TZDateTime + var scheduledDate = tz.TZDateTime.now(tz.local).add(Duration(seconds: 5)); + + // Create a mutable PendingIntent for Android S+ + final androidNotification = AndroidNotificationDetails( + 'wger_channel', + 'wger_channel', + 'Channel for wger notifications', + importance: Importance.max, + priority: Priority.high, + ticker: 'ticker', + ); + final notificationDetails = NotificationDetails( + android: androidNotification); + final androidPlugin = FlutterLocalNotificationsPlugin(); + await androidPlugin.zonedSchedule( + 0, + 'Nutrition Reminder', + 'It\'s time for your meal!', + scheduledDate, + notificationDetails, + androidAllowWhileIdle: true, + uiLocalNotificationDateInterpretation: + UILocalNotificationDateInterpretation.absoluteTime, + payload: 'meal', // Use payload to identify the notification type + ); + print('notification scheduled!!!'); + } } + + // Schedule notifications // TO DO + // Look at Nutrition Provider: reads out current meal and has access to individual meals and their time + // Present notification that it's time for a meal and provide first 3 ingredients + // Present option to open meal or save to diary + // Need to create a user preference whether to turn off notifications (use logMealToDiary method) diff --git a/pubspec.yaml b/pubspec.yaml index 7425a7e86..2b83919c2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -29,6 +29,8 @@ environment: dependencies: flutter: sdk: flutter + flutter_local_notifications: ^5.0.0+4 + permission_handler: ^10.3.0 flutter_localizations: sdk: flutter @@ -41,30 +43,30 @@ dependencies: flutter_typeahead: ^5.2.0 font_awesome_flutter: ^10.7.0 http: ^1.2.0 - image_picker: ^1.0.8 + image_picker: ^1.0.6 intl: ^0.18.1 json_annotation: ^4.8.1 version: ^3.0.2 - package_info_plus: ^7.0.0 + package_info_plus: ^6.0.0 provider: ^6.1.2 rive: ^0.13.1 - shared_preferences: ^2.2.3 + shared_preferences: ^2.2.2 table_calendar: ^3.0.8 url_launcher: ^6.2.5 flutter_barcode_scanner: ^2.0.0 - video_player: ^2.8.6 + video_player: ^2.8.3 flutter_staggered_grid_view: ^0.7.0 carousel_slider: ^4.2.1 multi_select_flutter: ^4.1.3 flutter_svg: ^2.0.10+1 fl_chart: ^0.66.2 flutter_zxing: ^1.5.2 - drift: ^2.16.0 + drift: ^2.15.0 path: ^1.8.3 path_provider: ^2.1.1 sqlite3_flutter_libs: ^0.5.20 - get_it: ^7.6.8 - flex_seed_scheme: ^1.5.0 + get_it: ^7.6.7 + flex_seed_scheme: ^1.4.0 flex_color_scheme: ^7.3.1 freezed_annotation: ^2.4.1 clock: ^1.1.1 @@ -74,14 +76,14 @@ dev_dependencies: sdk: flutter integration_test: sdk: flutter - build_runner: ^2.4.9 + build_runner: ^2.4.8 json_serializable: ^6.7.1 mockito: ^5.4.4 network_image_mock: ^2.1.1 - flutter_lints: ^3.0.2 + flutter_lints: ^3.0.1 cider: ^0.2.7 drift_dev: ^2.15.0 - freezed: ^2.5.1 + freezed: ^2.4.7 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec