✨ Matching alert
This commit is contained in:
		| @@ -63,7 +63,7 @@ class MyApp extends StatelessWidget { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   void _initializeProviders(BuildContext context) async { |   void _initializeProviders(BuildContext context) async { | ||||||
|     Get.lazyPut(() => FoodDataController()); |     Get.put(FoodDataController()); | ||||||
|     Get.lazyPut(() => AlertController()); |     Get.put(AlertController()); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -22,3 +22,23 @@ class AlertConfiguration { | |||||||
|         maxValue: json['max_value'], |         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/models/food_data.dart'; | ||||||
|  | import 'package:dietary_guard/widgets/alert_detect_result.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:get/get.dart'; | import 'package:get/get.dart'; | ||||||
|  |  | ||||||
| class FoodDetailsScreen extends StatelessWidget { | class FoodDetailsScreen extends StatefulWidget { | ||||||
|   final FoodData item; |   final FoodData item; | ||||||
|  |  | ||||||
|   const FoodDetailsScreen({super.key, required this.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 |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     return Material( |     return Material( | ||||||
|       color: Theme.of(context).colorScheme.surface, |       color: Theme.of(context).colorScheme.surface, | ||||||
|       child: Scaffold( |       child: Scaffold( | ||||||
|         appBar: AppBar( |         appBar: AppBar( | ||||||
|           title: Text(item.description), |           title: Text(widget.item.description), | ||||||
|         ), |         ), | ||||||
|         body: Column( |         body: Column( | ||||||
|           crossAxisAlignment: CrossAxisAlignment.start, |           crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|           children: [ |           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), |             Text('nutrients'.tr).paddingOnly(left: 24, right: 24, bottom: 8), | ||||||
|             Expanded( |             Expanded( | ||||||
|               child: ListView.builder( |               child: ListView.builder( | ||||||
|                 itemCount: item.foodNutrients.length, |                 itemCount: widget.item.foodNutrients.length, | ||||||
|                 itemBuilder: (context, idx) { |                 itemBuilder: (context, idx) { | ||||||
|                   final entry = item.foodNutrients[idx]; |                   final entry = widget.item.foodNutrients[idx]; | ||||||
|                   final unitName = unitNameValues.reverse[entry.unitName]; |                   final unitName = unitNameValues.reverse[entry.unitName]; | ||||||
|                   return ListTile( |                   return ListTile( | ||||||
|                     contentPadding: const EdgeInsets.symmetric(horizontal: 24), |                     contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
| @@ -35,13 +148,15 @@ class FoodDetailsScreen extends StatelessWidget { | |||||||
|                         Badge(label: Text('#${entry.nutrientId}')) |                         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> { | class _QueryScreenState extends State<QueryScreen> { | ||||||
|   bool _isLoading = false; |   bool _isLoading = false; | ||||||
|  |  | ||||||
|   int _totalCount = 0; |  | ||||||
|   List<FoodData> _foodData = List.empty(); |   List<FoodData> _foodData = List.empty(); | ||||||
|  |  | ||||||
|   Future<void> _searchFood(String probe) async { |   Future<void> _searchFood(String probe) async { | ||||||
| @@ -30,7 +29,6 @@ class _QueryScreenState extends State<QueryScreen> { | |||||||
|     final result = await data.searchFood(probe); |     final result = await data.searchFood(probe); | ||||||
|  |  | ||||||
|     setState(() { |     setState(() { | ||||||
|       _totalCount = result.totalHits; |  | ||||||
|       _foodData = result.foods; |       _foodData = result.foods; | ||||||
|       _isLoading = false; |       _isLoading = false; | ||||||
|     }); |     }); | ||||||
| @@ -77,7 +75,7 @@ class _QueryScreenState extends State<QueryScreen> { | |||||||
|                             const EdgeInsets.symmetric(horizontal: 24), |                             const EdgeInsets.symmetric(horizontal: 24), | ||||||
|                         title: Text(item.description), |                         title: Text(item.description), | ||||||
|                         subtitle: Text( |                         subtitle: Text( | ||||||
|                           DateFormat("yyyy-MM-dd").format(item.publishedDate), |                           '${DateFormat("yyyy-MM-dd").format(item.mostRecentAcquisitionDate ?? item.publishedDate)} ${foodCategoryValues.reverse[item.foodCategory] ?? ''}', | ||||||
|                         ), |                         ), | ||||||
|                         onTap: () => open(), |                         onTap: () => open(), | ||||||
|                       ), |                       ), | ||||||
|   | |||||||
| @@ -12,4 +12,5 @@ const i18nEnglish = { | |||||||
|   'alertNutrientId': 'Nutrient ID', |   'alertNutrientId': 'Nutrient ID', | ||||||
|   'alertMaxValue': 'Max', |   'alertMaxValue': 'Max', | ||||||
|   'alertMinValue': 'Min', |   'alertMinValue': 'Min', | ||||||
|  |   'alerts': 'Alerts', | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -12,4 +12,10 @@ const i18nSimplifiedChinese = { | |||||||
|   'alertNutrientId': '营养物质编号', |   'alertNutrientId': '营养物质编号', | ||||||
|   'alertMaxValue': '告警上限', |   'alertMaxValue': '告警上限', | ||||||
|   'alertMinValue': '告警下限', |   '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