Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

1 onboarding profile builder #18

Merged
merged 3 commits into from
Nov 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: test, seed, assets
.PHONY: test seed assets

clean:
flutter clean && flutter pub get
Expand Down
2 changes: 1 addition & 1 deletion android/app/src/debug/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="com.google.android.gms.permission.AD_ID"/>
<!-- <uses-permission android:name="com.google.android.gms.permission.AD_ID"/> -->

<application
android:usesCleartextTraffic="true"
Expand Down
6 changes: 4 additions & 2 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@
<intent>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent>
<!-- Add more queries as needed -->
<intent>
<action android:name="android.speech.RecognitionService" />
</intent>
</queries>
<uses-permission android:name="android.permission.INTERNET"/>

Expand All @@ -84,7 +86,7 @@
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>

<uses-permission android:name="com.google.android.gms.permission.AD_ID"/>
<!-- <uses-permission android:name="com.google.android.gms.permission.AD_ID"/> -->

<uses-permission android:name="android.permission.BODY_SENSORS"/>
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION"/>
Expand Down
96 changes: 86 additions & 10 deletions lib/app/models/user.dart
Original file line number Diff line number Diff line change
@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> toJson() =>
{"uid": uid, "email": email, "name": name, "fcmTokenMap": fcmTokenMap};
Map<String, dynamic> 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
};
}
170 changes: 162 additions & 8 deletions lib/app/modules/diary/controllers/diary_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<String, dynamic> 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<FoodVoiceResult> fromOpenAiCompletion(List<dynamic> 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<Logger>();
final healthService = Get.find<HealthService>();
Expand Down Expand Up @@ -68,13 +127,21 @@ class DiaryController extends GetxController {
final lastError = Rxn<SpeechRecognitionError>();
final recognizedWords = ''.obs;
final systemLocale = Rxn<LocaleName>();
final isTestMode = false.obs; // Toggle this for testing

final voiceResults = RxList<FoodVoiceResult>();

String selectedVoiceFood = '';
RxList<FoodVoiceResult> searchResults = RxList<FoodVoiceResult>();

// Barcode scanning
final Rxn<Barcode> barcode = Rxn<Barcode>();
final Rxn<BarcodeCapture> capture = Rxn<BarcodeCapture>();
late MobileScannerController scannerController;
StreamSubscription<Object?>? scannerSubscription;

ChartSeriesController? chartController;

@override
void onInit() async {
super.onInit();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -200,6 +268,7 @@ class DiaryController extends GetxController {
}
}

// TODO: Each Health Data Type should be its own method, this should aggregate them all
Future<void> getHealthDataForSelectedDay() async {
// Retrieve weight data
final sameDay = diaryDate.value.year == DateTime.now().year &&
Expand Down Expand Up @@ -411,12 +480,97 @@ class DiaryController extends GetxController {
}

Future<void> extractFoodItemsOpenAI(String text) async {
final result = await OpenAIService.to.extractFoodsFromText(text);
matchedFoods.value = result.choices.first.message.content
?.map((item) => item.text)
.whereType<String>()
.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<dynamic>);
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();
}
}
}
Loading