Editable search data collections

This commit is contained in:
LittleSheep 2024-08-15 20:10:07 +08:00
parent 09e904f52f
commit 403639088f
12 changed files with 439 additions and 184 deletions

View File

@ -25,13 +25,21 @@ class FoodDataController extends GetxController {
await _prefs.setString("data_fdc_api_key", value);
}
List<String>? getDataCollections() {
return _prefs.getStringList("data_enabled_collections");
}
Future<void> setDataCollections(List<String> value) async {
await _prefs.setStringList("data_enabled_collections", value);
}
Future<FoodDataQueryResponse> searchFood(String probe) async {
final client = Dio();
final resp = await client.get(
'https://api.nal.usda.gov/fdc/v1/foods/search',
queryParameters: {
'query': probe,
'dataType': 'Foundation',
'dataType': getDataCollections()?.join(','),
'pageSize': 25,
'pageNumber': 1,
'sortBy': 'dataType.keyword',

View File

@ -2,6 +2,8 @@ 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';
import 'package:dietary_guard/screens/settings/alert.dart';
import 'package:dietary_guard/screens/settings/data_source.dart';
import 'package:dietary_guard/shells/nav_shell.dart';
import 'package:dietary_guard/translations.dart';
import 'package:flutter/material.dart';
@ -26,6 +28,16 @@ final router = GoRouter(routes: [
name: "settings",
builder: (context, state) => const SettingsScreen(),
),
GoRoute(
path: "/settings/alerts",
name: "settingsAlert",
builder: (context, state) => const AlertSettingsScreen(),
),
GoRoute(
path: "/settings/data-source",
name: "settingsDataSource",
builder: (context, state) => const DataSourceSettingsScreen(),
),
],
),
]);

View File

