diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..0c04c53 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "cmake.configureOnOpen": false +} diff --git a/TODO b/TODO index a7fccd9..3f69227 100644 --- a/TODO +++ b/TODO @@ -1,4 +1,25 @@ MAIN TASKS: + Database: + ☐ set name properties as unique (and add checks to forms) + ☐ implement users + ☐ check objectbox docs on how to make users + ☐ tie all data to users + ☐ add user filters to all getters + ☐ change settings to not be a singleton, but only one settings instance per user and one default entry + ☐ add login and authentification + ☐ enable restoring data from sync + ☐ find a solution for storage + ☐ hosting + ☐ objectbox sync (commercial use is not free) + ☐ implement alternative data export for now? + ☐ create default datasets for configuration (meal categories, portion types, accuracies, event types, possibly meal source) + Features: + ☐ app icon + ☐ add explanations to each section + ☐ german language support + ☐ indicate nested creation process (creating from dropdown etc) + ☐ indicate read only fields + ☐ app info/credits screen Components/Framework: ☐ come up with new concept for duration component ☐ update duration fields to use corresponding component @@ -6,59 +27,43 @@ MAIN TASKS: ☐ log event detail (reminder duration) ☐ meal (bolus delay) ☐ log bolus (delay) - ☐ set name properties as unique (and add checks to forms) ☐ check through all detail forms and set required fields/according messages ☐ change placement of delete and floating button because its very easy to accidentally hit delete - ☐ implement deletion by swiping left on item instead? ☐ check for changes before navigating as well (not just on cancel) - Reports: - ☐ evaluate what type of reports there should be - ☐ try out graph/diagram components - -FUTURE TASKS: - Features: - ☐ app icon - ☐ desktop version - ☐ add explanations to each section - ☐ alternate languages - ☐ log hba1c - ☐ indicate nested creation process (creating from dropdown etc) - ☐ enable restoring data from sync - ☐ indicate read only fields - Components/Framework: ☐ show indicator and make all fields readonly if user somehow gets to a deleted record detail view ☐ dropdown tweaks ☐ edit item -> cancel: shouldn't clear dropdwon - ☐ keep focus on textfield when typing ☐ account for deleted/disabled elements - Accuracy: - ☐ same icons in detail as in overview to indicate what's what - Recipe: - ☐ update to use correct components, init/dispose etc - ☐ change the entire concept of ingredients - ☐ add functionality to create a meal from a recipe - Reports: - ☐ meal tweaking - ☐ bolus tweaking - ☐ basal test - ☐ daily graph (showing glucose curve, events, boli and meals) - Log Overview: + Log: ☐ add filters - Log Entry: ☐ check if there is still an active bolus when suggesting glucose bolus - Event Types: - ☐ add colors as indicators for log entries (and later graphs in reports) - ☐ implement reminders as push notifications + Log Events: + ☐ add filters + Categorization: + ☐ add colors to event types as indicators for log entries and graphs in reports + ☐ implement reminders as push notifications Settings: - ☐ add option to hide extra customization options (ie. changing pre calculated values)? ☐ option to switch theme ☐ add fields for glucose target tiers (as map of cutoff glucose and colors) ☐ add field for active insulin duration - ☐ add setting for carb units/bread units ☐ add option to switch 'save' and 'save & close' buttons ☐ add functionality to delete dead records (meaning: set deleted flag and no relations to undeleted records) Archive: + ✔ switch day on swipe @done(22-03-19 23:20) @project(MAIN TASKS.Log) + ✔ switch day on swipe @done(22-03-19 23:23) @project(MAIN TASKS.Log Events) + ✔ switch day on swipe @done(22-03-19 23:20) @project(MAIN TASKS.Reports) + ✔ don't update calculation on deleting field content @done(22-03-12 04:33) @project(MAIN TASKS.Meal) + ✔ don't use suggested time if date is set to today @done(22-03-12 04:36) @project(MAIN TASKS.Log) + ✔ don't use suggested time if date is set to today @done(22-03-12 04:36) @project(MAIN TASKS.Log Events) + ✔ pack all categorization stuff (meal categories, meal source, portion type, accuracies, event type) into one sub menu @done(22-03-12 04:21) @project(MAIN TASKS.Categorization) + ✔ same icons in accuracy detail as in overview to indicate what's what @done(22-03-12 04:24) @project(MAIN TASKS.Categorization) + ✔ view daily graphs @done(22-03-12 03:47) @project(MAIN TASKS.Reports) + ✔ keep focus on textfield when typing @done(22-03-10 22:15) @project(MAIN TASKS.Components/Framework) + ✔ calculation ends up wrong on re-entering log meal detail screen @done(22-02-06 21:46) @project(BUG FIXES) + ✔ export log tables @done(22-03-10 22:01) @project(MAIN TASKS.Components/Framework) + ✔ export graphs @done(22-03-10 22:01) @project(MAIN TASKS.Components/Framework) + ✔ change auto calculation to remember fields that were filled in last @done(22-02-06 21:37) @project(MAIN TASKS.Meal) ✔ only show current day @done(22-01-24 05:39) @project(MAIN TASKS.Log Overview) ✔ add calendar field on top to navigate @done(22-01-24 05:39) @project(MAIN TASKS.Log Overview) ✔ use currently selected day when adding a log entry @done(22-01-24 05:39) @project(MAIN TASKS.Log Overview) diff --git a/google-chrome-stable_current_amd64.deb b/google-chrome-stable_current_amd64.deb new file mode 100644 index 0000000..e4e7ea9 Binary files /dev/null and b/google-chrome-stable_current_amd64.deb differ diff --git a/lib/components/app_theme.dart b/lib/components/app_theme.dart index e46df56..53765f7 100644 --- a/lib/components/app_theme.dart +++ b/lib/components/app_theme.dart @@ -26,6 +26,7 @@ class AppTheme { ), ), scrollbarTheme: baseThemeData.scrollbarTheme.copyWith( + // thumbVisibility: MaterialStateProperty.all(true), isAlwaysShown: true, ), textTheme: baseThemeData.textTheme.copyWith( diff --git a/lib/components/forms/auto_complete_dropdown_button.dart b/lib/components/forms/auto_complete_dropdown_button.dart index cd858e6..d249f1d 100644 --- a/lib/components/forms/auto_complete_dropdown_button.dart +++ b/lib/components/forms/auto_complete_dropdown_button.dart @@ -56,7 +56,6 @@ class _AutoCompleteDropdownButtonState void showOverlay() { hideOverlay(); - focusNode.requestFocus(); List items = []; Divider? divider; @@ -116,6 +115,7 @@ class _AutoCompleteDropdownButtonState showWhenUnlinked: false, child: Scrollbar( controller: _scrollController, + // thumbVisibility: true, isAlwaysShown: true, child: Material( elevation: 8, @@ -141,7 +141,7 @@ class _AutoCompleteDropdownButtonState selected: item == widget.selectedItem, title: Row( children: [ - Expanded( + Flexible( child: Text(item.toString()), ), ], diff --git a/lib/components/forms/boolean_form_field.dart b/lib/components/forms/boolean_form_field.dart index 16f5e52..d3f0390 100644 --- a/lib/components/forms/boolean_form_field.dart +++ b/lib/components/forms/boolean_form_field.dart @@ -6,6 +6,7 @@ class BooleanFormField extends StatefulWidget { final void Function(bool) onChanged; final bool? enabled; final EdgeInsets? contentPadding; + final Icon? icon; const BooleanFormField( {Key? key, @@ -13,7 +14,8 @@ class BooleanFormField extends StatefulWidget { required this.label, required this.onChanged, this.enabled, - this.contentPadding}) + this.contentPadding, + this.icon}) : super(key: key); @override @@ -25,6 +27,7 @@ class _BooleanFormFieldState extends State { Widget build(BuildContext context) { return FormField(builder: (state) { return ListTile( + leading: widget.icon, contentPadding: widget.contentPadding, onTap: () => widget.onChanged(!widget.value), trailing: Switch( diff --git a/lib/components/forms/number_form_field.dart b/lib/components/forms/number_form_field.dart index 9b41cb1..f70fdd3 100644 --- a/lib/components/forms/number_form_field.dart +++ b/lib/components/forms/number_form_field.dart @@ -86,7 +86,7 @@ class _NumberFormFieldState extends State { ), ) : Container(), - Expanded( + Flexible( child: TextFormField( readOnly: widget.readOnly, controller: widget.controller, diff --git a/lib/main.dart b/lib/main.dart index 54b0044..caf29fc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,28 +2,30 @@ import 'package:diameter/components/app_theme.dart'; import 'package:diameter/config.dart'; import 'package:diameter/models/settings.dart'; import 'package:diameter/object_box.dart'; -import 'package:diameter/screens/accuracy_detail.dart'; +import 'package:diameter/screens/category/categories.dart'; +import 'package:diameter/screens/category/accuracy_detail.dart'; +import 'package:diameter/screens/category/accuracy_list.dart'; +import 'package:diameter/screens/category/event_type_detail.dart'; +import 'package:diameter/screens/category/event_type_list.dart'; +import 'package:diameter/screens/category/meal_category_detail.dart'; +import 'package:diameter/screens/category/meal_category_list.dart'; +import 'package:diameter/screens/category/meal_portion_type_detail.dart'; +import 'package:diameter/screens/category/meal_portion_type_list.dart'; +import 'package:diameter/screens/category/meal_source_detail.dart'; +import 'package:diameter/screens/category/meal_source_list.dart'; import 'package:diameter/screens/basal/basal_profile_detail.dart'; import 'package:diameter/screens/bolus/bolus_profile_detail.dart'; -import 'package:diameter/screens/log/log.dart'; +import 'package:diameter/screens/log/log_overview.dart'; +import 'package:diameter/screens/reports/daily_chart.dart'; import 'package:diameter/screens/log/log_entry/log_entry.dart'; -import 'package:diameter/screens/log/log_event/log_event_detail.dart'; -import 'package:diameter/screens/log/log_event/log_event_list.dart'; -import 'package:diameter/screens/log/log_event/log_event_type_detail.dart'; -import 'package:diameter/screens/log/log_event/log_event_type_list.dart'; -import 'package:diameter/screens/meal/meal_category_detail.dart'; -import 'package:diameter/screens/meal/meal_category_list.dart'; +import 'package:diameter/screens/log/log_event_detail.dart'; +import 'package:diameter/screens/log/log_event_list.dart'; import 'package:diameter/screens/meal/meal_detail.dart'; import 'package:diameter/screens/meal/meal_list.dart'; -import 'package:diameter/screens/meal/meal_portion_type_detail.dart'; -import 'package:diameter/screens/meal/meal_portion_type_list.dart'; -import 'package:diameter/screens/meal/meal_source_detail.dart'; -import 'package:diameter/screens/meal/meal_source_list.dart'; -import 'package:diameter/screens/recipe/recipe_detail.dart'; -import 'package:diameter/screens/recipe/recipe_list.dart'; +import 'package:diameter/screens/reports/export.dart'; +import 'package:diameter/screens/reports/reports.dart'; import 'package:diameter/settings.dart'; import 'package:flutter/material.dart'; -import 'package:diameter/screens/accuracy_list.dart'; import 'package:diameter/screens/basal/basal_profile_list.dart'; import 'package:diameter/screens/bolus/bolus_profile_list.dart'; import 'package:diameter/navigation.dart'; @@ -56,15 +58,15 @@ Future main() async { Routes.log: (context) => const LogScreen(), Routes.logEntry: (context) => const LogEntryScreen(), Routes.logEvent: (context) => const LogEventDetailScreen(), - Routes.logEventTypes: (context) => const LogEventTypeListScreen(), - Routes.logEventType: (context) => const EventTypeDetailScreen(), - Routes.events: (context) => const LogEventListScreen(), - Routes.accuracies: (context) => const AccuracyListScreen(), - Routes.accuracy: (context) => const AccuracyDetailScreen(), + Routes.eventTypes: (context) => const EventTypeListScreen(), + Routes.eventType: (context) => const EventTypeDetailScreen(), + Routes.logEvents: (context) => const LogEventListScreen(), + Routes.reports: (context) => const ReportsOverviewScreen(), + Routes.export: (context) => const ExportDialog(), + Routes.dailyChart: (context) => const DailyChart(), Routes.meals: (context) => const MealListScreen(), Routes.meal: (context) => const MealDetailScreen(), - Routes.recipes: (context) => const RecipeListScreen(), - Routes.recipe: (context) => const RecipeDetailScreen(), + Routes.category: (context) => const CategoryOverviewScreen(), Routes.mealCategories: (context) => const MealCategoryListScreen(), Routes.mealCategory: (context) => const MealCategoryDetailScreen(), Routes.mealPortionTypes: (context) => @@ -73,6 +75,8 @@ Future main() async { const MealPortionTypeDetailScreen(), Routes.mealSources: (context) => const MealSourceListScreen(), Routes.mealSource: (context) => const MealSourceDetailScreen(), + Routes.accuracies: (context) => const AccuracyListScreen(), + Routes.accuracy: (context) => const AccuracyDetailScreen(), Routes.bolusProfiles: (context) => const BolusProfileListScreen(), Routes.bolusProfile: (context) => const BolusProfileDetailScreen(), Routes.basalProfiles: (context) => const BasalProfileListScreen(), diff --git a/lib/models/basal.dart b/lib/models/basal.dart index 9ccd952..14c8435 100644 --- a/lib/models/basal.dart +++ b/lib/models/basal.dart @@ -1,6 +1,7 @@ import 'package:diameter/main.dart'; import 'package:diameter/models/basal_profile.dart'; import 'package:diameter/utils/date_time_utils.dart'; +import 'package:flutter/material.dart'; import 'package:objectbox/objectbox.dart'; import 'package:diameter/objectbox.g.dart' show Basal_, BasalProfile_; @@ -68,6 +69,27 @@ class Basal { return sum; } + static Basal? getRateForTime(DateTime? dateTime) { + if (dateTime != null) { + final basalProfile = BasalProfile.getActive(dateTime); + final time = DateTimeUtils.convertTimeOfDayToDateTime( + TimeOfDay.fromDateTime(dateTime)); + if (basalProfile != null) { + final rates = Basal.getAllForProfile(basalProfile.id); + final result = rates.where((rate) { + DateTime endTime = rate.endTime == dummyDate + ? rate.endTime.add(const Duration(days: 1)) + : rate.endTime; + return (time.isAfter(rate.startTime) || + time.isAtSameMomentAs(rate.startTime)) && + time.isBefore(endTime); + }); + return result.length != 1 ? null : result.single; + } + } + return null; + } + @override String toString() { return DateTimeUtils.displayTime(startTime); diff --git a/lib/models/log_entry.dart b/lib/models/log_entry.dart index 3891aeb..a4edfa7 100644 --- a/lib/models/log_entry.dart +++ b/lib/models/log_entry.dart @@ -53,30 +53,14 @@ class LogEntry { return false; } - static Map> getDailyEntryMap() { - Map> dateMap = >{}; - - QueryBuilder allByDate = box - .query(LogEntry_.deleted.equals(false)) - ..order(LogEntry_.time, flags: Order.descending); - List entries = allByDate.build().find(); - DateTime? date; - - for (LogEntry entry in entries) { - date = DateTime.utc(entry.time.year, entry.time.month, entry.time.day); - dateMap.putIfAbsent(date, () => []).add(entry); - } - - return dateMap; - } - static List getAllForDate(DateTime date) { DateTime startOfDay = DateTime(date.year, date.month, date.day); DateTime endOfDay = startOfDay.add(const Duration(days: 1)); QueryBuilder builder = box.query(LogEntry_.deleted.equals(false)) ..order(LogEntry_.time, flags: Order.descending); return builder.build().find().where((entry) { - return (entry.time.compareTo(startOfDay) >= 0 && entry.time.isBefore(endOfDay)); + return (entry.time.compareTo(startOfDay) >= 0 && + entry.time.isBefore(endOfDay)); }).toList(); } diff --git a/lib/models/log_event.dart b/lib/models/log_event.dart index d35d5f3..3cd1c7d 100644 --- a/lib/models/log_event.dart +++ b/lib/models/log_event.dart @@ -44,6 +44,7 @@ class LogEvent { // methods static LogEvent? get(int id) => box.get(id); + static List getAll() => box.getAll(); static void put(LogEvent logEvent) => box.put(logEvent); static void remove(int id) { @@ -91,55 +92,6 @@ class LogEvent { return eventsWithEndTime.isNotEmpty; } - static Map> getDailyEntryMap() { - Map> dateMap = >{}; - Map> sortedDateMap = >{}; - - QueryBuilder allByDate = box - .query(LogEvent_.deleted.equals(false)) - ..order(LogEvent_.time, flags: Order.descending); - List events = allByDate.build().find(); - - DateTime? date; - - for (LogEvent event in events) { - date = DateTime.utc(event.time.year, event.time.month, event.time.day); - LogEvent startEvent = event; - startEvent.title = - '${event.toString()} ${event.hasEndTime ? '(Start)' : ''}'; - dateMap.putIfAbsent(date, () => []).add(startEvent); - } - - QueryBuilder allByEndDate = box - .query(LogEvent_.deleted.equals(false).and(LogEvent_.endTime.notNull())) - ..order(LogEvent_.endTime, flags: Order.descending); - List endEvents = allByEndDate.build().find(); - - for (LogEvent event in endEvents) { - date = DateTime.utc( - event.endTime!.year, event.endTime!.month, event.endTime!.day); - LogEvent endEvent = event; - endEvent.isEndEvent = true; - endEvent.title = '${event.toString()} (End)'; - dateMap.putIfAbsent(date, () => []).add(endEvent); - } - - final dates = dateMap.keys.toList(); - dates.sort(); - for (DateTime date in dates.reversed) { - dateMap[date]!.sort((LogEvent a, LogEvent b) { - final dateA = a.isEndEvent ? a.endTime : a.time; - final dateB = b.isEndEvent ? b.endTime : b.time; - return -(dateA!.compareTo(dateB!)); - }); - sortedDateMap - .putIfAbsent(date, () => []) - .addAll(dateMap[date]!); - } - - return sortedDateMap; - } - static List getAllForDate(DateTime date) { DateTime startOfDay = DateTime(date.year, date.month, date.day); DateTime endOfDay = startOfDay.add(const Duration(days: 1)); diff --git a/lib/models/settings.dart b/lib/models/settings.dart index e43acde..75394ca 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -101,8 +101,7 @@ class Settings { static NutritionMeasurement get nutritionMeasurement => NutritionMeasurement.values[get().nutritionMeasurementIndex]; - static GlucoseMeasurement get glucoseMeasurement => - GlucoseMeasurement.values[get().glucoseMeasurementIndex]; + static GlucoseMeasurement get glucoseMeasurement => GlucoseMeasurement.values[get().glucoseMeasurementIndex]; static GlucoseDisplayMode get glucoseDisplayMode => GlucoseDisplayMode.values[get().glucoseDisplayModeIndex]; diff --git a/lib/models/user.dart b/lib/models/user.dart new file mode 100644 index 0000000..deac546 --- /dev/null +++ b/lib/models/user.dart @@ -0,0 +1,46 @@ +import 'package:diameter/main.dart'; +import 'package:objectbox/objectbox.dart'; + +@Entity() +@Sync() +class User { + static final Box box = objectBox.store.box(); + // properties + int id; + bool deleted; + String name; + String email; + String password; + + // constructor + User({ + this.id = 0, + this.deleted = false, + required this.name, + required this.email, + required this.password, + }); + + // methods + static User? get(int id) => box.get(id); + static void put(User user) => box.put(user); + + // static String login(String name, String password) { + // password.h + + // return + // } + + static void remove(int id) { + final item = box.get(id); + if (item != null) { + item.deleted = true; + box.put(item); + } + } + + @override + String toString() { + return name; + } +} diff --git a/lib/navigation.dart b/lib/navigation.dart index 6d308ad..02151c9 100644 --- a/lib/navigation.dart +++ b/lib/navigation.dart @@ -1,28 +1,31 @@ -import 'package:diameter/screens/accuracy_detail.dart'; -import 'package:diameter/screens/accuracy_list.dart'; +import 'package:diameter/screens/category/categories.dart'; +import 'package:diameter/screens/category/accuracy_detail.dart'; +import 'package:diameter/screens/category/accuracy_list.dart'; +import 'package:diameter/screens/category/event_type_detail.dart'; +import 'package:diameter/screens/category/event_type_list.dart'; +import 'package:diameter/screens/category/meal_source_detail.dart'; +import 'package:diameter/screens/category/meal_source_list.dart'; +import 'package:diameter/screens/category/meal_category_detail.dart'; +import 'package:diameter/screens/category/meal_category_list.dart'; +import 'package:diameter/screens/category/meal_portion_type_detail.dart'; +import 'package:diameter/screens/category/meal_portion_type_list.dart'; import 'package:diameter/screens/basal/basal_detail.dart'; import 'package:diameter/screens/basal/basal_profile_detail.dart'; import 'package:diameter/screens/basal/basal_profile_list.dart'; import 'package:diameter/screens/bolus/bolus_detail.dart'; import 'package:diameter/screens/bolus/bolus_profile_detail.dart'; import 'package:diameter/screens/bolus/bolus_profile_list.dart'; -import 'package:diameter/screens/log/log.dart'; +import 'package:diameter/screens/log/log_entry/log_bolus_detail.dart'; +import 'package:diameter/screens/log/log_overview.dart'; +import 'package:diameter/screens/reports/daily_chart.dart'; import 'package:diameter/screens/log/log_entry/log_entry.dart'; -import 'package:diameter/screens/log/log_event/log_event_detail.dart'; -import 'package:diameter/screens/log/log_event/log_event_list.dart'; -import 'package:diameter/screens/log/log_event/log_event_type_detail.dart'; -import 'package:diameter/screens/log/log_event/log_event_type_list.dart'; +import 'package:diameter/screens/log/log_event_detail.dart'; +import 'package:diameter/screens/log/log_event_list.dart'; import 'package:diameter/screens/log/log_entry/log_meal_detail.dart'; -import 'package:diameter/screens/meal/meal_category_detail.dart'; -import 'package:diameter/screens/meal/meal_category_list.dart'; import 'package:diameter/screens/meal/meal_detail.dart'; import 'package:diameter/screens/meal/meal_list.dart'; -import 'package:diameter/screens/meal/meal_portion_type_detail.dart'; -import 'package:diameter/screens/meal/meal_portion_type_list.dart'; -import 'package:diameter/screens/meal/meal_source_detail.dart'; -import 'package:diameter/screens/meal/meal_source_list.dart'; -import 'package:diameter/screens/recipe/recipe_detail.dart'; -import 'package:diameter/screens/recipe/recipe_list.dart'; +import 'package:diameter/screens/reports/export.dart'; +import 'package:diameter/screens/reports/reports.dart'; import 'package:diameter/settings.dart'; import 'package:flutter/material.dart'; @@ -39,30 +42,38 @@ class Routes { static const String log = LogScreen.routeName; static const String logEntry = LogEntryScreen.routeName; - static const String logEvent = LogEventDetailScreen.routeName; static const String logMeal = LogMealDetailScreen.routeName; - static const List logEntryRoutes = [logEntry, logEvent, logMeal]; - static const String logEventType = EventTypeDetailScreen.routeName; - static const String logEventTypes = LogEventTypeListScreen.routeName; - static const List logEventTypeRoutes = [logEventType, logEventTypes]; - static const String events = LogEventListScreen.routeName; + static const String logBolus = LogBolusDetailScreen.routeName; + static const List logEntryRoutes = [log, logEntry, logMeal, logBolus]; - static const String recipe = RecipeDetailScreen.routeName; - static const String recipes = RecipeListScreen.routeName; - static const List recipeRoutes = [recipe, recipes]; + static const String logEvent = LogEventDetailScreen.routeName; + static const String logEvents = LogEventListScreen.routeName; + static const List logEventRoutes = [logEvent, logEvents]; + + static const String reports = ReportsOverviewScreen.routeName; + static const String export = ExportDialog.routeName; + static const String dailyChart = DailyChart.routeName; + static const List reportRoutes = [export, reports, dailyChart]; static const String meal = MealDetailScreen.routeName; static const String meals = MealListScreen.routeName; static const List mealRoutes = [meal, meals]; + + static const String eventType = EventTypeDetailScreen.routeName; + static const String eventTypes = EventTypeListScreen.routeName; + static const List eventTypeRoutes = [eventType, eventTypes]; + static const String mealCategory = MealCategoryDetailScreen.routeName; static const String mealCategories = MealCategoryListScreen.routeName; static const List mealCategoryRoutes = [mealCategory, mealCategories]; + static const String mealPortionType = MealPortionTypeDetailScreen.routeName; static const String mealPortionTypes = MealPortionTypeListScreen.routeName; static const List mealPortionTypeRoutes = [ mealPortionType, mealPortionTypes ]; + static const String mealSource = MealSourceDetailScreen.routeName; static const String mealSources = MealSourceListScreen.routeName; static const List mealSourceRoutes = [mealSource, mealSources]; @@ -71,6 +82,16 @@ class Routes { static const String accuracies = AccuracyListScreen.routeName; static const List accuracyRoutes = [accuracy, accuracies]; + static const String category = CategoryOverviewScreen.routeName; + static const List categoryRoutes = [ + category, + ...eventTypeRoutes, + ...mealCategoryRoutes, + ...mealPortionTypeRoutes, + ...mealSourceRoutes, + ...accuracyRoutes + ]; + static const String settings = SettingsScreen.routeName; } @@ -109,18 +130,18 @@ class _NavigationState extends State { title: const Text('Log Events'), leading: const Icon(Icons.event), onTap: () { - selectDestination(Routes.events); + selectDestination(Routes.logEvents); }, - selected: widget.currentLocation == Routes.events, + selected: widget.currentLocation == Routes.logEvents, + ), + ListTile( + title: const Text('Reports'), + leading: const Icon(Icons.show_chart), + onTap: () { + selectDestination(Routes.reports); + }, + selected: Routes.reportRoutes.contains(widget.currentLocation), ), - // ListTile( - // title: const Text('Recipes'), - // leading: const Icon(Icons.local_dining), - // onTap: () { - // selectDestination(Routes.recipes); - // }, - // selected: Routes.recipeRoutes.contains(widget.currentLocation), - // ), ListTile( title: const Text('Meals'), leading: const Icon(Icons.dinner_dining), @@ -129,46 +150,6 @@ class _NavigationState extends State { }, selected: Routes.mealRoutes.contains(widget.currentLocation), ), - ListTile( - title: const Text('Meal Categories'), - leading: const Icon(Icons.category), - onTap: () { - selectDestination(Routes.mealCategories); - }, - selected: Routes.mealCategoryRoutes.contains(widget.currentLocation), - ), - ListTile( - title: const Text('Meal Portion Types'), - leading: const Icon(Icons.pie_chart), - onTap: () { - selectDestination(Routes.mealPortionTypes); - }, - selected: Routes.mealPortionTypeRoutes.contains(widget.currentLocation), - ), - ListTile( - title: const Text('Meal Sources'), - leading: const Icon(Icons.local_grocery_store), - onTap: () { - selectDestination(Routes.mealSources); - }, - selected: Routes.mealSourceRoutes.contains(widget.currentLocation), - ), - ListTile( - title: const Text('Accuracies'), - leading: const Icon(Icons.architecture), - onTap: () { - selectDestination(Routes.accuracies); - }, - selected: Routes.accuracyRoutes.contains(widget.currentLocation), - ), - ListTile( - title: const Text('Log Event Types'), - leading: const Icon(Icons.event), - onTap: () { - selectDestination(Routes.logEventTypes); - }, - selected: Routes.logEventTypeRoutes.contains(widget.currentLocation), - ), ListTile( title: const Text('Basal Profiles'), leading: const Icon(Icons.access_time), @@ -185,6 +166,14 @@ class _NavigationState extends State { }, selected: Routes.bolusRoutes.contains(widget.currentLocation), ), + ListTile( + title: const Text('Categorization'), + leading: const Icon(Icons.category), + onTap: () { + selectDestination(Routes.category); + }, + selected: Routes.categoryRoutes.contains(widget.currentLocation), + ), ListTile( title: const Text('Settings'), leading: const Icon(Icons.settings), diff --git a/lib/objectbox-model.json b/lib/objectbox-model.json index e0a1e35..a96c422 100644 --- a/lib/objectbox-model.json +++ b/lib/objectbox-model.json @@ -818,7 +818,7 @@ }, { "id": "16:3989341091218179227", - "lastPropertyId": "23:3611447442844013652", + "lastPropertyId": "27:3553639710779248831", "name": "Settings", "flags": 2, "properties": [ @@ -892,6 +892,26 @@ "id": "23:3611447442844013652", "name": "useDarkTheme", "type": 1 + }, + { + "id": "24:3003519517071020397", + "name": "insulinIncrements", + "type": 8 + }, + { + "id": "25:402003851844402390", + "name": "nutritionIncrements", + "type": 8 + }, + { + "id": "26:3104312506075410259", + "name": "mmolPerLIncrements", + "type": 8 + }, + { + "id": "27:3553639710779248831", + "name": "amountIncrements", + "type": 8 } ], "relations": [] @@ -1024,9 +1044,44 @@ } ], "relations": [] + }, + { + "id": "20:4237292160526261631", + "lastPropertyId": "5:3477783279940695158", + "name": "User", + "flags": 2, + "properties": [ + { + "id": "1:785183194548024772", + "name": "id", + "type": 6, + "flags": 1 + }, + { + "id": "2:2837394475791909830", + "name": "deleted", + "type": 1 + }, + { + "id": "3:6612000015066380372", + "name": "name", + "type": 9 + }, + { + "id": "4:861275034953202445", + "name": "email", + "type": 9 + }, + { + "id": "5:3477783279940695158", + "name": "password", + "type": 9 + } + ], + "relations": [] } ], - "lastEntityId": "19:6950311793136068892", + "lastEntityId": "20:4237292160526261631", "lastIndexId": "31:3277019237664417023", "lastRelationId": "0:0", "lastSequenceId": "0:0", diff --git a/lib/objectbox.g.dart b/lib/objectbox.g.dart index ce823fb..ca3d579 100644 --- a/lib/objectbox.g.dart +++ b/lib/objectbox.g.dart @@ -1,10 +1,14 @@ // GENERATED CODE - DO NOT MODIFY BY HAND +// This code was generated by ObjectBox. To update it run the generator again: +// With a Flutter package, run `flutter pub run build_runner build`. +// With a Dart package, run `dart run build_runner build`. +// See also https://docs.objectbox.io/getting-started#generate-objectbox-code // ignore_for_file: camel_case_types import 'dart:typed_data'; -import 'package:objectbox/flatbuffers/flat_buffers.dart' as fb; +import 'package:flat_buffers/flat_buffers.dart' as fb; import 'package:objectbox/internal.dart'; // generated code can access "internal" functionality import 'package:objectbox/objectbox.dart'; import 'package:objectbox_sync_flutter_libs/objectbox_sync_flutter_libs.dart'; @@ -27,6 +31,7 @@ import 'models/meal_portion_type.dart'; import 'models/meal_source.dart'; import 'models/recipe.dart'; import 'models/settings.dart'; +import 'models/user.dart'; export 'package:objectbox/objectbox.dart'; // so that callers only have to import this file @@ -807,7 +812,7 @@ final _entities = [ ModelEntity( id: const IdUid(16, 3989341091218179227), name: 'Settings', - lastPropertyId: const IdUid(23, 3611447442844013652), + lastPropertyId: const IdUid(27, 3553639710779248831), flags: 2, properties: [ ModelProperty( @@ -879,6 +884,26 @@ final _entities = [ id: const IdUid(23, 3611447442844013652), name: 'useDarkTheme', type: 1, + flags: 0), + ModelProperty( + id: const IdUid(24, 3003519517071020397), + name: 'insulinIncrements', + type: 8, + flags: 0), + ModelProperty( + id: const IdUid(25, 402003851844402390), + name: 'nutritionIncrements', + type: 8, + flags: 0), + ModelProperty( + id: const IdUid(26, 3104312506075410259), + name: 'mmolPerLIncrements', + type: 8, + flags: 0), + ModelProperty( + id: const IdUid(27, 3553639710779248831), + name: 'amountIncrements', + type: 8, flags: 0) ], relations: [], @@ -1005,6 +1030,40 @@ final _entities = [ relationTarget: 'Meal') ], relations: [], + backlinks: []), + ModelEntity( + id: const IdUid(20, 4237292160526261631), + name: 'User', + lastPropertyId: const IdUid(5, 3477783279940695158), + flags: 2, + properties: [ + ModelProperty( + id: const IdUid(1, 785183194548024772), + name: 'id', + type: 6, + flags: 1), + ModelProperty( + id: const IdUid(2, 2837394475791909830), + name: 'deleted', + type: 1, + flags: 0), + ModelProperty( + id: const IdUid(3, 6612000015066380372), + name: 'name', + type: 9, + flags: 0), + ModelProperty( + id: const IdUid(4, 861275034953202445), + name: 'email', + type: 9, + flags: 0), + ModelProperty( + id: const IdUid(5, 3477783279940695158), + name: 'password', + type: 9, + flags: 0) + ], + relations: [], backlinks: []) ]; @@ -1028,7 +1087,7 @@ Future openStore( ModelDefinition getObjectBoxModel() { final model = ModelInfo( entities: _entities, - lastEntityId: const IdUid(19, 6950311793136068892), + lastEntityId: const IdUid(20, 4237292160526261631), lastIndexId: const IdUid(31, 3277019237664417023), lastRelationId: const IdUid(0, 0), lastSequenceId: const IdUid(0, 0), @@ -1140,11 +1199,11 @@ ModelDefinition getObjectBoxModel() { id: const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0), deleted: const fb.BoolReader() .vTableGet(buffer, rootOffset, 12, false), - name: - const fb.StringReader().vTableGet(buffer, rootOffset, 6, ''), + name: const fb.StringReader(asciiOptimization: true) + .vTableGet(buffer, rootOffset, 6, ''), active: const fb.BoolReader().vTableGet(buffer, rootOffset, 8, false), - notes: const fb.StringReader() + notes: const fb.StringReader(asciiOptimization: true) .vTableGetNullable(buffer, rootOffset, 10)); return object; @@ -1225,11 +1284,11 @@ ModelDefinition getObjectBoxModel() { id: const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0), deleted: const fb.BoolReader() .vTableGet(buffer, rootOffset, 12, false), - name: - const fb.StringReader().vTableGet(buffer, rootOffset, 6, ''), + name: const fb.StringReader(asciiOptimization: true) + .vTableGet(buffer, rootOffset, 6, ''), active: const fb.BoolReader().vTableGet(buffer, rootOffset, 8, false), - notes: const fb.StringReader() + notes: const fb.StringReader(asciiOptimization: true) .vTableGetNullable(buffer, rootOffset, 10)); return object; @@ -1272,7 +1331,7 @@ ModelDefinition getObjectBoxModel() { .vTableGetNullable(buffer, rootOffset, 10), glucoseTrend: const fb.Float64Reader() .vTableGetNullable(buffer, rootOffset, 22), - notes: const fb.StringReader() + notes: const fb.StringReader(asciiOptimization: true) .vTableGetNullable(buffer, rootOffset, 18)); return object; @@ -1321,7 +1380,7 @@ ModelDefinition getObjectBoxModel() { .vTableGet(buffer, rootOffset, 10, false), reminderDuration: const fb.Int64Reader() .vTableGetNullable(buffer, rootOffset, 26), - notes: const fb.StringReader() + notes: const fb.StringReader(asciiOptimization: true) .vTableGetNullable(buffer, rootOffset, 12)); object.eventType.targetId = const fb.Int64Reader().vTableGet(buffer, rootOffset, 18, 0); @@ -1367,13 +1426,13 @@ ModelDefinition getObjectBoxModel() { id: const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0), deleted: const fb.BoolReader() .vTableGet(buffer, rootOffset, 14, false), - value: - const fb.StringReader().vTableGet(buffer, rootOffset, 6, ''), + value: const fb.StringReader(asciiOptimization: true) + .vTableGet(buffer, rootOffset, 6, ''), hasEndTime: const fb.BoolReader().vTableGet(buffer, rootOffset, 8, false), defaultReminderDuration: const fb.Int64Reader() .vTableGetNullable(buffer, rootOffset, 10), - notes: const fb.StringReader() + notes: const fb.StringReader(asciiOptimization: true) .vTableGetNullable(buffer, rootOffset, 12)); object.bolusProfile.targetId = const fb.Int64Reader().vTableGet(buffer, rootOffset, 16, 0); @@ -1431,8 +1490,8 @@ ModelDefinition getObjectBoxModel() { id: const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0), deleted: const fb.BoolReader() .vTableGet(buffer, rootOffset, 36, false), - value: - const fb.StringReader().vTableGet(buffer, rootOffset, 6, ''), + value: const fb.StringReader(asciiOptimization: true) + .vTableGet(buffer, rootOffset, 6, ''), amount: const fb.Float64Reader().vTableGet(buffer, rootOffset, 38, 0), carbsRatio: const fb.Float64Reader() @@ -1441,7 +1500,7 @@ ModelDefinition getObjectBoxModel() { .vTableGetNullable(buffer, rootOffset, 10), totalCarbs: const fb.Float64Reader() .vTableGetNullable(buffer, rootOffset, 40), - notes: const fb.StringReader() + notes: const fb.StringReader(asciiOptimization: true) .vTableGetNullable(buffer, rootOffset, 20)) ..bolus = const fb.Float64Reader() .vTableGetNullable(buffer, rootOffset, 14); @@ -1512,8 +1571,8 @@ ModelDefinition getObjectBoxModel() { id: const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0), deleted: const fb.BoolReader() .vTableGet(buffer, rootOffset, 30, false), - value: - const fb.StringReader().vTableGet(buffer, rootOffset, 6, ''), + value: const fb.StringReader(asciiOptimization: true) + .vTableGet(buffer, rootOffset, 6, ''), carbsRatio: const fb.Float64Reader() .vTableGetNullable(buffer, rootOffset, 8), portionSize: const fb.Float64Reader() @@ -1524,7 +1583,7 @@ ModelDefinition getObjectBoxModel() { .vTableGetNullable(buffer, rootOffset, 14), delayedBolusPercentage: const fb.Float64Reader() .vTableGetNullable(buffer, rootOffset, 32), - notes: const fb.StringReader() + notes: const fb.StringReader(asciiOptimization: true) .vTableGetNullable(buffer, rootOffset, 18)); object.mealSource.targetId = const fb.Int64Reader().vTableGet(buffer, rootOffset, 20, 0); @@ -1571,9 +1630,9 @@ ModelDefinition getObjectBoxModel() { id: const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0), deleted: const fb.BoolReader() .vTableGet(buffer, rootOffset, 10, false), - value: - const fb.StringReader().vTableGet(buffer, rootOffset, 6, ''), - notes: const fb.StringReader() + value: const fb.StringReader(asciiOptimization: true) + .vTableGet(buffer, rootOffset, 6, ''), + notes: const fb.StringReader(asciiOptimization: true) .vTableGetNullable(buffer, rootOffset, 8)); return object; @@ -1606,9 +1665,9 @@ ModelDefinition getObjectBoxModel() { id: const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0), deleted: const fb.BoolReader() .vTableGet(buffer, rootOffset, 10, false), - value: - const fb.StringReader().vTableGet(buffer, rootOffset, 6, ''), - notes: const fb.StringReader() + value: const fb.StringReader(asciiOptimization: true) + .vTableGet(buffer, rootOffset, 6, ''), + notes: const fb.StringReader(asciiOptimization: true) .vTableGetNullable(buffer, rootOffset, 8)); return object; @@ -1650,9 +1709,9 @@ ModelDefinition getObjectBoxModel() { id: const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0), deleted: const fb.BoolReader() .vTableGet(buffer, rootOffset, 18, false), - value: - const fb.StringReader().vTableGet(buffer, rootOffset, 6, ''), - notes: const fb.StringReader() + value: const fb.StringReader(asciiOptimization: true) + .vTableGet(buffer, rootOffset, 6, ''), + notes: const fb.StringReader(asciiOptimization: true) .vTableGetNullable(buffer, rootOffset, 8)); object.defaultMealCategory.targetId = const fb.Int64Reader().vTableGet(buffer, rootOffset, 10, 0); @@ -1728,7 +1787,7 @@ ModelDefinition getObjectBoxModel() { .vTableGetNullable(buffer, rootOffset, 38), setManually: const fb.BoolReader() .vTableGet(buffer, rootOffset, 16, false), - notes: const fb.StringReader() + notes: const fb.StringReader(asciiOptimization: true) .vTableGetNullable(buffer, rootOffset, 18)); object.logEntry.targetId = const fb.Int64Reader().vTableGet(buffer, rootOffset, 20, 0); @@ -1772,15 +1831,15 @@ ModelDefinition getObjectBoxModel() { id: const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0), deleted: const fb.BoolReader() .vTableGet(buffer, rootOffset, 16, false), - value: - const fb.StringReader().vTableGet(buffer, rootOffset, 6, ''), + value: const fb.StringReader(asciiOptimization: true) + .vTableGet(buffer, rootOffset, 6, ''), forCarbsRatio: const fb.BoolReader().vTableGet(buffer, rootOffset, 8, false), forPortionSize: const fb.BoolReader() .vTableGet(buffer, rootOffset, 10, false), confidenceRating: const fb.Int64Reader() .vTableGetNullable(buffer, rootOffset, 12), - notes: const fb.StringReader() + notes: const fb.StringReader(asciiOptimization: true) .vTableGetNullable(buffer, rootOffset, 14)); return object; @@ -1802,7 +1861,7 @@ ModelDefinition getObjectBoxModel() { final longTimeFormatOffset = object.longTimeFormat == null ? null : fbb.writeString(object.longTimeFormat!); - fbb.startTable(24); + fbb.startTable(28); fbb.addInt64(0, object.id); fbb.addOffset(1, dateFormatOffset); fbb.addOffset(2, longDateFormatOffset); @@ -1817,6 +1876,10 @@ ModelDefinition getObjectBoxModel() { fbb.addInt64(20, object.targetGlucoseMgPerDl); fbb.addFloat64(21, object.targetGlucoseMmolPerL); fbb.addBool(22, object.useDarkTheme); + fbb.addFloat64(23, object.insulinIncrements); + fbb.addFloat64(24, object.nutritionIncrements); + fbb.addFloat64(25, object.mmolPerLIncrements); + fbb.addFloat64(26, object.amountIncrements); fbb.finish(fbb.endTable()); return object.id; }, @@ -1832,24 +1895,27 @@ ModelDefinition getObjectBoxModel() { const fb.Int64Reader().vTableGet(buffer, rootOffset, 40, 0), glucoseMeasurementIndex: const fb.Int64Reader().vTableGet(buffer, rootOffset, 42, 0), - dateFormat: - const fb.StringReader().vTableGet(buffer, rootOffset, 6, ''), - longDateFormat: const fb.StringReader() + insulinIncrements: + const fb.Float64Reader().vTableGet(buffer, rootOffset, 50, 0), + nutritionIncrements: + const fb.Float64Reader().vTableGet(buffer, rootOffset, 52, 0), + mmolPerLIncrements: + const fb.Float64Reader().vTableGet(buffer, rootOffset, 54, 0), + amountIncrements: + const fb.Float64Reader().vTableGet(buffer, rootOffset, 56, 0), + dateFormat: const fb.StringReader(asciiOptimization: true) + .vTableGet(buffer, rootOffset, 6, ''), + longDateFormat: const fb.StringReader(asciiOptimization: true) .vTableGetNullable(buffer, rootOffset, 8), - timeFormat: - const fb.StringReader().vTableGet(buffer, rootOffset, 10, ''), - longTimeFormat: const fb.StringReader() + timeFormat: const fb.StringReader(asciiOptimization: true) + .vTableGet(buffer, rootOffset, 10, ''), + longTimeFormat: const fb.StringReader(asciiOptimization: true) .vTableGetNullable(buffer, rootOffset, 12), - showConfirmationDialogOnCancel: const fb.BoolReader() - .vTableGet(buffer, rootOffset, 14, false), - showConfirmationDialogOnDelete: const fb.BoolReader() - .vTableGet(buffer, rootOffset, 16, false), - showConfirmationDialogOnStopEvent: const fb.BoolReader() - .vTableGet(buffer, rootOffset, 18, false), - targetGlucoseMgPerDl: - const fb.Int64Reader().vTableGet(buffer, rootOffset, 44, 0), - targetGlucoseMmolPerL: - const fb.Float64Reader().vTableGet(buffer, rootOffset, 46, 0), + showConfirmationDialogOnCancel: const fb.BoolReader().vTableGet(buffer, rootOffset, 14, false), + showConfirmationDialogOnDelete: const fb.BoolReader().vTableGet(buffer, rootOffset, 16, false), + showConfirmationDialogOnStopEvent: const fb.BoolReader().vTableGet(buffer, rootOffset, 18, false), + targetGlucoseMgPerDl: const fb.Int64Reader().vTableGet(buffer, rootOffset, 44, 0), + targetGlucoseMmolPerL: const fb.Float64Reader().vTableGet(buffer, rootOffset, 46, 0), useDarkTheme: const fb.BoolReader().vTableGet(buffer, rootOffset, 48, false)); return object; @@ -1925,11 +1991,11 @@ ModelDefinition getObjectBoxModel() { id: const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0), deleted: const fb.BoolReader().vTableGet(buffer, rootOffset, 6, false), - name: - const fb.StringReader().vTableGet(buffer, rootOffset, 8, ''), + name: const fb.StringReader(asciiOptimization: true) + .vTableGet(buffer, rootOffset, 8, ''), servings: const fb.Float64Reader() .vTableGetNullable(buffer, rootOffset, 24), - notes: const fb.StringReader() + notes: const fb.StringReader(asciiOptimization: true) .vTableGetNullable(buffer, rootOffset, 20)); object.portion.targetId = const fb.Int64Reader().vTableGet(buffer, rootOffset, 22, 0); @@ -1972,6 +2038,44 @@ ModelDefinition getObjectBoxModel() { const fb.Int64Reader().vTableGet(buffer, rootOffset, 12, 0); object.ingredient.attach(store); return object; + }), + User: EntityDefinition( + model: _entities[18], + toOneRelations: (User object) => [], + toManyRelations: (User object) => {}, + getId: (User object) => object.id, + setId: (User object, int id) { + object.id = id; + }, + objectToFB: (User object, fb.Builder fbb) { + final nameOffset = fbb.writeString(object.name); + final emailOffset = fbb.writeString(object.email); + final passwordOffset = fbb.writeString(object.password); + fbb.startTable(6); + fbb.addInt64(0, object.id); + fbb.addBool(1, object.deleted); + fbb.addOffset(2, nameOffset); + fbb.addOffset(3, emailOffset); + fbb.addOffset(4, passwordOffset); + fbb.finish(fbb.endTable()); + return object.id; + }, + objectFromFB: (Store store, ByteData fbData) { + final buffer = fb.BufferContext(fbData); + final rootOffset = buffer.derefObject(0); + + final object = User( + id: const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0), + deleted: + const fb.BoolReader().vTableGet(buffer, rootOffset, 6, false), + name: const fb.StringReader(asciiOptimization: true) + .vTableGet(buffer, rootOffset, 8, ''), + email: const fb.StringReader(asciiOptimization: true) + .vTableGet(buffer, rootOffset, 10, ''), + password: const fb.StringReader(asciiOptimization: true) + .vTableGet(buffer, rootOffset, 12, '')); + + return object; }) }; @@ -2537,6 +2641,22 @@ class Settings_ { /// see [Settings.useDarkTheme] static final useDarkTheme = QueryBooleanProperty(_entities[14].properties[13]); + + /// see [Settings.insulinIncrements] + static final insulinIncrements = + QueryDoubleProperty(_entities[14].properties[14]); + + /// see [Settings.nutritionIncrements] + static final nutritionIncrements = + QueryDoubleProperty(_entities[14].properties[15]); + + /// see [Settings.mmolPerLIncrements] + static final mmolPerLIncrements = + QueryDoubleProperty(_entities[14].properties[16]); + + /// see [Settings.amountIncrements] + static final amountIncrements = + QueryDoubleProperty(_entities[14].properties[17]); } /// [GlucoseTarget] entity fields to define ObjectBox queries. @@ -2616,3 +2736,23 @@ class Ingredient_ { static final ingredient = QueryRelationToOne(_entities[17].properties[4]); } + +/// [User] entity fields to define ObjectBox queries. +class User_ { + /// see [User.id] + static final id = QueryIntegerProperty(_entities[18].properties[0]); + + /// see [User.deleted] + static final deleted = + QueryBooleanProperty(_entities[18].properties[1]); + + /// see [User.name] + static final name = QueryStringProperty(_entities[18].properties[2]); + + /// see [User.email] + static final email = QueryStringProperty(_entities[18].properties[3]); + + /// see [User.password] + static final password = + QueryStringProperty(_entities[18].properties[4]); +} diff --git a/lib/screens/accuracy_detail.dart b/lib/screens/category/accuracy_detail.dart similarity index 98% rename from lib/screens/accuracy_detail.dart rename to lib/screens/category/accuracy_detail.dart index fb5b304..ac58d8c 100644 --- a/lib/screens/accuracy_detail.dart +++ b/lib/screens/category/accuracy_detail.dart @@ -156,15 +156,7 @@ class _AccuracyDetailScreenState extends State { }, ), BooleanFormField( - value: _forCarbsRatio, - label: 'for carbs ratio', - onChanged: (value) { - setState(() { - _forCarbsRatio = value; - }); - }, - ), - BooleanFormField( + icon: const Icon(Icons.square_foot), value: _forPortionSize, label: 'for portion size', onChanged: (value) { @@ -173,6 +165,16 @@ class _AccuracyDetailScreenState extends State { }); }, ), + BooleanFormField( + icon: const Icon(Icons.pie_chart), + value: _forCarbsRatio, + label: 'for carbs ratio', + onChanged: (value) { + setState(() { + _forCarbsRatio = value; + }); + }, + ), NumberFormField( controller: _confidenceRatingController, label: 'Confidence Rating', diff --git a/lib/screens/accuracy_list.dart b/lib/screens/category/accuracy_list.dart similarity index 99% rename from lib/screens/accuracy_list.dart rename to lib/screens/category/accuracy_list.dart index ad282be..df0f7fa 100644 --- a/lib/screens/accuracy_list.dart +++ b/lib/screens/category/accuracy_list.dart @@ -1,7 +1,7 @@ import 'package:diameter/utils/dialog_utils.dart'; import 'package:diameter/models/settings.dart'; import 'package:diameter/navigation.dart'; -import 'package:diameter/screens/accuracy_detail.dart'; +import 'package:diameter/screens/category/accuracy_detail.dart'; import 'package:flutter/material.dart'; import 'package:diameter/models/accuracy.dart'; diff --git a/lib/screens/category/categories.dart b/lib/screens/category/categories.dart new file mode 100644 index 0000000..0d45c03 --- /dev/null +++ b/lib/screens/category/categories.dart @@ -0,0 +1,145 @@ +import 'package:diameter/navigation.dart'; +import 'package:flutter/material.dart'; + +class CategoryOverviewScreen extends StatefulWidget { + static const String routeName = '/category'; + const CategoryOverviewScreen({Key? key}) : super(key: key); + + @override + _CategoryOverviewScreenState createState() => _CategoryOverviewScreenState(); +} + +class _CategoryOverviewScreenState extends State { + final ScrollController _scrollController = ScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Reports'), + ), + drawer: + const Navigation(currentLocation: CategoryOverviewScreen.routeName), + body: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: GridView.count( + crossAxisCount: 2, + padding: const EdgeInsets.all(10.0), + children: [ + GestureDetector( + onTap: () => Navigator.pushNamed(context, Routes.mealSources), + child: Card( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.all(10.0), + child: Icon(Icons.local_grocery_store, size: 50, color: Theme.of(context).textTheme.subtitle2?.color), + ), + Text( + 'MEAL SOURCES', + style: Theme.of(context).textTheme.subtitle2, + textAlign: TextAlign.center + ), + ], + ), + ), + ), + GestureDetector( + onTap: () => Navigator.pushNamed(context, Routes.mealCategories), + child: Padding( + padding: const EdgeInsets.only(left: 10.0), + child: Card( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.all(10.0), + child: Icon(Icons.category, size: 50, color: Theme.of(context).textTheme.subtitle2?.color), + ), + Text( + 'MEAL CATEGORIES', + style: Theme.of(context).textTheme.subtitle2, + textAlign: TextAlign.center + ), + ], + ), + ), + ), + ), + GestureDetector( + onTap: () => Navigator.pushNamed(context, Routes.mealPortionTypes), + child: Card( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.all(10.0), + child: Icon(Icons.pie_chart, size: 50, color: Theme.of(context).textTheme.subtitle2?.color), + ), + Text( + 'PORTION TYPES', + style: Theme.of(context).textTheme.subtitle2, + textAlign: TextAlign.center + ), + ], + ), + ), + ), + GestureDetector( + onTap: () => Navigator.pushNamed(context, Routes.accuracies), + child: Padding( + padding: const EdgeInsets.only(left: 10.0), + child: Card( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.all(10.0), + child: Icon(Icons.architecture, size: 50, color: Theme.of(context).textTheme.subtitle2?.color), + ), + Text( + 'ACCURACIES', + style: Theme.of(context).textTheme.subtitle2, + textAlign: TextAlign.center + ), + ], + ), + ), + ), + ), + GestureDetector( + onTap: () => Navigator.pushNamed(context, Routes.eventTypes), + child: Card( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.all(10.0), + child: Icon(Icons.event, size: 50, color: Theme.of(context).textTheme.subtitle2?.color), + ), + Text( + 'EVENT TYPES', + style: Theme.of(context).textTheme.subtitle2, + textAlign: TextAlign.center + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/log/log_event/log_event_type_detail.dart b/lib/screens/category/event_type_detail.dart similarity index 100% rename from lib/screens/log/log_event/log_event_type_detail.dart rename to lib/screens/category/event_type_detail.dart diff --git a/lib/screens/log/log_event/log_event_type_list.dart b/lib/screens/category/event_type_list.dart similarity index 90% rename from lib/screens/log/log_event/log_event_type_list.dart rename to lib/screens/category/event_type_list.dart index 07be148..b720a5c 100644 --- a/lib/screens/log/log_event/log_event_type_list.dart +++ b/lib/screens/category/event_type_list.dart @@ -1,17 +1,17 @@ import 'package:diameter/models/log_event_type.dart'; import 'package:diameter/navigation.dart'; -import 'package:diameter/screens/log/log_event/log_event_type_detail.dart'; +import 'package:diameter/screens/category/event_type_detail.dart'; import 'package:flutter/material.dart'; -class LogEventTypeListScreen extends StatefulWidget { +class EventTypeListScreen extends StatefulWidget { static const String routeName = '/log-event-types'; - const LogEventTypeListScreen({Key? key}) : super(key: key); + const EventTypeListScreen({Key? key}) : super(key: key); @override - _LogEventTypeListScreenState createState() => _LogEventTypeListScreenState(); + _EventTypeListScreenState createState() => _EventTypeListScreenState(); } -class _LogEventTypeListScreenState extends State { +class _EventTypeListScreenState extends State { List _logEventTypes = []; final ScrollController _scrollController = ScrollController(); @@ -52,7 +52,7 @@ class _LogEventTypeListScreenState extends State { IconButton(onPressed: reload, icon: const Icon(Icons.refresh)) ]), drawer: - const Navigation(currentLocation: LogEventTypeListScreen.routeName), + const Navigation(currentLocation: EventTypeListScreen.routeName), body: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ diff --git a/lib/screens/meal/meal_category_detail.dart b/lib/screens/category/meal_category_detail.dart similarity index 100% rename from lib/screens/meal/meal_category_detail.dart rename to lib/screens/category/meal_category_detail.dart diff --git a/lib/screens/meal/meal_category_list.dart b/lib/screens/category/meal_category_list.dart similarity index 98% rename from lib/screens/meal/meal_category_list.dart rename to lib/screens/category/meal_category_list.dart index d4c8211..cfe612a 100644 --- a/lib/screens/meal/meal_category_list.dart +++ b/lib/screens/category/meal_category_list.dart @@ -1,7 +1,7 @@ import 'package:diameter/utils/dialog_utils.dart'; import 'package:diameter/models/settings.dart'; import 'package:diameter/navigation.dart'; -import 'package:diameter/screens/meal/meal_category_detail.dart'; +import 'package:diameter/screens/category/meal_category_detail.dart'; import 'package:flutter/material.dart'; import 'package:diameter/models/meal_category.dart'; diff --git a/lib/screens/meal/meal_portion_type_detail.dart b/lib/screens/category/meal_portion_type_detail.dart similarity index 100% rename from lib/screens/meal/meal_portion_type_detail.dart rename to lib/screens/category/meal_portion_type_detail.dart diff --git a/lib/screens/meal/meal_portion_type_list.dart b/lib/screens/category/meal_portion_type_list.dart similarity index 98% rename from lib/screens/meal/meal_portion_type_list.dart rename to lib/screens/category/meal_portion_type_list.dart index 7e453d5..e100a62 100644 --- a/lib/screens/meal/meal_portion_type_list.dart +++ b/lib/screens/category/meal_portion_type_list.dart @@ -1,7 +1,7 @@ import 'package:diameter/utils/dialog_utils.dart'; import 'package:diameter/models/settings.dart'; import 'package:diameter/navigation.dart'; -import 'package:diameter/screens/meal/meal_portion_type_detail.dart'; +import 'package:diameter/screens/category/meal_portion_type_detail.dart'; import 'package:flutter/material.dart'; import 'package:diameter/models/meal_portion_type.dart'; diff --git a/lib/screens/meal/meal_source_detail.dart b/lib/screens/category/meal_source_detail.dart similarity index 98% rename from lib/screens/meal/meal_source_detail.dart rename to lib/screens/category/meal_source_detail.dart index fb26892..55bbcc8 100644 --- a/lib/screens/meal/meal_source_detail.dart +++ b/lib/screens/category/meal_source_detail.dart @@ -8,9 +8,9 @@ import 'package:diameter/models/meal_portion_type.dart'; import 'package:diameter/models/meal_source.dart'; import 'package:diameter/models/settings.dart'; import 'package:diameter/navigation.dart'; -import 'package:diameter/screens/accuracy_detail.dart'; -import 'package:diameter/screens/meal/meal_category_detail.dart'; -import 'package:diameter/screens/meal/meal_portion_type_detail.dart'; +import 'package:diameter/screens/category/accuracy_detail.dart'; +import 'package:diameter/screens/category/meal_category_detail.dart'; +import 'package:diameter/screens/category/meal_portion_type_detail.dart'; import 'package:flutter/material.dart'; class MealSourceDetailScreen extends StatefulWidget { diff --git a/lib/screens/meal/meal_source_list.dart b/lib/screens/category/meal_source_list.dart similarity index 98% rename from lib/screens/meal/meal_source_list.dart rename to lib/screens/category/meal_source_list.dart index 479fcc9..00994f4 100644 --- a/lib/screens/meal/meal_source_list.dart +++ b/lib/screens/category/meal_source_list.dart @@ -2,7 +2,7 @@ import 'package:diameter/utils/dialog_utils.dart'; import 'package:diameter/models/meal_source.dart'; import 'package:diameter/models/settings.dart'; import 'package:diameter/navigation.dart'; -import 'package:diameter/screens/meal/meal_source_detail.dart'; +import 'package:diameter/screens/category/meal_source_detail.dart'; import 'package:flutter/material.dart'; class MealSourceListScreen extends StatefulWidget { diff --git a/lib/screens/log/log.dart b/lib/screens/log/log.dart deleted file mode 100644 index c1d9b5e..0000000 --- a/lib/screens/log/log.dart +++ /dev/null @@ -1,331 +0,0 @@ -import 'package:diameter/utils/dialog_utils.dart'; -import 'package:diameter/models/glucose_target.dart'; -import 'package:diameter/models/log_bolus.dart'; -import 'package:diameter/models/log_entry.dart'; -import 'package:diameter/models/log_meal.dart'; -import 'package:diameter/models/settings.dart'; -import 'package:diameter/navigation.dart'; -import 'package:diameter/screens/log/log_entry/log_entry.dart'; -import 'package:diameter/utils/date_time_utils.dart'; -import 'package:flutter/material.dart'; -import 'dart:math' as math; - -class LogScreen extends StatefulWidget { - static const String routeName = '/log'; - const LogScreen({Key? key}) : super(key: key); - - @override - _LogScreenState createState() => _LogScreenState(); -} - -class _LogScreenState extends State { - late List _logEntries; - - final ScrollController _scrollController = ScrollController(); - final TextEditingController _dateController = TextEditingController(text: ''); - - late DateTime _date; - - @override - void initState() { - super.initState(); - - _date = DateTime.now(); - _dateController.text = DateTimeUtils.displayDate(_date); - - reload(); - } - - @override - void dispose() { - _scrollController.dispose(); - _dateController.dispose(); - super.dispose(); - } - - void reload({String? message}) { - setState(() { - _logEntries = LogEntry.getAllForDate(_date); - }); - setState(() { - if (message != null) { - var snackBar = SnackBar( - content: Text(message), - duration: const Duration(seconds: 2), - ); - ScaffoldMessenger.of(context) - ..removeCurrentSnackBar() - ..showSnackBar(snackBar); - } - }); - } - - void onDelete(LogEntry logEntry) { - LogEntry.remove(logEntry.id); - reload(message: 'Log Entry deleted'); - } - - void handleDeleteAction(LogEntry logEntry) async { - if (Settings.get().showConfirmationDialogOnDelete) { - DialogUtils.showConfirmationDialog( - context: context, - onConfirm: () => onDelete(logEntry), - message: 'Are you sure you want to delete this Log Entry?', - ); - } else { - onDelete(logEntry); - } - } - - void onChangeDate(DateTime? date) { - if (date != null) { - setState(() { - _date = DateTime(date.year, date.month, date.day); - _dateController.text = DateTimeUtils.displayDate(date); - }); - reload(); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Log Entries'), - actions: [ - IconButton( - onPressed: () => onChangeDate(DateTime.now()), - icon: const Icon(Icons.today)), - IconButton(onPressed: reload, icon: const Icon(Icons.refresh)), - ], - ), - drawer: const Navigation(currentLocation: LogScreen.routeName), - body: Column( - children: [ - Row( - children: [ - IconButton( - onPressed: _date.isAtSameMomentAs(DateTime(2000, 1, 1)) - ? null - : () => - onChangeDate(_date.subtract(const Duration(days: 1))), - icon: const Icon(Icons.arrow_back), - ), - Expanded( - child: GestureDetector( - onTap: () async { - final newTime = await showDatePicker( - context: context, - initialDate: _date, - firstDate: DateTime(2000, 1, 1), - lastDate: DateTime.now().add(const Duration(days: 365)), - ); - onChangeDate(newTime); - }, - child: Expanded( - child: Text( - DateTimeUtils.displayDate(_date).toUpperCase(), - style: Theme.of(context).textTheme.subtitle2, - textAlign: TextAlign.center, - ), - ), - ), - ), - IconButton( - onPressed: - _date.add(const Duration(days: 1)).isBefore(DateTime.now()) - ? () => onChangeDate(_date.add(const Duration(days: 1))) - : null, - icon: const Icon(Icons.arrow_forward), - ), - ], - ), - Expanded( - child: _logEntries.isNotEmpty - ? Scrollbar( - controller: _scrollController, - child: ListView.builder( - controller: _scrollController, - padding: const EdgeInsets.all(10.0), - shrinkWrap: true, - itemCount: _logEntries.length, - itemBuilder: (context, index) { - LogEntry logEntry = _logEntries[index]; - double bolus = - LogBolus.getTotalBolusForEntry(logEntry.id); - double carbs = - LogMeal.getTotalCarbsForEntry(logEntry.id); - TextStyle glucoseStyle = TextStyle( - color: GlucoseTarget.getColorForGlucose( - mgPerDl: logEntry.mgPerDl ?? 0, - mmolPerL: logEntry.mmolPerL ?? 0)); - return Card( - child: ListTile( - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - LogEntryScreen(id: logEntry.id), - ), - ).then((result) => reload(message: result?[0])); - }, - title: Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Text( - DateTimeUtils.displayTime(logEntry.time), - ), - ), - Expanded( - child: Column( - children: logEntry.mgPerDl != null && - (Settings.glucoseMeasurement == - GlucoseMeasurement - .mgPerDl || - Settings.glucoseDisplayMode == - GlucoseDisplayMode.both || - Settings.glucoseDisplayMode == - GlucoseDisplayMode - .bothForList) - ? [ - Row( - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - Text( - logEntry.mgPerDl.toString(), - style: glucoseStyle), - logEntry.glucoseTrend != null - ? Transform.rotate( - angle: logEntry - .glucoseTrend! * - math.pi / - 180, - child: Icon( - Icons.arrow_upward, - color: glucoseStyle - .color, - size: 16.0, - ), - ) - : Container(), - ], - ), - const Text( - 'mg/dl', - textScaleFactor: 0.75, - ), - ] - : [], - ), - ), - Expanded( - child: Column( - children: logEntry.mmolPerL != null && - (Settings.glucoseMeasurement == - GlucoseMeasurement - .mmolPerL || - Settings.glucoseDisplayMode == - GlucoseDisplayMode.both || - Settings.glucoseDisplayMode == - GlucoseDisplayMode - .bothForList) - ? [ - Row( - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - Text( - logEntry.mmolPerL - .toString(), - style: glucoseStyle), - logEntry.glucoseTrend != null - ? Transform.rotate( - angle: logEntry - .glucoseTrend! * - math.pi / - 180, - child: Icon( - Icons.arrow_upward, - color: glucoseStyle - .color, - size: 16.0, - ), - ) - : Container(), - ], - ), - const Text( - 'mmol/l', - textScaleFactor: 0.75, - ), - ] - : [], - ), - ), - Expanded( - child: Column( - children: (bolus > 0) - ? [ - Text(bolus.toStringAsPrecision(3)), - const Text('U', - textScaleFactor: 0.75), - ] - : [], - ), - ), - Expanded( - child: Column( - children: (carbs > 0) - ? [ - Text(carbs.toStringAsPrecision(3)), - Text( - '${Settings.nutritionMeasurementSuffix} carbs', - textScaleFactor: 0.75), - ] - : [], - ), - ), - ], - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: () => handleDeleteAction(logEntry), - icon: const Icon(Icons.delete, - color: Colors.blue), - ) - ], - ), - ), - ); - }, - ), - ) - : const Center( - child: Text( - 'You have not created any Log Entries for this date yet!'), - ), - ), - ], - ), - floatingActionButton: FloatingActionButton( - onPressed: () { - final now = DateTime.now(); - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => LogEntryScreen( - suggestedDate: _date.isAtSameMomentAs(DateTime(now.year, now.month, now.day)) ? now : _date), - ), - ).then((result) => reload(message: result?[0])); - }, - child: const Icon(Icons.add), - ), - ); - } -} diff --git a/lib/screens/log/log_entry/log_meal_detail.dart b/lib/screens/log/log_entry/log_meal_detail.dart index 6704d9a..7f188a9 100644 --- a/lib/screens/log/log_entry/log_meal_detail.dart +++ b/lib/screens/log/log_entry/log_meal_detail.dart @@ -12,11 +12,11 @@ import 'package:diameter/models/meal_portion_type.dart'; import 'package:diameter/models/meal_source.dart'; import 'package:diameter/models/settings.dart'; import 'package:diameter/navigation.dart'; -import 'package:diameter/screens/accuracy_detail.dart'; -import 'package:diameter/screens/meal/meal_category_detail.dart'; +import 'package:diameter/screens/category/accuracy_detail.dart'; +import 'package:diameter/screens/category/meal_category_detail.dart'; +import 'package:diameter/screens/category/meal_portion_type_detail.dart'; +import 'package:diameter/screens/category/meal_source_detail.dart'; import 'package:diameter/screens/meal/meal_detail.dart'; -import 'package:diameter/screens/meal/meal_portion_type_detail.dart'; -import 'package:diameter/screens/meal/meal_source_detail.dart'; import 'package:diameter/utils/utils.dart'; import 'package:flutter/material.dart'; @@ -33,6 +33,8 @@ class LogMealDetailScreen extends StatefulWidget { _LogMealDetailScreenState createState() => _LogMealDetailScreenState(); } +enum NutritionProperty { totalCarbs, carbsRatio, portionSize } + class _LogMealDetailScreenState extends State { LogMeal? _logMeal; bool _isNew = true; @@ -40,6 +42,9 @@ class _LogMealDetailScreenState extends State { bool _isExpanded = false; bool _setManually = false; + NutritionProperty? lastChanged; + NutritionProperty? secondToLastChanged; + final GlobalKey _logMealForm = GlobalKey(); final ScrollController _scrollController = ScrollController(); @@ -91,6 +96,7 @@ class _LogMealDetailScreenState extends State { _portionSizeController.text = (_logMeal!.portionSize ?? '').toString(); _totalCarbsController.text = (_logMeal!.totalCarbs ?? '').toString(); _amountController.text = (_logMeal!.amount).toString(); + _amount = _logMeal!.amount; _notesController.text = _logMeal!.notes ?? ''; _meal = _logMeal!.meal.target; _mealController.text = (_meal ?? '').toString(); @@ -299,6 +305,8 @@ class _LogMealDetailScreenState extends State { if (basePortionSize != null) { setState(() { portionSize = basePortionSize! * newAmount; + secondToLastChanged = NutritionProperty.carbsRatio; + lastChanged = NutritionProperty.portionSize; _portionSizeController.text = Utils.toStringMatchingTemplateFractionPrecision( portionSize!, Settings.nutritionSteps); @@ -331,25 +339,34 @@ class _LogMealDetailScreenState extends State { const calcPortionSize = 3; if (carbsRatioUpdate != null) { - if (portionSize != null && portionSize != 0) { + if (secondToLastChanged == NutritionProperty.portionSize && + portionSize != null && + portionSize != 0) { toCalculate = calcTotalCarbs; } else if (totalCarbs != null && totalCarbs != 0) { toCalculate = calcPortionSize; } } else if (portionSizeUpdate != null) { - if (carbsRatio != null && carbsRatio != 0) { + if (secondToLastChanged == NutritionProperty.carbsRatio && + carbsRatio != null && + carbsRatio != 0) { toCalculate = calcTotalCarbs; } else if (totalCarbs != null && totalCarbs != 0) { toCalculate = calcCarbsRatio; } } else if (totalCarbsUpdate != null) { - if (carbsRatio != null && carbsRatio != 0) { + if (secondToLastChanged == NutritionProperty.carbsRatio && + carbsRatio != null && + carbsRatio != 0) { toCalculate = calcPortionSize; } else if (portionSize != null && portionSize != 0) { toCalculate = calcCarbsRatio; } } else { - if (carbsRatio != null && carbsRatio != 0) { + if ((secondToLastChanged == NutritionProperty.carbsRatio || + lastChanged == NutritionProperty.carbsRatio) && + carbsRatio != null && + carbsRatio != 0) { if (portionSize != null && portionSize != 0) { toCalculate = calcTotalCarbs; } else if (totalCarbs != null && totalCarbs != 0) { @@ -514,9 +531,17 @@ class _LogMealDetailScreenState extends State { autoRoundToMultipleOfStep: true, step: Settings.nutritionSteps, onChanged: (value) async { + if (lastChanged != NutritionProperty.portionSize) { + setState(() { + secondToLastChanged = lastChanged; + lastChanged = NutritionProperty.portionSize; + }); + } await Future.delayed(const Duration(seconds: 1)); - calculateThirdMeasurementOfPortionCarbsRelation( - portionSizeUpdate: value); + if (value != null && value != 0) { + calculateThirdMeasurementOfPortionCarbsRelation( + portionSizeUpdate: value); + } }, ), ), @@ -528,9 +553,17 @@ class _LogMealDetailScreenState extends State { controller: _carbsRatioController, showSteppers: false, onChanged: (value) async { + if (lastChanged != NutritionProperty.carbsRatio) { + setState(() { + secondToLastChanged = lastChanged; + lastChanged = NutritionProperty.carbsRatio; + }); + } await Future.delayed(const Duration(seconds: 1)); - calculateThirdMeasurementOfPortionCarbsRelation( - carbsRatioUpdate: value); + if (value != null && value != 0) { + calculateThirdMeasurementOfPortionCarbsRelation( + carbsRatioUpdate: value); + } }, ), ), @@ -545,24 +578,30 @@ class _LogMealDetailScreenState extends State { step: Settings.nutritionSteps, onChanged: (value) async { await Future.delayed(const Duration(seconds: 1)); - calculateThirdMeasurementOfPortionCarbsRelation( - totalCarbsUpdate: value); + if (lastChanged != NutritionProperty.totalCarbs) { + setState(() { + secondToLastChanged = lastChanged; + lastChanged = NutritionProperty.totalCarbs; + }); + } + if (value != null && value != 0) { + calculateThirdMeasurementOfPortionCarbsRelation( + totalCarbsUpdate: value); + } }, ), ), ], ), - Expanded( - child: BooleanFormField( - value: _setManually, - label: 'set carbs ratio manually', - onChanged: (value) { - setState(() { - _setManually = value; - calculateThirdMeasurementOfPortionCarbsRelation(); - }); - }, - ), + BooleanFormField( + value: _setManually, + label: 'set carbs ratio manually', + onChanged: (value) { + setState(() { + _setManually = value; + calculateThirdMeasurementOfPortionCarbsRelation(); + }); + }, ), TextFormField( controller: _notesController, @@ -600,15 +639,12 @@ class _LogMealDetailScreenState extends State { const EdgeInsets.symmetric(vertical: 5.0), child: Row( children: [ - Expanded( - child: - AutoCompleteDropdownButton( - controller: _mealSourceController, - selectedItem: _mealSource, - label: 'Meal Source', - items: _mealSources, - onChanged: updateMealSource, - ), + AutoCompleteDropdownButton( + controller: _mealSourceController, + selectedItem: _mealSource, + label: 'Meal Source', + items: _mealSources, + onChanged: updateMealSource, ), IconButton( onPressed: () { @@ -638,15 +674,12 @@ class _LogMealDetailScreenState extends State { const EdgeInsets.symmetric(vertical: 5.0), child: Row( children: [ - Expanded( - child: AutoCompleteDropdownButton< - MealCategory>( - controller: _mealCategoryController, - selectedItem: _mealCategory, - label: 'Meal Category', - items: _mealCategories, - onChanged: updateMealCategory, - ), + AutoCompleteDropdownButton( + controller: _mealCategoryController, + selectedItem: _mealCategory, + label: 'Meal Category', + items: _mealCategories, + onChanged: updateMealCategory, ), IconButton( onPressed: () { @@ -676,15 +709,12 @@ class _LogMealDetailScreenState extends State { const EdgeInsets.symmetric(vertical: 5.0), child: Row( children: [ - Expanded( - child: AutoCompleteDropdownButton< - MealPortionType>( - controller: _mealPortionTypeController, - selectedItem: _mealPortionType, - label: 'Meal Portion Type', - items: _mealPortionTypes, - onChanged: updateMealPortionType, - ), + AutoCompleteDropdownButton( + controller: _mealPortionTypeController, + selectedItem: _mealPortionType, + label: 'Meal Portion Type', + items: _mealPortionTypes, + onChanged: updateMealPortionType, ), IconButton( onPressed: () { @@ -714,15 +744,12 @@ class _LogMealDetailScreenState extends State { const EdgeInsets.symmetric(vertical: 5.0), child: Row( children: [ - Expanded( - child: AutoCompleteDropdownButton( - controller: - _portionSizeAccuracyController, - selectedItem: _portionSizeAccuracy, - label: 'Portion Size Accuracy', - items: _portionSizeAccuracies, - onChanged: updatePortionSizeAccuracy, - ), + AutoCompleteDropdownButton( + controller: _portionSizeAccuracyController, + selectedItem: _portionSizeAccuracy, + label: 'Portion Size Accuracy', + items: _portionSizeAccuracies, + onChanged: updatePortionSizeAccuracy, ), IconButton( onPressed: () { @@ -753,14 +780,12 @@ class _LogMealDetailScreenState extends State { const EdgeInsets.symmetric(vertical: 5.0), child: Row( children: [ - Expanded( - child: AutoCompleteDropdownButton( - controller: _carbsRatioAccuracyController, - selectedItem: _carbsRatioAccuracy, - label: 'Carbs Ratio Accuracy', - items: _carbsRatioAccuracies, - onChanged: updateCarbsRatioAccuracy, - ), + AutoCompleteDropdownButton( + controller: _carbsRatioAccuracyController, + selectedItem: _carbsRatioAccuracy, + label: 'Carbs Ratio Accuracy', + items: _carbsRatioAccuracies, + onChanged: updateCarbsRatioAccuracy, ), IconButton( onPressed: () { diff --git a/lib/screens/log/log_event/log_event_list.dart b/lib/screens/log/log_event/log_event_list.dart deleted file mode 100644 index 6ab1dc9..0000000 --- a/lib/screens/log/log_event/log_event_list.dart +++ /dev/null @@ -1,366 +0,0 @@ -import 'package:diameter/utils/dialog_utils.dart'; -import 'package:diameter/models/log_event.dart'; -import 'package:diameter/models/settings.dart'; -import 'package:diameter/screens/log/log_event/log_event_detail.dart'; -import 'package:diameter/utils/date_time_utils.dart'; -import 'package:flutter/material.dart'; -import 'package:diameter/navigation.dart'; - -class LogEventListScreen extends StatefulWidget { - static const String routeName = '/log-events'; - const LogEventListScreen({Key? key}) : super(key: key); - - @override - _LogEventListScreenState createState() => _LogEventListScreenState(); -} - -class _LogEventListScreenState extends State { - List _activeEvents = []; - late List _logEvents; - - final ScrollController _scrollController = ScrollController(); - - final TextEditingController _dateController = TextEditingController(text: ''); - - late DateTime _date; - bool _showActive = true; - - @override - void initState() { - super.initState(); - - _date = DateTime.now(); - _dateController.text = DateTimeUtils.displayDate(_date); - - reload(); - } - - @override - void dispose() { - _scrollController.dispose(); - _dateController.dispose(); - super.dispose(); - } - - void reload({String? message}) { - setState(() { - _activeEvents = LogEvent.getAllActiveForTime(DateTime.now()); - _logEvents = LogEvent.getAllForDate(_date); - }); - - setState(() { - if (message != null) { - var snackBar = SnackBar( - content: Text(message), - duration: const Duration(seconds: 2), - ); - ScaffoldMessenger.of(context) - ..removeCurrentSnackBar() - ..showSnackBar(snackBar); - } - }); - } - - void handleAddNewEvent() async { - final now = DateTime.now(); - Navigator.push( - context, - MaterialPageRoute( - builder: (context) { - return LogEventDetailScreen( - suggestedDate: _date.isAtSameMomentAs(DateTime(now.year, now.month, now.day)) ? now : _date, - ); - }, - ), - ).then((result) => reload(message: result?[0])); - } - - void handleEditAction(LogEvent event) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => LogEventDetailScreen( - id: event.id, - ), - ), - ).then((result) => reload(message: result?[0])); - } - - void onDelete(LogEvent logEvent) { - LogEvent.remove(logEvent.id); - reload(message: 'Event deleted'); - } - - void handleDeleteAction(LogEvent logEvent) async { - if (Settings.get().showConfirmationDialogOnDelete) { - DialogUtils.showConfirmationDialog( - context: context, - onConfirm: () => onDelete(logEvent), - message: 'Are you sure you want to delete this Event?', - ); - } else { - onDelete(logEvent); - } - } - - void onStop(LogEvent event) async { - event.endTime = DateTime.now(); - LogEvent.put(event); - reload(message: 'Event ended'); - } - - void handleStopAction(LogEvent event) async { - if (Settings.get().showConfirmationDialogOnStopEvent) { - DialogUtils.showConfirmationDialog( - context: context, - onConfirm: () => onStop(event), - message: 'Are you sure you want to end this Event?', - confirmationLabel: 'END EVENT', - ); - } else { - onStop(event); - } - } - - void onChangeDate(DateTime? date) { - if (date != null) { - setState(() { - _date = DateTime(date.year, date.month, date.day); - _dateController.text = DateTimeUtils.displayDate(date); - }); - reload(); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Log Events'), - actions: [ - IconButton( - onPressed: () => onChangeDate(DateTime.now()), - icon: const Icon(Icons.today)), - IconButton(onPressed: reload, icon: const Icon(Icons.refresh)) - ], - ), - drawer: const Navigation(currentLocation: LogEventListScreen.routeName), - body: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - GestureDetector( - onTap: () => setState(() { - _showActive = !_showActive; - }), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - mainAxisSize: MainAxisSize.max, - children: [ - Expanded( - child: Text( - 'ACTIVE EVENTS', - style: Theme.of(context).textTheme.subtitle2, - textAlign: TextAlign.center, - ), - ), - Icon(_showActive - ? Icons.expand_less - : Icons.expand_more), - ], - ), - ), - ), - !_showActive ? Container() : - _activeEvents.isNotEmpty - ? ListView.builder( - shrinkWrap: true, - padding: const EdgeInsets.all(10.0), - itemCount: _activeEvents.length, - itemBuilder: (context, index) { - LogEvent event = _activeEvents[index]; - return Card( - child: ListTile( - onTap: () { - handleEditAction(event); - }, - title: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - DateTimeUtils.displayDateTime(event.time), - ), - const SizedBox(width: 24), - Expanded( - child: Text( - (event.title ?? - event.eventType.target?.value ?? - '') - .toUpperCase(), - style: Theme.of(context).textTheme.subtitle2, - ), - ), - ], - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - event.hasEndTime && event.endTime == null - ? IconButton( - icon: const Icon( - Icons.stop, - color: Colors.blue, - ), - onPressed: () => handleStopAction(event), - ) - : const SizedBox(width: 50), - IconButton( - icon: const Icon( - Icons.edit, - color: Colors.blue, - ), - onPressed: () => handleEditAction(event), - ), - IconButton( - icon: const Icon( - Icons.delete, - color: Colors.blue, - ), - onPressed: () => handleDeleteAction(event), - ), - ], - ), - ), - ); - }, - ) - : const Center( - child: Text('There are no Active Events!'), - ), - const Padding( - padding: EdgeInsets.all(10.0), - child: Divider(), - ), - Row( - children: [ - IconButton( - onPressed: _date.isAtSameMomentAs(DateTime(2000, 1, 1)) - ? null - : () => - onChangeDate(_date.subtract(const Duration(days: 1))), - icon: const Icon(Icons.arrow_back), - ), - Expanded( - child: GestureDetector( - onTap: () async { - final newTime = await showDatePicker( - context: context, - initialDate: _date, - firstDate: DateTime(2000, 1, 1), - lastDate: DateTime.now().add(const Duration(days: 365)), - ); - onChangeDate(newTime); - }, - child: Expanded( - child: Text( - DateTimeUtils.displayDate(_date).toUpperCase(), - style: Theme.of(context).textTheme.subtitle2, - textAlign: TextAlign.center, - ), - ), - ), - ), - IconButton( - onPressed: - _date.add(const Duration(days: 1)).isBefore(DateTime.now()) - ? () => onChangeDate(_date.add(const Duration(days: 1))) - : null, - icon: const Icon(Icons.arrow_forward), - ), - ], - ), - Expanded( - child: _logEvents.isNotEmpty - ? Scrollbar( - controller: _scrollController, - child: ListView.builder( - controller: _scrollController, - shrinkWrap: true, - padding: const EdgeInsets.all(10.0), - itemCount: _logEvents.length, - itemBuilder: (context, index) { - LogEvent event = _logEvents[index]; - return Card( - child: ListTile( - onTap: () { - handleEditAction(event); - }, - title: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - DateTimeUtils.displayTime(event.isEndEvent - ? event.endTime - : event.time), - ), - const SizedBox(width: 24), - Expanded( - child: Text( - (event.title ?? - event.eventType.target?.value ?? - '') - .toUpperCase(), - style: - Theme.of(context).textTheme.subtitle2, - ), - ), - ], - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - event.hasEndTime && event.endTime == null - ? IconButton( - icon: const Icon( - Icons.stop, - color: Colors.blue, - ), - onPressed: () => - handleStopAction(event), - ) - : const SizedBox(width: 50), - IconButton( - icon: const Icon( - Icons.edit, - color: Colors.blue, - ), - onPressed: () => handleEditAction(event), - ), - IconButton( - icon: const Icon( - Icons.delete, - color: Colors.blue, - ), - onPressed: () => handleDeleteAction(event), - ), - ], - ), - ), - ); - }, - )) - : const Center( - child: Text('There are no Events for that date!'), - ), - ), - ], - ), - floatingActionButton: FloatingActionButton( - onPressed: handleAddNewEvent, - child: const Icon(Icons.add), - ), - ); - } -} diff --git a/lib/screens/log/log_event/log_event_detail.dart b/lib/screens/log/log_event_detail.dart similarity index 99% rename from lib/screens/log/log_event/log_event_detail.dart rename to lib/screens/log/log_event_detail.dart index 93ec4ab..aea4a57 100644 --- a/lib/screens/log/log_event/log_event_detail.dart +++ b/lib/screens/log/log_event_detail.dart @@ -188,7 +188,7 @@ class _LogEventDetailScreenState extends State { } Future checkIfActiveEventOfTypeExistsBeforeSaving() async { - if (_eventType != null && + if (_isNew && _eventType != null && LogEvent.eventTypeExistsForTime(_eventType!.id, _time)) { await showDialog( context: context, diff --git a/lib/screens/log/log_event_list.dart b/lib/screens/log/log_event_list.dart new file mode 100644 index 0000000..1211b36 --- /dev/null +++ b/lib/screens/log/log_event_list.dart @@ -0,0 +1,386 @@ +import 'package:diameter/utils/dialog_utils.dart'; +import 'package:diameter/models/log_event.dart'; +import 'package:diameter/models/settings.dart'; +import 'package:diameter/screens/log/log_event_detail.dart'; +import 'package:diameter/utils/date_time_utils.dart'; +import 'package:flutter/material.dart'; +import 'package:diameter/navigation.dart'; + +class LogEventListScreen extends StatefulWidget { + static const String routeName = '/log-events'; + const LogEventListScreen({Key? key}) : super(key: key); + + @override + _LogEventListScreenState createState() => _LogEventListScreenState(); +} + +class _LogEventListScreenState extends State { + List _activeEvents = []; + late List _logEvents; + + final ScrollController _scrollController = ScrollController(); + + final TextEditingController _dateController = TextEditingController(text: ''); + + late DateTime _date; + bool _showActive = true; + String? swipeDirection; + + @override + void initState() { + super.initState(); + + _date = DateTime.now(); + _dateController.text = DateTimeUtils.displayDate(_date); + + reload(); + } + + @override + void dispose() { + _scrollController.dispose(); + _dateController.dispose(); + super.dispose(); + } + + void reload({String? message}) { + setState(() { + _activeEvents = LogEvent.getAllActiveForTime(DateTime.now()); + _logEvents = LogEvent.getAllForDate(_date); + }); + + setState(() { + if (message != null) { + var snackBar = SnackBar( + content: Text(message), + duration: const Duration(seconds: 2), + ); + ScaffoldMessenger.of(context) + ..removeCurrentSnackBar() + ..showSnackBar(snackBar); + } + }); + } + + void handleAddNewEvent() async { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + return _date.isAtSameMomentAs(DateTimeUtils.today()) + ? const LogEventDetailScreen() + : LogEventDetailScreen( + suggestedDate: _date, + ); + }, + ), + ).then((result) => reload(message: result?[0])); + } + + void handleEditAction(LogEvent event) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => LogEventDetailScreen( + id: event.id, + ), + ), + ).then((result) => reload(message: result?[0])); + } + + void onDelete(LogEvent logEvent) { + LogEvent.remove(logEvent.id); + reload(message: 'Event deleted'); + } + + void handleDeleteAction(LogEvent logEvent) async { + if (Settings.get().showConfirmationDialogOnDelete) { + DialogUtils.showConfirmationDialog( + context: context, + onConfirm: () => onDelete(logEvent), + message: 'Are you sure you want to delete this Event?', + ); + } else { + onDelete(logEvent); + } + } + + void onStop(LogEvent event) async { + event.endTime = DateTime.now(); + LogEvent.put(event); + reload(message: 'Event ended'); + } + + void handleStopAction(LogEvent event) async { + if (Settings.get().showConfirmationDialogOnStopEvent) { + DialogUtils.showConfirmationDialog( + context: context, + onConfirm: () => onStop(event), + message: 'Are you sure you want to end this Event?', + confirmationLabel: 'END EVENT', + ); + } else { + onStop(event); + } + } + + void onChangeDate(DateTime? date) { + if (date != null) { + setState(() { + _date = DateTime(date.year, date.month, date.day); + _dateController.text = DateTimeUtils.displayDate(date); + }); + reload(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Log Events'), + actions: [ + IconButton( + onPressed: () => onChangeDate(DateTime.now()), + icon: const Icon(Icons.today)), + IconButton(onPressed: reload, icon: const Icon(Icons.refresh)) + ], + ), + drawer: const Navigation(currentLocation: LogEventListScreen.routeName), + body: GestureDetector( + onPanUpdate: (details) { + swipeDirection = details.delta.dx < 0 ? 'left' : 'right'; + }, + onPanEnd: (details) { + if (swipeDirection == null) { + return; + } + if (swipeDirection == 'right' && + !_date.isAtSameMomentAs(DateTime(2000, 1, 1))) { + onChangeDate(_date.subtract(const Duration(days: 1))); + } + if (swipeDirection == 'left' && + _date.add(const Duration(days: 1)).isBefore(DateTime.now())) { + onChangeDate(_date.add(const Duration(days: 1))); + } + }, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + GestureDetector( + onTap: () => setState(() { + _showActive = !_showActive; + }), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + child: Text( + 'ACTIVE EVENTS', + style: Theme.of(context).textTheme.subtitle2, + textAlign: TextAlign.center, + ), + ), + Icon(_showActive ? Icons.expand_less : Icons.expand_more), + ], + ), + ), + ), + !_showActive + ? Container() + : _activeEvents.isNotEmpty + ? ListView.builder( + shrinkWrap: true, + padding: const EdgeInsets.all(10.0), + itemCount: _activeEvents.length, + itemBuilder: (context, index) { + LogEvent event = _activeEvents[index]; + return Card( + child: ListTile( + onTap: () { + handleEditAction(event); + }, + title: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + DateTimeUtils.displayDateTime(event.time), + ), + const SizedBox(width: 24), + Expanded( + child: Text( + (event.title ?? + event.eventType.target?.value ?? + '') + .toUpperCase(), + style: + Theme.of(context).textTheme.subtitle2, + ), + ), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + event.hasEndTime && event.endTime == null + ? IconButton( + icon: const Icon( + Icons.stop, + color: Colors.blue, + ), + onPressed: () => + handleStopAction(event), + ) + : const SizedBox(width: 50), + IconButton( + icon: const Icon( + Icons.edit, + color: Colors.blue, + ), + onPressed: () => handleEditAction(event), + ), + IconButton( + icon: const Icon( + Icons.delete, + color: Colors.blue, + ), + onPressed: () => handleDeleteAction(event), + ), + ], + ), + ), + ); + }, + ) + : const Center( + child: Text('There are no Active Events!'), + ), + const Padding( + padding: EdgeInsets.all(10.0), + child: Divider(), + ), + Row( + children: [ + IconButton( + onPressed: _date.isAtSameMomentAs(DateTime(2000, 1, 1)) + ? null + : () => + onChangeDate(_date.subtract(const Duration(days: 1))), + icon: const Icon(Icons.arrow_back), + ), + Expanded( + child: GestureDetector( + onTap: () async { + final newTime = await showDatePicker( + context: context, + initialDate: _date, + firstDate: DateTime(2000, 1, 1), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + onChangeDate(newTime); + }, + child: Text( + DateTimeUtils.displayDate(_date).toUpperCase(), + style: Theme.of(context).textTheme.subtitle2, + textAlign: TextAlign.center, + ), + ), + ), + IconButton( + onPressed: _date + .add(const Duration(days: 1)) + .isBefore(DateTime.now()) + ? () => onChangeDate(_date.add(const Duration(days: 1))) + : null, + icon: const Icon(Icons.arrow_forward), + ), + ], + ), + Expanded( + child: _logEvents.isNotEmpty + ? Scrollbar( + controller: _scrollController, + child: ListView.builder( + controller: _scrollController, + shrinkWrap: true, + padding: const EdgeInsets.all(10.0), + itemCount: _logEvents.length, + itemBuilder: (context, index) { + LogEvent event = _logEvents[index]; + return Card( + child: ListTile( + onTap: () { + handleEditAction(event); + }, + title: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + DateTimeUtils.displayTime(event.isEndEvent + ? event.endTime + : event.time), + ), + const SizedBox(width: 24), + Expanded( + child: Text( + (event.title ?? + event.eventType.target?.value ?? + '') + .toUpperCase(), + style: + Theme.of(context).textTheme.subtitle2, + ), + ), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + event.hasEndTime && event.endTime == null + ? IconButton( + icon: const Icon( + Icons.stop, + color: Colors.blue, + ), + onPressed: () => + handleStopAction(event), + ) + : const SizedBox(width: 50), + IconButton( + icon: const Icon( + Icons.edit, + color: Colors.blue, + ), + onPressed: () => handleEditAction(event), + ), + IconButton( + icon: const Icon( + Icons.delete, + color: Colors.blue, + ), + onPressed: () => handleDeleteAction(event), + ), + ], + ), + ), + ); + }, + )) + : const Center( + child: Text('There are no Events for that date!'), + ), + ), + ], + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: handleAddNewEvent, + child: const Icon(Icons.add), + ), + ); + } +} diff --git a/lib/screens/log/log_overview.dart b/lib/screens/log/log_overview.dart new file mode 100644 index 0000000..6330d76 --- /dev/null +++ b/lib/screens/log/log_overview.dart @@ -0,0 +1,364 @@ +import 'package:diameter/screens/reports/export.dart'; +import 'package:diameter/utils/dialog_utils.dart'; +import 'package:diameter/models/glucose_target.dart'; +import 'package:diameter/models/log_bolus.dart'; +import 'package:diameter/models/log_entry.dart'; +import 'package:diameter/models/log_meal.dart'; +import 'package:diameter/models/settings.dart'; +import 'package:diameter/navigation.dart'; +import 'package:diameter/screens/log/log_entry/log_entry.dart'; +import 'package:diameter/utils/date_time_utils.dart'; +import 'package:flutter/material.dart'; +import 'dart:math' as math; + +class LogScreen extends StatefulWidget { + static const String routeName = '/log'; + const LogScreen({Key? key}) : super(key: key); + + @override + _LogScreenState createState() => _LogScreenState(); +} + +class _LogScreenState extends State { + late List _logEntries; + + final ScrollController _scrollController = ScrollController(); + final TextEditingController _dateController = TextEditingController(text: ''); + + String? swipeDirection; + + late DateTime _date; + + @override + void initState() { + super.initState(); + + _date = DateTime.now(); + _dateController.text = DateTimeUtils.displayDate(_date); + + reload(); + } + + @override + void dispose() { + _scrollController.dispose(); + _dateController.dispose(); + super.dispose(); + } + + void reload({String? message}) { + setState(() { + _logEntries = LogEntry.getAllForDate(_date); + }); + setState(() { + if (message != null) { + var snackBar = SnackBar( + content: Text(message), + duration: const Duration(seconds: 2), + ); + ScaffoldMessenger.of(context) + ..removeCurrentSnackBar() + ..showSnackBar(snackBar); + } + }); + } + + void onDelete(LogEntry logEntry) { + LogEntry.remove(logEntry.id); + reload(message: 'Log Entry deleted'); + } + + void handleDeleteAction(LogEntry logEntry) async { + if (Settings.get().showConfirmationDialogOnDelete) { + DialogUtils.showConfirmationDialog( + context: context, + onConfirm: () => onDelete(logEntry), + message: 'Are you sure you want to delete this Log Entry?', + ); + } else { + onDelete(logEntry); + } + } + + void onChangeDate(DateTime? date) { + if (date != null) { + setState(() { + _date = DateTime(date.year, date.month, date.day); + _dateController.text = DateTimeUtils.displayDate(date); + }); + reload(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Log Entries'), + actions: [ + IconButton( + onPressed: () => onChangeDate(DateTime.now()), + icon: const Icon(Icons.today)), + IconButton( + onPressed: () => showDialog( + context: context, + builder: (context) => ExportDialog(date: _date)), + icon: const Icon(Icons.file_download)), + IconButton(onPressed: reload, icon: const Icon(Icons.refresh)), + ], + ), + drawer: const Navigation(currentLocation: LogScreen.routeName), + body: GestureDetector( + onPanUpdate: (details) { + swipeDirection = details.delta.dx < 0 ? 'left' : 'right'; + }, + onPanEnd: (details) { + if (swipeDirection == null) { + return; + } + if (swipeDirection == 'right' && + !_date.isAtSameMomentAs(DateTime(2000, 1, 1))) { + onChangeDate(_date.subtract(const Duration(days: 1))); + } + if (swipeDirection == 'left' && + _date.add(const Duration(days: 1)).isBefore(DateTime.now())) { + onChangeDate(_date.add(const Duration(days: 1))); + } + }, + child: Column( + children: [ + Row( + children: [ + IconButton( + onPressed: _date.isAtSameMomentAs(DateTime(2000, 1, 1)) + ? null + : () => + onChangeDate(_date.subtract(const Duration(days: 1))), + icon: const Icon(Icons.arrow_back), + ), + Expanded( + child: GestureDetector( + onTap: () async { + final newTime = await showDatePicker( + context: context, + initialDate: _date, + firstDate: DateTime(2000, 1, 1), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + onChangeDate(newTime); + }, + child: Text( + DateTimeUtils.displayDate(_date).toUpperCase(), + style: Theme.of(context).textTheme.subtitle2, + textAlign: TextAlign.center, + ), + ), + ), + IconButton( + onPressed: _date + .add(const Duration(days: 1)) + .isBefore(DateTime.now()) + ? () => onChangeDate(_date.add(const Duration(days: 1))) + : null, + icon: const Icon(Icons.arrow_forward), + ), + ], + ), + Expanded( + child: _logEntries.isNotEmpty + ? Scrollbar( + controller: _scrollController, + child: ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(10.0), + shrinkWrap: true, + itemCount: _logEntries.length, + itemBuilder: (context, index) { + LogEntry logEntry = _logEntries[index]; + double bolus = + LogBolus.getTotalBolusForEntry(logEntry.id); + double carbs = + LogMeal.getTotalCarbsForEntry(logEntry.id); + TextStyle glucoseStyle = TextStyle( + color: GlucoseTarget.getColorForGlucose( + mgPerDl: logEntry.mgPerDl ?? 0, + mmolPerL: logEntry.mmolPerL ?? 0)); + return Card( + child: ListTile( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + LogEntryScreen(id: logEntry.id), + ), + ).then((result) => reload(message: result?[0])); + }, + title: Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + DateTimeUtils.displayTime(logEntry.time), + ), + ), + Expanded( + child: Column( + children: logEntry.mgPerDl != null && + (Settings.glucoseMeasurement == + GlucoseMeasurement + .mgPerDl || + Settings.glucoseDisplayMode == + GlucoseDisplayMode.both || + Settings.glucoseDisplayMode == + GlucoseDisplayMode + .bothForList) + ? [ + Row( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Text( + logEntry.mgPerDl + .toString(), + style: glucoseStyle), + logEntry.glucoseTrend != null + ? Transform.rotate( + angle: logEntry + .glucoseTrend! * + math.pi / + 180, + child: Icon( + Icons.arrow_upward, + color: glucoseStyle + .color, + size: 16.0, + ), + ) + : Container(), + ], + ), + const Text( + 'mg/dl', + textScaleFactor: 0.75, + ), + ] + : [], + ), + ), + Expanded( + child: Column( + children: logEntry.mmolPerL != null && + (Settings.glucoseMeasurement == + GlucoseMeasurement + .mmolPerL || + Settings.glucoseDisplayMode == + GlucoseDisplayMode.both || + Settings.glucoseDisplayMode == + GlucoseDisplayMode + .bothForList) + ? [ + Row( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Text( + logEntry.mmolPerL + .toString(), + style: glucoseStyle), + logEntry.glucoseTrend != null + ? Transform.rotate( + angle: logEntry + .glucoseTrend! * + math.pi / + 180, + child: Icon( + Icons.arrow_upward, + color: glucoseStyle + .color, + size: 16.0, + ), + ) + : Container(), + ], + ), + const Text( + 'mmol/l', + textScaleFactor: 0.75, + ), + ] + : [], + ), + ), + Expanded( + child: Column( + children: (bolus > 0) + ? [ + Text( + bolus.toStringAsPrecision(3)), + const Text('U', + textScaleFactor: 0.75), + ] + : [], + ), + ), + Expanded( + child: Column( + children: (carbs > 0) + ? [ + Text( + carbs.toStringAsPrecision(3)), + Text( + '${Settings.nutritionMeasurementSuffix} carbs', + textScaleFactor: 0.75), + ] + : [], + ), + ), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: () => + handleDeleteAction(logEntry), + icon: const Icon(Icons.delete, + color: Colors.blue), + ) + ], + ), + ), + ); + }, + ), + ) + : const Center( + child: Text( + 'You have not created any Log Entries for this date yet!'), + ), + ), + ], + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + return _date.isAtSameMomentAs(DateTimeUtils.today()) + ? const LogEntryScreen() + : LogEntryScreen( + suggestedDate: _date, + ); + }, + ), + ).then((result) => reload(message: result?[0])); + }, + child: const Icon(Icons.add), + ), + ); + } +} diff --git a/lib/screens/meal/meal_detail.dart b/lib/screens/meal/meal_detail.dart index 83a5588..cf4a030 100644 --- a/lib/screens/meal/meal_detail.dart +++ b/lib/screens/meal/meal_detail.dart @@ -11,10 +11,10 @@ import 'package:diameter/models/meal_portion_type.dart'; import 'package:diameter/models/meal_source.dart'; import 'package:diameter/models/settings.dart'; import 'package:diameter/navigation.dart'; -import 'package:diameter/screens/accuracy_detail.dart'; -import 'package:diameter/screens/meal/meal_category_detail.dart'; -import 'package:diameter/screens/meal/meal_portion_type_detail.dart'; -import 'package:diameter/screens/meal/meal_source_detail.dart'; +import 'package:diameter/screens/category/accuracy_detail.dart'; +import 'package:diameter/screens/category/meal_category_detail.dart'; +import 'package:diameter/screens/category/meal_portion_type_detail.dart'; +import 'package:diameter/screens/category/meal_source_detail.dart'; import 'package:diameter/utils/utils.dart'; import 'package:flutter/material.dart'; @@ -272,49 +272,49 @@ class _MealDetailScreenState extends State { double? carbsPerPortionUpdate}) { if (!_setManually) { double? carbsRatio = - carbsRatioUpdate ?? double.tryParse(_carbsRatioController.text); - double? portionSize = - portionSizeUpdate ?? double.tryParse(_portionSizeController.text); - double? carbsPerPortion = carbsPerPortionUpdate ?? - double.tryParse(_carbsPerPortionController.text); + carbsRatioUpdate ?? double.tryParse(_carbsRatioController.text); + double? portionSize = + portionSizeUpdate ?? double.tryParse(_portionSizeController.text); + double? carbsPerPortion = carbsPerPortionUpdate ?? + double.tryParse(_carbsPerPortionController.text); - int toCalculate = 0; - const calcCarbsRatio = 1; - const calcCarbsPerPortion = 2; - const calcPortionSize = 3; - - if (carbsRatioUpdate != null) { - if (portionSize != null && portionSize != 0) { - toCalculate = calcCarbsPerPortion; - } else if (carbsPerPortion != null && carbsPerPortion != 0) { - toCalculate = calcPortionSize; - } - } else if (portionSizeUpdate != null) { - if (carbsRatio != null && carbsRatio != 0) { - toCalculate = calcCarbsPerPortion; - } else if (carbsPerPortion != null && carbsPerPortion != 0) { - toCalculate = calcCarbsRatio; - } - } else if (carbsPerPortionUpdate != null) { - if (carbsRatio != null && carbsRatio != 0) { - toCalculate = calcPortionSize; - } else if (portionSize != null && portionSize != 0) { - toCalculate = calcCarbsRatio; - } - } else { - if (carbsRatio != null && carbsRatio != 0) { + int toCalculate = 0; + const calcCarbsRatio = 1; + const calcCarbsPerPortion = 2; + const calcPortionSize = 3; + + if (carbsRatioUpdate != null) { if (portionSize != null && portionSize != 0) { toCalculate = calcCarbsPerPortion; } else if (carbsPerPortion != null && carbsPerPortion != 0) { toCalculate = calcPortionSize; } - } else if (portionSize != null && - portionSize != 0 && - carbsPerPortion != null && - carbsPerPortion != 0) { - toCalculate = calcCarbsRatio; + } else if (portionSizeUpdate != null) { + if (carbsRatio != null && carbsRatio != 0) { + toCalculate = calcCarbsPerPortion; + } else if (carbsPerPortion != null && carbsPerPortion != 0) { + toCalculate = calcCarbsRatio; + } + } else if (carbsPerPortionUpdate != null) { + if (carbsRatio != null && carbsRatio != 0) { + toCalculate = calcPortionSize; + } else if (portionSize != null && portionSize != 0) { + toCalculate = calcCarbsRatio; + } + } else { + if (carbsRatio != null && carbsRatio != 0) { + if (portionSize != null && portionSize != 0) { + toCalculate = calcCarbsPerPortion; + } else if (carbsPerPortion != null && carbsPerPortion != 0) { + toCalculate = calcPortionSize; + } + } else if (portionSize != null && + portionSize != 0 && + carbsPerPortion != null && + carbsPerPortion != 0) { + toCalculate = calcCarbsRatio; + } } - } setState(() { if (toCalculate == calcCarbsRatio) { @@ -435,7 +435,10 @@ class _MealDetailScreenState extends State { showSteppers: false, onChanged: (value) async { await Future.delayed(const Duration(seconds: 1)); - calculateThirdMeasurementOfPortionCarbsRelation(carbsRatioUpdate: value); + if (value != null && value != 0) { + calculateThirdMeasurementOfPortionCarbsRelation( + carbsRatioUpdate: value); + } }, ), ), @@ -448,7 +451,10 @@ class _MealDetailScreenState extends State { showSteppers: false, onChanged: (value) async { await Future.delayed(const Duration(seconds: 1)); - calculateThirdMeasurementOfPortionCarbsRelation(portionSizeUpdate: value); + if (value != null && value != 0) { + calculateThirdMeasurementOfPortionCarbsRelation( + portionSizeUpdate: value); + } }, ), ), @@ -461,7 +467,10 @@ class _MealDetailScreenState extends State { showSteppers: false, onChanged: (value) async { await Future.delayed(const Duration(seconds: 1)); - calculateThirdMeasurementOfPortionCarbsRelation(carbsPerPortionUpdate: value); + if (value != null && value != 0) { + calculateThirdMeasurementOfPortionCarbsRelation( + carbsPerPortionUpdate: value); + } }, ), ), @@ -562,15 +571,12 @@ class _MealDetailScreenState extends State { const EdgeInsets.symmetric(vertical: 5.0), child: Row( children: [ - Expanded( - child: AutoCompleteDropdownButton< - MealCategory>( - controller: _mealCategoryController, - selectedItem: _mealCategory, - label: 'Meal Category', - items: _mealCategories, - onChanged: updateMealCategory, - ), + AutoCompleteDropdownButton( + controller: _mealCategoryController, + selectedItem: _mealCategory, + label: 'Meal Category', + items: _mealCategories, + onChanged: updateMealCategory, ), IconButton( onPressed: () { @@ -600,15 +606,12 @@ class _MealDetailScreenState extends State { const EdgeInsets.symmetric(vertical: 5.0), child: Row( children: [ - Expanded( - child: AutoCompleteDropdownButton( - controller: - _portionSizeAccuracyController, - selectedItem: _portionSizeAccuracy, - label: 'Portion Size Accuracy', - items: _portionSizeAccuracies, - onChanged: updatePortionSizeAccuracy, - ), + AutoCompleteDropdownButton( + controller: _portionSizeAccuracyController, + selectedItem: _portionSizeAccuracy, + label: 'Portion Size Accuracy', + items: _portionSizeAccuracies, + onChanged: updatePortionSizeAccuracy, ), IconButton( onPressed: () { @@ -639,14 +642,12 @@ class _MealDetailScreenState extends State { const EdgeInsets.symmetric(vertical: 5.0), child: Row( children: [ - Expanded( - child: AutoCompleteDropdownButton( - controller: _carbsRatioAccuracyController, - selectedItem: _carbsRatioAccuracy, - label: 'Carbs Ratio Accuracy', - items: _carbsRatioAccuracies, - onChanged: updateCarbsRatioAccuracy, - ), + AutoCompleteDropdownButton( + controller: _carbsRatioAccuracyController, + selectedItem: _carbsRatioAccuracy, + label: 'Carbs Ratio Accuracy', + items: _carbsRatioAccuracies, + onChanged: updateCarbsRatioAccuracy, ), IconButton( onPressed: () { diff --git a/lib/screens/recipe/recipe_detail.dart b/lib/screens/recipe/recipe_detail.dart deleted file mode 100644 index 6c08173..0000000 --- a/lib/screens/recipe/recipe_detail.dart +++ /dev/null @@ -1,322 +0,0 @@ -import 'package:diameter/components/detail.dart'; -import 'package:diameter/utils/dialog_utils.dart'; -import 'package:diameter/components/forms/auto_complete_dropdown_button.dart'; -import 'package:diameter/components/forms/form_wrapper.dart'; -import 'package:diameter/models/ingredient.dart'; -import 'package:diameter/models/meal.dart'; -import 'package:diameter/models/recipe.dart'; -import 'package:diameter/models/settings.dart'; -import 'package:diameter/navigation.dart'; -import 'package:diameter/screens/meal/meal_detail.dart'; -import 'package:flutter/material.dart'; - -class RecipeDetailScreen extends StatefulWidget { - static const String routeName = '/recipe'; - final int id; - - const RecipeDetailScreen({Key? key, this.id = 0}) : super(key: key); - - @override - _RecipeDetailScreenState createState() => _RecipeDetailScreenState(); -} - -class _RecipeDetailScreenState extends State { - Recipe? _recipe; - List _ingredients = []; - - bool _isNew = true; - bool _isSaving = false; - - final GlobalKey _recipeForm = GlobalKey(); - final ScrollController _scrollController = ScrollController(); - - final _nameController = TextEditingController(text: ''); - final _notesController = TextEditingController(text: ''); - - double _servings = 1; - - final List _ingredientControllers = []; - - List _meals = []; - - @override - void initState() { - super.initState(); - - reload(); - - _meals = Meal.getAll(); - - if (_recipe != null) { - _nameController.text = _recipe!.name; - _servings = _recipe!.servings ?? 1; - _notesController.text = _recipe!.notes ?? ''; - - if (_ingredients.isNotEmpty) { - for (Ingredient ingredient in _ingredients) { - _ingredientControllers.add( - TextEditingController(text: ingredient.ingredient.target?.value)); - } - } - } - } - - void reload({String? message}) { - if (widget.id != 0) { - setState(() { - _recipe = Recipe.get(widget.id); - _ingredients = Ingredient.getAllForRecipe(widget.id); - }); - } - _isNew = _recipe == null; - - setState(() { - if (message != null) { - var snackBar = SnackBar( - content: Text(message), - duration: const Duration(seconds: 2), - ); - ScaffoldMessenger.of(context) - ..removeCurrentSnackBar() - ..showSnackBar(snackBar); - } - }); - } - - void onAddIngredient() { - final newIngredient = Ingredient(amount: 0); - setState(() { - newIngredient.recipe.target = _recipe; - _ingredients.add(newIngredient); - _ingredientControllers.add(TextEditingController(text: '')); - }); - } - - void handleSaveAction({bool close = false}) async { - setState(() { - _isSaving = true; - }); - if (_recipeForm.currentState!.validate()) { - Recipe recipe = Recipe( - id: widget.id, - name: _nameController.text, - servings: _servings, - notes: _notesController.text, - ); - Recipe.put(recipe); - List ingredients = _ingredients.map((ingredient) { - if (ingredient.id != 0 && - (!ingredient.ingredient.hasValue || ingredient.amount == 0)) { - ingredient.deleted = true; - } - return ingredient; - }).toList(); - ingredients.retainWhere((ingredient) { - return ingredient.id != 0 || - (ingredient.amount > 0 && ingredient.ingredient.hasValue); - }); - Ingredient.putMany(ingredients); - - if (close) { - Navigator.pop(context, ['${_isNew ? 'New' : ''} Recipe Saved', recipe]); - } else { - if (_isNew) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => RecipeDetailScreen(id: recipe.id), - ), - ).then((result) => Navigator.pop(context, result)); - } else { - reload(message: 'Recipe saved'); - } - } - - setState(() { - _isSaving = false; - }); - } - } - - void handleCancelAction() { - if (Settings.get().showConfirmationDialogOnCancel && - ((_isNew && - (_nameController.text != '' || - _servings != 1 || - _notesController.text != '')) || - (!_isNew && - (_nameController.text != _recipe!.name || - _servings != _recipe!.servings || - _notesController.text != (_recipe!.notes ?? ''))))) { - DialogUtils.showCancelConfirmationDialog( - context: context, - isNew: _isNew, - onSave: handleSaveAction, - ); - } else { - Navigator.pop(context); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(_isNew ? 'New Recipe' : _recipe!.name), - ), - drawer: const Navigation(currentLocation: RecipeDetailScreen.routeName), - body: Scrollbar( - controller: _scrollController, - child: SingleChildScrollView( - controller: _scrollController, - child: Column( - children: [ - FormWrapper( - formState: _recipeForm, - fields: [ - TextFormField( - controller: _nameController, - decoration: const InputDecoration( - labelText: 'Name', - ), - validator: (value) { - if (value!.trim().isEmpty) { - return 'Empty title'; - } - return null; - }, - ), - // NumberFormField( - // value: _servings, - // label: 'Servings', - // suffix: ' portions', - // min: 0, - // onChanged: (value) { - // if (value != null && value >= 0) { - // setState(() { - // _servings = value.toDouble(); - // }); - // } - // }, - // ), - TextFormField( - keyboardType: TextInputType.multiline, - controller: _notesController, - decoration: const InputDecoration( - labelText: 'Notes', - ), - minLines: 2, - maxLines: 5, - ), - const Divider(), - GestureDetector( - onTap: onAddIngredient, - child: Row( - children: [ - Text( - 'INGREDIENTS', - style: Theme.of(context).textTheme.subtitle2, - ), - const Spacer(), - IconButton( - onPressed: onAddIngredient, - icon: const Icon(Icons.add), - ), - ], - ), - ), - ], - ), - !_isNew && _ingredients.isNotEmpty - ? ListBody( - children: _ingredients.map((item) { - final ingredient = item.ingredient.target; - final index = _ingredients.indexOf(item); - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10.0, vertical: 5.0), - child: Column( - children: [ - Row( - children: [ - Expanded( - child: AutoCompleteDropdownButton( - controller: _ingredientControllers[index], - selectedItem: ingredient, - label: 'Meal Category', - items: _meals, - onChanged: (value) { - setState(() { - _ingredients[index] - .ingredient - .target = value; - _ingredientControllers[index].text = - value?.value ?? ''; - }); - }, - ), - ), - IconButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - ingredient == null - ? const MealDetailScreen() - : MealDetailScreen( - id: ingredient.id), - ), - ).then((result) { - _ingredients[index].ingredient.target = - result?[1]; - _ingredientControllers[index].text = - result?[1].value ?? ''; - reload(message: result?[0]); - }); - }, - icon: Icon(ingredient == null - ? Icons.add - : Icons.edit), - ), - ], - ), - // Padding( - // padding: const EdgeInsets.only(top: 10.0), - // child: NumberFormField( - // controller: - // _ingredients[index].amount, - // label: 'Amount', - // suffix: Settings.nutritionMeasurementSuffix, - // min: 0, - // onChanged: (value) { - // if (value != null && value >= 0) { - // setState(() { - // _ingredients[index].amount = value.toDouble(); - // }); - // } - // }, - // ), - // ), - ], - ), - ); - }).toList(), - ) - : Center( - child: Text(_isNew - ? 'Save the Recipe in order to add ingredients!' - : 'You have not added any Ingredients yet!'), - ) - ], - ), - ), - ), - bottomNavigationBar: DetailBottomRow( - onCancel: handleCancelAction, - onAction: _isSaving ? null : handleSaveAction, - onMiddleAction: _isSaving ? null : () => handleSaveAction(close: true), - ), - ); - } -} diff --git a/lib/screens/recipe/recipe_list.dart b/lib/screens/recipe/recipe_list.dart deleted file mode 100644 index b4de28f..0000000 --- a/lib/screens/recipe/recipe_list.dart +++ /dev/null @@ -1,200 +0,0 @@ -import 'package:diameter/utils/dialog_utils.dart'; -import 'package:diameter/models/ingredient.dart'; -import 'package:diameter/models/recipe.dart'; -import 'package:diameter/models/settings.dart'; -import 'package:diameter/navigation.dart'; -import 'package:diameter/screens/recipe/recipe_detail.dart'; -import 'package:flutter/material.dart'; - -class RecipeListScreen extends StatefulWidget { - static const String routeName = '/recipes'; - - const RecipeListScreen({Key? key}) : super(key: key); - - @override - _RecipeListScreenState createState() => _RecipeListScreenState(); -} - -class _RecipeListScreenState extends State { - List _recipes = []; - - final ScrollController _scrollController = ScrollController(); - - @override - void initState() { - super.initState(); - reload(); - } - - void reload({String? message}) { - setState(() { - _recipes = Recipe.getAll(); - }); - setState(() { - if (message != null) { - var snackBar = SnackBar( - content: Text(message), - duration: const Duration(seconds: 2), - ); - ScaffoldMessenger.of(context) - ..removeCurrentSnackBar() - ..showSnackBar(snackBar); - } - }); - } - - void onDelete(Recipe recipe) { - Recipe.remove(recipe.id); - reload(message: 'Recipe deleted'); - } - - void handleDeleteAction(Recipe recipe) async { - if (Settings.get().showConfirmationDialogOnDelete) { - DialogUtils.showConfirmationDialog( - context: context, - onConfirm: () => onDelete(recipe), - message: 'Are you sure you want to delete this Recipe?', - ); - } else { - onDelete(recipe); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Recipes'), actions: [ - IconButton(onPressed: reload, icon: const Icon(Icons.refresh)) - ]), - drawer: const Navigation(currentLocation: RecipeListScreen.routeName), - body: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: _recipes.isNotEmpty - ? Scrollbar( - controller: _scrollController, - child: ListView.builder( - controller: _scrollController, - padding: const EdgeInsets.all(10.0), - itemCount: _recipes.length, - itemBuilder: (context, index) { - final recipe = _recipes[index]; - final carbsRatio = - Ingredient.getCarbsRatioForRecipe(recipe.id); - final carbsPerPortion = Recipe.getCarbsPerPortion(recipe.id); - return Card( - child: ListTile( - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - RecipeDetailScreen(id: recipe.id), - ), - ).then((result) => reload(message: result?[0])); - }, - title: Text( - recipe.name.toUpperCase(), - style: Theme.of(context).textTheme.subtitle2, - ), - subtitle: Padding( - padding: - const EdgeInsets.symmetric(vertical: 10.0), - child: Row( - children: [ - Column( - children: [ - Text(recipe.notes ?? ''), - ], - ), - Expanded( - child: Column( - mainAxisAlignment: - MainAxisAlignment.center, - crossAxisAlignment: - CrossAxisAlignment.center, - children: - ((carbsRatio ?? 0) > 0) - ? [ - Text(carbsRatio!.toString()), - const Text('% carbs', - textScaleFactor: 0.75), - ] - : [], - ), - ), - Expanded( - child: Column( - mainAxisAlignment: - MainAxisAlignment.center, - crossAxisAlignment: - CrossAxisAlignment.center, - children: - (recipe.servings != null) - ? [ - Text(recipe.servings! - .toStringAsPrecision(3)), - const Text('servings', - textScaleFactor: 0.75), - ] - : [], - ), - ), - Expanded( - child: Column( - mainAxisAlignment: - MainAxisAlignment.center, - crossAxisAlignment: - CrossAxisAlignment.center, - children: - ((carbsPerPortion ?? 0) > 0) - ? [ - Text(carbsPerPortion! - .toStringAsPrecision(3)), - Text( - '${Settings.nutritionMeasurementSuffix} carbs per serving', - textScaleFactor: 0.75), - ] - : [], - ), - ), - ], - ), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.end, - children: [ - IconButton( - onPressed: () => handleDeleteAction(recipe), - icon: const Icon(Icons.delete, - color: Colors.blue), - ) - ], - ), - ), - ); - }, - ), - ) - : const Center( - child: Text('You have not created any Recipes yet!'), - ), - ), - ], - ), - floatingActionButton: FloatingActionButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const RecipeDetailScreen(), - ), - ).then((result) => reload(message: result?[0])); - }, - child: const Icon(Icons.add), - ), - ); - } -} diff --git a/lib/screens/reports/daily_chart.dart b/lib/screens/reports/daily_chart.dart new file mode 100644 index 0000000..b3404ec --- /dev/null +++ b/lib/screens/reports/daily_chart.dart @@ -0,0 +1,391 @@ +import 'package:charts_flutter/flutter.dart' as charts; +import 'package:diameter/models/basal.dart'; +import 'package:diameter/models/glucose_target.dart'; +import 'package:diameter/models/log_bolus.dart'; +import 'package:diameter/models/log_entry.dart'; +import 'package:diameter/models/log_event.dart'; +import 'package:diameter/models/log_meal.dart'; +import 'package:diameter/models/settings.dart'; +import 'package:diameter/navigation.dart'; +import 'package:diameter/utils/date_time_utils.dart'; +import 'package:flutter/material.dart'; + +charts.TimeSeriesChart generateChart( + DateTime date, + List logEntries, + List logEvents, + List targets, + bool showMeals, + bool showBasal, + bool showBolus) { + final targetRange = targets.singleWhere((target) => + Settings.glucoseMeasurement == GlucoseMeasurement.mgPerDl + ? Settings.targetMgPerDl >= target.fromMgPerDL && + Settings.targetMgPerDl <= target.toMgPerDl + : Settings.targetMmolPerL >= target.fromMmolPerL && + Settings.targetMmolPerL <= target.toMmolPerL); + + final List basalRate = []; + + if (showBasal) { + Basal? lastBasal; + + for (var time = date; + time.isBefore(date.add(const Duration(days: 1))); + time = time.add(const Duration(minutes: 1))) { + final basal = Basal.getRateForTime(time); + if (basal != null && basal.units != lastBasal?.units) { + if (basalRate.isNotEmpty) { + basalRate.last.endTime = time; + } + basalRate.add( + Basal(startTime: time, endTime: basal.endTime, units: basal.units)); + lastBasal = basal; + } + } + } + + return charts.TimeSeriesChart( + [ + charts.Series( + id: 'Glucose', + colorFn: (LogEntry entry, _) => charts.Color.black, + domainFn: (LogEntry entry, _) => entry.time, + measureFn: (LogEntry entry, _) => + Settings.glucoseMeasurement == GlucoseMeasurement.mgPerDl + ? entry.mgPerDl + : entry.mmolPerL, + data: logEntries, + )..setAttribute(charts.rendererIdKey, 'glucoseRenderer'), + charts.Series( + id: 'Carbohydrates', + colorFn: (LogEntry entry, _) => + charts.MaterialPalette.yellow.shadeDefault, + domainFn: (LogEntry entry, _) => entry.time, + measureFn: (LogEntry entry, _) => + LogMeal.getTotalCarbsForEntry(entry.id), + data: showMeals ? logEntries : [], + )..setAttribute(charts.rendererIdKey, 'carbsRenderer'), + charts.Series( + id: 'Basal', + colorFn: (Basal basal, _) => charts.Color.black.lighter, + domainFn: (Basal basal, _) => basal.startTime, + measureFn: (Basal basal, _) => basal.units, + data: showBasal ? basalRate : [], + ) + ..setAttribute( + charts.measureAxisIdKey, charts.Axis.secondaryMeasureAxisId) + ..setAttribute(charts.rendererIdKey, 'basalRenderer'), + charts.Series( + id: 'Bolus', + colorFn: (LogEntry entry, _) => + charts.MaterialPalette.blue.shadeDefault, + domainFn: (LogEntry entry, _) => entry.time, + measureFn: (LogEntry entry, _) => + LogBolus.getTotalBolusForEntry(entry.id), + data: showBolus ? logEntries : [], + ) + ..setAttribute( + charts.measureAxisIdKey, charts.Axis.secondaryMeasureAxisId) + ..setAttribute(charts.rendererIdKey, 'bolusRenderer'), + ], + domainAxis: const charts.DateTimeAxisSpec( + tickFormatterSpec: charts.AutoDateTimeTickFormatterSpec( + hour: charts.TimeFormatterSpec( + format: 'HH', + transitionFormat: 'HH', + ), + ), + ), + primaryMeasureAxis: const charts.NumericAxisSpec( + tickProviderSpec: charts.BasicNumericTickProviderSpec( + desiredTickCount: 5, + ), + ), + secondaryMeasureAxis: const charts.NumericAxisSpec( + tickProviderSpec: charts.BasicNumericTickProviderSpec( + desiredTickCount: 5, + ), + ), + animate: false, + customSeriesRenderers: [ + charts.LineRendererConfig( + includeLine: true, + includePoints: false, + customRendererId: 'glucoseRenderer', + ), + charts.LineRendererConfig( + includeLine: true, + includePoints: false, + includeArea: true, + roundEndCaps: true, + strokeWidthPx: 1, + customRendererId: 'basalRenderer', + ), + charts.LineRendererConfig( + includeLine: false, + includePoints: true, + customRendererId: 'bolusRenderer', + ), + charts.LineRendererConfig( + includeLine: false, + includePoints: true, + customRendererId: 'carbsRenderer', + ), + ], + behaviors: [ + charts.RangeAnnotation([ + charts.RangeAnnotationSegment( + Settings.glucoseMeasurement == GlucoseMeasurement.mgPerDl + ? targetRange.fromMgPerDL + : targetRange.fromMmolPerL, + Settings.glucoseMeasurement == GlucoseMeasurement.mgPerDl + ? targetRange.toMgPerDl + : targetRange.toMmolPerL, + charts.RangeAnnotationAxisType.measure, + color: charts.MaterialPalette.green.shadeDefault.lighter, + ), + ...logEvents + .where((element) => element.hasEndTime) + .map((LogEvent event) => charts.RangeAnnotationSegment( + event.time, + event.endTime ?? DateTime.now(), + charts.RangeAnnotationAxisType.domain, + startLabel: event.eventType.target?.value, + color: charts.MaterialPalette.gray.shade300, + )) + .toList(), + ...logEvents + .where((element) => !element.hasEndTime) + .map((LogEvent event) => charts.LineAnnotationSegment( + event.time, + charts.RangeAnnotationAxisType.domain, + startLabel: '${event.eventType.target?.value}', + )) + .toList(), + ]), + ], + ); +} + +class DailyChart extends StatefulWidget { + static const String routeName = '/reports/base'; + const DailyChart({Key? key}) : super(key: key); + + @override + _DailyChartState createState() => _DailyChartState(); +} + +class _DailyChartState extends State { + late List logEntries; + late List logEvents; + late List targets; + + final ScrollController _scrollController = ScrollController(); + final TextEditingController _dateController = TextEditingController(text: ''); + + late DateTime date; + + String? swipeDirection; + + bool showChart = true; + bool showBolus = true; + bool showBasal = true; + bool showMeals = true; + + @override + void initState() { + super.initState(); + + date = DateTimeUtils.today(); + logEntries = LogEntry.getAllForDate(date); + logEvents = LogEvent.getAllForDate(date); + targets = GlucoseTarget.getAll(); + _dateController.text = DateTimeUtils.displayDate(date); + } + + @override + void dispose() { + _scrollController.dispose(); + _dateController.dispose(); + super.dispose(); + } + + void reload({String? message}) { + setState(() { + logEntries = LogEntry.getAllForDate(date); + logEvents = LogEvent.getAllForDate(date); + }); + setState(() { + if (message != null) { + var snackBar = SnackBar( + content: Text(message), + duration: const Duration(seconds: 2), + ); + ScaffoldMessenger.of(context) + ..removeCurrentSnackBar() + ..showSnackBar(snackBar); + } + }); + } + + void onChangeDate(DateTime? newDate) { + if (newDate != null) { + setState(() { + date = DateTime(newDate.year, newDate.month, newDate.day); + _dateController.text = DateTimeUtils.displayDate(newDate); + }); + reload(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Daily Chart'), + actions: [ + IconButton( + onPressed: () => onChangeDate(DateTime.now()), + icon: const Icon(Icons.today)), + IconButton( + onPressed: () => showDialog( + context: context, + builder: (context) => AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + CheckboxListTile( + value: showChart, + onChanged: (_) => + setState(() => showChart = !showChart), + title: const Text('show Chart'), + controlAffinity: ListTileControlAffinity.leading, + ), + Padding( + padding: const EdgeInsets.only(left: 20.0), + child: CheckboxListTile( + value: showBolus, + onChanged: showChart + ? (_) => + setState(() => showBolus = !showBolus) + : null, + title: const Text('show Bolus'), + controlAffinity: + ListTileControlAffinity.leading, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 20.0), + child: CheckboxListTile( + value: showBasal, + onChanged: showChart + ? (_) => + setState(() => showBasal = !showBasal) + : null, + title: const Text('show Basal'), + controlAffinity: + ListTileControlAffinity.leading, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 20.0), + child: CheckboxListTile( + value: showMeals, + onChanged: showChart + ? (_) => + setState(() => showMeals = !showMeals) + : null, + title: const Text('show Meals'), + controlAffinity: + ListTileControlAffinity.leading, + ), + ), + ], + ), + actions: [ + ElevatedButton( + onPressed: () => Navigator.pop(context), + child: const Text('CLOSE'), + ), + ]), + ), + icon: const Icon(Icons.settings)), + IconButton( + onPressed: reload, + icon: const Icon(Icons.refresh), + ), + ], + ), + drawer: const Navigation(currentLocation: DailyChart.routeName), + body: GestureDetector( + onPanUpdate: (details) { + swipeDirection = details.delta.dx < 0 ? 'left' : 'right'; + }, + onPanEnd: (details) { + if (swipeDirection == null) { + return; + } + if (swipeDirection == 'right' && + !date.isAtSameMomentAs(DateTime(2000, 1, 1))) { + onChangeDate(date.subtract(const Duration(days: 1))); + } + if (swipeDirection == 'left' && + date.add(const Duration(days: 1)).isBefore(DateTime.now())) { + onChangeDate(date.add(const Duration(days: 1))); + } + }, + child: Column( + children: [ + Row( + children: [ + IconButton( + onPressed: date.isAtSameMomentAs(DateTime(2000, 1, 1)) + ? null + : () => + onChangeDate(date.subtract(const Duration(days: 1))), + icon: const Icon(Icons.arrow_back), + ), + Expanded( + child: GestureDetector( + onTap: () async { + final newTime = await showDatePicker( + context: context, + initialDate: date, + firstDate: DateTime(2000, 1, 1), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + onChangeDate(newTime); + }, + child: Text( + DateTimeUtils.displayDate(date).toUpperCase(), + style: Theme.of(context).textTheme.subtitle2, + textAlign: TextAlign.center, + ), + ), + ), + IconButton( + onPressed: + date.add(const Duration(days: 1)).isBefore(DateTime.now()) + ? () => onChangeDate(date.add(const Duration(days: 1))) + : null, + icon: const Icon(Icons.arrow_forward), + ), + ], + ), + Expanded( + child: logEntries.isNotEmpty + ? generateChart(date, logEntries, logEvents, targets, showMeals, + showBasal, showBolus) + : const Center( + child: Text( + 'You have not created any Log Entries for this date yet!'), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/reports/export.dart b/lib/screens/reports/export.dart new file mode 100644 index 0000000..29226e5 --- /dev/null +++ b/lib/screens/reports/export.dart @@ -0,0 +1,476 @@ +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; +import 'package:diameter/components/forms/date_time_form_field.dart'; +import 'package:diameter/models/glucose_target.dart'; +import 'package:diameter/models/log_bolus.dart'; +import 'package:diameter/models/log_entry.dart'; +import 'package:diameter/models/log_event.dart'; +import 'package:diameter/models/log_meal.dart'; +import 'package:diameter/models/settings.dart'; +import 'package:diameter/screens/reports/daily_chart.dart'; +import 'package:diameter/utils/date_time_utils.dart'; +import 'package:diameter/utils/utils.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:open_file/open_file.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; +import 'package:printing/printing.dart'; + +class MealData { + int id; + String text; + String portion; + String carbs; + String notes; + String boli; + + MealData({ + this.id = 0, + this.text = '', + this.portion = '', + this.carbs = '', + this.notes = '', + this.boli = '', + }); +} + +class ExportDialog extends StatefulWidget { + static const String routeName = '/export'; + + final DateTime? date; + + const ExportDialog({Key? key, this.date}) : super(key: key); + + @override + _ExportDialogState createState() => _ExportDialogState(); +} + +class _ExportDialogState extends State { + final ScrollController _scrollController = ScrollController(); + + late DateTime exportStartDate; + late DateTime exportEndDate; + + TextEditingController exportStartDateController = + TextEditingController(text: ''); + TextEditingController exportEndDateController = + TextEditingController(text: ''); + + bool _isSaving = false; + bool exportRange = false; + bool showChart = true; + bool showBolus = true; + bool showBasal = true; + bool showMeals = true; + bool showTable = true; + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + void initState() { + super.initState(); + exportStartDate = widget.date ?? DateTimeUtils.today(); + exportEndDate = DateTimeUtils.today(); + + exportStartDateController.text = DateTimeUtils.displayDate(exportStartDate); + exportEndDateController.text = DateTimeUtils.displayDate(exportEndDate); + } + + void onExport(BuildContext context) async { + setState(() { + _isSaving = true; + }); + final bytes = await generateLogReport( + startDate: exportStartDate, + endDate: exportRange ? exportEndDate : exportStartDate, + showChart: showChart, + showTable: showTable, + showMeals: showMeals, + showBasal: showBasal, + showBolus: showBolus, + ); + final appDocDir = await getApplicationDocumentsDirectory(); + final appDocPath = appDocDir.path; + + final DateFormat formatter = DateFormat(DateFormat.YEAR_MONTH_DAY); + final file = File( + '$appDocPath/diameter_${exportRange ? '${formatter.format(exportStartDate)}-${formatter.format(exportEndDate)}' : formatter.format(exportStartDate)}.pdf'); + await file.writeAsBytes(bytes); + await OpenFile.open(file.path); + Navigator.pop(context); + setState(() { + _isSaving = false; + }); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Row( + children: [ + Expanded( + child: RadioListTile( + title: const Text('single date'), + groupValue: exportRange, + value: false, + onChanged: (_) { + setState(() { + exportRange = false; + }); + }), + ), + Expanded( + child: RadioListTile( + title: const Text('range'), + groupValue: exportRange, + value: true, + onChanged: (value) { + setState(() { + exportRange = true; + }); + }), + ), + ], + ), + Row( + children: [ + Expanded( + child: DateTimeFormField( + date: exportStartDate, + label: 'Date', + controller: exportStartDateController, + onChanged: (newDate) { + if (newDate != null) { + exportStartDate = + DateTime(newDate.year, newDate.month, newDate.day); + exportStartDateController.text = + DateTimeUtils.displayDate(exportStartDate); + } + }, + ), + ), + exportRange + ? Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 5.0), + child: DateTimeFormField( + date: exportEndDate, + label: 'End Date', + controller: exportEndDateController, + onChanged: (newDate) { + if (newDate != null) { + exportEndDate = DateTime( + newDate.year, newDate.month, newDate.day); + exportEndDateController.text = + DateTimeUtils.displayDate(exportEndDate); + } + }, + ), + ), + ) + : Container(), + ], + ), + CheckboxListTile( + value: showChart, + onChanged: (_) => setState(() => showChart = !showChart), + title: const Text('show Chart'), + controlAffinity: ListTileControlAffinity.leading, + ), + Padding( + padding: const EdgeInsets.only(left: 20.0), + child: CheckboxListTile( + value: showBolus, + onChanged: showChart + ? (_) => setState(() => showBolus = !showBolus) + : null, + title: const Text('show Bolus'), + controlAffinity: ListTileControlAffinity.leading, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 20.0), + child: CheckboxListTile( + value: showBasal, + onChanged: showChart + ? (_) => setState(() => showBasal = !showBasal) + : null, + title: const Text('show Basal'), + controlAffinity: ListTileControlAffinity.leading, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 20.0), + child: CheckboxListTile( + value: showMeals, + onChanged: showChart + ? (_) => setState(() => showMeals = !showMeals) + : null, + title: const Text('show Meals'), + controlAffinity: ListTileControlAffinity.leading, + ), + ), + CheckboxListTile( + value: showTable, + onChanged: (_) => setState(() => showTable = !showTable), + title: const Text('show Table'), + controlAffinity: ListTileControlAffinity.leading, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('CANCEL'), + ), + ElevatedButton( + onPressed: !_isSaving && (showChart || showTable) + ? () => onExport(context) + : null, + child: const Text('EXPORT'), + ), + ]); + } +} + +Future generateChartAsImage( + DateTime date, + List logEntries, + List logEvents, + List targets, + bool showMeals, + bool showBasal, + bool showBolus) async { + final chart = generateChart( + date, logEntries, logEvents, targets, showMeals, showBasal, showBolus); + + return pw.Image( + await WidgetWraper.fromWidget( + widget: chart, + constraints: BoxConstraints( + minWidth: PdfPageFormat.a4.landscape.availableWidth, + maxWidth: PdfPageFormat.a4.landscape.availableWidth, + minHeight: PdfPageFormat.a4.landscape.availableHeight / 2, + maxHeight: PdfPageFormat.a4.landscape.availableHeight, + ), + ), + ); +} + +pw.Table generateTable(List logEntries) { + const baseColor = PdfColors.grey; + const small = pw.TextStyle(fontSize: 8.0); + + final tableHeaders = [ + 'Time', + 'Glucose', + 'Bolus', + 'Notes', + 'Meals', + 'Portion Size', + 'Carbohydrates', + 'Meal Bolus', + 'Meal Notes', + ]; + + final List data = logEntries.map((logEntry) { + pw.TextStyle glucoseStyle = pw.TextStyle( + color: PdfColor.fromInt(GlucoseTarget.getColorForGlucose( + mgPerDl: logEntry.mgPerDl ?? 0, mmolPerL: logEntry.mmolPerL ?? 0) + .value), + ); + + final boli = LogBolus.getAllForEntry(logEntry.id); + final meals = LogMeal.getAllForEntry(logEntry.id) + .map((meal) => MealData( + id: meal.id, + text: meal.value, + portion: + '${Utils.getFractionDigitsLength(meal.amount) == 0 ? meal.amount.toInt().toString() : meal.amount} ${meal.mealPortionType.target?.value}', + carbs: Utils.displayNutritionAmount(meal.totalCarbs ?? 0, + cutExtraDigits: true), + boli: [ + for (var bolus in boli + .where((bolus) => bolus.meal.targetId == meal.id) + .toList()) + '${bolus.units} U${bolus.delay != null ? ' (over ${bolus.delay} min)' : ''}' + ].join(' + '), + notes: meal.notes ?? '', + )) + .toList(); + final glucoseBoli = boli + .where((bolus) => + Settings.glucoseMeasurement == GlucoseMeasurement.mgPerDl + ? bolus.mgPerDlCorrection != null + : bolus.mmolPerLCorrection != null) + .toList(); + final otherMealBoli = boli + .where((bolus) => + bolus.meal.targetId != 0 && + !meals.any((meal) => meal.id == bolus.meal.targetId)) + .map((bolus) => + '${bolus.units} U${bolus.delay != null ? ' (over ${bolus.delay} min)' : ''}${bolus.meal.target != null ? ' (for ${bolus.meal.target!.value})' : ''}') + .toList(); + + return pw.TableRow( + decoration: pw.BoxDecoration( + color: logEntries.indexOf(logEntry) % 2 == 0 ? PdfColors.grey200 : null, + border: const pw.Border( + bottom: pw.BorderSide( + color: baseColor, + width: .5, + ), + ), + ), + children: [ + // Time + pw.Text(DateTimeUtils.displayTime(logEntry.time)), + // Blood Glucose + pw.Row( + children: [ + pw.Text( + Utils.displayGlucose( + mgPerDl: logEntry.mgPerDl, mmolPerL: logEntry.mmolPerL), + style: glucoseStyle, + ), + logEntry.glucoseTrend != null + ? pw.Transform.rotate( + angle: -(logEntry.glucoseTrend! * pi / 180), + child: pw.Icon( + const pw.IconData(0xe5d8), + color: glucoseStyle.color, + size: 10.0, + ), + ) + : pw.Container(), + ], + ), + // Bolus + pw.Text( + [for (var bolus in glucoseBoli) '${bolus.units} U'].join(' + ')), + // Notes + pw.Text(logEntry.notes ?? '', style: small), + // Meals + pw.Text([for (var meal in meals) meal.text].join('\n'), style: small), + // Portion Size + pw.Text([for (var meal in meals) meal.portion].join('\n'), + style: small), + // Carbohydrates + pw.Text([for (var meal in meals) meal.carbs].join('\n'), style: small), + // Meal Bolus + pw.Text( + [ + for (var meal in meals) meal.boli, + for (var bolus in otherMealBoli) bolus, + ].join('\n'), + style: small), + // Meal Notes + pw.Text([for (var meal in meals) meal.notes].join('\n'), style: small), + ], + ); + }).toList(); + + return pw.Table( + border: null, + tableWidth: pw.TableWidth.max, + children: [ + pw.TableRow( + decoration: const pw.BoxDecoration( + color: baseColor, + ), + children: tableHeaders + .map((header) => pw.Text( + header, + style: pw.TextStyle( + color: PdfColors.white, + fontWeight: pw.FontWeight.bold, + ), + )) + .toList()), + ] + + data, + ); +} + +Future generateLogReport( + {required DateTime startDate, + DateTime? endDate, + bool showChart = true, + bool showTable = true, + bool showMeals = true, + bool showBasal = true, + bool showBolus = true}) async { + final List targets = GlucoseTarget.getAll(); + + final document = pw.Document(); + + final pageFormat = PdfPageFormat.a4.landscape.applyMargin( + left: 2.0 * PdfPageFormat.cm, + top: 2.0 * PdfPageFormat.cm, + right: 2.0 * PdfPageFormat.cm, + bottom: 2.0 * PdfPageFormat.cm); + + final theme = pw.ThemeData.withFont( + base: await PdfGoogleFonts.robotoRegular(), + bold: await PdfGoogleFonts.robotoBold(), + icons: await PdfGoogleFonts.materialIcons(), + ).copyWith( + defaultTextStyle: const pw.TextStyle(fontSize: 9.0), + ); + + for (DateTime date = startDate; + endDate != null && !endDate.isBefore(startDate) && !date.isAfter(endDate); + date = date.add(const Duration(days: 1))) { + List logEntries = LogEntry.getAllForDate(date); + List logEvents = LogEvent.getAllForDate(date); + + pw.Widget? chart = showChart + ? await generateChartAsImage(date, logEntries, logEvents, targets, + showMeals, showBasal, showBolus) + : pw.Container(); + pw.Widget? table = showTable ? generateTable(logEntries) : pw.Container(); + + document.addPage( + pw.Page( + pageFormat: pageFormat, + theme: theme, + build: (context) { + return pw.Column( + children: [ + pw.Text( + 'LOG REPORT', + style: pw.TextStyle( + fontWeight: pw.FontWeight.bold, + fontSize: 12, + letterSpacing: 2.0, + ), + ), + pw.Text( + 'for ${DateTimeUtils.displayDate(date)}'.toUpperCase(), + style: const pw.TextStyle( + letterSpacing: 2.0, + ), + ), + chart, + pw.SizedBox(height: 10), + table, + ], + ); + }, + ), + ); + } + + return await document.save(); +} diff --git a/lib/screens/reports/reports.dart b/lib/screens/reports/reports.dart new file mode 100644 index 0000000..c74837b --- /dev/null +++ b/lib/screens/reports/reports.dart @@ -0,0 +1,91 @@ +import 'package:diameter/navigation.dart'; +import 'package:diameter/screens/reports/export.dart'; +import 'package:flutter/material.dart'; + +class ReportsOverviewScreen extends StatefulWidget { + static const String routeName = '/reports'; + const ReportsOverviewScreen({Key? key}) : super(key: key); + + @override + _ReportsOverviewScreenState createState() => _ReportsOverviewScreenState(); +} + +class _ReportsOverviewScreenState extends State { + final ScrollController _scrollController = ScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Reports'), + ), + drawer: + const Navigation(currentLocation: ReportsOverviewScreen.routeName), + body: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: GridView.count( + crossAxisCount: 2, + children: [ + GestureDetector( + onTap: () => Navigator.pushNamed(context, Routes.dailyChart), + child: Padding( + padding: const EdgeInsets.all(10.0), + child: Card( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.all(10.0), + child: Icon(Icons.today, size: 50, color: Theme.of(context).textTheme.subtitle2?.color), + ), + Text( + 'DAILY REPORT', + style: Theme.of(context).textTheme.subtitle2, + textAlign: TextAlign.center + ), + ], + ), + ), + ), + ), + GestureDetector( + onTap: () => showDialog( + context: context, + builder: (context) => const ExportDialog(), + ), + child: Padding( + padding: const EdgeInsets.all(10.0), + child: Card( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.all(10.0), + child: Icon(Icons.today, size: 50, color: Theme.of(context).textTheme.subtitle2?.color), + ), + Text( + 'PDF REPORT', + style: Theme.of(context).textTheme.subtitle2, + textAlign: TextAlign.center + ), + ], + ), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/settings.dart b/lib/settings.dart index 4c856da..b875e88 100644 --- a/lib/settings.dart +++ b/lib/settings.dart @@ -122,10 +122,10 @@ class _SettingsScreenState extends State { void saveSettings() { Settings.put(Settings( id: _settings.id, - nutritionMeasurementIndex: nutritionMeasurementLabels - .indexOf(_nutritionMeasurementLabelController.text), - glucoseMeasurementIndex: glucoseMeasurementLabels - .indexOf(_glucoseMeasurementLabelController.text), + nutritionMeasurementIndex: _nutritionMeasurementLabelController.text != '' ? nutritionMeasurementLabels + .indexOf(_nutritionMeasurementLabelController.text) : 0, + glucoseMeasurementIndex: _glucoseMeasurementLabelController.text != '' ? glucoseMeasurementLabels + .indexOf(_glucoseMeasurementLabelController.text) : 0, glucoseDisplayModeIndex: _onlyDisplayActiveGlucoseMeasurement ? GlucoseDisplayMode.activeOnly.index : _displayBothGlucoseMeasurementsInDetailView && diff --git a/lib/utils/date_time_utils.dart b/lib/utils/date_time_utils.dart index d213d58..4b95a36 100644 --- a/lib/utils/date_time_utils.dart +++ b/lib/utils/date_time_utils.dart @@ -5,6 +5,11 @@ import 'package:intl/intl.dart'; final DateTime dummyDate = DateTime(2000); class DateTimeUtils { + static DateTime today() { + DateTime now = DateTime.now(); + return DateTime(now.year, now.month, now.day); + } + static String displayDateTime(DateTime? date, {String fallback = ''}) { if (date == null) { return fallback; @@ -26,7 +31,7 @@ class DateTimeUtils { } static String displayTime(DateTime? date, - {String fallback = '', bool? longFormat}) { + {String fallback = '', bool longFormat = false}) { if (date == null) { return fallback; } diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index be9e234..b1c2b10 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -1,5 +1,7 @@ import 'dart:math'; +import 'package:diameter/models/settings.dart'; + class Utils { // static double roundToDecimalPlaces(double value, int precision) { // double mod = pow(10.0, precision).toDouble(); @@ -7,24 +9,31 @@ class Utils { // } static double roundToMultipleOfBase(double value, double base) { - double result = value; + double result = value; + if (base != 0) { double remainder = value % base; int precision = Utils.getFractionDigitsLength(base); - + if (remainder != 0) { result = Utils.addDoublesWithPrecision(result, -remainder, precision); - if (remainder > base / 2) { + if (remainder > (base / 2)) { result = Utils.addDoublesWithPrecision(result, base, precision); } } + } - return result; + return result; } static double addDoublesWithPrecision(double a, double b, int precision) { double mod = pow(10.0, precision).toDouble(); double difference = (a * mod) + (b * mod); - return difference.round() / mod; + + if (mod != 0) { + return difference.round() / mod; + } + + return difference.round().toDouble(); } static int getFractionDigitsLength(double value) { @@ -51,17 +60,33 @@ class Utils { return Utils.roundToMultipleOfBase(carbsRatio * portionSize / 100, step); } - static double calculateCarbsRatio( - double carbsPerPortion, double portionSize, {double step = 0.01}) { + static double calculateCarbsRatio(double carbsPerPortion, double portionSize, + {double step = 0.01}) { return portionSize > 0 ? Utils.roundToMultipleOfBase(carbsPerPortion * 100 / portionSize, step) : 0; } - static double calculatePortionSize( - double carbsRatio, double carbsPerPortion, {double step = 0.01}) { + static double calculatePortionSize(double carbsRatio, double carbsPerPortion, + {double step = 0.01}) { return carbsRatio > 0 ? Utils.roundToMultipleOfBase(carbsPerPortion * 100 / carbsRatio, step) : 0; } + + static String displayGlucose({int? mgPerDl, double? mmolPerL}) { + return '${Settings.glucoseMeasurement == GlucoseMeasurement.mgPerDl ? (mgPerDl ?? '') : (mmolPerL ?? '')}'; + } + + static String displayGlucoseWithSuffix({int? mgPerDl, double? mmolPerL}) { + return '${Utils.displayGlucose(mgPerDl: mgPerDl, mmolPerL: mmolPerL)} ${Settings.glucoseMeasurementSuffix}'; + } + + static String displayNutritionAmount(double amount, + {bool cutExtraDigits = false}) { + final value = cutExtraDigits && Utils.getFractionDigitsLength(amount) == 0 + ? amount.toInt().toString() + : amount; + return '$value${Settings.nutritionMeasurementSuffix}'; + } } diff --git a/objectbox/data.mdb b/objectbox/data.mdb index c30d7a2..9389081 100644 Binary files a/objectbox/data.mdb and b/objectbox/data.mdb differ diff --git a/objectbox/lock.mdb b/objectbox/lock.mdb index d2ddfb8..26f24a9 100644 Binary files a/objectbox/lock.mdb and b/objectbox/lock.mdb differ diff --git a/pubspec.lock b/pubspec.lock index 7a848e5..b861133 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -7,14 +7,21 @@ packages: name: _fe_analyzer_shared url: "https://pub.dartlang.org" source: hosted - version: "31.0.0" + version: "36.0.0" analyzer: - dependency: transitive + dependency: "direct dev" description: name: analyzer url: "https://pub.dartlang.org" source: hosted - version: "2.8.0" + version: "3.3.1" + archive: + dependency: transitive + description: + name: archive + url: "https://pub.dartlang.org" + source: hosted + version: "3.2.2" args: dependency: transitive description: @@ -29,6 +36,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.8.2" + barcode: + dependency: transitive + description: + name: barcode + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.0" boolean_selector: dependency: transitive description: @@ -42,7 +56,7 @@ packages: name: build url: "https://pub.dartlang.org" source: hosted - version: "2.1.1" + version: "2.2.1" build_config: dependency: transitive description: @@ -63,21 +77,21 @@ packages: name: build_resolvers url: "https://pub.dartlang.org" source: hosted - version: "2.0.5" + version: "2.0.6" build_runner: dependency: "direct dev" description: name: build_runner url: "https://pub.dartlang.org" source: hosted - version: "2.1.5" + version: "2.1.8" build_runner_core: dependency: transitive description: name: build_runner_core url: "https://pub.dartlang.org" source: hosted - version: "7.2.2" + version: "7.2.3" built_collection: dependency: transitive description: @@ -91,7 +105,7 @@ packages: name: built_value url: "https://pub.dartlang.org" source: hosted - version: "8.1.3" + version: "8.1.4" characters: dependency: transitive description: @@ -106,6 +120,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.3.1" + charts_common: + dependency: transitive + description: + name: charts_common + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.0" + charts_flutter: + dependency: "direct main" + description: + name: charts_flutter + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.0" checked_yaml: dependency: transitive description: @@ -113,13 +141,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.1" - cli_util: - dependency: transitive - description: - name: cli_util - url: "https://pub.dartlang.org" - source: hosted - version: "0.3.5" clock: dependency: transitive description: @@ -168,7 +189,7 @@ packages: name: dart_style url: "https://pub.dartlang.org" source: hosted - version: "2.2.0" + version: "2.2.2" fake_async: dependency: transitive description: @@ -197,6 +218,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.0" + flat_buffers: + dependency: transitive + description: + name: flat_buffers + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.5" flex_color_scheme: dependency: "direct main" description: @@ -221,6 +249,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" frontend_server_client: dependency: transitive description: @@ -242,13 +275,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.0" + http: + dependency: transitive + description: + name: http + url: "https://pub.dartlang.org" + source: hosted + version: "0.13.4" http_multi_server: dependency: transitive description: name: http_multi_server url: "https://pub.dartlang.org" source: hosted - version: "3.0.1" + version: "3.2.0" http_parser: dependency: transitive description: @@ -256,6 +296,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "4.0.0" + image: + dependency: transitive + description: + name: image + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.3" intl: dependency: "direct main" description: @@ -339,21 +386,28 @@ packages: name: objectbox url: "https://pub.dartlang.org" source: hosted - version: "1.3.0" + version: "1.4.1" objectbox_generator: dependency: "direct dev" description: name: objectbox_generator url: "https://pub.dartlang.org" source: hosted - version: "1.3.0" + version: "1.4.1" objectbox_sync_flutter_libs: dependency: "direct main" description: name: objectbox_sync_flutter_libs url: "https://pub.dartlang.org" source: hosted - version: "1.3.0" + version: "1.4.1" + open_file: + dependency: "direct main" + description: + name: open_file + url: "https://pub.dartlang.org" + source: hosted + version: "3.2.1" package_config: dependency: transitive description: @@ -368,55 +422,76 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.8.0" + path_parsing: + dependency: transitive + description: + name: path_parsing + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" path_provider: dependency: "direct main" description: name: path_provider url: "https://pub.dartlang.org" source: hosted - version: "2.0.7" + version: "2.0.9" path_provider_android: dependency: transitive description: name: path_provider_android url: "https://pub.dartlang.org" source: hosted - version: "2.0.9" + version: "2.0.12" path_provider_ios: dependency: transitive description: name: path_provider_ios url: "https://pub.dartlang.org" source: hosted - version: "2.0.7" + version: "2.0.8" path_provider_linux: dependency: transitive description: name: path_provider_linux url: "https://pub.dartlang.org" source: hosted - version: "2.1.2" + version: "2.1.5" path_provider_macos: dependency: transitive description: name: path_provider_macos url: "https://pub.dartlang.org" source: hosted - version: "2.0.4" + version: "2.0.5" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.0.1" + version: "2.0.3" path_provider_windows: dependency: transitive description: name: path_provider_windows url: "https://pub.dartlang.org" source: hosted - version: "2.0.4" + version: "2.0.5" + pdf: + dependency: "direct main" + description: + name: pdf + url: "https://pub.dartlang.org" + source: hosted + version: "3.7.3" + petitparser: + dependency: transitive + description: + name: petitparser + url: "https://pub.dartlang.org" + source: hosted + version: "4.4.0" platform: dependency: transitive description: @@ -430,7 +505,7 @@ packages: name: plugin_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.0.2" + version: "2.1.2" pool: dependency: transitive description: @@ -438,6 +513,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.5.0" + printing: + dependency: "direct main" + description: + name: printing + url: "https://pub.dartlang.org" + source: hosted + version: "5.7.4" process: dependency: transitive description: @@ -451,14 +533,14 @@ packages: name: provider url: "https://pub.dartlang.org" source: hosted - version: "6.0.1" + version: "6.0.2" pub_semver: dependency: transitive description: name: pub_semver url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.1.1" pubspec_parse: dependency: transitive description: @@ -466,6 +548,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.2.0" + qr: + dependency: transitive + description: + name: qr + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" shelf: dependency: transitive description: @@ -491,7 +580,7 @@ packages: name: source_gen url: "https://pub.dartlang.org" source: hosted - version: "1.2.0" + version: "1.2.1" source_span: dependency: transitive description: @@ -582,14 +671,21 @@ packages: name: win32 url: "https://pub.dartlang.org" source: hosted - version: "2.3.1" + version: "2.4.2" xdg_directories: dependency: transitive description: name: xdg_directories url: "https://pub.dartlang.org" source: hosted - version: "0.2.0" + version: "0.2.0+1" + xml: + dependency: transitive + description: + name: xml + url: "https://pub.dartlang.org" + source: hosted + version: "5.3.1" yaml: dependency: transitive description: @@ -598,5 +694,5 @@ packages: source: hosted version: "3.1.0" sdks: - dart: ">=2.14.0 <3.0.0" - flutter: ">=2.5.0" + dart: ">=2.15.0 <3.0.0" + flutter: ">=2.8.0" diff --git a/pubspec.yaml b/pubspec.yaml index b200920..d7cd3e7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,12 +11,16 @@ environment: dependencies: flutter: sdk: flutter - path_provider: ^2.0.5 + path_provider: ^2.0.9 cupertino_icons: ^1.0.2 flex_color_scheme: ^3.0.1 intl: ^0.17.0 objectbox: ^1.2.0 objectbox_sync_flutter_libs: any + charts_flutter: ^0.12.0 + printing: ^5.7.2 + pdf: ^3.7.1 + open_file: ^3.2.1 dev_dependencies: flutter_test: @@ -25,6 +29,7 @@ dev_dependencies: provider: ^6.0.1 build_runner: ^2.0.0 objectbox_generator: any + analyzer: ^3.3.1 flutter: uses-material-design: true diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 0000000..363b63f --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,101 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(diameter LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "diameter") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..930d207 --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,104 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + windows-x64 $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..496cec5 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,17 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + ObjectboxSyncFlutterLibsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ObjectboxSyncFlutterLibsPlugin")); + PrintingPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PrintingPlugin")); +} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..21bcd69 --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,17 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + objectbox_sync_flutter_libs + printing +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..b9e550f --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,32 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 0000000..8194ca1 --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#ifdef FLUTTER_BUILD_NUMBER +#define VERSION_AS_NUMBER FLUTTER_BUILD_NUMBER +#else +#define VERSION_AS_NUMBER 1,0,0 +#endif + +#ifdef FLUTTER_BUILD_NAME +#define VERSION_AS_STRING #FLUTTER_BUILD_NAME +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "diameter" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "diameter" "\0" + VALUE "LegalCopyright", "Copyright (C) 2022 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "diameter.exe" "\0" + VALUE "ProductName", "diameter" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..b43b909 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,61 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 0000000..0b53ee8 --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.CreateAndShow(L"diameter", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/windows/runner/resources/app_icon.ico differ diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..c977c4a --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,20 @@ + + + + + PerMonitorV2 + + + + + + + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 0000000..d19bdbb --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,64 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr); + if (target_length == 0) { + return std::string(); + } + std::string utf8_string; + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, utf8_string.data(), + target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 0000000..c10f08d --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,245 @@ +#include "win32_window.h" + +#include + +#include "resource.h" + +namespace { + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + FreeLibrary(user32_module); + } +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::CreateAndShow(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW | WS_VISIBLE, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + return OnCreate(); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 0000000..17ba431 --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,98 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates and shows a win32 window with |title| and position and size using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size to will treat the width height passed in to this function + // as logical pixels and scale to appropriate for the default monitor. Returns + // true if the window was created successfully. + bool CreateAndShow(const std::wstring& title, + const Point& origin, + const Size& size); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responsponds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_