diff --git a/Makefile b/Makefile index e444986..76f4a72 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: test, seed, assets +.PHONY: test seed assets clean: flutter clean && flutter pub get diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index f4a79e3..a85c6c5 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -4,7 +4,7 @@ to allow setting breakpoints, to provide hot reload, etc. --> - + - + + + @@ -84,7 +86,7 @@ - + diff --git a/lib/app/models/user.dart b/lib/app/models/user.dart index a84897a..006c834 100644 --- a/lib/app/models/user.dart +++ b/lib/app/models/user.dart @@ -1,21 +1,97 @@ //User Model -class UserModel { +import 'package:cloud_firestore/cloud_firestore.dart'; + +class Zone2User { final String uid; final String email; String name; - Map fcmTokenMap; - - UserModel( - {required this.uid, required this.email, required this.name, required this.fcmTokenMap}); + final bool onboardingComplete; + ZoneSettings? zoneSettings; + Zone2User( + {required this.uid, + required this.email, + required this.name, + required this.onboardingComplete, + this.zoneSettings}); - factory UserModel.fromJson(Map data) { - return UserModel( + factory Zone2User.fromJson(Map data) { + return Zone2User( uid: data['uid'], email: data['email'] ?? '', name: data['name'] ?? '', - fcmTokenMap: data['fcmTokenMap'] ?? {}); + onboardingComplete: data['onboardingComplete'] ?? false, + zoneSettings: ZoneSettings.fromJson(data['zoneSettings'] ?? {})); + } + + Map toJson() => { + "uid": uid, + "email": email, + "name": name, + "onboardingComplete": onboardingComplete, + "zoneSettings": zoneSettings?.toJson() ?? {} + }; +} + +class ZoneSettings { + final Timestamp journeyStartDate; + final int dailyWaterGoalInOz; + final int dailyZonePointsGoal; + final int dailyCalorieIntakeGoal; + final int dailyCaloriesBurnedGoal; + final int dailyStepsGoal; + final String reasonForStartingJourney; + final double initialWeightInLbs; + final double targetWeightInLbs; + final double heightInInches; + final int heightInFeet; + final String birthDate; + final String gender; + + ZoneSettings( + {required this.journeyStartDate, + required this.dailyWaterGoalInOz, + required this.dailyZonePointsGoal, + required this.dailyCalorieIntakeGoal, + required this.dailyCaloriesBurnedGoal, + required this.dailyStepsGoal, + required this.reasonForStartingJourney, + required this.initialWeightInLbs, + required this.targetWeightInLbs, + required this.heightInInches, + required this.heightInFeet, + required this.birthDate, + required this.gender}); + + factory ZoneSettings.fromJson(Map data) { + return ZoneSettings( + journeyStartDate: data['journeyStartDate'] as Timestamp? ?? Timestamp.now(), + dailyWaterGoalInOz: (data['dailyWaterGoalInOz'] as num?)?.toInt() ?? 100, + dailyZonePointsGoal: (data['dailyZonePointsGoal'] as num?)?.toInt() ?? 100, + dailyCalorieIntakeGoal: (data['dailyCalorieIntakeGoal'] as num?)?.toInt() ?? 0, + dailyCaloriesBurnedGoal: (data['dailyCaloriesBurnedGoal'] as num?)?.toInt() ?? 0, + dailyStepsGoal: (data['dailyStepsGoal'] as num?)?.toInt() ?? 10000, + reasonForStartingJourney: data['reasonForStartingJourney'] as String? ?? '', + initialWeightInLbs: (data['initialWeightInLbs'] as num?)?.toDouble() ?? 0.0, + targetWeightInLbs: (data['targetWeightInLbs'] as num?)?.toDouble() ?? 0.0, + heightInInches: (data['heightInInches'] as num?)?.toDouble() ?? 0.0, + heightInFeet: (data['heightInFeet'] as num?)?.toInt() ?? 0, + birthDate: data['birthDate'] as String? ?? '', + gender: data['gender'] as String? ?? ''); } - Map toJson() => - {"uid": uid, "email": email, "name": name, "fcmTokenMap": fcmTokenMap}; + Map toJson() => { + "journeyStartDate": journeyStartDate, + "dailyWaterGoalInOz": dailyWaterGoalInOz, + "dailyZonePointsGoal": dailyZonePointsGoal, + "dailyCalorieIntakeGoal": dailyCalorieIntakeGoal, + "dailyCaloriesBurnedGoal": dailyCaloriesBurnedGoal, + "dailyStepsGoal": dailyStepsGoal, + "reasonForStartingJourney": reasonForStartingJourney, + "initialWeightInLbs": initialWeightInLbs, + "targetWeightInLbs": targetWeightInLbs, + "heightInInches": heightInInches, + "heightInFeet": heightInFeet, + "birthDate": birthDate, + "gender": gender + }; } diff --git a/lib/app/modules/diary/controllers/diary_controller.dart b/lib/app/modules/diary/controllers/diary_controller.dart index 5511613..9c37127 100644 --- a/lib/app/modules/diary/controllers/diary_controller.dart +++ b/lib/app/modules/diary/controllers/diary_controller.dart @@ -9,6 +9,7 @@ import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:speech_to_text/speech_recognition_error.dart'; import 'package:speech_to_text/speech_recognition_result.dart'; import 'package:speech_to_text/speech_to_text.dart'; +import 'package:syncfusion_flutter_charts/charts.dart'; import 'package:zone2/app/modules/diary/controllers/activity_manager.dart'; import 'package:zone2/app/models/food.dart'; import 'package:zone2/app/services/food_service.dart'; @@ -17,6 +18,64 @@ import 'package:intl/intl.dart'; // Added for date formatting import 'package:zone2/app/services/notification_service.dart'; import 'package:zone2/app/services/openai_service.dart'; +class FoodVoiceResult { + final String label; + final String searchTerm; + final double quantity; + final String unit; + final MealType mealType; + + FoodVoiceResult({ + required this.label, + required this.searchTerm, + required this.quantity, + required this.unit, + required this.mealType, + }); + + // Factory method to create a FoodVoiceResult from JSON + factory FoodVoiceResult.fromJson(Map json) { + return FoodVoiceResult( + label: json['label'], + searchTerm: json['searchTerm'], + quantity: json['quantity'].toDouble(), + unit: json['unit'], + mealType: _parseMealType(json['mealType']), + ); + } + + // Factory method to create a list of FoodVoiceResult from OpenAI completion + static List fromOpenAiCompletion(List items) { + return items.map((item) { + // Handle nulls for food items + final food = item['food']; + return FoodVoiceResult( + label: food?['label'] as String? ?? 'Unknown Food', // Default value for label + searchTerm: food?['searchTerm'] as String? ?? '', // Default to empty string + quantity: (food?['quantity'] as double?) ?? 0.0, // Default to 0.0 + unit: food?['unit'] as String? ?? 'units', // Default unit + mealType: _parseMealType(food?['mealType'] as String? ?? 'UNKNOWN'), // Default to UNKNOWN + ); + }).toList(); + } + + // Helper method to parse meal type from string + static MealType _parseMealType(String type) { + switch (type.toUpperCase()) { + case 'BREAKFAST': + return MealType.BREAKFAST; + case 'LUNCH': + return MealType.LUNCH; + case 'DINNER': + return MealType.DINNER; + case 'SNACK': + return MealType.SNACK; + default: + return MealType.UNKNOWN; + } + } +} + class DiaryController extends GetxController { final logger = Get.find(); final healthService = Get.find(); @@ -68,6 +127,12 @@ class DiaryController extends GetxController { final lastError = Rxn(); final recognizedWords = ''.obs; final systemLocale = Rxn(); + final isTestMode = false.obs; // Toggle this for testing + + final voiceResults = RxList(); + + String selectedVoiceFood = ''; + RxList searchResults = RxList(); // Barcode scanning final Rxn barcode = Rxn(); @@ -75,6 +140,8 @@ class DiaryController extends GetxController { late MobileScannerController scannerController; StreamSubscription? scannerSubscription; + ChartSeriesController? chartController; + @override void onInit() async { super.onInit(); @@ -113,11 +180,12 @@ class DiaryController extends GetxController { if (!isAvailable.value || isListening.value) return; try { - isListening.value = await speech.listen( + await speech.listen( onResult: _onSpeechResult, listenOptions: SpeechListenOptions(partialResults: true), localeId: currentLocaleId.value, ); + isListening.value = true; } catch (e) { logger.e('Error starting speech recognition: $e'); isListening.value = false; @@ -200,6 +268,7 @@ class DiaryController extends GetxController { } } + // TODO: Each Health Data Type should be its own method, this should aggregate them all Future getHealthDataForSelectedDay() async { // Retrieve weight data final sameDay = diaryDate.value.year == DateTime.now().year && @@ -411,12 +480,97 @@ class DiaryController extends GetxController { } Future extractFoodItemsOpenAI(String text) async { - final result = await OpenAIService.to.extractFoodsFromText(text); - matchedFoods.value = result.choices.first.message.content - ?.map((item) => item.text) - .whereType() - .toList() ?? - []; - logger.i('Extracted food items: $result'); + try { + isProcessing.value = true; + + if (isTestMode.value) { + await Future.delayed(const Duration(seconds: 1)); + voiceResults.value = [ + FoodVoiceResult( + label: "2 scrambled eggs with spinach", + searchTerm: "eggs", + quantity: 2, + unit: "large", + mealType: MealType.BREAKFAST, + ), + FoodVoiceResult( + label: "1 slice whole grain toast with avocado", + searchTerm: "whole grain bread", + quantity: 1, + unit: "slice", + mealType: MealType.BREAKFAST, + ), + // ... other test items + ]; + matchedFoods.value = voiceResults.map((r) => r.label).toList(); + } else { + final openAIChatCompletion = await OpenAIService.to.extractFoodsFromText(text); + final newItems = FoodVoiceResult.fromOpenAiCompletion(openAIChatCompletion['foods']['items'] as List); + voiceResults.value = newItems; + + matchedFoods.value = voiceResults.map((r) => r.label).toList(); + } + } catch (e) { + logger.e('Error extracting foods: $e'); + NotificationService.to + .showError('Error', 'Failed to process speech input. Please try again.'); + voiceResults.clear(); + matchedFoods.clear(); + } finally { + isProcessing.value = false; + } + } + + MealType _parseMealType(String type) { + switch (type.toUpperCase()) { + case 'BREAKFAST': + return MealType.BREAKFAST; + case 'LUNCH': + return MealType.LUNCH; + case 'DINNER': + return MealType.DINNER; + case 'SNACK': + return MealType.SNACK; + default: + return MealType.UNKNOWN; + } + } + + void selectFoodFromVoice(String foodDescription) async { + try { + // Store the selected food description + selectedVoiceFood = foodDescription; + + // Reset search results before new search + foodSearchResults.value = null; + + await EasyLoading.show( + status: 'Searching for food...', + maskType: EasyLoadingMaskType.black, + ); + + // Perform the search + final results = await foodService.searchFood(foodDescription); + foodSearchResults.value = results; + + if (results.foods.isNotEmpty) { + // Navigate to search results view + Get.snackbar('Got results', 'Found ${results.foods.length} results'); + } else { + Get.snackbar( + 'No Results', + 'No foods found matching "$foodDescription"', + snackPosition: SnackPosition.TOP, + ); + } + } catch (error) { + Get.snackbar( + 'Error', + 'Failed to search for food: ${error.toString()}', + snackPosition: SnackPosition.TOP, + ); + } finally { + await EasyLoading.dismiss(); + } } } diff --git a/lib/app/modules/diary/views/activity/widgets/steps.dart b/lib/app/modules/diary/views/activity/widgets/steps.dart index 3717f67..24fca97 100644 --- a/lib/app/modules/diary/views/activity/widgets/steps.dart +++ b/lib/app/modules/diary/views/activity/widgets/steps.dart @@ -12,6 +12,30 @@ class StepsChart extends GetView { Widget build(BuildContext context) { return Obx( () => SfCartesianChart( + zoomPanBehavior: ZoomPanBehavior( + enablePanning: true, + enablePinching: true, + ), + trackballBehavior: TrackballBehavior( + enable: true, + activationMode: ActivationMode.singleTap, + tooltipDisplayMode: TrackballDisplayMode.floatAllPoints, + shouldAlwaysShow: true, + tooltipSettings: InteractiveTooltip(enable: true, color: Colors.red), + builder: (BuildContext context, TrackballDetails trackballDetails) { + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '${trackballDetails.point!.y?.toInt()} steps', + style: const TextStyle(color: Colors.white), + ), + ); + }, + ), title: const ChartTitle(text: 'Total Steps'), primaryXAxis: DateTimeAxis( intervalType: DateTimeIntervalType.hours, @@ -50,7 +74,24 @@ class StepsChart extends GetView { ), ), ], - tooltipBehavior: TooltipBehavior(enable: true), + // tooltipBehavior: TooltipBehavior( + // enable: true, + // activationMode: ActivationMode.longPress, + // shouldAlwaysShow: true, + // builder: (dynamic data, dynamic point, dynamic series, int pointIndex, int seriesIndex) { + // return Container( + // padding: const EdgeInsets.all(8), + // decoration: BoxDecoration( + // color: Colors.black87, + // borderRadius: BorderRadius.circular(4), + // ), + // child: Text( + // '${data.numericValue} steps', + // style: const TextStyle(color: Colors.white), + // ), + // ); + // }, + // ), ), ); } diff --git a/lib/app/modules/diary/views/food/ai_food.dart b/lib/app/modules/diary/views/food/ai_food.dart index cbec21a..eb3e1b8 100644 --- a/lib/app/modules/diary/views/food/ai_food.dart +++ b/lib/app/modules/diary/views/food/ai_food.dart @@ -9,51 +9,432 @@ class AISearchBottomSheet extends GetView { @override Widget build(BuildContext context) { return Scaffold( - floatingActionButton: FloatingActionButton( - onPressed: - controller.isListening.value ? controller.stopListening : controller.startListening, - child: Icon(controller.isListening.value ? Icons.mic_off : Icons.mic), + body: SafeArea( + child: Container( + height: MediaQuery.of(context).size.height, + padding: const EdgeInsets.all(16.0), + child: Obx( + () => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Close button + Align( + alignment: Alignment.topLeft, + child: IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), // Close the bottom sheet + ), + ), + // Top section with suggestions + if (!controller.isListening.value && controller.matchedFoods.isEmpty) + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Try saying something like:', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + _buildQuickSuggestion( + context, + title: 'Quick Breakfast', + phrase: 'I had two eggs and toast for breakfast', + onTap: () { + controller + .extractFoodItemsOpenAI('I had two eggs and toast for breakfast'); + }, + expectedResults: [ + '2 eggs (scrambled)', + '1 slice toast', + ], + ), + _buildQuickSuggestion( + context, + title: 'Simple Lunch', + phrase: 'Chicken salad with avocado', + onTap: () { + controller.extractFoodItemsOpenAI('Chicken salad with avocado'); + }, + expectedResults: [ + '1 grilled chicken breast', + '2 cups mixed greens', + '1/2 avocado', + ], + ), + _buildQuickSuggestion( + context, + title: 'Easy Dinner', + phrase: 'Salmon with rice and vegetables', + onTap: () { + controller.extractFoodItemsOpenAI('Salmon with rice and vegetables'); + }, + expectedResults: [ + '6 oz salmon (baked)', + '1 cup brown rice', + '1 cup mixed vegetables', + ], + ), + _buildQuickSuggestion( + context, + title: 'Healthy Snack', + phrase: 'Apple and almonds', + onTap: () { + controller.extractFoodItemsOpenAI('Apple and almonds'); + }, + expectedResults: [ + '1 medium apple', + '1 oz almonds (about 23)', + ], + ), + ], + ), + ), + ), + + // Results section (when foods are matched) + if (controller.voiceResults.isNotEmpty) + Expanded( + child: Container( + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Found Items', + style: Theme.of(context).textTheme.titleLarge, + ), + ), + // if (controller.recognizedWords.value.isNotEmpty) + // Padding( + // padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + // child: Text( + // '"${controller.recognizedWords.value}"', + // style: TextStyle( + // color: Colors.grey[600], + // fontStyle: FontStyle.italic, + // ), + // ), + // ), + Expanded( + child: ListView.builder( + itemCount: controller.voiceResults.length, + itemBuilder: (context, index) { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: ListTile( + leading: const Icon(Icons.check_circle), + title: Text(controller.voiceResults[index].label), + trailing: IconButton( + icon: const Icon(Icons.add_circle_outline), + onPressed: () { + controller.selectFoodFromVoice( + controller.voiceResults[index].searchTerm); + }, + ), + ), + ); + }, + ), + ), + ], + ), + ), + ), + + // Voice input button always at bottom + Container( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Column( + children: [ + GestureDetector( + onTapDown: (_) => controller.startListening(), + onTapUp: (_) => controller.stopListening(), + onTapCancel: () => controller.cancelListening(), + child: Stack( + alignment: Alignment.center, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 300), + width: controller.isListening.value ? 100 : 80, + height: controller.isListening.value ? 100 : 80, + decoration: BoxDecoration( + color: controller.isListening.value + ? Colors.green + : Theme.of(context).primaryColor, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + spreadRadius: 2, + ), + ], + ), + child: Icon( + Icons.mic, + size: controller.isListening.value ? 50 : 40, + color: Colors.white, + ), + ), + if (controller.isProcessing.value) + const CircularProgressIndicator( + color: Colors.white, + ), + ], + ), + ), + const SizedBox(height: 16), + Text( + controller.isListening.value ? 'Listening...' : 'Hold to speak', + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + ), + ], + ), + ), ), - body: SafeArea( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: Column( + ), + ); + } + + Widget _buildSuggestionCard(BuildContext context, + {required String title, + required String example, + required VoidCallback onTap, + List? results}) { + return Card( + elevation: 2, + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( children: [ - Text( - controller.isListening.value ? 'Listening...' : 'Tap the microphone to start', - style: const TextStyle(fontSize: 16), + Icon( + _getIconForMeal(title), + color: Theme.of(context).primaryColor, ), - const SizedBox(height: 20), + const SizedBox(width: 8), Text( - controller.recognizedWords.value, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + title, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), ), - const SizedBox(height: 20), ], ), - ), - if (controller.isProcessing.value) - const CircularProgressIndicator() - else - Obx( - () => Expanded( - child: ListView.builder( - itemCount: controller.matchedFoods.length, - itemBuilder: (context, index) { - final foodMatch = controller.matchedFoods[index]; - return ListTile( - title: Text(foodMatch), - onTap: () { - // Handle selection of specific food item - }, - ); - }, + const SizedBox(height: 8), + Text( + 'Try saying:', + style: TextStyle( + color: Colors.grey[600], + fontSize: 12, + ), + ), + Text( + example, + style: const TextStyle( + fontSize: 14, + fontStyle: FontStyle.italic, + ), + ), + if (results != null) ...[ + const SizedBox(height: 8), + Text( + 'Will extract:', + style: TextStyle( + color: Colors.grey[600], + fontSize: 12, ), ), + ...results.map( + (item) => Padding( + padding: const EdgeInsets.only(top: 4), + child: Row( + children: [ + const Icon(Icons.check_circle_outline, size: 16), + const SizedBox(width: 8), + Text(item), + ], + ), + ), + ), + ], + ], + ), + ), + ), + ); + } + + IconData _getIconForMeal(String meal) { + switch (meal.toLowerCase()) { + case 'breakfast': + return Icons.breakfast_dining; + case 'lunch': + return Icons.lunch_dining; + case 'dinner': + return Icons.dinner_dining; + case 'snack': + return Icons.apple; + default: + return Icons.restaurant; + } + } + + Widget _buildErrorState(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + size: 48, + color: Colors.red, + ), + const SizedBox(height: 16), + Text( + 'Speech Recognition Failed', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + controller.lastError.value?.errorMsg ?? 'Unknown error', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.red, + ), + ), + const SizedBox(height: 24), + const Text( + 'Try these instead:', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + _buildSuggestionCard( + context, + title: 'Breakfast', + example: 'For breakfast I had two eggs with toast and coffee', + onTap: () => controller + .extractFoodItemsOpenAI('For breakfast I had two eggs with toast and coffee'), + results: [ + '2 eggs (scrambled)', + '1 slice toast', + '1 cup black coffee', + ], + ), + _buildSuggestionCard( + context, + title: 'Lunch', + example: 'I ate a grilled chicken salad with avocado for lunch', + onTap: () => controller.extractFoodItemsOpenAI( + 'I ate a grilled chicken salad with avocado for lunch'), + results: [ + '1 grilled chicken breast', + '2 cups mixed salad', + '1/2 avocado', + ], + ), + _buildSuggestionCard( + context, + title: 'Dinner', + example: 'My dinner was salmon with rice and broccoli', + onTap: () => controller + .extractFoodItemsOpenAI('My dinner was salmon with rice and broccoli'), + results: [ + '6 oz salmon (baked)', + '1 cup brown rice', + '1 cup steamed broccoli', + ], + ), + _buildSuggestionCard( + context, + title: 'Snack', + example: 'Had an apple with peanut butter as a snack', + onTap: () => controller + .extractFoodItemsOpenAI('Had an apple with peanut butter as a snack'), + results: [ + '1 medium apple', + '2 tbsp peanut butter', + ], + ), + ], + ), + ), + ), + ], + ); + } + + Widget _buildQuickSuggestion( + BuildContext context, { + required String title, + required String phrase, + required VoidCallback onTap, + required List expectedResults, + }) { + return Card( + margin: const EdgeInsets.only(bottom: 16), + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), ), - ], - ))); + const SizedBox(height: 8), + Text( + '"$phrase"', + style: const TextStyle( + fontStyle: FontStyle.italic, + color: Colors.blue, + ), + ), + const Divider(), + const Text( + 'Will extract:', + style: TextStyle( + fontSize: 12, + color: Colors.grey, + ), + ), + const SizedBox(height: 4), + ...expectedResults.map((result) => Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + const Icon(Icons.check_circle_outline, size: 16, color: Colors.green), + const SizedBox(width: 8), + Text(result), + ], + ), + )), + ], + ), + ), + ), + ); } } diff --git a/lib/app/modules/global_bindings.dart b/lib/app/modules/global_bindings.dart index 8004ed9..c505030 100644 --- a/lib/app/modules/global_bindings.dart +++ b/lib/app/modules/global_bindings.dart @@ -1,54 +1,63 @@ import 'package:dart_openai/dart_openai.dart'; +import 'package:zone2/app/models/user.dart'; import 'package:zone2/app/modules/loading_service.dart'; -import 'package:zone2/app/services/auth_service.dart'; import 'package:zone2/app/services/firebase_service.dart'; import 'package:zone2/app/services/food_service.dart'; import 'package:zone2/app/services/health_service.dart'; import 'package:zone2/app/services/notification_service.dart'; import 'package:zone2/app/services/openai_service.dart'; -import 'package:zone2/app/services/shared_preferences_service.dart'; +import 'package:zone2/app/services/theme_service.dart'; import 'package:zone2/app/style/palette.dart'; -import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_analytics/firebase_analytics.dart'; -import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/foundation.dart'; import 'package:get/get.dart'; -import 'package:google_sign_in/google_sign_in.dart'; -import 'package:logger/logger.dart'; import 'package:super_tooltip/super_tooltip.dart'; +import 'package:timezone/data/latest.dart' as tz; +import 'package:zone2/app/utils/env.dart'; class GlobalBindings extends Bindings { final Palette palette; - final Logger logger; - final SharedPreferencesService sharedPreferencesService; + final FirebaseService firebaseService; + final Zone2User? initialUser; - GlobalBindings( - {required this.palette, required this.logger, required this.sharedPreferencesService}); + GlobalBindings({required this.palette, required this.firebaseService, required this.initialUser}); @override dependencies() { //This should remain first as many things will log to Analytics FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(!kDebugMode); + // Set OpenAI API key and logging + // TODO: Move to server side + OpenAI.apiKey = Env.openaiApiKey; + OpenAI.showLogs = kDebugMode; + + // Initialize timezone data + tz.initializeTimeZones(); + + // Firebase Analytics Get.put(FirebaseAnalytics.instance, permanent: true); + // Notification service Get.lazyPut(() => NotificationService(), fenix: true); + + // Super tooltip controller Get.lazyPut(() => SuperTooltipController(), fenix: true); - Get.put(FirebaseAuth.instance); - Get.put(FirebaseFirestore.instance); - Get.put(GoogleSignIn()); + // Theme service + Get.put(ThemeService(), permanent: true); + // Food service Get.put(FoodService(), permanent: true); Get.put(HealthService(), permanent: true); // Get.put(FcmService(), permanent: true); - Get.put(AuthService(), permanent: true); + + // Firebase service + Get.put(firebaseService, permanent: true); Get.put(OpenAI.instance); Get.put(OpenAIService(), permanent: true); - Get.put(FirebaseService(), permanent: true); - Get.put(BusyIndicatorService(), permanent: true); // Palette needed by Settings to get theme information diff --git a/lib/app/modules/intro/controllers/intro_controller.dart b/lib/app/modules/intro/controllers/intro_controller.dart index 6b1a4b1..e88949b 100644 --- a/lib/app/modules/intro/controllers/intro_controller.dart +++ b/lib/app/modules/intro/controllers/intro_controller.dart @@ -1,6 +1,9 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import 'package:zone2/app/models/user.dart'; import 'package:zone2/app/routes/app_pages.dart'; +import 'package:zone2/app/services/firebase_service.dart'; import 'package:zone2/app/services/health_service.dart'; import 'package:zone2/app/services/shared_preferences_service.dart'; import 'package:get/get.dart'; @@ -24,6 +27,11 @@ class IntroController extends GetxController { final suggestedWeightLossUpperBound = 0.0.obs; final suggestedWeightLossTarget = ''.obs; final zone2TargetWeight = 0.0.obs; + final RxInt dailyWaterGoalInOz = 100.obs; + final RxInt dailyZonePointsGoal = 100.obs; + final RxInt dailyCalorieIntakeGoal = 1700.obs; + final RxInt dailyCaloriesBurnedGoal = 0.obs; + final RxInt dailyStepsGoal = 10000.obs; final suggestedWeightLossMessage = "We'll use your progress to predict when you'll hit your target as you follow your custom plan and adopting a healthy lifestyle. Your results cannot be guaranteed, but users typically lose 1-2 lb per week." @@ -120,6 +128,39 @@ class IntroController extends GetxController { return targetWeightController.text.isNotEmpty; } + Future setDailyWaterGoal(int goal) async { + dailyWaterGoalInOz.value = goal; + showNextButton.value = await haveAllGoals(); + } + + Future setDailyZonePointsGoal(int goal) async { + dailyZonePointsGoal.value = goal; + showNextButton.value = await haveAllGoals(); + } + + Future setDailyCalorieIntakeGoal(int goal) async { + dailyCalorieIntakeGoal.value = goal; + showNextButton.value = await haveAllGoals(); + } + + Future setDailyCaloriesBurnedGoal(int goal) async { + dailyCaloriesBurnedGoal.value = goal; + showNextButton.value = await haveAllGoals(); + } + + Future setDailyStepsGoal(int goal) async { + dailyStepsGoal.value = goal; + showNextButton.value = await haveAllGoals(); + } + + Future haveAllGoals() async { + return dailyWaterGoalInOz.value != 0 && + dailyZonePointsGoal.value != 0 && + dailyCalorieIntakeGoal.value != 0 && + dailyCaloriesBurnedGoal.value != 0 && + dailyStepsGoal.value != 0; + } + Future setReason(String reason) async { introLogger.i('setReason: $reason'); zone2Reason.value = reason; @@ -152,9 +193,12 @@ class IntroController extends GetxController { showNextButton.value = await haveGoals(); break; case 3: - showNextButton.value = await haveTheMotivatingFactor(); + showNextButton.value = await haveAllGoals(); break; case 4: + showNextButton.value = await haveTheMotivatingFactor(); + break; + case 5: showNextButton.value = false; await requestHealthPermissions(); break; @@ -164,12 +208,22 @@ class IntroController extends GetxController { //create a method called onFinish that saves a boolean called introFinished to sharedPreferences void onFinish() { _sharedPreferencesService.setIsIntroductionFinished(true); - _sharedPreferencesService.setZone2Goals( - double.parse(weightController.text), - double.parse(targetWeightController.text), - zone2Reason.value, - zone2Birthdate.value, - zone2Gender.value!); + introLogger.i('Introduction Finished'); + FirebaseService.to.updateUserZoneSettings(ZoneSettings( + journeyStartDate: Timestamp.now(), + dailyWaterGoalInOz: dailyWaterGoalInOz.value, + dailyZonePointsGoal: dailyZonePointsGoal.value, + dailyCalorieIntakeGoal: dailyCalorieIntakeGoal.value, + dailyCaloriesBurnedGoal: dailyCaloriesBurnedGoal.value, + dailyStepsGoal: dailyStepsGoal.value, + reasonForStartingJourney: zone2Reason.value, + initialWeightInLbs: double.parse(weightController.text), + targetWeightInLbs: double.parse(targetWeightController.text), + heightInInches: heightInches.value.toDouble(), + heightInFeet: heightFeet.value.toInt(), + birthDate: zone2Birthdate.value, + gender: zone2Gender.value!)); + introLogger.i('Introduction Finished'); Get.offNamed(Routes.home); } diff --git a/lib/app/modules/intro/controllers/redirect_middleware.dart b/lib/app/modules/intro/controllers/redirect_middleware.dart index e8b637a..b3b961a 100644 --- a/lib/app/modules/intro/controllers/redirect_middleware.dart +++ b/lib/app/modules/intro/controllers/redirect_middleware.dart @@ -1,21 +1,17 @@ import 'package:zone2/app/routes/app_pages.dart'; -import 'package:zone2/app/services/shared_preferences_service.dart'; +import 'package:zone2/app/services/auth_service.dart'; import 'package:firebase_auth/firebase_auth.dart'; -import 'package:firebase_remote_config/firebase_remote_config.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:logger/logger.dart'; class RedirectMiddleware extends GetMiddleware { - final remoteConfig = Get.find(); final logger = Get.find(); @override RouteSettings? redirect(String? route) { - final sharedPrefs = Get.find(); - if (FirebaseAuth.instance.currentUser != null) { - if (sharedPrefs.isIntroductionFinished) { + if (AuthService.to.appUser.value.onboardingComplete) { return const RouteSettings(name: Routes.home); } else { return const RouteSettings(name: Routes.intro); diff --git a/lib/app/modules/intro/views/intro_small.dart b/lib/app/modules/intro/views/intro_small.dart index b2a0c7b..dfd4cb2 100644 --- a/lib/app/modules/intro/views/intro_small.dart +++ b/lib/app/modules/intro/views/intro_small.dart @@ -288,6 +288,81 @@ class IntroSmall extends GetWidget { ), decoration: pageDecoration, ), + PageViewModel( + titleWidget: Padding( + padding: const EdgeInsets.only(top: 28.0), + child: Text( + "Additional Targets", + style: titleStyle, + ), + ), + bodyWidget: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + CommonAssets.images.undraw.undrawHealthyLifestyleReIfwg.svg( + width: 200, + height: 200, + ), + Text( + "These can be changed later from the settings menu", + style: bodyStyle, + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + TextField( + decoration: const InputDecoration( + labelText: 'Daily Water Goal (oz)', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + controller: TextEditingController(text: '100'), + onChanged: (value) => controller.setDailyWaterGoal(int.tryParse(value) ?? 100), + ), + const SizedBox(height: 20), + TextField( + decoration: const InputDecoration( + labelText: 'Daily Zone Points Goal', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + controller: TextEditingController(text: '100'), + onChanged: (value) => + controller.setDailyZonePointsGoal(int.tryParse(value) ?? 100), + ), + const SizedBox(height: 20), + TextField( + decoration: const InputDecoration( + labelText: 'Daily Calorie Intake Goal', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + onChanged: (value) => + controller.setDailyCalorieIntakeGoal(int.tryParse(value) ?? 0), + ), + const SizedBox(height: 20), + TextField( + decoration: const InputDecoration( + labelText: 'Daily Calories Burned Goal', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + onChanged: (value) => + controller.setDailyCaloriesBurnedGoal(int.tryParse(value) ?? 0), + ), + const SizedBox(height: 20), + TextField( + decoration: const InputDecoration( + labelText: 'Daily Steps Goal', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + controller: TextEditingController(text: '10000'), + onChanged: (value) => controller.setDailyStepsGoal(int.tryParse(value) ?? 10000), + ), + ], + ), + decoration: pageDecoration, + ), PageViewModel( titleWidget: const Padding( padding: EdgeInsets.only(top: 16.0), diff --git a/lib/app/services/auth_service.dart b/lib/app/services/auth_service.dart index 22a0b01..ed754ac 100644 --- a/lib/app/services/auth_service.dart +++ b/lib/app/services/auth_service.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:zone2/app/models/user.dart'; import 'package:zone2/app/services/notification_service.dart'; -import 'package:zone2/app/services/shared_preferences_service.dart'; import 'package:zone2/app/utils/routes.dart'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_auth/firebase_auth.dart'; @@ -21,23 +20,35 @@ class AuthService { final GoogleSignIn googleSignIn = Get.find(); Rxn firebaseUser = Rxn(); // final fcmService = Get.find(); - final sharedPrefs = Get.find(); + final StreamController userStreamController = StreamController.broadcast(); + + final appUser = Zone2User( + uid: '', + email: '', + name: '', + onboardingComplete: false, + zoneSettings: ZoneSettings.fromJson({})).obs; - final appUser = UserModel(uid: '', email: '', name: '', fcmTokenMap: {}).obs; final isAuthenticatedUser = false.obs; - AuthService() { + AuthService(Zone2User? z2User) { + // If the user is provided from main.dart, set the appUser + if (z2User != null) { + appUser.value = z2User; + } + + // Bind the firebaseUser to the user stream ever(firebaseUser, handleUserChanged); firebaseUser.bindStream(user); - // auth.authStateChanges().listen(handleUserChanged); } handleUserChanged(User? updatedUser) async { - //get user data from firestore logger.i('handleUserChanged: $updatedUser'); + if (updatedUser?.uid != null) { try { appUser.value = await getUser(); + listenForUserChanges(); } catch (e) { FirebaseCrashlytics.instance.recordError(e, StackTrace.current); if (e is FirebaseException) { @@ -45,6 +56,13 @@ class AuthService { .showError('Authentication Error', 'Error connecting to application backend'); } } + } else { + appUser.value = Zone2User( + uid: '', + email: '', + name: '', + onboardingComplete: false, + zoneSettings: ZoneSettings.fromJson({})); } isAuthenticatedUser.value = updatedUser != null && updatedUser.isAnonymous == false; @@ -57,40 +75,43 @@ class AuthService { if (Get.currentRoute != homeRoute && Get.currentRoute != introRoute) { Get.offAllNamed(introOrHomeRoute); } - - //If the user has no fcmTokenMap, then update the user with the fcmTokenMap acquired at startup - //TODO implement better TTL management - // if (appUser.value.fcmTokenMap.isEmpty) { - // await db.collection('users').doc(appUser.value.uid).update({ - // 'fcmTokenMap': fcmService.tokenMap, - // }); - // } else { - // //If the user has an fcmTokenMap, throw away the one we collected at startup - // fcmService.tokenMap.clear(); - // } } } // Firebase user a realtime stream - Stream get user => firebaseUser.stream; + Stream get user => auth.authStateChanges(); // get the firestore user from the firestore collection - Future getUser() { - return db.collection('users').doc(firebaseUser.value!.uid).get().then((documentSnapshot) { + Future getUser() { + return db.collection('users').doc(auth.currentUser!.uid).get().then((documentSnapshot) { return getOrCreateUser(documentSnapshot); }); } - updateUserDetails(UserModel appUser) async { - if (appUser.uid.isNotEmpty) { + updateUserDetails(Zone2User appUser) async { + if (firebaseUser.value!.uid.isNotEmpty) { logger.i('updateUserDetails: $appUser'); db.collection('users').doc(appUser.uid).set(appUser.toJson()); } } - UserModel getOrCreateUser(DocumentSnapshot> documentSnapshot) { + Future listenForUserChanges() async { + if (auth.currentUser == null) { + return; + } + + // Listen for user changes + db.collection('users').doc(auth.currentUser!.uid).snapshots().listen((snapshot) { + if (snapshot.exists) { + Zone2User updatedUser = Zone2User.fromJson(snapshot.data()!); + userStreamController.add(updatedUser); + } + }); + } + + Zone2User getOrCreateUser(DocumentSnapshot> documentSnapshot) { if ((documentSnapshot.data() != null)) { - return UserModel.fromJson(documentSnapshot.data()!); + return Zone2User.fromJson(documentSnapshot.data()!); } else { return createUser(); } @@ -100,16 +121,17 @@ class AuthService { return UsernameGen().generate(); } - UserModel createUser() { + Zone2User createUser() { Map data = { "uid": firebaseUser.value!.uid, "email": firebaseUser.value!.email, "name": (firebaseUser.value?.displayName ?? '').isEmpty ? generateRandomName() : firebaseUser.value!.displayName, - "svgString": "{}" + "onboardingComplete": false, + "zoneSettings": {} }; - UserModel user = UserModel.fromJson(data); + Zone2User user = Zone2User.fromJson(data); db.doc('/users/${firebaseUser.value!.uid}').set(data); return user; @@ -123,96 +145,10 @@ class AuthService { await googleSignIn.signOut(); } - // sharedPrefs.deleteAll(); await auth.signOut(); firebaseUser.value = null; } - Future signInAnonymously() async { - logger.w('signing in anonymously - should be mecca'); - UserCredential authResult = await auth.signInAnonymously(); - firebaseUser.value = authResult.user; - logger.w('user: ${authResult.user?.displayName}'); - assert(await firebaseUser.value?.getIdToken() != null); - } - - Future convertWithGoogle() async { - GoogleSignInAccount? googleSignInAccount = await googleSignIn.signIn(); - GoogleSignInAuthentication? googleSignInAuthentication = - await googleSignInAccount?.authentication; - AuthCredential credential = GoogleAuthProvider.credential( - accessToken: googleSignInAuthentication?.accessToken, - idToken: googleSignInAuthentication?.idToken, - ); - - try { - final result = await FirebaseAuth.instance.currentUser?.linkWithCredential(credential); - final user = result?.user; - UserModel bUser = UserModel( - uid: user?.uid ?? '', - email: user?.email ?? '', - name: user?.providerData[0].displayName ?? '', - fcmTokenMap: {}); - await updateUserDetails(bUser); - return true; - } on FirebaseAuthException catch (e) { - switch (e.code) { - case "provider-already-linked": - UserCredential authResult = await auth.signInWithCredential(credential); - firebaseUser.value = authResult.user; - logger.i("Already linked, just sign in"); - return true; - case "invalid-credential": - logger.i("The provider's credential is not valid."); - break; - case "credential-already-in-use": - logger.i("The account corresponding to the credential already exists, " - "or is already linked to a Firebase User."); - UserCredential authResult = await auth.signInWithCredential(credential); - firebaseUser.value = authResult.user; - logger.i("Already linked, just sign in"); - return true; - // See the API reference for the full list of error codes. - default: - logger.i("Unknown error."); - } - } - return false; - } - - Future convertWithApple() async { - final appleProvider = AppleAuthProvider(); - try { - FirebaseAuth.instance.currentUser?.linkWithProvider(appleProvider); - return true; - } on FirebaseAuthException catch (e) { - switch (e.code) { - case "provider-already-linked": - UserCredential authResult = await FirebaseAuth.instance.signInWithProvider(appleProvider); - - firebaseUser.value = authResult.user; - firebaseUser.value = authResult.user; - logger.i("Already linked, just sign in"); - return true; - case "invalid-credential": - logger.i("The provider's credential is not valid."); - break; - case "credential-already-in-use": - logger.i("The account corresponding to the credential already exists, " - "or is already linked to a Firebase User."); - UserCredential authResult = await FirebaseAuth.instance.signInWithProvider(appleProvider); - - firebaseUser.value = authResult.user; - logger.i("Already linked, just sign in"); - return true; - // See the API reference for the full list of error codes. - default: - logger.i("Unknown error."); - } - } - return false; - } - Future signInWithGoogle() async { GoogleSignInAccount? googleSignInAccount = await googleSignIn.signIn(); GoogleSignInAuthentication? googleSignInAuthentication = diff --git a/lib/app/services/firebase_service.dart b/lib/app/services/firebase_service.dart index 828230f..8164725 100644 --- a/lib/app/services/firebase_service.dart +++ b/lib/app/services/firebase_service.dart @@ -1,6 +1,8 @@ import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_auth/firebase_auth.dart'; import 'package:get/get.dart'; import 'package:logger/logger.dart'; +import 'package:zone2/app/models/user.dart'; class FirebaseService { static FirebaseService get to => FirebaseService(); @@ -29,4 +31,31 @@ class FirebaseService { // return FirebaseFunctionResult.failure("An unknown error occurred while creating the game"); // } // } + + Future updateUserOnboardingComplete() async { + await userCollectionReference + .doc(FirebaseAuth.instance.currentUser!.uid) + .update({'onboardingComplete': true}); + } + + Future updateUserZoneSettings(ZoneSettings zoneSettings) async { + await userCollectionReference + .doc(FirebaseAuth.instance.currentUser!.uid) + .update({'zoneSettings': zoneSettings.toJson()}); + } + + Future getUser() async { + if (FirebaseAuth.instance.currentUser == null) { + return null; + } + + final documentSnapshot = + await userCollectionReference.doc(FirebaseAuth.instance.currentUser!.uid).get(); + + if (documentSnapshot.exists) { + return Zone2User.fromJson(documentSnapshot.data() as Map); + } + + return null; + } } diff --git a/lib/app/services/openai_service.dart b/lib/app/services/openai_service.dart index 2bbaf57..bc522f7 100644 --- a/lib/app/services/openai_service.dart +++ b/lib/app/services/openai_service.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:dart_openai/dart_openai.dart'; import 'package:get/get.dart'; import 'package:logger/logger.dart'; @@ -33,34 +35,97 @@ class OpenAIService extends GetxService { return completion.choices.first; } - Future extractFoodsFromText(String prompt) async { + Future> extractFoodsFromText(String text) async { try { - final completion = await openAI.chat.create( - model: 'gpt-4o-mini', + final sumNumbersTool = OpenAIToolModel( + type: "function", + function: OpenAIFunctionModel.withParameters( + name: "extract_foods", + description: "Extract food items from user speech input", + parameters: [ + OpenAIFunctionProperty.object( + name: "foods", + description: "Array of food items with details", + properties: [ + OpenAIFunctionProperty.array( + name: "items", + description: "List of food items", + items: OpenAIFunctionProperty.object( + name: "food", + properties: [ + OpenAIFunctionProperty.string( + name: "label", + description: "Display label for the food item", + ), + OpenAIFunctionProperty.string( + name: "searchTerm", + description: "Simple search term for the food database", + ), + OpenAIFunctionProperty.number( + name: "quantity", + description: "Numeric quantity of the food", + ), + OpenAIFunctionProperty.string( + name: "unit", + description: "Unit of measurement", + ), + OpenAIFunctionProperty.string( + name: "mealType", + description: "Type of meal (BREAKFAST, LUNCH, DINNER, SNACK)", + enumValues: ["BREAKFAST", "LUNCH", "DINNER", "SNACK"], + ), + ], + ), + ), + ], + ), + ], + ), + ); + + // Create the chat completion + final chat = await OpenAI.instance.chat.create( + model: "gpt-3.5-turbo", messages: [ OpenAIChatCompletionChoiceMessageModel( role: OpenAIChatMessageRole.system, content: [ OpenAIChatCompletionChoiceMessageContentItemModel.text( - """You are a food entity extraction system. Extract only the food items from the given text. - Return the response as a JSON array of strings containing only the food items. - Break down compound items into their main components. - Example: "I had a cheeseburger with french fries and a chocolate milkshake" - -> ["cheeseburger", "french fries", "chocolate milkshake", "burger", "cheese"]""", - ), + '''Extract food items from user's speech, normalizing portions and combining similar items. + Include quantity and preparation method when mentioned.'''), ], ), OpenAIChatCompletionChoiceMessageModel( role: OpenAIChatMessageRole.user, - content: [OpenAIChatCompletionChoiceMessageContentItemModel.text(prompt)], + content: [ + OpenAIChatCompletionChoiceMessageContentItemModel.text(text), + ], ), ], + tools: [sumNumbersTool], ); - return completion; + // Handle the response + final message = chat.choices.first.message; + + if (message.toolCalls != null && message.toolCalls!.isNotEmpty) { + final call = message.toolCalls!.first; + if (call.function.name == "extract_foods") { + final decodedArgs = jsonDecode(call.function.arguments); + logger.d('OpenAI Response: $decodedArgs'); + return decodedArgs; + } + } + + logger.w('No valid tool calls in response'); + return { + 'foods': {'items': []} + }; } catch (e) { - logger.e('Error extracting food items with OpenAI: $e'); - rethrow; + logger.e('Error calling OpenAI: $e'); + return { + 'foods': {'items': []} + }; } } } diff --git a/lib/app/services/shared_preferences_service.dart b/lib/app/services/shared_preferences_service.dart index 087b541..95f1132 100644 --- a/lib/app/services/shared_preferences_service.dart +++ b/lib/app/services/shared_preferences_service.dart @@ -77,7 +77,7 @@ class SharedPreferencesService { await _persistence.saveSoundsOn(_soundsOn.value); } - bool get isIntroductionFinished => _getIsIntroductionFinished(); + bool get isIntroductionFinished => _getIsIntroductionFinished(); bool _getIsIntroductionFinished() { return _persistence.box.read(_persistence.isIntroductionFinished) ?? false; @@ -88,15 +88,6 @@ class SharedPreferencesService { await _persistence.saveIsIntroductionFinished(value); } - Future setZone2Goals( - double startingWeight, double targetWeight, String motivatingFactor, String birthdate, String gender) async { - await _persistence.saveZone2StartingWeight(startingWeight); - await _persistence.saveZone2TargetWeight(targetWeight); - await _persistence.saveZone2MotivatingFactor(motivatingFactor); - await _persistence.saveZone2Birthdate(birthdate); - await _persistence.saveZone2Gender(gender); - } - Stream get soundsOnStream => _soundsOnStreamController.stream; Stream get darkModeStream => _darkModeStreamController.stream; diff --git a/lib/main.dart b/lib/main.dart index 3a152f6..4489911 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,19 +1,20 @@ // ignore_for_file: dead_code -import 'package:dart_openai/dart_openai.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:firebase_remote_config/firebase_remote_config.dart'; +import 'package:zone2/app/services/shared_preferences_service.dart'; import 'package:flutter/services.dart'; +import 'package:google_sign_in/google_sign_in.dart'; import 'package:zone2/app/modules/global_bindings.dart'; -import 'package:zone2/app/services/shared_preferences_service.dart'; -import 'package:zone2/app/services/theme_service.dart'; +import 'package:zone2/app/services/auth_service.dart'; +import 'package:zone2/app/services/firebase_service.dart'; import 'package:zone2/app/style/theme.dart'; import 'package:zone2/app/style/palette.dart'; -import 'package:zone2/app/utils/env.dart'; import 'package:zone2/firebase_options.dart'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:cloud_functions/cloud_functions.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart'; -import 'package:firebase_remote_config/firebase_remote_config.dart'; import 'package:toastification/toastification.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -25,7 +26,6 @@ import 'package:logger/logger.dart'; import 'package:network_info_plus/network_info_plus.dart'; import 'app/routes/app_pages.dart'; -import 'package:timezone/data/latest.dart' as tz; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -35,22 +35,35 @@ Future main() async { await GetStorage.init('food_data'); await GetStorage.init('theme_data'); - OpenAI.apiKey = Env.openaiApiKey; - OpenAI.showLogs = kDebugMode; - - tz.initializeTimeZones(); - + // Application palette/colors final Palette palette = Palette(); + + // Initialize Firebase await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + + // Instantiate and register a Logger final logger = Logger( filter: null, printer: PrettyPrinter(), level: kDebugMode ? Level.debug : Level.warning); + Get.lazyPut(() => logger, fenix: true); + // Disable persistence for Firestore FirebaseFirestore.instance.settings = const Settings(persistenceEnabled: false); - Get.lazyPut(() => logger, fenix: true); + // Firebase auth is registered from the firebase auth instance. Can be mocked in tests + Get.put(FirebaseAuth.instance); + + // Firebase firestore is registered from the firebase firestore instance. Can be mocked in tests + Get.put(FirebaseFirestore.instance); - Get.put(ThemeService(), permanent: true); + // Firebase service is instantiated here to get the initial user if logged in to determine if onboarding is complete + final firebaseService = FirebaseService(); + final initialUser = await firebaseService.getUser(); + // Google sign in + Get.put(GoogleSignIn()); + // Auth service is instantiated with initial user if exists + Get.put(AuthService(initialUser), permanent: true); + // Firebase Remote Config final remoteConfig = FirebaseRemoteConfig.instance; await remoteConfig.setConfigSettings(RemoteConfigSettings( fetchTimeout: const Duration(minutes: 1), @@ -64,10 +77,10 @@ Future main() async { await sharedPreferencesService.loadStateFromPersistence(); Get.lazyPut(() => sharedPreferencesService, fenix: true); - final GlobalBindings globalBindings = GlobalBindings( - palette: palette, logger: logger, sharedPreferencesService: sharedPreferencesService); + final GlobalBindings globalBindings = + GlobalBindings(palette: palette, firebaseService: firebaseService, initialUser: initialUser); - if (kDebugMode && !kIsWeb) { + if (kDebugMode) { bool useEmulator = false; final info = NetworkInfo();