@ -98,7 +98,7 @@ class Nutrients {
}
class FoodSearchCriteria {
List<Type?> dataType;
List<FoodType?> dataType;
String query;
String generalSearchInput;
int pageNumber;
@ -107,7 +107,7 @@ class FoodSearchCriteria {
int numberOfResultsPerPage;
int pageSize;
bool requireAllWords;
List<Type?> foodTypes;
List<FoodType?> foodTypes;
FoodSearchCriteria({
required this.dataType,
@ -124,7 +124,7 @@ class FoodSearchCriteria {
factory FoodSearchCriteria.fromJson(Map<String, dynamic> json) =>
FoodSearchCriteria(
dataType: List<Type>.from(
dataType: List<FoodType?>.from(
json["dataType"]?.map((x) => typeValues.map[x]) ?? List.empty()),
query: json["query"],
generalSearchInput: json["generalSearchInput"],
@ -134,7 +134,7 @@ class FoodSearchCriteria {
numberOfResultsPerPage: json["numberOfResultsPerPage"],
pageSize: json["pageSize"],
requireAllWords: json["requireAllWords"],
foodTypes: List<Type>.from(
foodTypes: List<FoodType>.from(
json["foodTypes"]?.map((x) => typeValues.map[x]) ?? List.empty()),
);
@ -154,20 +154,20 @@ class FoodSearchCriteria {
};
}
enum Type { FOUNDATION, SR_LEGACY }
enum FoodType { FOUNDATION, SR_LEGACY }
final typeValues =
EnumValues({"Foundation": Type.FOUNDATION, "SR Legacy": Type.SR_LEGACY});
final typeValues = EnumValues(
{"Foundation": FoodType.FOUNDATION, "SR Legacy": FoodType.SR_LEGACY});
class FoodData {
int fdcId;
String description;
String? commonNames;
String? additionalDescriptions;
Type? dataType;
FoodType? dataType;
int? ndbNumber;
DateTime publishedDate;
FoodCategory? foodCategory;
String? foodCategory;
DateTime? mostRecentAcquisitionDate;
String allHighlightFields;
double score;
@ -208,7 +208,7 @@ class FoodData {
dataType: typeValues.map[json["dataType"]],
ndbNumber: json["ndbNumber"],
publishedDate: DateTime.parse(json["publishedDate"]),
foodCategory: foodCategoryValues.map[json["foodCategory"]],
foodCategory: json["foodCategory"],
mostRecentAcquisitionDate: json["mostRecentAcquisitionDate"] == null
? null
: DateTime.parse(json["mostRecentAcquisitionDate"]),
@ -237,7 +237,7 @@ class FoodData {
"ndbNumber": ndbNumber,
"publishedDate":
"${publishedDate.year.toString().padLeft(4, '0')}-${publishedDate.month.toString().padLeft(2, '0')}-${publishedDate.day.toString().padLeft(2, '0')}",
"foodCategory": foodCategoryValues.reverse[foodCategory],
"foodCategory": foodCategory,
"mostRecentAcquisitionDate":
"${mostRecentAcquisitionDate!.year.toString().padLeft(4, '0')}-${mostRecentAcquisitionDate!.month.toString().padLeft(2, '0')}-${mostRecentAcquisitionDate!.day.toString().padLeft(2, '0')}",
"allHighlightFields": allHighlightFields,
@ -255,11 +255,6 @@ class FoodData {
};
}
enum FoodCategory { DAIRY_AND_EGG_PRODUCTS }
final foodCategoryValues =
EnumValues({"Dairy and Egg Products": FoodCategory.DAIRY_AND_EGG_PRODUCTS});
class FoodNutrient {
int nutrientId;
String nutrientName;

View File

@ -28,13 +28,19 @@ class _FoodDetailsScreenState extends State<FoodDetailsScreen> {
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 isUndetected = true;
bool isOutOfRange = false;
double? difference;
bool isUndetected = false;
String name = 'undetected'.tr;
String? unitName;
double? current;
for (final nutrient in widget.item.foodNutrients) {
if (item.nutrientId != nutrient.nutrientId) continue;
name = nutrient.nutrientName;
unitName = unitNameValues.reverse[nutrient.unitName];
if (nutrient.value != null) {
final value = nutrient.value ?? 0;
final value = nutrient.value!;
current = value;
if (value > item.maxValue) {
difference = value - item.maxValue;
isOutOfRange = true;
@ -42,22 +48,21 @@ class _FoodDetailsScreenState extends State<FoodDetailsScreen> {
difference = value - item.minValue;
isOutOfRange = true;
}
} else {
isUndetected = true;
isUndetected = false;
}
}
_alertDetectResult.add(AlertDetectResult(
config: item,
name: nutrient.nutrientName,
unitName: unitNameValues.reverse[nutrient.unitName],
current: nutrient.value,
name: name,
unitName: unitName,
current: current,
difference: difference,
isOutOfRange: isOutOfRange,
isUndetected: isUndetected,
));
}
}
}
Widget _buildShowAlertResultButton() {
return IconButton(

View File

@ -75,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.mostRecentAcquisitionDate ?? item.publishedDate)} ${foodCategoryValues.reverse[item.foodCategory] ?? ''}',
'${DateFormat("yyyy-MM-dd").format(item.mostRecentAcquisitionDate ?? item.publishedDate)} ${item.foodCategory ?? ''}',
),
onTap: () => open(),
),

View File

@ -1,71 +1,10 @@
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';
import 'package:go_router/go_router.dart';
class SettingsScreen extends StatefulWidget {
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
final TextEditingController _fdcApiKeyController = TextEditingController();
List<AlertConfiguration> _currentAlerts = List.empty(growable: true);
void _addAlert() {
setState(() {
_currentAlerts.add(AlertConfiguration(
nutrientId: 0,
maxValue: 0,
minValue: 0,
));
});
}
Future<void> _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),
));
}
@override
void initState() {
final FoodDataController data = Get.find();
_fdcApiKeyController.text = data.getApiKey() ?? '';
final AlertController alert = Get.find();
_currentAlerts = List.from(alert.configuration, growable: true);
super.initState();
}
@override
void dispose() {
_fdcApiKeyController.dispose();
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 Material(
@ -76,92 +15,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
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;
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
trailing: const Icon(Icons.chevron_right),
title: Text('settingsAlertSection'.tr),
onTap: () {
GoRouter.of(context).pushNamed('settingsAlert');
},
),
),
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;
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
trailing: const Icon(Icons.chevron_right),
title: Text('settingsDataSection'.tr),
onTap: () {
GoRouter.of(context).pushNamed('settingsDataSource');
},
),
),
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,
),
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),
],
),
),

View File

