✨ Matching alert
This commit is contained in:
		| @@ -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()); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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, | ||||
|   }); | ||||
| } | ||||
|   | ||||
| @@ -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<FoodDetailsScreen> createState() => _FoodDetailsScreenState(); | ||||
| } | ||||
|  | ||||
| class _FoodDetailsScreenState extends State<FoodDetailsScreen> { | ||||
|   final List<AlertDetectResult> _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), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|   | ||||
| @@ -16,7 +16,6 @@ class QueryScreen extends StatefulWidget { | ||||
| class _QueryScreenState extends State<QueryScreen> { | ||||
|   bool _isLoading = false; | ||||
|  | ||||
|   int _totalCount = 0; | ||||
|   List<FoodData> _foodData = List.empty(); | ||||
|  | ||||
|   Future<void> _searchFood(String probe) async { | ||||
| @@ -30,7 +29,6 @@ class _QueryScreenState extends State<QueryScreen> { | ||||
|     final result = await data.searchFood(probe); | ||||
|  | ||||
|     setState(() { | ||||
|       _totalCount = result.totalHits; | ||||
|       _foodData = result.foods; | ||||
|       _isLoading = false; | ||||
|     }); | ||||
| @@ -77,7 +75,7 @@ class _QueryScreenState extends State<QueryScreen> { | ||||
|                             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(), | ||||
|                       ), | ||||
|   | ||||
| @@ -12,4 +12,5 @@ const i18nEnglish = { | ||||
|   'alertNutrientId': 'Nutrient ID', | ||||
|   'alertMaxValue': 'Max', | ||||
|   'alertMinValue': 'Min', | ||||
|   'alerts': 'Alerts', | ||||
| }; | ||||
|   | ||||
| @@ -12,4 +12,10 @@ const i18nSimplifiedChinese = { | ||||
|   'alertNutrientId': '营养物质编号', | ||||
|   'alertMaxValue': '告警上限', | ||||
|   'alertMinValue': '告警下限', | ||||
|   'alerts': '告警', | ||||
|   'alertOutOfRange': '有 @count 项告警规则触发,点击右侧按钮了解详情', | ||||
|   'alertUnclear': '有 @count 项告警规则并无数据支持,点击右侧按钮了解详情', | ||||
|   'alertEmpty': '无告警规则配置,前往设置添加规则', | ||||
|   'alertSafe': '无告警规则触发,可安心食用', | ||||
|   'alertDetectResult': '告警匹配详情', | ||||
| }; | ||||
|   | ||||
							
								
								
									
										79
									
								
								lib/widgets/alert_detect_result.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								lib/widgets/alert_detect_result.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -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<AlertDetectResult> 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}', | ||||
|                   ), | ||||
|                 ); | ||||
|               }, | ||||
|             ), | ||||
|           ) | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user