From a11a7a6a84b705267f9d6798af2c37aa45bd53b4 Mon Sep 17 00:00:00 2001
From: garrismi <garrismi@umich.edu>
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 @@
 
     <uses-permission android:name="android.permission.WAKE_LOCK" />
     <uses-permission android:name="android.permission.INTERNET" />
+    <uses-permission android:name="android.permission.VIBRATE"/>
+    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
+    <uses-permission android:name="android.permission.RECEIVE_NOTIFICATIONS"/>
+    <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
 
     <queries>
         <intent>
@@ -62,5 +66,8 @@
         <meta-data
             android:name="flutterEmbedding"
             android:value="2" />
+
+        <receiver android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver" android:exported="false" />
+
     </application>
 </manifest>
\ 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<void> _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<AuthProvider, NutritionPlansProvider>(
           create: (context) => NutritionPlansProvider(
-            WgerBaseProvider(Provider.of<AuthProvider>(context, listen: false)),
+            context, WgerBaseProvider(Provider.of<AuthProvider>(context, listen: false)),
             [],
           ),
           update: (context, auth, previous) =>
-              previous ?? NutritionPlansProvider(WgerBaseProvider(auth), []),
+              previous ?? NutritionPlansProvider(context, WgerBaseProvider(auth), []),
         ),
         ChangeNotifierProxyProvider<AuthProvider, MeasurementProvider>(
           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<NutritionalPlan> _plans = [];
   List<Ingredient> _ingredients = [];
 
-  NutritionPlansProvider(this.baseProvider, List<NutritionalPlan> entries) : _plans = entries;
+  FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
+  FlutterLocalNotificationsPlugin();
+
+  NutritionPlansProvider(this.context, this.baseProvider,
+      List<NutritionalPlan> 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<NutritionalPlan> 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<void> 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<void> 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<Meal> 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<MealItem> 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<List<IngredientApiSearchEntry>> searchIngredient(
-    String name, {
+  Future<List<IngredientApiSearchEntry>> 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<void> logIngredientToDiary(MealItem mealItem, int planId, [DateTime? dateTime]) async {
+  Future<void> 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<void> _initializeNotifications() async {
+    var androidPlatformChannelSpecifics =
+    AndroidInitializationSettings('@mipmap/ic_launcher');
+    var initializationSettings =
+    InitializationSettings(android: androidPlatformChannelSpecifics);
+    await flutterLocalNotificationsPlugin.initialize(initializationSettings);
+    _scheduleNotifications();
+  }
+
+  Future<void> _scheduleNotifications() async {
+    await flutterLocalNotificationsPlugin.cancelAll();
+    await _scheduleSingleNotification();
+  }
+
+  Future<void> _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