@ -0,0 +1,152 @@
import 'package:dietary_guard/controllers/alert.dart';
import 'package:dietary_guard/models/alert_configuration.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class AlertSettingsScreen extends StatefulWidget {
const AlertSettingsScreen({super.key});
@override
State<AlertSettingsScreen> createState() => _AlertSettingsScreenState();
}
class _AlertSettingsScreenState extends State<AlertSettingsScreen> {
List<AlertConfiguration> _currentAlerts = List.empty(growable: true);
void _addAlert() {
setState(() {
_currentAlerts.add(AlertConfiguration(
nutrientId: 0,
maxValue: 0,
minValue: 0,
));
});
}
void _removeAlert(AlertConfiguration item) {
setState(() {
_currentAlerts.remove(item);
});
}
Future<void> _applySettings() async {
final AlertController alert = Get.find();
await alert.setAlertConfiguration(_currentAlerts);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('settingsApplied'.tr),
));
}
@override
void initState() {
final AlertController alert = Get.find();
_currentAlerts = List.from(alert.configuration, growable: true);
super.initState();
}
@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).colorScheme.surface,
child: Scaffold(
appBar: AppBar(
title: Text('alertSettings'.tr),
),
body: ListView(
children: [
..._currentAlerts.map(
(x) => Card(
child: Column(
children: [
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),
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
_removeAlert(x);
},
),
],
),
const SizedBox(height: 12),
Row(
children: [
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;
},
),
),
],
),
],
).paddingAll(16),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
icon: const Icon(Icons.save, size: 16),
label: Text('apply'.tr),
onPressed: () => _applySettings(),
),
ElevatedButton.icon(
style: const ButtonStyle(
foregroundColor: WidgetStatePropertyAll(Colors.teal),
),
icon: const Icon(Icons.add, size: 16),
label: Text('newAlert'.tr),
onPressed: () => _addAlert(),
),
],
).paddingSymmetric(vertical: 4),
],
).paddingSymmetric(horizontal: 12),
),
);
}
}

View File

@ -0,0 +1,188 @@
import 'package:dietary_guard/controllers/food_data.dart';
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class DataSourceSettingsScreen extends StatefulWidget {
const DataSourceSettingsScreen({super.key});
@override
State<DataSourceSettingsScreen> createState() =>
_DataSourceSettingsScreenState();
}
class _DataSourceSettingsScreenState extends State<DataSourceSettingsScreen> {
final TextEditingController _fdcApiKeyController = TextEditingController();
final List<(String, String, String)> _dataCollectionList = [
(
"Foundation",
"dataCollectionFoundation".tr,
"dataCollectionFoundationDescription".tr,
),
(
"Branded",
"dataCollectionBranded".tr,
"dataCollectionBrandedDescription".tr,
),
(
"Survey (FNDDS)",
"dataCollectionSurvey".tr,
"dataCollectionSurveyDescription".tr,
),
(
"SR Legacy",
"dataCollectionLegacy".tr,
"dataCollectionLegacyDescription".tr,
),
];
List<String> _enabledDataCollections = List.empty(growable: true);
Future<void> _applySettings() async {
final FoodDataController data = Get.find();
await data.setApiKey(_fdcApiKeyController.text);
await data.setDataCollections(_enabledDataCollections);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('settingsApplied'.tr),
));
}
@override
void initState() {
final FoodDataController data = Get.find();
_fdcApiKeyController.text = data.getApiKey() ?? '';
_enabledDataCollections = List.from(
data.getDataCollections() ?? List.empty(),
growable: true,
);
super.initState();
}
Color get _unFocusColor =>
Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).colorScheme.surface,
child: Scaffold(
appBar: AppBar(
title: Text('dataSourceSettings'.tr),
),
body: ListView(
children: [
const SizedBox(height: 8),
DropdownButtonFormField2<String>(
isExpanded: true,
decoration: const InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
hint: Text(
'dataCollectionSelection'.tr,
style: TextStyle(
fontSize: 14,
color: Theme.of(context).hintColor,
),
),
items: _dataCollectionList.map((item) {
return DropdownMenuItem(
value: item.$1,
enabled: false,
child: StatefulBuilder(
builder: (context, menuSetState) {
final isSelected =
_enabledDataCollections.contains(item.$1);
return InkWell(
onTap: () {
isSelected
? _enabledDataCollections.remove(item.$1)
: _enabledDataCollections.add(item.$1);
setState(() {});
menuSetState(() {});
},
child: ListTile(
contentPadding:
const EdgeInsets.symmetric(horizontal: 16.0),
leading: isSelected
? const Icon(Icons.check_box_outlined)
: const Icon(Icons.check_box_outline_blank),
title: Text(item.$2),
subtitle: Text(item.$3),
),
);
},
),
);
}).toList(),
value: _enabledDataCollections.isEmpty
? null
: _enabledDataCollections.last,
onChanged: (value) {},
selectedItemBuilder: (context) {
return _dataCollectionList.map(
(item) {
return Text(
_enabledDataCollections.join(', '),
style: const TextStyle(
fontSize: 14,
overflow: TextOverflow.ellipsis,
),
maxLines: 1,
);
},
).toList();
},
buttonStyleData: const ButtonStyleData(height: 20),
menuItemStyleData: const MenuItemStyleData(
height: 80,
padding: EdgeInsets.zero,
),
),
const SizedBox(height: 16),
TextField(
controller: _fdcApiKeyController,
obscureText: true,
decoration: InputDecoration(
border: const OutlineInputBorder(),
label: Text("fdcApiKey".tr),
isDense: true,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
Text(
'fdcApiKeyHint'.tr,
style: TextStyle(color: _unFocusColor),
).paddingOnly(
left: 8,
right: 8,
top: 8,
),
const Divider(height: 1, thickness: 0.3).paddingSymmetric(
vertical: 8,
),
Text(
'fdcApiCredit'.tr,
style: TextStyle(color: _unFocusColor),
).paddingSymmetric(horizontal: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ElevatedButton.icon(
icon: const Icon(Icons.save, size: 16),
label: Text('apply'.tr),
onPressed: () => _applySettings(),
),
],
).paddingSymmetric(vertical: 12),
],
).paddingSymmetric(horizontal: 24),
),
);
}
}

