diff --git a/Gemfile.lock b/Gemfile.lock index 86dccb02f..b4b74e3ce 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -5,25 +5,25 @@ GEM base64 nkf rexml - addressable (2.8.6) - public_suffix (>= 2.0.2, < 6.0) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.3.0) - aws-partitions (1.944.0) - aws-sdk-core (3.197.0) + aws-partitions (1.960.0) + aws-sdk-core (3.201.3) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.651.0) aws-sigv4 (~> 1.8) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.84.0) - aws-sdk-core (~> 3, >= 3.197.0) - aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.152.3) - aws-sdk-core (~> 3, >= 3.197.0) + aws-sdk-kms (1.88.0) + aws-sdk-core (~> 3, >= 3.201.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.156.0) + aws-sdk-core (~> 3, >= 3.201.0) aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.8) - aws-sigv4 (1.8.0) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.9.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) base64 (0.2.0) @@ -38,7 +38,7 @@ GEM domain_name (0.6.20240107) dotenv (2.8.1) emoji_regex (3.2.3) - excon (0.110.0) + excon (0.111.0) faraday (1.10.3) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) @@ -60,7 +60,7 @@ GEM faraday-httpclient (1.0.1) faraday-multipart (1.0.4) multipart-post (~> 2) - faraday-net_http (1.0.1) + faraday-net_http (1.0.2) faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) faraday-rack (1.0.0) @@ -68,7 +68,7 @@ GEM faraday_middleware (1.2.0) faraday (~> 1.0) fastimage (2.3.1) - fastlane (2.220.0) + fastlane (2.222.0) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) @@ -153,9 +153,9 @@ GEM httpclient (2.8.3) jmespath (1.6.2) json (2.7.2) - jwt (2.8.1) + jwt (2.8.2) base64 - mini_magick (4.13.1) + mini_magick (4.13.2) mini_mime (1.1.5) multi_json (1.15.0) multipart-post (2.4.1) @@ -165,7 +165,7 @@ GEM optparse (0.5.0) os (1.1.4) plist (3.7.1) - public_suffix (5.1.0) + public_suffix (6.0.1) rake (13.2.1) representable (3.2.0) declarative (< 0.1.0) diff --git a/android/settings.gradle b/android/settings.gradle index ef03c12b4..1c38bbdf1 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -19,7 +19,7 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "com.android.application" version "7.3.1" apply false - id "org.jetbrains.kotlin.android" version "1.7.10" apply false + id "org.jetbrains.kotlin.android" version "2.0.21" apply false } include ":app" diff --git a/fastlane/report.xml b/fastlane/report.xml index 87898c060..31b384403 100644 --- a/fastlane/report.xml +++ b/fastlane/report.xml @@ -5,12 +5,12 @@ - + - + diff --git a/lib/api_client.dart b/lib/api_client.dart new file mode 100644 index 000000000..bde351c5c --- /dev/null +++ b/lib/api_client.dart @@ -0,0 +1,64 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'helpers/consts.dart'; + +final log = Logger('powersync-test'); + +class ApiClient { + final String baseUrl; + + const ApiClient(this.baseUrl); + + /// Returns a powersync JWT token token + /// + /// Note that at the moment we use the permanent API token for authentication + /// but this should be probably changed to the wger API JWT tokens in the + /// future since they are not permanent and could be easily revoked. + Future> getPowersyncToken() async { + final prefs = await SharedPreferences.getInstance(); + final apiData = json.decode(prefs.getString(PREFS_USER)!); + print('posting our token "${apiData["token"]}" to $baseUrl/api/v2/powersync-token'); + final response = await http.get( + Uri.parse('$baseUrl/api/v2/powersync-token'), + headers: { + HttpHeaders.contentTypeHeader: 'application/json', + HttpHeaders.authorizationHeader: 'Token ${apiData["token"]}', + }, + ); + print('response: status ${response.statusCode}, body ${response.body}'); + if (response.statusCode == 200) { + log.log(Level.ALL, response.body); + return json.decode(response.body); + } + throw Exception('Failed to fetch token'); + } + + Future upsert(Map record) async { + await http.put( + Uri.parse('$baseUrl/api/upload-powersync-data'), + headers: {'Content-Type': 'application/json'}, + body: json.encode(record), + ); + } + + Future update(Map record) async { + await http.patch( + Uri.parse('$baseUrl/api/upload-powersync-data'), + headers: {'Content-Type': 'application/json'}, + body: json.encode(record), + ); + } + + Future delete(Map record) async { + await http.delete( + Uri.parse('$baseUrl/api/v2/upload-powersync-data'), + headers: {'Content-Type': 'application/json'}, + body: json.encode(record), + ); + } +} diff --git a/lib/helpers/consts.dart b/lib/helpers/consts.dart index 2b2ebc7e0..6482ec3a7 100644 --- a/lib/helpers/consts.dart +++ b/lib/helpers/consts.dart @@ -109,7 +109,7 @@ enum EXERCISE_IMAGE_ART_STYLE { } /// Dummy ID for pseudo meals -const PSEUDO_MEAL_ID = -1; +const PSEUDO_MEAL_ID = 'deadbeef'; /// Colors used for muscles const COLOR_MAIN_MUSCLES = Colors.red; diff --git a/lib/main.dart b/lib/main.dart index ac4abfe1a..a7827d918 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,10 +16,12 @@ * along with this program. If not, see . */ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:provider/provider.dart'; import 'package:wger/core/locator.dart'; +import 'package:wger/powersync.dart'; import 'package:wger/providers/add_exercise.dart'; import 'package:wger/providers/base_provider.dart'; import 'package:wger/providers/body_weight.dart'; @@ -52,17 +54,32 @@ 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:logging/logging.dart'; import 'providers/auth.dart'; void main() async { //zx.setLogEnabled(kDebugMode); + Logger.root.level = Level.INFO; + Logger.root.onRecord.listen((record) { + if (kDebugMode) { + print('[${record.loggerName}] ${record.level.name}: ${record.time}: ${record.message}'); + + if (record.error != null) { + print(record.error); + } + if (record.stackTrace != null) { + print(record.stackTrace); + } + } + }); // Needs to be called before runApp WidgetsFlutterBinding.ensureInitialized(); // Locator to initialize exerciseDB await ServiceLocator().configure(); + print('running myapp'); // Application runApp(const MyApp()); } diff --git a/lib/models/muscle.dart b/lib/models/muscle.dart new file mode 100644 index 000000000..2c86437e5 --- /dev/null +++ b/lib/models/muscle.dart @@ -0,0 +1,37 @@ +import 'package:powersync/sqlite3.dart' as sqlite; +import 'package:wger/models/schema.dart'; +import 'package:wger/powersync.dart'; + +class Muscle { + final String id; + final String name; + final String nameEn; + final bool isFront; + + const Muscle({ + required this.id, + required this.name, + required this.nameEn, + required this.isFront, + }); + + factory Muscle.fromRow(sqlite.Row row) { + return Muscle( + id: row['id'], + name: row['name'], + nameEn: row['name_en'], + isFront: row['is_front'] == 1, + ); + } + + Future delete() async { + await db.execute('DELETE FROM $tableMuscles WHERE id = ?', [id]); + } + + /// Watch all lists. + static Stream> watchMuscles() { + return db.watch('SELECT * FROM $tableMuscles ORDER BY id').map((results) { + return results.map(Muscle.fromRow).toList(growable: false); + }); + } +} diff --git a/lib/models/nutrition/log.dart b/lib/models/nutrition/log.dart index ac4aa8457..362edf05a 100644 --- a/lib/models/nutrition/log.dart +++ b/lib/models/nutrition/log.dart @@ -17,24 +17,28 @@ */ import 'package:json_annotation/json_annotation.dart'; +import 'package:powersync/powersync.dart'; +import 'package:powersync/sqlite3.dart' as sqlite; import 'package:wger/helpers/json.dart'; import 'package:wger/models/nutrition/ingredient.dart'; import 'package:wger/models/nutrition/ingredient_weight_unit.dart'; import 'package:wger/models/nutrition/meal_item.dart'; import 'package:wger/models/nutrition/nutritional_values.dart'; +import 'package:wger/models/schema.dart'; +import 'package:wger/powersync.dart'; part 'log.g.dart'; @JsonSerializable() class Log { @JsonKey(required: true) - int? id; + String? id; @JsonKey(required: false, name: 'meal') - int? mealId; + String? mealId; @JsonKey(required: true, name: 'plan') - int planId; + String planId; @JsonKey(required: true) late DateTime datetime; @@ -75,6 +79,19 @@ class Log { amount = mealItem.amount; } + factory Log.fromRow(sqlite.Row row) { + return Log( + id: row['id'], + mealId: row['meal_id'], + ingredientId: row['ingredient_id'], + weightUnitId: row['weight_unit_id'], + amount: row['amount'], + planId: row['plan_id'], + datetime: DateTime.parse(row['datetime']), + comment: row['comment'], + ); + } + // Boilerplate factory Log.fromJson(Map json) => _$LogFromJson(json); @@ -89,4 +106,38 @@ class Log { return ingredient.nutritionalValues / (100 / weight); } + + static Future> readByMealId(String mealId) async { + final results = await db.getAll('SELECT * FROM $tableLogItems WHERE meal_id = ?', [mealId]); + return results.map((r) => Log.fromRow(r)).toList(); + } + + static Future> readByPlanId(String planId) async { + final results = await db.getAll('SELECT * FROM $tableLogItems WHERE plan_id = ?', [planId]); + return results.map((r) => Log.fromRow(r)).toList(); + } + +/* + Future delete() async { + await db.execute('DELETE FROM $logItemsTable WHERE id = ?', [id]); + } + */ + + Future log() async { + print('DIETER Log.log called id=$id, planId=$planId'); + await db.execute( + 'INSERT INTO $tableLogItems (id, meal_id, ingredient_id, weight_unit_id, amount, plan_id, datetime, comment) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [ + // generate an id using uuid + uuid.v4(), + mealId, + ingredientId, + weightUnitId, + amount, + planId, + datetime.toIso8601String(), + comment, + ], + ); + } } diff --git a/lib/models/nutrition/log.g.dart b/lib/models/nutrition/log.g.dart index 72e0a6aeb..11e5eb547 100644 --- a/lib/models/nutrition/log.g.dart +++ b/lib/models/nutrition/log.g.dart @@ -19,12 +19,12 @@ Log _$LogFromJson(Map json) { ], ); return Log( - id: (json['id'] as num?)?.toInt(), - mealId: (json['meal'] as num?)?.toInt(), + id: json['id'] as String?, + mealId: json['meal'] as String?, ingredientId: (json['ingredient'] as num).toInt(), weightUnitId: (json['weight_unit'] as num?)?.toInt(), amount: stringToNum(json['amount'] as String?), - planId: (json['plan'] as num).toInt(), + planId: json['plan'] as String, datetime: DateTime.parse(json['datetime'] as String), comment: json['comment'] as String?, ); diff --git a/lib/models/nutrition/meal.dart b/lib/models/nutrition/meal.dart index ecb3ca3f5..10b6109c8 100644 --- a/lib/models/nutrition/meal.dart +++ b/lib/models/nutrition/meal.dart @@ -18,22 +18,25 @@ import 'package:flutter/material.dart'; import 'package:json_annotation/json_annotation.dart'; +import 'package:powersync/sqlite3.dart' as sqlite; import 'package:wger/helpers/consts.dart'; import 'package:wger/helpers/json.dart'; import 'package:wger/helpers/misc.dart'; import 'package:wger/models/nutrition/log.dart'; import 'package:wger/models/nutrition/meal_item.dart'; import 'package:wger/models/nutrition/nutritional_values.dart'; +import 'package:wger/models/schema.dart'; +import 'package:wger/powersync.dart'; part 'meal.g.dart'; @JsonSerializable() class Meal { @JsonKey(required: false) - late int? id; + late String? id; @JsonKey(name: 'plan') - late int planId; + late String planId; @JsonKey(toJson: timeToString, fromJson: stringToTime) TimeOfDay? time; @@ -52,7 +55,7 @@ class Meal { Meal({ this.id, - int? plan, + String? plan, this.time, String? name, List? mealItems, @@ -87,11 +90,20 @@ class Meal { // Boilerplate factory Meal.fromJson(Map json) => _$MealFromJson(json); + factory Meal.fromRow(sqlite.Row row) { + return Meal( + id: row['id'], + plan: row['plan_id'], + time: stringToTime(row['time']), + name: row['name'], + ); + } + Map toJson() => _$MealToJson(this); Meal copyWith({ - int? id, - int? planId, + String? id, + String? plan, TimeOfDay? time, String? name, List? mealItems, @@ -99,11 +111,31 @@ class Meal { }) { return Meal( id: id ?? this.id, - plan: planId ?? this.planId, + plan: plan ?? planId, time: time ?? this.time, name: name ?? this.name, mealItems: mealItems ?? this.mealItems, diaryEntries: diaryEntries ?? this.diaryEntries, ); } + + Future loadChildren() async { + print('loadChildren called. plan is $planId'); + return copyWith( + mealItems: await MealItem.readByMealId(id!), + diaryEntries: await Log.readByMealId(id!), + ); + } + + static Future read(String id) async { + final results = await db.get('SELECT * FROM $tableMeals WHERE id = ?', [id]); + return Meal.fromRow(results); + } + + static Future> readByPlanId(String planId) async { + print('Meal.readByPlanId: SELECT * FROM $tableMeals WHERE plan_id = $planId'); + final results = await db.getAll('SELECT * FROM $tableMeals WHERE plan_id = ?', [planId]); + print(results.rows.length); + return Future.wait(results.map((r) => Meal.fromRow(r).loadChildren())); + } } diff --git a/lib/models/nutrition/meal.g.dart b/lib/models/nutrition/meal.g.dart index 2258775c8..187537b4d 100644 --- a/lib/models/nutrition/meal.g.dart +++ b/lib/models/nutrition/meal.g.dart @@ -7,10 +7,10 @@ part of 'meal.dart'; // ************************************************************************** Meal _$MealFromJson(Map json) => Meal( - id: (json['id'] as num?)?.toInt(), + id: json['id'] as String?, time: stringToTime(json['time'] as String?), name: json['name'] as String?, - )..planId = (json['plan'] as num).toInt(); + )..planId = json['plan'] as String; Map _$MealToJson(Meal instance) => { 'id': instance.id, diff --git a/lib/models/nutrition/meal_item.dart b/lib/models/nutrition/meal_item.dart index 410e43064..e740d4c2c 100644 --- a/lib/models/nutrition/meal_item.dart +++ b/lib/models/nutrition/meal_item.dart @@ -17,10 +17,14 @@ */ import 'package:json_annotation/json_annotation.dart'; +import 'package:powersync/sqlite3.dart' as sqlite; + import 'package:wger/helpers/json.dart'; import 'package:wger/models/nutrition/ingredient.dart'; import 'package:wger/models/nutrition/ingredient_weight_unit.dart'; import 'package:wger/models/nutrition/nutritional_values.dart'; +import 'package:wger/models/schema.dart'; +import 'package:wger/powersync.dart'; part 'meal_item.g.dart'; @@ -30,7 +34,7 @@ class MealItem { int? id; @JsonKey(required: false, name: 'meal') - late int mealId; + late String mealId; @JsonKey(required: false, name: 'ingredient') late int ingredientId; @@ -49,7 +53,7 @@ class MealItem { MealItem({ this.id, - int? mealId, + String? mealId, required this.ingredientId, this.weightUnitId, required this.amount, @@ -71,6 +75,15 @@ class MealItem { Map toJson() => _$MealItemToJson(this); + factory MealItem.fromRow(sqlite.Row row) { + return MealItem( + amount: row['amount'], + weightUnitId: row['weight_unit_id'], + mealId: row['meal_id'], + ingredientId: row['ingredient_id'], + ); + } + /// Calculations /// TODO why does this not consider weightUnitObj ? should we do the same as Log.nutritionalValues here? NutritionalValues get nutritionalValues { @@ -94,7 +107,7 @@ class MealItem { MealItem copyWith({ int? id, - int? mealId, + String? mealId, int? ingredientId, int? weightUnitId, num? amount, @@ -112,4 +125,9 @@ class MealItem { m.weightUnitObj = weightUnitObj ?? this.weightUnitObj; return m; } + + static Future> readByMealId(String mealId) async { + final results = await db.getAll('SELECT * FROM $tableMealItems WHERE meal_id = ?', [mealId]); + return results.map((r) => MealItem.fromRow(r)).toList(); + } } diff --git a/lib/models/nutrition/meal_item.g.dart b/lib/models/nutrition/meal_item.g.dart index 256f9cce7..e503d3fc9 100644 --- a/lib/models/nutrition/meal_item.g.dart +++ b/lib/models/nutrition/meal_item.g.dart @@ -13,7 +13,7 @@ MealItem _$MealItemFromJson(Map json) { ); return MealItem( id: (json['id'] as num?)?.toInt(), - mealId: (json['meal'] as num?)?.toInt(), + mealId: json['meal'] as String?, ingredientId: (json['ingredient'] as num).toInt(), weightUnitId: (json['weight_unit'] as num?)?.toInt(), amount: stringToNum(json['amount'] as String?), diff --git a/lib/models/nutrition/nutritional_plan.dart b/lib/models/nutrition/nutritional_plan.dart index a0f678397..a8cbbef64 100644 --- a/lib/models/nutrition/nutritional_plan.dart +++ b/lib/models/nutrition/nutritional_plan.dart @@ -20,6 +20,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:json_annotation/json_annotation.dart'; +import 'package:powersync/sqlite3.dart' as sqlite; import 'package:wger/helpers/consts.dart'; import 'package:wger/helpers/json.dart'; import 'package:wger/models/nutrition/log.dart'; @@ -27,13 +28,15 @@ import 'package:wger/models/nutrition/meal.dart'; import 'package:wger/models/nutrition/meal_item.dart'; import 'package:wger/models/nutrition/nutritional_goals.dart'; import 'package:wger/models/nutrition/nutritional_values.dart'; +import 'package:wger/models/schema.dart'; +import 'package:wger/powersync.dart'; part 'nutritional_plan.g.dart'; @JsonSerializable(explicitToJson: true) class NutritionalPlan { - @JsonKey(required: true) - int? id; + @JsonKey(required: false) + String? id; @JsonKey(required: true) late String description; @@ -82,6 +85,55 @@ class NutritionalPlan { this.diaryEntries = diaryEntries ?? []; } + factory NutritionalPlan.fromRow(sqlite.Row row) { + return NutritionalPlan( + id: row['id'], + description: row['description'], + creationDate: DateTime.parse(row['creation_date']), + onlyLogging: row['only_logging'] == 1, + goalEnergy: row['goal_energy'], + goalProtein: row['goal_protein'], + goalCarbohydrates: row['goal_carbohydrates'], + goalFat: row['goal_fat'], + goalFiber: row['goal_fiber'], + ); + } + + NutritionalPlan copyWith({ + String? id, + String? description, + DateTime? creationDate, + bool? onlyLogging, + num? goalEnergy, + num? goalProtein, + num? goalCarbohydrates, + num? goalFat, + num? goalFiber, + List? meals, + List? diaryEntries, + }) { + return NutritionalPlan( + id: id ?? this.id, + description: description ?? this.description, + creationDate: creationDate ?? this.creationDate, + onlyLogging: onlyLogging ?? this.onlyLogging, + goalEnergy: goalEnergy ?? this.goalEnergy, + goalProtein: goalProtein ?? this.goalProtein, + goalCarbohydrates: goalCarbohydrates ?? this.goalCarbohydrates, + goalFat: goalFat ?? this.goalFat, + goalFiber: goalFiber ?? this.goalFiber, + meals: meals ?? this.meals, + diaryEntries: diaryEntries ?? this.diaryEntries, + ); + } + + Future loadChildren() async { + return copyWith( + diaryEntries: await Log.readByPlanId(id!), + meals: await Meal.readByPlanId(id!), + ); + } + NutritionalPlan.empty() { creationDate = DateTime.now(); description = ''; @@ -246,4 +298,68 @@ class NutritionalPlan { diaryEntries: diaryEntries.where((e) => e.mealId == null).toList(), ); } + + static Future read(String id) async { + final row = await db.get('SELECT * FROM $tableNutritionPlans WHERE id = ?', [id]); + return NutritionalPlan.fromRow(row).loadChildren(); + } + +// this is a bit complicated. +// what we need at the end of the day, is a stream of List, where +// a new value is emitted any time a plan is changed. But the plan is not just the plan record +// we need to load data for Logs and Meals corresponding to the plan also. +// so our options are: +// 1) db.watch with a select query on plans; and extra dart code to load the logs/meals stuff, +// but this only triggers for updates on the plans table, and misses logs/meals updates +// 2) db.watch with a huge join query across all tables from which we need info, +// so we have all the data in our resultset to create the datastructures with, but: +// - this creates long rows with lots of duplicated data (e.g. all the plan data) for every row +// which would only differ for e.g. the meal or the log item +// - it would probably get a bit messy to parse the resultset into the datastructures +// 3) the best of both worlds: load the data we need in dart at runtime, but explicitly +// trigger our code execution when *any* of the relevant tables changes +// + static Stream> watchNutritionPlans() { + return db.onChange([tableNutritionPlans, tableLogItems, tableMeals]).asyncMap((event) async { + final data = await db.getAll('SELECT * FROM $tableNutritionPlans ORDER BY creation_date'); + final futures = Future.wait(data.map((row) => NutritionalPlan.fromRow(row).loadChildren())); + return (await futures).toList(growable: false); + }); + } + + static Stream watchNutritionPlan(String id) { + return db.onChange([tableNutritionPlans, tableLogItems, tableMeals]).asyncMap((event) async { + final row = await db.getOptional('SELECT * FROM $tableNutritionPlans WHERE id = ?', [id]); + return row == null ? null : NutritionalPlan.fromRow(row).loadChildren(); + }); + } + + static Stream watchNutritionPlanLast() { + return db.onChange([tableNutritionPlans, tableLogItems, tableMeals]).asyncMap((event) async { + final res = + await db.getAll('SELECT * FROM $tableNutritionPlans ORDER BY creation_date DESC LIMIT 1'); + if (res.isEmpty) { + return null; + } + return NutritionalPlan.fromRow(res.first).loadChildren(); + }); + } +/* + static Stream> watchNutritionPlan(int id) { + return db + .watch('SELECT * FROM $tableNutritionPlans WHERE id = ?', parameters: [id]).map((results) { + return results.map(NutritionalPlan.fromRow).toList(growable: false); + }); + } + + static Stream> watchNutritionPlans() { + return db.watch('SELECT * FROM $tableNutritionPlans ORDER BY creation_date').map((results) { + return results.map(NutritionalPlan.fromRow).toList(growable: false); + }); + } + */ + + Future delete() async { + await db.execute('DELETE FROM $tableNutritionPlans WHERE id = ?', [id]); + } } diff --git a/lib/models/nutrition/nutritional_plan.g.dart b/lib/models/nutrition/nutritional_plan.g.dart index b4a8bbe73..934459424 100644 --- a/lib/models/nutrition/nutritional_plan.g.dart +++ b/lib/models/nutrition/nutritional_plan.g.dart @@ -10,7 +10,6 @@ NutritionalPlan _$NutritionalPlanFromJson(Map json) { $checkKeys( json, requiredKeys: const [ - 'id', 'description', 'creation_date', 'only_logging', @@ -22,7 +21,7 @@ NutritionalPlan _$NutritionalPlanFromJson(Map json) { ], ); return NutritionalPlan( - id: (json['id'] as num?)?.toInt(), + id: json['id'] as String?, description: json['description'] as String, creationDate: DateTime.parse(json['creation_date'] as String), onlyLogging: json['only_logging'] as bool? ?? false, diff --git a/lib/models/schema.dart b/lib/models/schema.dart new file mode 100644 index 000000000..08ecd01b2 --- /dev/null +++ b/lib/models/schema.dart @@ -0,0 +1,82 @@ +import 'package:powersync/powersync.dart'; + +/* nutrition tables in postgres: +| public | nutrition_image | table> +| public | nutrition_ingredient | table> * # millions of ingredients +| public | nutrition_ingredientcategory | table> +| public | nutrition_ingredientweightunit | table> +| public | nutrition_logitem | table> * OK +| public | nutrition_meal | table> * OK +| public | nutrition_mealitem | table> * +| public | nutrition_nutritionplan | table> * OK +| public | nutrition_weightunit | table> + +assumptions: nutrition_ingredientcategory, nutrition_weightunit, nutrition_ingredientweightunit globals? +*/ + +// User,NutritionPlan,Meal,LogItem,MealItem,Ingredient +const tableMuscles = 'exercises_muscle'; +const tableLogItems = 'nutrition_logitem'; +const tableNutritionPlans = 'nutrition_nutritionplan'; +const tableMeals = 'nutrition_meal'; +const tableMealItems = 'nutrition_mealitem'; + +Schema schema = const Schema([ + Table( + tableMuscles, + [Column.text('name'), Column.text('name_en'), Column.text('is_front')], + ), + Table( + tableNutritionPlans, + [ + Column.text('creation_date'), + Column.text('description'), + Column.integer('has_goal_calories'), + Column.integer('user_id'), + Column.integer('remote_id'), + Column.integer('only_logging'), + Column.integer('goal_carbohydrates'), + Column.integer('goal_energy'), + Column.integer('goal_fat'), + Column.integer('goal_protein'), + Column.integer('goal_fiber'), + ], + ), + Table( + tableLogItems, + [ + Column.text('datetime'), + Column.text('comment'), + Column.integer('amount'), + Column.integer('remote_id'), + Column.integer('ingredient_id'), + Column.text('plan_id'), + Column.integer('weight_unit_id'), + Column.text('meal_id'), // optional + ], + indexes: [ + // Index('plan', [IndexedColumn('plan_id')]) + ], + ), + Table( + tableMeals, + [ + Column.integer('order'), + Column.integer('remote_id'), + Column.text('time'), + Column.text('plan_id'), + Column.text('name'), + ], + ), + Table( + tableMealItems, + [ + Column.integer('order'), + Column.integer('amount'), + Column.integer('ingredient_id'), + Column.text('meal_id'), + Column.integer('remote_id'), + Column.integer('weight_unit_id'), + ], + ), +]); diff --git a/lib/powersync.dart b/lib/powersync.dart new file mode 100644 index 000000000..0a2122c97 --- /dev/null +++ b/lib/powersync.dart @@ -0,0 +1,122 @@ +// This file performs setup of the PowerSync database +import 'package:logging/logging.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:powersync/powersync.dart'; +import 'package:wger/api_client.dart'; + +import './models/schema.dart'; + +final log = Logger('powersync-django'); + +/// Postgres Response codes that we cannot recover from by retrying. +final List fatalResponseCodes = [ + // Class 22 — Data Exception + // Examples include data type mismatch. + RegExp(r'^22...$'), + // Class 23 — Integrity Constraint Violation. + // Examples include NOT NULL, FOREIGN KEY and UNIQUE violations. + RegExp(r'^23...$'), + // INSUFFICIENT PRIVILEGE - typically a row-level security violation + RegExp(r'^42501$'), +]; + +class DjangoConnector extends PowerSyncBackendConnector { + PowerSyncDatabase db; + String baseUrl; + String powersyncUrl; + late ApiClient apiClient; + + DjangoConnector(this.db, this.baseUrl, this.powersyncUrl) { + apiClient = ApiClient(baseUrl); + } + + /// Get a token to authenticate against the PowerSync instance. + @override + Future fetchCredentials() async { + // Somewhat contrived to illustrate usage, see auth docs here: + // https://docs.powersync.com/usage/installation/authentication-setup/custom + // final wgerSession = await apiClient.getWgerJWTToken(); + final session = await apiClient.getPowersyncToken(); + // note: we don't set userId and expires property here. not sure if needed + return PowerSyncCredentials(endpoint: this.powersyncUrl, token: session['token']); + } + + // Upload pending changes to Postgres via Django backend + // this is generic. on the django side we inspect the request and do model-specific operations + // would it make sense to do api calls here specific to the relevant model? (e.g. put to a todo-specific endpoint) + @override + Future uploadData(PowerSyncDatabase database) async { + final transaction = await database.getNextCrudTransaction(); + + if (transaction == null) { + return; + } + + try { + for (final op in transaction.crud) { + final record = { + 'table': op.table, + 'data': {'id': op.id, ...?op.opData}, + }; + + log.fine('DIETER Uploading record', record); + + switch (op.op) { + case UpdateType.put: + await apiClient.upsert(record); + break; + case UpdateType.patch: + await apiClient.update(record); + break; + case UpdateType.delete: + await apiClient.delete(record); + break; + } + } + await transaction.complete(); + } on Exception catch (e) { + log.severe('Error uploading data', e); + // Error may be retryable - e.g. network error or temporary server error. + // Throwing an error here causes this call to be retried after a delay. + rethrow; + } + } +} + +/// Global reference to the database +late final PowerSyncDatabase db; + +// Hacky flag to ensure the database is only initialized once, better to do this with listeners +bool _dbInitialized = false; + +Future getDatabasePath() async { + final dir = await getApplicationSupportDirectory(); + return join(dir.path, 'powersync-demo.db'); +} + +// opens the database and connects if logged in +Future openDatabase(bool connect, String baseUrl, String powersyncUrl) async { + // Open the local database + if (!_dbInitialized) { + db = PowerSyncDatabase(schema: schema, path: await getDatabasePath(), logger: attachedLogger); + await db.initialize(); + _dbInitialized = true; + } + + if (connect) { + // If the user is already logged in, connect immediately. + // Otherwise, connect once logged in. + + final currentConnector = DjangoConnector(db, baseUrl, powersyncUrl); + db.connect(connector: currentConnector); + + // TODO: should we respond to login state changing? like here: + // https://www.powersync.com/blog/flutter-tutorial-building-an-offline-first-chat-app-with-supabase-and-powersync#implement-auth-methods + } +} + +/// Explicit sign out - clear database and log out. +Future logout() async { + await db.disconnectAndClear(); +} diff --git a/lib/providers/auth.dart b/lib/providers/auth.dart index 61e25617d..fa07f0d9f 100644 --- a/lib/providers/auth.dart +++ b/lib/providers/auth.dart @@ -182,6 +182,7 @@ class AuthProvider with ChangeNotifier { } // Log user in + // should we update the backend to just include a powersync token also? token = responseData['token']; notifyListeners(); diff --git a/lib/providers/nutrition.dart b/lib/providers/nutrition.dart index d1a17de8b..62c8ef3f3 100644 --- a/lib/providers/nutrition.dart +++ b/lib/providers/nutrition.dart @@ -16,6 +16,7 @@ * along with this program. If not, see . */ +import 'dart:async'; import 'dart:convert'; import 'dart:developer'; @@ -35,6 +36,7 @@ import 'package:wger/models/nutrition/nutritional_plan.dart'; import 'package:wger/providers/base_provider.dart'; class NutritionPlansProvider with ChangeNotifier { + // TODO: should be able to delete many of these paths and their corresponding code static const _nutritionalPlansPath = 'nutritionplan'; static const _nutritionalPlansInfoPath = 'nutritionplaninfo'; static const _mealPath = 'meal'; @@ -72,13 +74,14 @@ class NutritionPlansProvider with ChangeNotifier { } return null; } - +/* NutritionalPlan findById(int id) { return _plans.firstWhere( (plan) => plan.id == id, orElse: () => throw const NoSuchEntryException(), ); } + Meal? findMealById(int id) { for (final plan in _plans) { @@ -89,91 +92,78 @@ class NutritionPlansProvider with ChangeNotifier { } return null; } + */ - /// Fetches and sets all plans sparsely, i.e. only with the data on the plan - /// object itself and no child attributes - Future fetchAndSetAllPlansSparse() async { - final data = await baseProvider.fetchPaginated( - baseProvider.makeUrl(_nutritionalPlansPath, query: {'limit': '1000'}), - ); - _plans = []; - for (final planData in data) { - final plan = NutritionalPlan.fromJson(planData); - _plans.add(plan); - _plans.sort((a, b) => b.creationDate.compareTo(a.creationDate)); - } - notifyListeners(); - } - - /// 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()); - } - - /// Fetches and sets the given nutritional plan - /// - /// This method only loads the data on the nutritional plan object itself, - /// no meals, etc. - Future fetchAndSetPlanSparse(int planId) async { - final url = baseProvider.makeUrl(_nutritionalPlansPath, id: planId); - final planData = await baseProvider.fetch(url); - final plan = NutritionalPlan.fromJson(planData); - _plans.add(plan); - _plans.sort((a, b) => b.creationDate.compareTo(a.creationDate)); - - notifyListeners(); - return plan; - } + Future _enrichPlan(NutritionalPlan plan) async { + // TODO: set up ingredient images - /// Fetches a plan fully, i.e. with all corresponding child objects - Future fetchAndSetPlanFull(int planId) async { - NutritionalPlan plan; - try { - plan = findById(planId); - } on NoSuchEntryException { - // TODO: remove this useless call, because we will fetch all details below - plan = await fetchAndSetPlanSparse(planId); + final List diaryEntries = []; + for (final diaryEntry in plan.diaryEntries) { + diaryEntry.ingredient = await fetchIngredient(diaryEntry.ingredientId); + diaryEntries.add(diaryEntry); } - // Plan - final url = baseProvider.makeUrl(_nutritionalPlansInfoPath, id: planId); - final fullPlanData = await baseProvider.fetch(url); - - // Meals final List meals = []; - for (final mealData in fullPlanData['meals']) { + for (final meal in plan.meals) { final List mealItems = []; - final meal = Meal.fromJson(mealData); - - // TODO: we should add these ingredients to the ingredient cache - for (final mealItemData in mealData['meal_items']) { - final mealItem = MealItem.fromJson(mealItemData); - - final ingredient = Ingredient.fromJson(mealItemData['ingredient_obj']); - if (mealItemData['image'] != null) { - final image = IngredientImage.fromJson(mealItemData['image']); - ingredient.image = image; - } - mealItem.ingredient = ingredient; + for (final mealItem in meal.mealItems) { + mealItem.ingredient = await fetchIngredient(mealItem.ingredientId); mealItems.add(mealItem); } meal.mealItems = mealItems; + meal.diaryEntries = diaryEntries.where((d) => d.mealId == meal.id).toList(); meals.add(meal); } - plan.meals = meals; - // Logs - await fetchAndSetLogs(plan); - for (final meal in meals) { - meal.diaryEntries = plan.diaryEntries.where((e) => e.mealId == meal.id).toList(); - } + plan.meals = meals; + plan.diaryEntries = diaryEntries; - // ... and done - notifyListeners(); return plan; } + Stream watchNutritionPlan(String id) { + return NutritionalPlan.watchNutritionPlan(id).transform( + StreamTransformer.fromHandlers( + handleData: (plan, sink) async { + if (plan == null) { + sink.add(plan); + return; + } + sink.add(await _enrichPlan(plan)); + }, + ), + ); + } + + Stream watchNutritionPlanLast() { + return NutritionalPlan.watchNutritionPlanLast().transform( + StreamTransformer.fromHandlers( + handleData: (plan, sink) async { + if (plan == null) { + return; + } + sink.add(await _enrichPlan(plan)); + }, + ), + ); + } + + Stream> watchNutritionPlans() { + return NutritionalPlan.watchNutritionPlans().transform( + StreamTransformer.fromHandlers( + handleData: (plans, sink) async { + sink.add(await Future.wait(plans.map((plan) => _enrichPlan(plan)))); + }, + ), + ); + } +/* +TODO implement: + ingredient.image = image; + mealItem.ingredient = ingredient; + + */ + Future addPlan(NutritionalPlan planData) async { final data = await baseProvider.post( planData.toJson(), @@ -187,31 +177,16 @@ class NutritionPlansProvider with ChangeNotifier { } Future editPlan(NutritionalPlan plan) async { - await baseProvider.patch( - plan.toJson(), - baseProvider.makeUrl(_nutritionalPlansPath, id: plan.id), - ); - notifyListeners(); +// TODO } - Future deletePlan(int id) async { - final existingPlanIndex = _plans.indexWhere((element) => element.id == id); - final existingPlan = _plans[existingPlanIndex]; - _plans.removeAt(existingPlanIndex); - notifyListeners(); - - final response = await baseProvider.deleteRequest(_nutritionalPlansPath, id); - - if (response.statusCode >= 400) { - _plans.insert(existingPlanIndex, existingPlan); - notifyListeners(); - throw WgerHttpException(response.body); - } - //existingPlan = null; + Future deletePlan(String id) async { +// TODO } /// Adds a meal to a plan - Future addMeal(Meal meal, int planId) async { + Future addMeal(Meal meal, String planId) async { + /* final plan = findById(planId); final data = await baseProvider.post( meal.toJson(), @@ -223,10 +198,13 @@ class NutritionPlansProvider with ChangeNotifier { notifyListeners(); return meal; + */ + return meal; } /// Edits an existing meal Future editMeal(Meal meal) async { + /* final data = await baseProvider.patch( meal.toJson(), baseProvider.makeUrl(_mealPath, id: meal.id), @@ -234,11 +212,13 @@ class NutritionPlansProvider with ChangeNotifier { meal = Meal.fromJson(data); notifyListeners(); + */ return meal; } /// Deletes a meal Future deleteMeal(Meal meal) async { + /* // Get the meal final plan = findById(meal.planId); final mealIndex = plan.meals.indexWhere((e) => e.id == meal.id); @@ -253,6 +233,8 @@ class NutritionPlansProvider with ChangeNotifier { notifyListeners(); throw WgerHttpException(response.body); } + */ + return; } /// Adds a meal item to a meal @@ -272,6 +254,7 @@ class NutritionPlansProvider with ChangeNotifier { /// Deletes a meal Future deleteMealItem(MealItem mealItem) async { + /* // Get the meal final meal = findMealById(mealItem.mealId)!; final mealItemIndex = meal.mealItems.indexWhere((e) => e.id == mealItem.id); @@ -286,6 +269,7 @@ class NutritionPlansProvider with ChangeNotifier { notifyListeners(); throw WgerHttpException(response.body); } + */ } Future clearIngredientCache() async { @@ -395,6 +379,7 @@ class NutritionPlansProvider with ChangeNotifier { /// Log meal to nutrition diary Future logMealToDiary(Meal meal) async { + /* for (final item in meal.mealItems) { final plan = findById(meal.planId); final Log log = Log.fromMealItem(item, plan.id!, meal.id); @@ -407,34 +392,29 @@ class NutritionPlansProvider with ChangeNotifier { plan.diaryEntries.add(log); } notifyListeners(); + */ } /// Log custom ingredient to nutrition diary Future logIngredientToDiary( MealItem mealItem, - int planId, [ + String planId, [ DateTime? dateTime, - ]) async { - final plan = findById(planId); - mealItem.ingredient = await fetchIngredient(mealItem.ingredientId); - final Log log = Log.fromMealItem(mealItem, plan.id!, null, dateTime); - - final data = await baseProvider.post( - log.toJson(), - baseProvider.makeUrl(_nutritionDiaryPath), - ); - log.id = data['id']; - plan.diaryEntries.add(log); - notifyListeners(); + ]) { + print( + 'DIETER logIngredientToDiary called ingredient=${mealItem.ingredientId}, planId=$planId, dateTime=$dateTime'); + return Log.fromMealItem(mealItem, planId, null, dateTime).log(); } /// Deletes a log entry - Future deleteLog(int logId, int planId) async { + Future deleteLog(String logId, String planId) async { + /* await baseProvider.deleteRequest(_nutritionDiaryPath, logId); final plan = findById(planId); plan.diaryEntries.removeWhere((element) => element.id == logId); notifyListeners(); + */ } /// Load nutrition diary entries for plan diff --git a/lib/screens/dashboard.dart b/lib/screens/dashboard.dart index 02d524f3c..35ab9f52e 100644 --- a/lib/screens/dashboard.dart +++ b/lib/screens/dashboard.dart @@ -16,8 +16,11 @@ * along with this program. If not, see . */ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:wger/models/muscle.dart'; import 'package:wger/widgets/core/app_bar.dart'; import 'package:wger/widgets/dashboard/calendar.dart'; import 'package:wger/widgets/dashboard/widgets.dart'; @@ -50,3 +53,46 @@ class _DashboardScreenState extends State { ); } } + +class DashboardMuscleWidget extends StatefulWidget { + const DashboardMuscleWidget({super.key}); + + @override + _DashboardMuscleWidgetState createState() => _DashboardMuscleWidgetState(); +} + +class _DashboardMuscleWidgetState extends State { + List _data = []; + StreamSubscription? _subscription; + + _DashboardMuscleWidgetState(); + + @override + void initState() { + super.initState(); + final stream = Muscle.watchMuscles(); + _subscription = stream.listen((data) { + if (!context.mounted) { + return; + } + setState(() { + _data = data; + }); + }); + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + color: Colors.brown, + child: Column( + children: [Text('muscles'), ..._data.map((e) => Text(e.name)).toList()], + )); + } +} diff --git a/lib/screens/home_tabs_screen.dart b/lib/screens/home_tabs_screen.dart index 765703be5..a2b79217b 100644 --- a/lib/screens/home_tabs_screen.dart +++ b/lib/screens/home_tabs_screen.dart @@ -16,13 +16,13 @@ * along with this program. If not, see . */ -import 'dart:developer'; - import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:logging/logging.dart'; import 'package:provider/provider.dart'; import 'package:rive/rive.dart'; +import 'package:wger/powersync.dart'; import 'package:wger/providers/auth.dart'; import 'package:wger/providers/body_weight.dart'; import 'package:wger/providers/exercises.dart'; @@ -39,6 +39,7 @@ import 'package:wger/screens/workout_plans_screen.dart'; class HomeTabsScreen extends StatefulWidget { const HomeTabsScreen(); + static const routeName = '/dashboard2'; @override @@ -52,6 +53,10 @@ class _HomeTabsScreenState extends State with SingleTickerProvid @override void initState() { super.initState(); + + // do we need to await this? or if it's async, how do we handle failures? + _setupPowersync(); + // Loading data here, since the build method can be called more than once _initialData = _loadEntries(); } @@ -70,6 +75,25 @@ class _HomeTabsScreenState extends State with SingleTickerProvid const GalleryScreen(), ]; + Future _setupPowersync() async { + final authProvider = context.read(); + final baseUrl = authProvider.serverUrl!; + final powerSyncUrl = baseUrl.replaceAll(':8000', ':8080'); + + await openDatabase(false, baseUrl, powerSyncUrl); + + final connector = DjangoConnector(db, baseUrl, powerSyncUrl); + try { + // TODO: should we cache these credentials? that's what their demo does? + // we could maybe get the initial token from the /api/v2/login call + final credentials = await connector.fetchCredentials(); + print('fetched credentials' + credentials.toString()); + await openDatabase(true, baseUrl, powerSyncUrl); + } catch (e) { + print('failed to fetchCredentials()' + e.toString()); + } + } + /// Load initial data from the server Future _loadEntries() async { final authProvider = context.read(); @@ -84,7 +108,7 @@ class _HomeTabsScreenState extends State with SingleTickerProvid final userProvider = context.read(); // Base data - log('Loading base data'); + log.log(Level.FINER, Level.FINER, 'Loading base data'); try { await Future.wait([ authProvider.setServerVersion(), @@ -94,39 +118,26 @@ class _HomeTabsScreenState extends State with SingleTickerProvid exercisesProvider.fetchAndSetInitialData(), ]); } catch (e) { - log('Exception loading base data'); - log(e.toString()); + log.log(Level.FINER, 'Exception loading base data'); + log.log(Level.FINER, e.toString()); } // Plans, weight and gallery - log('Loading plans, weight, measurements and gallery'); + log.log(Level.FINER, 'Loading workouts, weight, measurements and gallery'); try { await Future.wait([ galleryProvider.fetchAndSetGallery(), - nutritionPlansProvider.fetchAndSetAllPlansSparse(), workoutPlansProvider.fetchAndSetAllPlansSparse(), weightProvider.fetchAndSetEntries(), measurementProvider.fetchAndSetAllCategoriesAndEntries(), ]); } catch (e) { - log('Exception loading plans, weight, measurements and gallery'); - log(e.toString()); - } - - // Current nutritional plan - log('Loading current nutritional plan'); - try { - if (nutritionPlansProvider.currentPlan != null) { - final plan = nutritionPlansProvider.currentPlan!; - await nutritionPlansProvider.fetchAndSetPlanFull(plan.id!); - } - } catch (e) { - log('Exception loading current nutritional plan'); - log(e.toString()); + log.log(Level.FINER, 'Exception loading plans, weight, measurements and gallery'); + log.log(Level.FINER, e.toString()); } // Current workout plan - log('Loading current workout plan'); + log.log(Level.FINER, 'Loading current workout plan'); if (workoutPlansProvider.activePlan != null) { final planId = workoutPlansProvider.activePlan!.id!; await workoutPlansProvider.fetchAndSetWorkoutPlanFull(planId); diff --git a/lib/screens/nutritional_diary_screen.dart b/lib/screens/nutritional_diary_screen.dart index 4e60f44f0..72d97daf8 100644 --- a/lib/screens/nutritional_diary_screen.dart +++ b/lib/screens/nutritional_diary_screen.dart @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; @@ -26,7 +28,7 @@ import 'package:wger/widgets/nutrition/nutritional_diary_detail.dart'; /// Arguments passed to the form screen class NutritionalDiaryArguments { /// Nutritional plan - final NutritionalPlan plan; + final String plan; /// Date to show data for final DateTime date; @@ -34,23 +36,50 @@ class NutritionalDiaryArguments { const NutritionalDiaryArguments(this.plan, this.date); } -class NutritionalDiaryScreen extends StatelessWidget { +class NutritionalDiaryScreen extends StatefulWidget { const NutritionalDiaryScreen(); static const routeName = '/nutritional-diary'; @override - Widget build(BuildContext context) { + State createState() => _NutritionalDiaryScreenState(); +} + +class _NutritionalDiaryScreenState extends State { + NutritionalPlan? _plan; + late DateTime date; + StreamSubscription? _subscription; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); final args = ModalRoute.of(context)!.settings.arguments as NutritionalDiaryArguments; + date = args.date; + + final stream = + Provider.of(context, listen: false).watchNutritionPlan(args.plan); + _subscription = stream.listen((plan) { + if (!context.mounted) { + return; + } + setState(() { + _plan = plan; + }); + }); + } + @override + Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text(DateFormat.yMd(Localizations.localeOf(context).languageCode).format(args.date)), + title: Text(DateFormat.yMd(Localizations.localeOf(context).languageCode).format(date)), ), body: Consumer( builder: (context, nutritionProvider, child) => SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(8.0), - child: NutritionalDiaryDetailWidget(args.plan, args.date), + child: _plan == null + ? const Text('plan not found') + : NutritionalDiaryDetailWidget(_plan!, date), ), ), ), diff --git a/lib/screens/nutritional_plan_screen.dart b/lib/screens/nutritional_plan_screen.dart index 6d695a299..4f1c5ec95 100644 --- a/lib/screens/nutritional_plan_screen.dart +++ b/lib/screens/nutritional_plan_screen.dart @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_svg_icons/flutter_svg_icons.dart'; @@ -32,157 +34,189 @@ enum NutritionalPlanOptions { delete, } -class NutritionalPlanScreen extends StatelessWidget { +class NutritionalPlanScreen extends StatefulWidget { const NutritionalPlanScreen(); static const routeName = '/nutritional-plan-detail'; - Future _loadFullPlan(BuildContext context, int planId) { - return Provider.of(context, listen: false).fetchAndSetPlanFull(planId); + @override + _NutritionalPlanScreenState createState() => _NutritionalPlanScreenState(); +} + +class _NutritionalPlanScreenState extends State { + NutritionalPlan? _plan; + StreamSubscription? _subscription; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final id = ModalRoute.of(context)!.settings.arguments as String; + //final id = 111; + + final stream = + Provider.of(context, listen: false).watchNutritionPlan(id); + _subscription = stream.listen((plan) { + if (!context.mounted) { + return; + } + setState(() { + _plan = plan; + }); + }); + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); } @override Widget build(BuildContext context) { const appBarForeground = Colors.white; - final nutritionalPlan = ModalRoute.of(context)!.settings.arguments as NutritionalPlan; return Scaffold( //appBar: getAppBar(nutritionalPlan), - floatingActionButton: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - FloatingActionButton( - heroTag: null, - tooltip: AppLocalizations.of(context).logIngredient, - onPressed: () { - Navigator.pushNamed( - context, - FormScreen.routeName, - arguments: FormScreenArguments( - AppLocalizations.of(context).logIngredient, - IngredientLogForm(nutritionalPlan), - hasListView: true, - ), - ); - }, - child: const SvgIcon( - icon: SvgIconData('assets/icons/ingredient-diary.svg'), - color: Colors.white, - ), - ), - const SizedBox(width: 8), - FloatingActionButton( - heroTag: null, - tooltip: AppLocalizations.of(context).logMeal, - onPressed: () { - Navigator.of(context).pushNamed( - LogMealsScreen.routeName, - arguments: nutritionalPlan, - ); - }, - child: const SvgIcon( - icon: SvgIconData('assets/icons/meal-diary.svg'), - color: Colors.white, - ), - ), - ], - ), - body: CustomScrollView( - slivers: [ - SliverAppBar( - foregroundColor: appBarForeground, - pinned: true, - iconTheme: const IconThemeData(color: appBarForeground), - actions: [ - if (!nutritionalPlan.onlyLogging) - IconButton( - icon: const SvgIcon( - icon: SvgIconData('assets/icons/meal-add.svg'), - ), + floatingActionButton: _plan == null + ? const Offstage() + : Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FloatingActionButton( + heroTag: null, + tooltip: AppLocalizations.of(context).logIngredient, onPressed: () { Navigator.pushNamed( context, FormScreen.routeName, arguments: FormScreenArguments( - AppLocalizations.of(context).addMeal, - MealForm(nutritionalPlan.id!), + AppLocalizations.of(context).logIngredient, + IngredientLogForm(_plan!), + hasListView: true, ), ); }, + child: const SvgIcon( + icon: SvgIconData('assets/icons/ingredient-diary.svg'), + color: Colors.white, + ), ), - PopupMenuButton( - icon: const Icon(Icons.more_vert, color: appBarForeground), - onSelected: (value) { - switch (value) { - case NutritionalPlanOptions.edit: - Navigator.pushNamed( - context, - FormScreen.routeName, - arguments: FormScreenArguments( - AppLocalizations.of(context).edit, - PlanForm(nutritionalPlan), - hasListView: true, + const SizedBox(width: 8), + FloatingActionButton( + heroTag: null, + tooltip: AppLocalizations.of(context).logMeal, + onPressed: () { + Navigator.of(context).pushNamed( + LogMealsScreen.routeName, + arguments: _plan, + ); + }, + child: const SvgIcon( + icon: SvgIconData('assets/icons/meal-diary.svg'), + color: Colors.white, + ), + ), + ], + ), + body: _plan == null + ? const Text('plan not found') + : CustomScrollView( + slivers: [ + SliverAppBar( + foregroundColor: appBarForeground, + pinned: true, + iconTheme: const IconThemeData(color: appBarForeground), + actions: [ + if (!_plan!.onlyLogging) + IconButton( + icon: const SvgIcon( + icon: SvgIconData('assets/icons/meal-add.svg'), ), - ); - break; - case NutritionalPlanOptions.delete: - Provider.of(context, listen: false) - .deletePlan(nutritionalPlan.id!); - Navigator.of(context).pop(); - break; - } - }, - itemBuilder: (BuildContext context) { - return [ - PopupMenuItem( - value: NutritionalPlanOptions.edit, - child: ListTile( - leading: const Icon(Icons.edit), - title: Text(AppLocalizations.of(context).edit), + onPressed: () { + Navigator.pushNamed( + context, + FormScreen.routeName, + arguments: FormScreenArguments( + AppLocalizations.of(context).addMeal, + MealForm(_plan!.id!), + ), + ); + }, ), + PopupMenuButton( + icon: const Icon(Icons.more_vert, color: appBarForeground), + onSelected: (value) { + switch (value) { + case NutritionalPlanOptions.edit: + Navigator.pushNamed( + context, + FormScreen.routeName, + arguments: FormScreenArguments( + AppLocalizations.of(context).edit, + PlanForm(_plan), + hasListView: true, + ), + ); + break; + case NutritionalPlanOptions.delete: + Provider.of(context, listen: false) + .deletePlan(_plan!.id!); + Navigator.of(context).pop(); + break; + } + }, + itemBuilder: (BuildContext context) { + return [ + PopupMenuItem( + value: NutritionalPlanOptions.edit, + child: ListTile( + leading: const Icon(Icons.edit), + title: Text(AppLocalizations.of(context).edit), + ), + ), + const PopupMenuDivider(), + PopupMenuItem( + value: NutritionalPlanOptions.delete, + child: ListTile( + leading: const Icon(Icons.delete), + title: Text(AppLocalizations.of(context).delete), + ), + ), + ]; + }, ), - const PopupMenuDivider(), - PopupMenuItem( - value: NutritionalPlanOptions.delete, - child: ListTile( - leading: const Icon(Icons.delete), - title: Text(AppLocalizations.of(context).delete), - ), + ], + flexibleSpace: FlexibleSpaceBar( + titlePadding: const EdgeInsets.fromLTRB(56, 0, 56, 16), + title: Text( + _plan!.getLabel(context), + style: + Theme.of(context).textTheme.titleLarge?.copyWith(color: appBarForeground), ), - ]; - }, - ), - ], - flexibleSpace: FlexibleSpaceBar( - titlePadding: const EdgeInsets.fromLTRB(56, 0, 56, 16), - title: Text( - nutritionalPlan.getLabel(context), - style: Theme.of(context).textTheme.titleLarge?.copyWith(color: appBarForeground), - ), - ), - ), - FutureBuilder( - future: _loadFullPlan(context, nutritionalPlan.id!), - builder: (context, AsyncSnapshot snapshot) => - snapshot.connectionState == ConnectionState.waiting - ? SliverList( - delegate: SliverChildListDelegate( - [ - const SizedBox( - height: 200, - child: Center( - child: CircularProgressIndicator(), + ), + ), + FutureBuilder( + future: NutritionalPlan.read(_plan!.id!), + builder: (context, AsyncSnapshot snapshot) => + snapshot.connectionState == ConnectionState.waiting + ? SliverList( + delegate: SliverChildListDelegate( + [ + const SizedBox( + height: 200, + child: Center( + child: CircularProgressIndicator(), + ), + ), + ], ), + ) + : Consumer( + builder: (context, value, child) => + NutritionalPlanDetailWidget(_plan!), ), - ], - ), - ) - : Consumer( - builder: (context, value, child) => - NutritionalPlanDetailWidget(nutritionalPlan), - ), - ), - ], - ), + ), + ], + ), ); } } diff --git a/lib/screens/nutritional_plans_screen.dart b/lib/screens/nutritional_plans_screen.dart index 43ce024c4..d6c9e706f 100644 --- a/lib/screens/nutritional_plans_screen.dart +++ b/lib/screens/nutritional_plans_screen.dart @@ -48,7 +48,7 @@ class NutritionalPlansScreen extends StatelessWidget { }, ), body: Consumer( - builder: (context, nutritionProvider, child) => NutritionalPlansList(nutritionProvider), + builder: (context, nutritionProvider, child) => NutritionalPlansList(), ), ); } diff --git a/lib/widgets/dashboard/widgets.dart b/lib/widgets/dashboard/widgets.dart index 3d08390aa..1397e1568 100644 --- a/lib/widgets/dashboard/widgets.dart +++ b/lib/widgets/dashboard/widgets.dart @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +import 'dart:async'; + import 'package:carousel_slider/carousel_slider.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -57,13 +59,30 @@ class DashboardNutritionWidget extends StatefulWidget { class _DashboardNutritionWidgetState extends State { NutritionalPlan? _plan; + StreamSubscription? _subscription; + bool _hasContent = false; @override void initState() { super.initState(); - _plan = Provider.of(context, listen: false).currentPlan; - _hasContent = _plan != null; + final stream = + Provider.of(context, listen: false).watchNutritionPlanLast(); + _subscription = stream.listen((plan) { + if (!context.mounted) { + return; + } + setState(() { + _plan = plan; + _hasContent = _plan != null; + }); + }); + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); } @override @@ -110,7 +129,7 @@ class _DashboardNutritionWidgetState extends State { onPressed: () { Navigator.of(context).pushNamed( NutritionalPlanScreen.routeName, - arguments: _plan, + arguments: _plan!.id, ); }, ), diff --git a/lib/widgets/nutrition/forms.dart b/lib/widgets/nutrition/forms.dart index ec336f376..faad4a6f9 100644 --- a/lib/widgets/nutrition/forms.dart +++ b/lib/widgets/nutrition/forms.dart @@ -36,7 +36,7 @@ import 'package:wger/widgets/nutrition/widgets.dart'; class MealForm extends StatelessWidget { late final Meal _meal; - final int _planId; + final String _planId; final _form = GlobalKey(); final _timeController = TextEditingController(); @@ -128,7 +128,7 @@ Widget MealItemForm( ]) { return IngredientForm( // TODO we use planId 0 here cause we don't have one and we don't need it I think? - recent: recent.map((e) => Log.fromMealItem(e, 0, e.mealId)).toList(), + recent: recent.map((e) => Log.fromMealItem(e, "0", e.mealId)).toList(), onSave: (BuildContext context, MealItem mealItem, DateTime? dt) { mealItem.mealId = meal.id!; Provider.of(context, listen: false).addMealItem(mealItem, meal); @@ -672,7 +672,7 @@ class _PlanFormState extends State { if (context.mounted) { Navigator.of(context).pushReplacementNamed( NutritionalPlanScreen.routeName, - arguments: widget._plan, + arguments: widget._plan.id, ); } } diff --git a/lib/widgets/nutrition/nutritional_diary_table.dart b/lib/widgets/nutrition/nutritional_diary_table.dart index ec7dddc87..f69bb814d 100644 --- a/lib/widgets/nutrition/nutritional_diary_table.dart +++ b/lib/widgets/nutrition/nutritional_diary_table.dart @@ -117,7 +117,7 @@ class NutritionalDiaryTable extends StatelessWidget { return GestureDetector( onTap: () => Navigator.of(context).pushNamed( NutritionalDiaryScreen.routeName, - arguments: NutritionalDiaryArguments(plan, date), + arguments: NutritionalDiaryArguments(plan.id!, date), ), child: element, ); diff --git a/lib/widgets/nutrition/nutritional_plans_list.dart b/lib/widgets/nutrition/nutritional_plans_list.dart index d1a6a48e0..16449e288 100644 --- a/lib/widgets/nutrition/nutritional_plans_list.dart +++ b/lib/widgets/nutrition/nutritional_plans_list.dart @@ -16,101 +16,122 @@ * along with this program. If not, see . */ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; +import 'package:wger/models/nutrition/nutritional_plan.dart'; import 'package:wger/providers/nutrition.dart'; import 'package:wger/screens/nutritional_plan_screen.dart'; import 'package:wger/widgets/core/text_prompt.dart'; -class NutritionalPlansList extends StatelessWidget { - final NutritionPlansProvider _nutritionProvider; +class NutritionalPlansList extends StatefulWidget { + @override + _NutritionalPlansListState createState() => _NutritionalPlansListState(); +} + +class _NutritionalPlansListState extends State { + List _plans = []; + StreamSubscription? _subscription; - const NutritionalPlansList(this._nutritionProvider); + @override + void initState() { + super.initState(); + final stream = + Provider.of(context, listen: false).watchNutritionPlans(); + _subscription = stream.listen((plans) { + if (!context.mounted) { + return; + } + setState(() { + _plans = plans; + }); + }); + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } @override Widget build(BuildContext context) { - return RefreshIndicator( - onRefresh: () => _nutritionProvider.fetchAndSetAllPlansSparse(), - child: _nutritionProvider.items.isEmpty - ? const TextPrompt() - : ListView.builder( - padding: const EdgeInsets.all(10.0), - itemCount: _nutritionProvider.items.length, - itemBuilder: (context, index) { - final currentPlan = _nutritionProvider.items[index]; - return Card( - child: ListTile( - onTap: () { - Navigator.of(context).pushNamed( - NutritionalPlanScreen.routeName, - arguments: currentPlan, - ); - }, - title: Text(currentPlan.getLabel(context)), - subtitle: Text( - DateFormat.yMd( - Localizations.localeOf(context).languageCode, - ).format(currentPlan.creationDate), - ), - trailing: Row(mainAxisSize: MainAxisSize.min, children: [ - const VerticalDivider(), - IconButton( - icon: const Icon(Icons.delete), - tooltip: AppLocalizations.of(context).delete, - onPressed: () async { - // Delete the plan from DB - await showDialog( - context: context, - builder: (BuildContext contextDialog) { - return AlertDialog( - content: Text( - AppLocalizations.of(context) - .confirmDelete(currentPlan.description), + final provider = Provider.of(context, listen: false); + + return _plans.isEmpty + ? const TextPrompt() + : ListView.builder( + padding: const EdgeInsets.all(10.0), + itemCount: _plans.length, + itemBuilder: (context, index) { + final currentPlan = _plans[index]; + return Card( + child: ListTile( + onTap: () { + Navigator.of(context).pushNamed( + NutritionalPlanScreen.routeName, + arguments: currentPlan.id, + ); + }, + title: Text(currentPlan.getLabel(context)), + subtitle: Text( + DateFormat.yMd( + Localizations.localeOf(context).languageCode, + ).format(currentPlan.creationDate), + ), + trailing: Row(mainAxisSize: MainAxisSize.min, children: [ + const VerticalDivider(), + IconButton( + icon: const Icon(Icons.delete), + tooltip: AppLocalizations.of(context).delete, + onPressed: () async { + await showDialog( + context: context, + builder: (BuildContext contextDialog) { + return AlertDialog( + content: Text( + AppLocalizations.of(context).confirmDelete(currentPlan.description), + ), + actions: [ + TextButton( + child: Text( + MaterialLocalizations.of(context).cancelButtonLabel, + ), + onPressed: () => Navigator.of(contextDialog).pop(), ), - actions: [ - TextButton( - child: Text( - MaterialLocalizations.of(context).cancelButtonLabel, + TextButton( + child: Text( + AppLocalizations.of(context).delete, + style: TextStyle( + color: Theme.of(context).colorScheme.error, ), - onPressed: () => Navigator.of(contextDialog).pop(), ), - TextButton( - child: Text( - AppLocalizations.of(context).delete, - style: TextStyle( - color: Theme.of(context).colorScheme.error, - ), - ), - onPressed: () { - // Confirmed, delete the plan - _nutritionProvider.deletePlan(currentPlan.id!); - - // Close the popup - Navigator.of(contextDialog).pop(); - - // and inform the user - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - AppLocalizations.of(context).successfullyDeleted, - textAlign: TextAlign.center, - ), + onPressed: () { + provider.deletePlan(currentPlan.id!); + Navigator.of(contextDialog).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context).successfullyDeleted, + textAlign: TextAlign.center, ), - ); - }, - ), - ], - ); - }, - ); - }, - ), - ]), - ), - ); - }, - ), - ); + ), + ); + }, + ), + ], + ); + }, + ); + }, + ), + ]), + ), + ); + }, + ); } } diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 9f99dda79..e1098f6ba 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include @@ -14,6 +15,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) powersync_flutter_libs_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "PowersyncFlutterLibsPlugin"); + powersync_flutter_libs_plugin_register_with_registrar(powersync_flutter_libs_registrar); g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 74369f251..296e5b2f2 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_linux + powersync_flutter_libs sqlite3_flutter_libs url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index e5d0d3081..20619c357 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,6 +8,7 @@ import Foundation import file_selector_macos import package_info_plus import path_provider_foundation +import powersync_flutter_libs import rive_common import shared_preferences_foundation import sqlite3_flutter_libs @@ -18,6 +19,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + PowersyncFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "PowersyncFlutterLibsPlugin")) RivePlugin.register(with: registry.registrar(forPlugin: "RivePlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) diff --git a/pubspec.lock b/pubspec.lock index f79942611..f7c40298b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -329,6 +329,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + fetch_api: + dependency: transitive + description: + name: fetch_api + sha256: "97f46c25b480aad74f7cc2ad7ccba2c5c6f08d008e68f95c1077286ce243d0e6" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + fetch_client: + dependency: transitive + description: + name: fetch_client + sha256: "9666ee14536778474072245ed5cba07db81ae8eb5de3b7bf4a2d1e2c49696092" + url: "https://pub.dev" + source: hosted + version: "1.1.2" ffi: dependency: transitive description: @@ -821,7 +837,7 @@ packages: source: hosted version: "1.0.2" logging: - dependency: transitive + dependency: "direct main" description: name: logging sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" @@ -892,6 +908,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.3" + mutex: + dependency: transitive + description: + name: mutex + sha256: "8827da25de792088eb33e572115a5eb0d61d61a3c01acbc8bcbe76ed78f1a1f2" + url: "https://pub.dev" + source: hosted + version: "3.1.0" nested: dependency: transitive description: @@ -1060,6 +1084,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.1" + powersync: + dependency: "direct main" + description: + name: powersync + sha256: "7f1d2f38a936d3afd82447c4aee3b103929c6987beeae8353ccb135fe7490534" + url: "https://pub.dev" + source: hosted + version: "1.8.6" + powersync_flutter_libs: + dependency: transitive + description: + name: powersync_flutter_libs + sha256: "9cddbbc91a5887eb54297fc8f189aff76ca5f70988eaf702cf46d1ab2bdb3b72" + url: "https://pub.dev" + source: hosted + version: "0.4.0" process: dependency: transitive description: @@ -1233,6 +1273,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.0" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" sqlite3: dependency: transitive description: @@ -1249,6 +1297,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.24" + sqlite3_web: + dependency: transitive + description: + name: sqlite3_web + sha256: b4043336e74cac54d3ca44c90434a3c310550b9a80851b09ad1af282af0df6d4 + url: "https://pub.dev" + source: hosted + version: "0.1.3" + sqlite_async: + dependency: "direct main" + description: + name: sqlite_async + sha256: c5c57b025133d0869cce6a647f99b378ab42cc26488ff22ff942ae9588201af0 + url: "https://pub.dev" + source: hosted + version: "0.9.0" sqlparser: dependency: transitive description: @@ -1337,6 +1401,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.2" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + url: "https://pub.dev" + source: hosted + version: "2.2.2" url_launcher: dependency: "direct main" description: @@ -1401,6 +1473,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.1" + uuid: + dependency: transitive + description: + name: uuid + sha256: "83d37c7ad7aaf9aa8e275490669535c8080377cfa7a7004c24dfac53afffaa90" + url: "https://pub.dev" + source: hosted + version: "4.4.2" vector_graphics: dependency: transitive description: @@ -1570,5 +1650,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.4.0 <4.0.0" + dart: ">=3.5.0 <4.0.0" flutter: ">=3.22.0" diff --git a/pubspec.yaml b/pubspec.yaml index 4a8ec05c9..aaee00e47 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,6 +33,7 @@ dependencies: sdk: flutter android_metadata: ^0.2.1 + powersync: ^1.5.5 collection: ^1.17.0 cupertino_icons: ^1.0.8 equatable: ^2.0.5 @@ -70,6 +71,8 @@ dependencies: freezed_annotation: ^2.4.4 clock: ^1.1.1 flutter_svg_icons: ^0.0.1 + sqlite_async: ^0.9.0 + logging: ^1.2.0 dependency_overrides: intl: ^0.19.0 diff --git a/test/core/settings_test.mocks.dart b/test/core/settings_test.mocks.dart index 303216098..5812e036b 100644 --- a/test/core/settings_test.mocks.dart +++ b/test/core/settings_test.mocks.dart @@ -711,79 +711,34 @@ class MockNutritionPlansProvider extends _i1.Mock ); @override - _i10.NutritionalPlan findById(int? id) => (super.noSuchMethod( + _i15.Stream<_i10.NutritionalPlan?> watchNutritionPlan(String? id) => + (super.noSuchMethod( Invocation.method( - #findById, + #watchNutritionPlan, [id], ), - returnValue: _FakeNutritionalPlan_8( - this, - Invocation.method( - #findById, - [id], - ), - ), - ) as _i10.NutritionalPlan); - - @override - _i11.Meal? findMealById(int? id) => (super.noSuchMethod(Invocation.method( - #findMealById, - [id], - )) as _i11.Meal?); - - @override - _i15.Future fetchAndSetAllPlansSparse() => (super.noSuchMethod( - Invocation.method( - #fetchAndSetAllPlansSparse, - [], - ), - returnValue: _i15.Future.value(), - returnValueForMissingStub: _i15.Future.value(), - ) as _i15.Future); - - @override - _i15.Future fetchAndSetAllPlansFull() => (super.noSuchMethod( - Invocation.method( - #fetchAndSetAllPlansFull, - [], - ), - returnValue: _i15.Future.value(), - returnValueForMissingStub: _i15.Future.value(), - ) as _i15.Future); + returnValue: _i15.Stream<_i10.NutritionalPlan?>.empty(), + ) as _i15.Stream<_i10.NutritionalPlan?>); @override - _i15.Future<_i10.NutritionalPlan> fetchAndSetPlanSparse(int? planId) => + _i15.Stream<_i10.NutritionalPlan> watchNutritionPlanLast() => (super.noSuchMethod( Invocation.method( - #fetchAndSetPlanSparse, - [planId], + #watchNutritionPlanLast, + [], ), - returnValue: - _i15.Future<_i10.NutritionalPlan>.value(_FakeNutritionalPlan_8( - this, - Invocation.method( - #fetchAndSetPlanSparse, - [planId], - ), - )), - ) as _i15.Future<_i10.NutritionalPlan>); + returnValue: _i15.Stream<_i10.NutritionalPlan>.empty(), + ) as _i15.Stream<_i10.NutritionalPlan>); @override - _i15.Future<_i10.NutritionalPlan> fetchAndSetPlanFull(int? planId) => + _i15.Stream> watchNutritionPlans() => (super.noSuchMethod( Invocation.method( - #fetchAndSetPlanFull, - [planId], + #watchNutritionPlans, + [], ), - returnValue: - _i15.Future<_i10.NutritionalPlan>.value(_FakeNutritionalPlan_8( - this, - Invocation.method( - #fetchAndSetPlanFull, - [planId], - ), - )), - ) as _i15.Future<_i10.NutritionalPlan>); + returnValue: _i15.Stream>.empty(), + ) as _i15.Stream>); @override _i15.Future<_i10.NutritionalPlan> addPlan(_i10.NutritionalPlan? planData) => @@ -813,7 +768,7 @@ class MockNutritionPlansProvider extends _i1.Mock ) as _i15.Future); @override - _i15.Future deletePlan(int? id) => (super.noSuchMethod( + _i15.Future deletePlan(String? id) => (super.noSuchMethod( Invocation.method( #deletePlan, [id], @@ -825,7 +780,7 @@ class MockNutritionPlansProvider extends _i1.Mock @override _i15.Future<_i11.Meal> addMeal( _i11.Meal? meal, - int? planId, + String? planId, ) => (super.noSuchMethod( Invocation.method( @@ -991,7 +946,7 @@ class MockNutritionPlansProvider extends _i1.Mock @override _i15.Future logIngredientToDiary( _i12.MealItem? mealItem, - int? planId, [ + String? planId, [ DateTime? dateTime, ]) => (super.noSuchMethod( @@ -1009,8 +964,8 @@ class MockNutritionPlansProvider extends _i1.Mock @override _i15.Future deleteLog( - int? logId, - int? planId, + String? logId, + String? planId, ) => (super.noSuchMethod( Invocation.method( diff --git a/test/nutrition/nutrition_provider_test.dart b/test/nutrition/nutrition_provider_test.dart index 2b8fb842a..d7922df24 100644 --- a/test/nutrition/nutrition_provider_test.dart +++ b/test/nutrition/nutrition_provider_test.dart @@ -103,7 +103,7 @@ void main() { group('fetchAndSetPlanFull', () { test('should correctly load a full nutritional plan', () async { // arrange - await nutritionProvider.fetchAndSetPlanFull(1); + // await nutritionProvider.fetchAndSetPlanFull(1); // assert expect(nutritionProvider.items.isEmpty, false); diff --git a/test/nutrition/nutritional_meal_form_test.dart b/test/nutrition/nutritional_meal_form_test.dart index fdb781f64..a053b19fa 100644 --- a/test/nutrition/nutritional_meal_form_test.dart +++ b/test/nutrition/nutritional_meal_form_test.dart @@ -60,7 +60,7 @@ void main() { localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, navigatorKey: key, - home: Scaffold(body: MealForm(1, meal)), + home: Scaffold(body: MealForm("1", meal)), routes: { NutritionalPlanScreen.routeName: (ctx) => const NutritionalPlanScreen(), }, diff --git a/test/nutrition/nutritional_meal_form_test.mocks.dart b/test/nutrition/nutritional_meal_form_test.mocks.dart index 3df6d7b22..08ce46e60 100644 --- a/test/nutrition/nutritional_meal_form_test.mocks.dart +++ b/test/nutrition/nutritional_meal_form_test.mocks.dart @@ -165,79 +165,34 @@ class MockNutritionPlansProvider extends _i1.Mock ); @override - _i4.NutritionalPlan findById(int? id) => (super.noSuchMethod( + _i9.Stream<_i4.NutritionalPlan?> watchNutritionPlan(String? id) => + (super.noSuchMethod( Invocation.method( - #findById, + #watchNutritionPlan, [id], ), - returnValue: _FakeNutritionalPlan_2( - this, - Invocation.method( - #findById, - [id], - ), - ), - ) as _i4.NutritionalPlan); - - @override - _i5.Meal? findMealById(int? id) => (super.noSuchMethod(Invocation.method( - #findMealById, - [id], - )) as _i5.Meal?); - - @override - _i9.Future fetchAndSetAllPlansSparse() => (super.noSuchMethod( - Invocation.method( - #fetchAndSetAllPlansSparse, - [], - ), - returnValue: _i9.Future.value(), - returnValueForMissingStub: _i9.Future.value(), - ) as _i9.Future); + returnValue: _i9.Stream<_i4.NutritionalPlan?>.empty(), + ) as _i9.Stream<_i4.NutritionalPlan?>); @override - _i9.Future fetchAndSetAllPlansFull() => (super.noSuchMethod( - Invocation.method( - #fetchAndSetAllPlansFull, - [], - ), - returnValue: _i9.Future.value(), - returnValueForMissingStub: _i9.Future.value(), - ) as _i9.Future); - - @override - _i9.Future<_i4.NutritionalPlan> fetchAndSetPlanSparse(int? planId) => + _i9.Stream<_i4.NutritionalPlan> watchNutritionPlanLast() => (super.noSuchMethod( Invocation.method( - #fetchAndSetPlanSparse, - [planId], + #watchNutritionPlanLast, + [], ), - returnValue: - _i9.Future<_i4.NutritionalPlan>.value(_FakeNutritionalPlan_2( - this, - Invocation.method( - #fetchAndSetPlanSparse, - [planId], - ), - )), - ) as _i9.Future<_i4.NutritionalPlan>); + returnValue: _i9.Stream<_i4.NutritionalPlan>.empty(), + ) as _i9.Stream<_i4.NutritionalPlan>); @override - _i9.Future<_i4.NutritionalPlan> fetchAndSetPlanFull(int? planId) => + _i9.Stream> watchNutritionPlans() => (super.noSuchMethod( Invocation.method( - #fetchAndSetPlanFull, - [planId], + #watchNutritionPlans, + [], ), - returnValue: - _i9.Future<_i4.NutritionalPlan>.value(_FakeNutritionalPlan_2( - this, - Invocation.method( - #fetchAndSetPlanFull, - [planId], - ), - )), - ) as _i9.Future<_i4.NutritionalPlan>); + returnValue: _i9.Stream>.empty(), + ) as _i9.Stream>); @override _i9.Future<_i4.NutritionalPlan> addPlan(_i4.NutritionalPlan? planData) => @@ -267,7 +222,7 @@ class MockNutritionPlansProvider extends _i1.Mock ) as _i9.Future); @override - _i9.Future deletePlan(int? id) => (super.noSuchMethod( + _i9.Future deletePlan(String? id) => (super.noSuchMethod( Invocation.method( #deletePlan, [id], @@ -279,7 +234,7 @@ class MockNutritionPlansProvider extends _i1.Mock @override _i9.Future<_i5.Meal> addMeal( _i5.Meal? meal, - int? planId, + String? planId, ) => (super.noSuchMethod( Invocation.method( @@ -445,7 +400,7 @@ class MockNutritionPlansProvider extends _i1.Mock @override _i9.Future logIngredientToDiary( _i6.MealItem? mealItem, - int? planId, [ + String? planId, [ DateTime? dateTime, ]) => (super.noSuchMethod( @@ -463,8 +418,8 @@ class MockNutritionPlansProvider extends _i1.Mock @override _i9.Future deleteLog( - int? logId, - int? planId, + String? logId, + String? planId, ) => (super.noSuchMethod( Invocation.method( diff --git a/test/nutrition/nutritional_plan_form_test.dart b/test/nutrition/nutritional_plan_form_test.dart index e366ef51f..ce89c0fef 100644 --- a/test/nutrition/nutritional_plan_form_test.dart +++ b/test/nutrition/nutritional_plan_form_test.dart @@ -35,7 +35,7 @@ void main() { var mockNutrition = MockNutritionPlansProvider(); final plan1 = NutritionalPlan( - id: 1, + id: 'deadbeef', creationDate: DateTime(2021, 1, 1), description: 'test plan 1', ); diff --git a/test/nutrition/nutritional_plan_form_test.mocks.dart b/test/nutrition/nutritional_plan_form_test.mocks.dart index 3b48c5305..735d15379 100644 --- a/test/nutrition/nutritional_plan_form_test.mocks.dart +++ b/test/nutrition/nutritional_plan_form_test.mocks.dart @@ -165,79 +165,34 @@ class MockNutritionPlansProvider extends _i1.Mock ); @override - _i4.NutritionalPlan findById(int? id) => (super.noSuchMethod( + _i9.Stream<_i4.NutritionalPlan?> watchNutritionPlan(String? id) => + (super.noSuchMethod( Invocation.method( - #findById, + #watchNutritionPlan, [id], ), - returnValue: _FakeNutritionalPlan_2( - this, - Invocation.method( - #findById, - [id], - ), - ), - ) as _i4.NutritionalPlan); - - @override - _i5.Meal? findMealById(int? id) => (super.noSuchMethod(Invocation.method( - #findMealById, - [id], - )) as _i5.Meal?); - - @override - _i9.Future fetchAndSetAllPlansSparse() => (super.noSuchMethod( - Invocation.method( - #fetchAndSetAllPlansSparse, - [], - ), - returnValue: _i9.Future.value(), - returnValueForMissingStub: _i9.Future.value(), - ) as _i9.Future); + returnValue: _i9.Stream<_i4.NutritionalPlan?>.empty(), + ) as _i9.Stream<_i4.NutritionalPlan?>); @override - _i9.Future fetchAndSetAllPlansFull() => (super.noSuchMethod( - Invocation.method( - #fetchAndSetAllPlansFull, - [], - ), - returnValue: _i9.Future.value(), - returnValueForMissingStub: _i9.Future.value(), - ) as _i9.Future); - - @override - _i9.Future<_i4.NutritionalPlan> fetchAndSetPlanSparse(int? planId) => + _i9.Stream<_i4.NutritionalPlan> watchNutritionPlanLast() => (super.noSuchMethod( Invocation.method( - #fetchAndSetPlanSparse, - [planId], + #watchNutritionPlanLast, + [], ), - returnValue: - _i9.Future<_i4.NutritionalPlan>.value(_FakeNutritionalPlan_2( - this, - Invocation.method( - #fetchAndSetPlanSparse, - [planId], - ), - )), - ) as _i9.Future<_i4.NutritionalPlan>); + returnValue: _i9.Stream<_i4.NutritionalPlan>.empty(), + ) as _i9.Stream<_i4.NutritionalPlan>); @override - _i9.Future<_i4.NutritionalPlan> fetchAndSetPlanFull(int? planId) => + _i9.Stream> watchNutritionPlans() => (super.noSuchMethod( Invocation.method( - #fetchAndSetPlanFull, - [planId], + #watchNutritionPlans, + [], ), - returnValue: - _i9.Future<_i4.NutritionalPlan>.value(_FakeNutritionalPlan_2( - this, - Invocation.method( - #fetchAndSetPlanFull, - [planId], - ), - )), - ) as _i9.Future<_i4.NutritionalPlan>); + returnValue: _i9.Stream>.empty(), + ) as _i9.Stream>); @override _i9.Future<_i4.NutritionalPlan> addPlan(_i4.NutritionalPlan? planData) => @@ -267,7 +222,7 @@ class MockNutritionPlansProvider extends _i1.Mock ) as _i9.Future); @override - _i9.Future deletePlan(int? id) => (super.noSuchMethod( + _i9.Future deletePlan(String? id) => (super.noSuchMethod( Invocation.method( #deletePlan, [id], @@ -279,7 +234,7 @@ class MockNutritionPlansProvider extends _i1.Mock @override _i9.Future<_i5.Meal> addMeal( _i5.Meal? meal, - int? planId, + String? planId, ) => (super.noSuchMethod( Invocation.method( @@ -445,7 +400,7 @@ class MockNutritionPlansProvider extends _i1.Mock @override _i9.Future logIngredientToDiary( _i6.MealItem? mealItem, - int? planId, [ + String? planId, [ DateTime? dateTime, ]) => (super.noSuchMethod( @@ -463,8 +418,8 @@ class MockNutritionPlansProvider extends _i1.Mock @override _i9.Future deleteLog( - int? logId, - int? planId, + String? logId, + String? planId, ) => (super.noSuchMethod( Invocation.method( diff --git a/test/nutrition/nutritional_plans_screen_test.dart b/test/nutrition/nutritional_plans_screen_test.dart index 5a60b2503..eea8de89a 100644 --- a/test/nutrition/nutritional_plans_screen_test.dart +++ b/test/nutrition/nutritional_plans_screen_test.dart @@ -67,12 +67,12 @@ void main() { mockBaseProvider, [ NutritionalPlan( - id: 1, + id: 'deadbeefa', description: 'test plan 1', creationDate: DateTime(2021, 01, 01), ), NutritionalPlan( - id: 2, + id: 'deadbeefb', description: 'test plan 2', creationDate: DateTime(2021, 01, 10), ), diff --git a/test_data/nutritional_plans.dart b/test_data/nutritional_plans.dart index 1b3a42cc6..9a202bc4c 100644 --- a/test_data/nutritional_plans.dart +++ b/test_data/nutritional_plans.dart @@ -156,32 +156,32 @@ NutritionalPlan getNutritionalPlan() { mealItem3.ingredient = ingredient3; final meal1 = Meal( - id: 1, - plan: 1, + id: 'deadbeefa', + plan: '1', time: const TimeOfDay(hour: 17, minute: 0), name: 'Initial Name 1', ); meal1.mealItems = [mealItem1, mealItem2]; final meal2 = Meal( - id: 2, - plan: 1, + id: 'deadbeefb', + plan: '1', time: const TimeOfDay(hour: 22, minute: 5), name: 'Initial Name 2', ); meal2.mealItems = [mealItem3]; final NutritionalPlan plan = NutritionalPlan( - id: 1, + id: 'deadbeefc', description: 'Less fat, more protein', creationDate: DateTime(2021, 5, 23), ); plan.meals = [meal1, meal2]; // Add logs - plan.diaryEntries.add(Log.fromMealItem(mealItem1, 1, 1, DateTime(2021, 6, 1))); - plan.diaryEntries.add(Log.fromMealItem(mealItem2, 1, 1, DateTime(2021, 6, 1))); - plan.diaryEntries.add(Log.fromMealItem(mealItem3, 1, 1, DateTime(2021, 6, 10))); + plan.diaryEntries.add(Log.fromMealItem(mealItem1, '1', '1', DateTime(2021, 6, 1))); + plan.diaryEntries.add(Log.fromMealItem(mealItem2, '1', '1', DateTime(2021, 6, 1))); + plan.diaryEntries.add(Log.fromMealItem(mealItem3, '1', '1', DateTime(2021, 6, 10))); return plan; } @@ -194,32 +194,32 @@ NutritionalPlan getNutritionalPlanScreenshot() { final mealItem3 = MealItem(ingredientId: 3, amount: 100, ingredient: apple); final meal1 = Meal( - id: 1, - plan: 1, + id: 'deadbeefa', + plan: '1', time: const TimeOfDay(hour: 8, minute: 30), name: 'Breakfast', mealItems: [mealItem1, mealItem2], ); final meal2 = Meal( - id: 2, - plan: 1, + id: 'deadbeefb', + plan: '1', time: const TimeOfDay(hour: 11, minute: 0), name: 'Snack 1', mealItems: [mealItem3], ); final NutritionalPlan plan = NutritionalPlan( - id: 1, + id: '1', description: 'Diet', creationDate: DateTime(2021, 5, 23), meals: [meal1, meal2], ); // Add logs - plan.diaryEntries.add(Log.fromMealItem(mealItem1, 1, 1, DateTime.now())); - plan.diaryEntries.add(Log.fromMealItem(mealItem2, 1, 1, DateTime.now())); - plan.diaryEntries.add(Log.fromMealItem(mealItem3, 1, 1, DateTime.now())); + plan.diaryEntries.add(Log.fromMealItem(mealItem1, '1', '1', DateTime.now())); + plan.diaryEntries.add(Log.fromMealItem(mealItem2, '1', '1', DateTime.now())); + plan.diaryEntries.add(Log.fromMealItem(mealItem3, '1', '1', DateTime.now())); for (final i in plan.diaryEntries) { i.datetime = DateTime.now();