diff --git a/lib/controllers/alert.dart b/lib/controllers/alert.dart new file mode 100644 index 0000000..0cc8616 --- /dev/null +++ b/lib/controllers/alert.dart @@ -0,0 +1,38 @@ +import 'dart:convert'; + +import 'package:dietary_guard/models/alert_configuration.dart'; +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AlertController extends GetxController { + List configuration = List.empty(); + + late final SharedPreferences _prefs; + + @override + void onInit() async { + _prefs = await SharedPreferences.getInstance(); + loadAlertConfiguration(); + super.onInit(); + } + + void loadAlertConfiguration() { + final raw = _prefs.getString("alert_configuration"); + if (raw == null) return; + configuration = List.from(jsonDecode(raw).map( + (x) => AlertConfiguration.fromJson(x), + )); + } + + Future setAlertConfiguration(List value) async { + await _prefs.setString( + "alert_configuration", + jsonEncode(value.map((x) => x.toJson()).toList()), + ); + loadAlertConfiguration(); + } + + void clearAlertConfiguration() { + _prefs.remove("alert_configuration"); + } +} diff --git a/lib/main.dart b/lib/main.dart index c7ff9eb..cc40709 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,4 @@ +import 'package:dietary_guard/controllers/alert.dart'; import 'package:dietary_guard/controllers/food_data.dart'; import 'package:dietary_guard/screens/query.dart'; import 'package:dietary_guard/screens/settings.dart'; @@ -62,6 +63,7 @@ class MyApp extends StatelessWidget { } void _initializeProviders(BuildContext context) async { - Get.put(FoodDataController()); + Get.lazyPut(() => FoodDataController()); + Get.lazyPut(() => AlertController()); } } diff --git a/lib/models/alert_configuration.dart b/lib/models/alert_configuration.dart new file mode 100644 index 0000000..7b5af68 --- /dev/null +++ b/lib/models/alert_configuration.dart @@ -0,0 +1,24 @@ +class AlertConfiguration { + int nutrientId; + double maxValue; + double minValue; + + AlertConfiguration({ + required this.nutrientId, + required this.maxValue, + required this.minValue, + }); + + Map toJson() => { + 'nutrient_id': nutrientId, + 'min_value': minValue, + 'max_value': maxValue, + }; + + factory AlertConfiguration.fromJson(Map json) => + AlertConfiguration( + nutrientId: json['nutrient_id'], + minValue: json['min_value'], + maxValue: json['max_value'], + ); +} diff --git a/lib/models/food_data.dart b/lib/models/food_data.dart index 4f046cf..0243b2c 100644 --- a/lib/models/food_data.dart +++ b/lib/models/food_data.dart @@ -98,7 +98,7 @@ class Nutrients { } class FoodSearchCriteria { - List dataType; + List dataType; String query; String generalSearchInput; int pageNumber; @@ -107,7 +107,7 @@ class FoodSearchCriteria { int numberOfResultsPerPage; int pageSize; bool requireAllWords; - List foodTypes; + List foodTypes; FoodSearchCriteria({ required this.dataType, @@ -124,8 +124,8 @@ class FoodSearchCriteria { factory FoodSearchCriteria.fromJson(Map json) => FoodSearchCriteria( - dataType: - List.from(json["dataType"].map((x) => typeValues.map[x]!)), + dataType: List.from( + json["dataType"]?.map((x) => typeValues.map[x]) ?? List.empty()), query: json["query"], generalSearchInput: json["generalSearchInput"], pageNumber: json["pageNumber"], @@ -134,8 +134,8 @@ class FoodSearchCriteria { numberOfResultsPerPage: json["numberOfResultsPerPage"], pageSize: json["pageSize"], requireAllWords: json["requireAllWords"], - foodTypes: - List.from(json["foodTypes"].map((x) => typeValues.map[x]!)), + foodTypes: List.from( + json["foodTypes"]?.map((x) => typeValues.map[x]) ?? List.empty()), ); Map toJson() => { @@ -162,10 +162,10 @@ final typeValues = class FoodData { int fdcId; String description; - String commonNames; - String additionalDescriptions; - Type dataType; - int ndbNumber; + String? commonNames; + String? additionalDescriptions; + Type? dataType; + int? ndbNumber; DateTime publishedDate; FoodCategory? foodCategory; DateTime? mostRecentAcquisitionDate; @@ -205,7 +205,7 @@ class FoodData { description: json["description"], commonNames: json["commonNames"], additionalDescriptions: json["additionalDescriptions"], - dataType: typeValues.map[json["dataType"]]!, + dataType: typeValues.map[json["dataType"]], ndbNumber: json["ndbNumber"], publishedDate: DateTime.parse(json["publishedDate"]), foodCategory: foodCategoryValues.map[json["foodCategory"]], @@ -264,7 +264,7 @@ class FoodNutrient { int nutrientId; String nutrientName; String nutrientNumber; - UnitName unitName; + UnitName? unitName; DerivationCode? derivationCode; String? derivationDescription; int? derivationId; @@ -305,15 +305,15 @@ class FoodNutrient { nutrientId: json["nutrientId"], nutrientName: json["nutrientName"], nutrientNumber: json["nutrientNumber"], - unitName: unitNameValues.map[json["unitName"]]!, - derivationCode: derivationCodeValues.map[json["derivationCode"]]!, + unitName: unitNameValues.map[json["unitName"]], + derivationCode: derivationCodeValues.map[json["derivationCode"]], derivationDescription: json["derivationDescription"], derivationId: json["derivationId"], value: json["value"]?.toDouble(), foodNutrientSourceId: json["foodNutrientSourceId"], foodNutrientSourceCode: json["foodNutrientSourceCode"], foodNutrientSourceDescription: foodNutrientSourceDescriptionValues - .map[json["foodNutrientSourceDescription"]]!, + .map[json["foodNutrientSourceDescription"]], rank: json["rank"], indentLevel: json["indentLevel"], foodNutrientId: json["foodNutrientId"], diff --git a/lib/screens/food/details.dart b/lib/screens/food/details.dart index 6def619..6e7bbfb 100644 --- a/lib/screens/food/details.dart +++ b/lib/screens/food/details.dart @@ -9,37 +9,40 @@ class FoodDetailsScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(item.description), + return Material( + color: Theme.of(context).colorScheme.surface, + child: Scaffold( + appBar: AppBar( + title: Text(item.description), + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('nutrients'.tr).paddingOnly(left: 24, right: 24, bottom: 8), + Expanded( + child: ListView.builder( + itemCount: item.foodNutrients.length, + itemBuilder: (context, idx) { + final entry = item.foodNutrients[idx]; + final unitName = unitNameValues.reverse[entry.unitName]; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + title: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text(entry.nutrientName), + const SizedBox(width: 6), + Badge(label: Text('#${entry.nutrientId}')) + ], + ), + subtitle: Text('${entry.nutrientNumber} ${unitName}'), + ); + }, + ), + ) + ], + ).paddingSymmetric(vertical: 24), ), - body: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('nutrients'.tr).paddingOnly(left: 24, right: 24, bottom: 8), - Expanded( - child: ListView.builder( - itemCount: item.foodNutrients.length, - itemBuilder: (context, idx) { - final entry = item.foodNutrients[idx]; - final unitName = unitNameValues.reverse[entry.unitName]; - return ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24), - title: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text(entry.nutrientName), - const SizedBox(width: 6), - Badge(label: Text('#${entry.nutrientId}')) - ], - ), - subtitle: Text('${entry.nutrientNumber} ${unitName}'), - ); - }, - ), - ) - ], - ).paddingSymmetric(vertical: 24), ); } } diff --git a/lib/screens/query.dart b/lib/screens/query.dart index 2da575f..58ebfd2 100644 --- a/lib/screens/query.dart +++ b/lib/screens/query.dart @@ -45,50 +45,53 @@ class _QueryScreenState extends State { @override Widget build(BuildContext context) { - return SafeArea( - child: Column( - children: [ - SearchBar( - padding: const WidgetStatePropertyAll( - EdgeInsets.symmetric(horizontal: 16.0), - ), - onSubmitted: (value) { - _searchFood(value); - }, - leading: const Icon(Icons.search), - ).paddingSymmetric(horizontal: 24), - if (_isLoading) - const SizedBox( - width: 28, - height: 28, - child: CircularProgressIndicator(strokeWidth: 3), - ).paddingSymmetric(vertical: 24) - else - Expanded( - child: ListView.builder( - itemCount: _foodData.length, - itemBuilder: (context, index) { - final item = _foodData[index]; - return OpenContainer( - closedBuilder: (_, open) => ListTile( - contentPadding: - const EdgeInsets.symmetric(horizontal: 24), - title: Text(item.description), - subtitle: Text( - DateFormat("yyyy-MM-dd").format(item.publishedDate), + return Material( + color: Theme.of(context).colorScheme.surface, + child: SafeArea( + child: Column( + children: [ + SearchBar( + padding: const WidgetStatePropertyAll( + EdgeInsets.symmetric(horizontal: 16.0), + ), + onSubmitted: (value) { + _searchFood(value); + }, + leading: const Icon(Icons.search), + ).paddingSymmetric(horizontal: 24), + if (_isLoading) + const SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator(strokeWidth: 3), + ).paddingSymmetric(vertical: 24) + else + Expanded( + child: ListView.builder( + itemCount: _foodData.length, + itemBuilder: (context, index) { + final item = _foodData[index]; + return OpenContainer( + closedBuilder: (_, open) => ListTile( + contentPadding: + const EdgeInsets.symmetric(horizontal: 24), + title: Text(item.description), + subtitle: Text( + DateFormat("yyyy-MM-dd").format(item.publishedDate), + ), + onTap: () => open(), ), - onTap: () => open(), - ), - openBuilder: (_, __) => FoodDetailsScreen(item: item), - openElevation: 0, - closedElevation: 0, - closedColor: Colors.transparent, - openColor: Colors.transparent, - ); - }, - ).paddingOnly(top: 8), - ), - ], + openBuilder: (_, __) => FoodDetailsScreen(item: item), + openElevation: 0, + closedElevation: 0, + closedColor: Theme.of(context).colorScheme.surface, + openColor: Colors.transparent, + ); + }, + ).paddingOnly(top: 8), + ), + ], + ), ), ); } diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart index 3a1dbc3..83a2e0f 100644 --- a/lib/screens/settings.dart +++ b/lib/screens/settings.dart @@ -1,4 +1,6 @@ +import 'package:dietary_guard/controllers/alert.dart'; import 'package:dietary_guard/controllers/food_data.dart'; +import 'package:dietary_guard/models/alert_configuration.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; @@ -12,10 +14,25 @@ class SettingsScreen extends StatefulWidget { class _SettingsScreenState extends State { final TextEditingController _fdcApiKeyController = TextEditingController(); + List _currentAlerts = List.empty(growable: true); + + void _addAlert() { + setState(() { + _currentAlerts.add(AlertConfiguration( + nutrientId: 0, + maxValue: 0, + minValue: 0, + )); + }); + } + Future _applySettings() async { final FoodDataController data = Get.find(); await data.setApiKey(_fdcApiKeyController.text); + final AlertController alert = Get.find(); + await alert.setAlertConfiguration(_currentAlerts); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text('settingsApplied'.tr), )); @@ -26,6 +43,9 @@ class _SettingsScreenState extends State { final FoodDataController data = Get.find(); _fdcApiKeyController.text = data.getApiKey() ?? ''; + final AlertController alert = Get.find(); + _currentAlerts = List.from(alert.configuration, growable: true); + super.initState(); } @@ -35,36 +55,116 @@ class _SettingsScreenState extends State { super.dispose(); } + Widget _buildSectionHeader(String title) { + return Container( + color: Theme.of(context).colorScheme.secondaryContainer, + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 8), + child: Text( + title, + style: Theme.of(context).textTheme.bodyLarge, + ), + ).marginOnly(bottom: 16); + } + @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text('settings'.tr), - ), - body: ListView( - children: [ - const SizedBox(height: 8), - TextField( - controller: _fdcApiKeyController, - obscureText: true, - decoration: const InputDecoration( - border: OutlineInputBorder(), - label: Text("FDC API Key"), - isDense: true, - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - ElevatedButton.icon( - icon: const Icon(Icons.save, size: 16), - label: Text('apply'.tr), - onPressed: () => _applySettings(), + return Material( + color: Theme.of(context).colorScheme.surface, + child: Scaffold( + appBar: AppBar( + title: Text('settings'.tr), + ), + body: ListView( + children: [ + _buildSectionHeader('settingsAlertSection'.tr), + Column( + children: [ + ...(_currentAlerts.map((x) => Row( + children: [ + Expanded( + child: TextFormField( + initialValue: x.nutrientId.toString(), + decoration: InputDecoration( + border: const OutlineInputBorder(), + label: Text("alertNutrientId".tr), + isDense: true, + ), + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), + onChanged: (value) { + x.nutrientId = int.tryParse(value) ?? 0; + }, + ), + ), + const SizedBox(width: 6), + Expanded( + child: TextFormField( + initialValue: x.maxValue.toString(), + decoration: InputDecoration( + border: const OutlineInputBorder(), + label: Text("alertMaxValue".tr), + isDense: true, + ), + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), + onChanged: (value) { + x.maxValue = double.tryParse(value) ?? 0; + }, + ), + ), + const SizedBox(width: 6), + Expanded( + child: TextFormField( + initialValue: x.minValue.toString(), + decoration: InputDecoration( + border: const OutlineInputBorder(), + label: Text("alertMinValue".tr), + isDense: true, + ), + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), + onChanged: (value) { + x.minValue = double.tryParse(value) ?? 0; + }, + ), + ), + ], + ))), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextButton.icon( + label: Text('newAlert'.tr), + onPressed: () => _addAlert(), + ), + ], + ).paddingSymmetric(vertical: 12, horizontal: 24), + ], + ).paddingSymmetric(horizontal: 24), + _buildSectionHeader('settingsDataSection'.tr), + TextField( + controller: _fdcApiKeyController, + obscureText: true, + decoration: const InputDecoration( + border: UnderlineInputBorder(), + label: Text("FDC API Key"), + isDense: false, ), - ], - ).paddingSymmetric(vertical: 12), - ], - ).paddingSymmetric(horizontal: 24), + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), + ).paddingSymmetric(horizontal: 24), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextButton.icon( + label: Text('apply'.tr), + onPressed: () => _applySettings(), + ), + ], + ).paddingSymmetric(vertical: 12, horizontal: 24), + ], + ), + ), ); } } diff --git a/lib/translations/en_us.dart b/lib/translations/en_us.dart index 1814c75..014c86d 100644 --- a/lib/translations/en_us.dart +++ b/lib/translations/en_us.dart @@ -3,7 +3,13 @@ const i18nEnglish = { 'settings': 'Settings', 'preparingData': 'Preparing data...', 'settingsApplied': 'Settings Applied', + 'settingsDataSection': 'Data Source', + 'settingsAlertSection': 'Alert', + 'newAlert': 'New Alert', 'apply': 'Apply', 'searchHistoryNotIncluded': 'Search History not Included, yet', 'nutrients': 'Nutrients', + 'alertNutrientId': 'Nutrient ID', + 'alertMaxValue': 'Max', + 'alertMinValue': 'Min', }; diff --git a/lib/translations/zh_cn.dart b/lib/translations/zh_cn.dart index c3df39d..19343da 100644 --- a/lib/translations/zh_cn.dart +++ b/lib/translations/zh_cn.dart @@ -3,7 +3,13 @@ const i18nSimplifiedChinese = { 'settings': '设置', 'preparingData': '准备数据中…', 'settingsApplied': '设置已应用', + 'settingsDataSection': '数据源', + 'settingsAlertSection': '危险告警', + 'newAlert': '新告警', 'apply': '应用', 'searchHistoryNotIncluded': '搜索记录还没实现', 'nutrients': '营养物质', + 'alertNutrientId': '营养物质编号', + 'alertMaxValue': '告警上限', + 'alertMinValue': '告警下限', };