View File

@ -5,6 +5,8 @@ const i18nSimplifiedChinese = {
'settingsApplied': '设置已应用',
'settingsDataSection': '数据源',
'settingsAlertSection': '危险告警',
'dataSourceSettings': '数据源设置',
'alertSettings': '危险告警配置',
'newAlert': '新告警',
'apply': '应用',
'searchHistoryNotIncluded': '搜索记录还没实现',
@ -18,4 +20,19 @@ const i18nSimplifiedChinese = {
'alertEmpty': '无告警规则配置,前往设置添加规则',
'alertSafe': '无告警规则触发,可安心食用',
'alertDetectResult': '告警匹配详情',
'undetected': '未检出',
'dataCollectionSelection': '搜索数据集范围',
'dataCollectionFoundation': '基础数据集',
'dataCollectionFoundationDescription': '包含食品原材料等,范围较小,但数据准确检测方面全',
'dataCollectionBranded': '品牌数据集',
'dataCollectionBrandedDescription': '包含各种品牌精加工食品',
'dataCollectionSurvey': '调查数据集',
'dataCollectionSurveyDescription': '使用在 We Eat in America 期刊上的数据',
'dataCollectionLegacy': '归档数据集',
'dataCollectionLegacyDescription': '来自分析、计算和公开文献的历史数据',
'fdcApiKey': 'USDA 食品数据中心 API 令牌',
'fdcApiKeyHint':
'DietaryGuard 的数据来自于美国农业部公开 API因此你需要配置一个 API 令牌,但是别担心,这是完全免费的,查看我们的维基了解如何获取一个 API 令牌。',
'fdcApiCredit':
'DietaryGuard 的食品数据来源于 U.S. Department of Agriculture, Agricultural Research Service, Beltsville Human Nutrition Research Center. FoodData Central. 在此感谢他们慷慨贡献的食品数据并发布在公有领域。',
};

View File

@ -66,7 +66,7 @@ class AlertDetectResultDialog extends StatelessWidget {
],
),
subtitle: Text(
'${item.current} ${(item.difference ?? 0) > 0 ? '' : ''}${item.difference?.abs()} ${item.unitName}',
'${item.current ?? 'undetected'.tr} ${(item.difference ?? 0) > 0 ? '' : ''}${item.difference?.abs().toPrecision(2) ?? '-'} ${item.unitName ?? ''}',
),
);
},

View File

@ -73,6 +73,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0"
dropdown_button2:
dependency: "direct main"
description:
name: dropdown_button2
sha256: b0fe8d49a030315e9eef6c7ac84ca964250155a6224d491c1365061bc974a9e1
url: "https://pub.dev"
source: hosted
version: "2.3.9"
fake_async:
dependency: transitive
description:

View File

@ -42,6 +42,7 @@ dependencies:
dio: ^5.6.0
intl: ^0.19.0
animations: ^2.0.11
dropdown_button2: ^2.3.9
dev_dependencies:
flutter_test: