From 09e904f52f62af7634ffc79ef808714fc60f8c29 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Thu, 15 Aug 2024 15:57:58 +0800 Subject: [PATCH] :sparkles: Matching alert --- lib/main.dart | 4 +- lib/models/alert_configuration.dart | 20 +++++ lib/screens/food/details.dart | 127 +++++++++++++++++++++++++-- lib/screens/query.dart | 4 +- lib/translations/en_us.dart | 1 + lib/translations/zh_cn.dart | 6 ++ lib/widgets/alert_detect_result.dart | 79 +++++++++++++++++ 7 files changed, 230 insertions(+), 11 deletions(-) create mode 100644 lib/widgets/alert_detect_result.dart diff --git a/lib/main.dart b/lib/main.dart index cc40709..185c498 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -63,7 +63,7 @@ class MyApp extends StatelessWidget { } void _initializeProviders(BuildContext context) async { - Get.lazyPut(() => FoodDataController()); - Get.lazyPut(() => AlertController()); + Get.put(FoodDataController()); + Get.put(AlertController()); } } diff --git a/lib/models/alert_configuration.dart b/lib/models/alert_configuration.dart index 7b5af68..713e15c 100644 --- a/lib/models/alert_configuration.dart +++ b/lib/models/alert_configuration.dart @@ -22,3 +22,23 @@ class AlertConfiguration { maxValue: json['max_value'], ); } + +class AlertDetectResult { + AlertConfiguration config; + String name; + String? unitName; + double? current; + double? difference; + bool isOutOfRange; + bool isUndetected; + + AlertDetectResult({ + required this.config, + required this.name, + required this.unitName, + required this.current, + required this.difference, + required this.isOutOfRange, + required this.isUndetected, + }); +} diff --git a/lib/screens/food/details.dart b/lib/screens/food/details.dart index 6e7bbfb..beeb872 100644 --- a/lib/screens/food/details.dart +++ b/lib/screens/food/details.dart @@ -1,29 +1,142 @@ +import 'package:dietary_guard/controllers/alert.dart'; +import 'package:dietary_guard/models/alert_configuration.dart'; import 'package:dietary_guard/models/food_data.dart'; +import 'package:dietary_guard/widgets/alert_detect_result.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -class FoodDetailsScreen extends StatelessWidget { +class FoodDetailsScreen extends StatefulWidget { final FoodData item; const FoodDetailsScreen({super.key, required this.item}); + @override + State createState() => _FoodDetailsScreenState(); +} + +class _FoodDetailsScreenState extends State { + final List _alertDetectResult = List.empty(growable: true); + + @override + void initState() { + super.initState(); + _detectAlert(); + } + + void _detectAlert() { + final AlertController alert = Get.find(); + if (alert.configuration.isEmpty) return; + + for (final item in alert.configuration) { + for (final nutrient in widget.item.foodNutrients) { + if (item.nutrientId != nutrient.nutrientId) continue; + bool isOutOfRange = false; + double? difference; + bool isUndetected = false; + if (nutrient.value != null) { + final value = nutrient.value ?? 0; + if (value > item.maxValue) { + difference = value - item.maxValue; + isOutOfRange = true; + } else if (value < item.minValue) { + difference = value - item.minValue; + isOutOfRange = true; + } + } else { + isUndetected = true; + } + + _alertDetectResult.add(AlertDetectResult( + config: item, + name: nutrient.nutrientName, + unitName: unitNameValues.reverse[nutrient.unitName], + current: nutrient.value, + difference: difference, + isOutOfRange: isOutOfRange, + isUndetected: isUndetected, + )); + } + } + } + + Widget _buildShowAlertResultButton() { + return IconButton( + icon: const Icon(Icons.assignment), + onPressed: () { + showModalBottomSheet( + useRootNavigator: true, + isScrollControlled: true, + context: context, + builder: (context) => AlertDetectResultDialog( + result: _alertDetectResult, + ), + ); + }, + ); + } + @override Widget build(BuildContext context) { return Material( color: Theme.of(context).colorScheme.surface, child: Scaffold( appBar: AppBar( - title: Text(item.description), + title: Text(widget.item.description), ), body: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (_alertDetectResult.isEmpty) + MaterialBanner( + padding: const EdgeInsets.only(left: 24, right: 8), + content: Text('alertEmpty'.tr), + actions: const [SizedBox()], + dividerColor: Colors.transparent, + ) + else if (_alertDetectResult.any((x) => x.isOutOfRange)) + MaterialBanner( + leading: const Icon(Icons.close), + backgroundColor: Colors.red, + padding: const EdgeInsets.only(left: 24, right: 8), + content: Text('alertOutOfRange'.trParams({ + 'count': _alertDetectResult + .where((x) => x.isOutOfRange) + .length + .toString() + })), + actions: [_buildShowAlertResultButton()], + dividerColor: Colors.transparent, + ) + else if (_alertDetectResult.any((x) => x.isUndetected)) + MaterialBanner( + leading: const Icon(Icons.question_mark), + backgroundColor: Colors.grey, + padding: const EdgeInsets.only(left: 24, right: 8), + content: Text('alertUnclear'.trParams({ + 'count': _alertDetectResult + .where((x) => x.isUndetected) + .length + .toString() + })), + actions: [_buildShowAlertResultButton()], + dividerColor: Colors.transparent, + ) + else + MaterialBanner( + leading: const Icon(Icons.check), + backgroundColor: Colors.green, + padding: const EdgeInsets.only(left: 24, right: 8), + content: Text('alertSafe'.tr), + actions: [_buildShowAlertResultButton()], + dividerColor: Colors.transparent, + ), + const SizedBox(height: 16), Text('nutrients'.tr).paddingOnly(left: 24, right: 24, bottom: 8), Expanded( child: ListView.builder( - itemCount: item.foodNutrients.length, + itemCount: widget.item.foodNutrients.length, itemBuilder: (context, idx) { - final entry = item.foodNutrients[idx]; + final entry = widget.item.foodNutrients[idx]; final unitName = unitNameValues.reverse[entry.unitName]; return ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 24), @@ -35,13 +148,15 @@ class FoodDetailsScreen extends StatelessWidget { Badge(label: Text('#${entry.nutrientId}')) ], ), - subtitle: Text('${entry.nutrientNumber} ${unitName}'), + subtitle: Text( + '${entry.value?.toString() ?? '-'} $unitName', + ), ); }, ), ) ], - ).paddingSymmetric(vertical: 24), + ), ), ); } diff --git a/lib/screens/query.dart b/lib/screens/query.dart index 58ebfd2..03584f4 100644 --- a/lib/screens/query.dart +++ b/lib/screens/query.dart @@ -16,7 +16,6 @@ class QueryScreen extends StatefulWidget { class _QueryScreenState extends State { bool _isLoading = false; - int _totalCount = 0; List _foodData = List.empty(); Future _searchFood(String probe) async { @@ -30,7 +29,6 @@ class _QueryScreenState extends State { final result = await data.searchFood(probe); setState(() { - _totalCount = result.totalHits; _foodData = result.foods; _isLoading = false; }); @@ -77,7 +75,7 @@ class _QueryScreenState extends State { const EdgeInsets.symmetric(horizontal: 24), title: Text(item.description), subtitle: Text( - DateFormat("yyyy-MM-dd").format(item.publishedDate), + '${DateFormat("yyyy-MM-dd").format(item.mostRecentAcquisitionDate ?? item.publishedDate)} ${foodCategoryValues.reverse[item.foodCategory] ?? ''}', ), onTap: () => open(), ), diff --git a/lib/translations/en_us.dart b/lib/translations/en_us.dart index 014c86d..df1b260 100644 --- a/lib/translations/en_us.dart +++ b/lib/translations/en_us.dart @@ -12,4 +12,5 @@ const i18nEnglish = { 'alertNutrientId': 'Nutrient ID', 'alertMaxValue': 'Max', 'alertMinValue': 'Min', + 'alerts': 'Alerts', }; diff --git a/lib/translations/zh_cn.dart b/lib/translations/zh_cn.dart index 19343da..b7a074c 100644 --- a/lib/translations/zh_cn.dart +++ b/lib/translations/zh_cn.dart @@ -12,4 +12,10 @@ const i18nSimplifiedChinese = { 'alertNutrientId': '营养物质编号', 'alertMaxValue': '告警上限', 'alertMinValue': '告警下限', + 'alerts': '告警', + 'alertOutOfRange': '有 @count 项告警规则触发,点击右侧按钮了解详情', + 'alertUnclear': '有 @count 项告警规则并无数据支持,点击右侧按钮了解详情', + 'alertEmpty': '无告警规则配置,前往设置添加规则', + 'alertSafe': '无告警规则触发,可安心食用', + 'alertDetectResult': '告警匹配详情', }; diff --git a/lib/widgets/alert_detect_result.dart b/lib/widgets/alert_detect_result.dart new file mode 100644 index 0000000..e33d9db --- /dev/null +++ b/lib/widgets/alert_detect_result.dart @@ -0,0 +1,79 @@ +import 'package:dietary_guard/models/alert_configuration.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class AlertDetectResultDialog extends StatelessWidget { + final List result; + + const AlertDetectResultDialog({super.key, required this.result}); + + Color _getColor(AlertDetectResult result) { + if (result.isOutOfRange) { + return Colors.red; + } else if (result.isUndetected) { + return Colors.grey; + } else { + return Colors.green; + } + } + + IconData _getIcon(AlertDetectResult result) { + if (result.isOutOfRange) { + return Icons.close; + } else if (result.isUndetected) { + return Icons.question_mark; + } else { + return Icons.check; + } + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: MediaQuery.of(context).size.height * 0.65, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.only( + left: 24, + right: 24, + top: 24, + bottom: 8, + ), + child: Text( + 'alertDetectResult'.tr, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + Expanded( + child: ListView.builder( + itemCount: result.length, + itemBuilder: (context, idx) { + final item = result[idx]; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: CircleAvatar( + backgroundColor: _getColor(item), + child: Icon(_getIcon(item), color: Colors.white), + ), + title: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text(item.name), + const SizedBox(width: 6), + Badge(label: Text('#${item.config.nutrientId}')) + ], + ), + subtitle: Text( + '${item.current} ${(item.difference ?? 0) > 0 ? '↑' : '↓'}${item.difference?.abs()} ${item.unitName}', + ), + ); + }, + ), + ) + ], + ), + ); + } +}