From 8acad9c63a77b8bd7d0a7f3c36b32ca14e96ba3b Mon Sep 17 00:00:00 2001 From: Cxx0822 <1556464090@qq.com> Date: Wed, 19 Nov 2025 16:35:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=A2=9E=E5=8A=A0=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E4=B8=AD=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/apis/recipe.dart | 13 +- lib/provider/food_provider.dart | 122 ++++++++++++++++- lib/utils/index.dart | 20 ++- lib/views/record.dart | 33 ++++- lib/views/record_form.dart | 217 ++++++++++++++++++++++--------- lib/views/stats.dart | 145 +++++++++------------ lib/widgets/common/chart.dart | 70 ++++++++++ lib/widgets/common/image.dart | 8 +- lib/widgets/common/index.dart | 57 +++++++- lib/widgets/moment/card.dart | 4 +- lib/widgets/recipe/calendar.dart | 114 +++++++--------- lib/widgets/recipe/card.dart | 7 +- lib/widgets/recipe/list.dart | 49 ++++--- lib/widgets/recipe/timeline.dart | 46 ++++--- lib/widgets/stats/card.dart | 55 -------- 15 files changed, 616 insertions(+), 344 deletions(-) diff --git a/lib/apis/recipe.dart b/lib/apis/recipe.dart index 1d70135..e89b495 100644 --- a/lib/apis/recipe.dart +++ b/lib/apis/recipe.dart @@ -39,15 +39,16 @@ Future> queryRecipeApi(RecipeQuery recipeQuery) { return HttpUtil().get>( "/food/recipe", queryParameters: recipeQuery.toJson(), - converter: (data) => convertListResponse(data, RecipeSummary.fromJson), + converter: + (data) => + convertListResponse(data, RecipeSummary.fromJson), ); } Future> queryFoodNameListApi() { return HttpUtil().get>( "/food/recipe/name", - converter: - (data) => convertListResponse(data, (json) => json.toString()), + converter: (data) => convertListStringResponse(data), ); } @@ -87,14 +88,14 @@ Future> queryRecordApi(String startDate, String endDate) { return HttpUtil().get>( "/food/record", queryParameters: {"startDate": startDate, "endDate": endDate}, - converter: (data) => convertListResponse(data, FoodRecord.fromJson), + converter: + (data) => convertListResponse(data, FoodRecord.fromJson), ); } Future> queryCategoryApi() { return HttpUtil().get>( "/food/category", - converter: - (data) => convertListResponse(data, (json) => json.toString()), + converter: (data) => convertListStringResponse(data), ); } diff --git a/lib/provider/food_provider.dart b/lib/provider/food_provider.dart index d233944..3dc2c76 100644 --- a/lib/provider/food_provider.dart +++ b/lib/provider/food_provider.dart @@ -1,20 +1,134 @@ import 'package:flutter/material.dart'; +import 'package:food_hub_app/apis/recipe.dart'; +import 'package:food_hub_app/apis/stats.dart'; import 'package:food_hub_app/models/recipe.dart'; +import 'package:food_hub_app/models/stats.dart'; +import 'package:food_hub_app/utils/date_util.dart'; +import 'package:food_hub_app/utils/index.dart'; class FoodProvider with ChangeNotifier { - late FoodRecord _recordFormItem; + late FoodRecord recordFormItem; late bool isEditing; + bool isLoading = false; + String? error; - FoodRecord get recordFormItem => _recordFormItem; + late List recipeSummaryList = []; + + DateTime selectedDay = DateTime.now(); + DateTime focusedDay = DateTime.now(); + + late List recordList = []; + late List selectRecordList = []; + + late SummaryStats summaryStats = SummaryStats( + recipeCount: 0, + categoryCount: 0, + workCount: 0, + ); + late double averageRecordCount = 0; + late List recordStats = []; + late List categoryStats = []; + late List rankStats = []; void resetRecordForm() { - _recordFormItem = FoodRecord.getEmpty(); + recordFormItem = FoodRecord.getEmpty(); notifyListeners(); } void initRecordForm(FoodRecord record) { - _recordFormItem = record; + recordFormItem = record; notifyListeners(); } + + Future refreshRecipeList() async { + if (isLoading) return; + + try { + isLoading = true; + error = null; + notifyListeners(); + + final result = await queryRecipeApi(RecipeQuery(category: "")); + recipeSummaryList = result; + } catch (e) { + error = '加载数据失败: $e'; + debugPrint('加载数据失败: $e'); + } finally { + isLoading = false; + notifyListeners(); + } + } + + Future refreshRecordList(String? startDate, String? endDate) async { + if (isLoading) return; + + try { + isLoading = true; + error = null; + notifyListeners(); + + startDate ??= getFirstDayOfMonth(focusedDay); + endDate ??= getLastDayOfMonth(focusedDay); + + final result = await queryRecordApi(startDate, endDate); + + recordList = result; + + if (recordList.isNotEmpty) { + selectedDay = DateTime.parse(recordList.last.date); + } else { + selectedDay = focusedDay; + } + + refreshSelectRecord(); + } catch (e) { + error = '加载数据失败: $e'; + debugPrint('加载数据失败: $e'); + } finally { + isLoading = false; + notifyListeners(); + } + } + + void refreshSelectRecord() { + selectRecordList = + recordList + .where((record) => record.date == formatDateTime(selectedDay)) + .toList(); + } + + Future refreshBlogStats() async { + if (isLoading) return; + + try { + isLoading = true; + error = null; + notifyListeners(); + + final statsResult = await queryStatsApi(); + final recordStatsResult = await queryRecordStatsApi(); + final categoryStatsResult = await queryCategoryStatsApi(); + final rankStatsResult = await queryRankStatsApi(); + + summaryStats = statsResult; + recordStats = recordStatsResult; + categoryStats = categoryStatsResult; + rankStats = rankStatsResult; + + if (recordStats.isNotEmpty) { + double sumValue = recordStats.fold( + 0.0, + (sum, item) => sum + item.value, + ); + averageRecordCount = sumValue / recordStats.length; + } + } catch (e) { + error = '加载数据失败: $e'; + debugPrint('加载数据失败: $e'); + } finally { + isLoading = false; + notifyListeners(); + } + } } diff --git a/lib/utils/index.dart b/lib/utils/index.dart index 105c47f..c460c19 100644 --- a/lib/utils/index.dart +++ b/lib/utils/index.dart @@ -17,9 +17,23 @@ String formatDateTime(DateTime dateTime, [String format = 'yyyy-MM-dd']) { } /// 通用列表转换函数 -List convertListResponse(dynamic data, T Function(Map) fromJson) { +List convertListResponse( + dynamic data, + T Function(Map) fromJson, +) { if (data is List) { return data.map((item) => fromJson(item as Map)).toList(); } - throw FormatException('Expected a list of items for conversion, but got ${data.runtimeType}'); -} \ No newline at end of file + throw FormatException( + 'Expected a list of items for conversion, but got ${data.runtimeType}', + ); +} + +List convertListStringResponse(dynamic data) { + if (data is List) { + return data.map((item) => item.toString()).toList(); + } + throw FormatException( + 'Expected a list of items for conversion, but got ${data.runtimeType}', + ); +} diff --git a/lib/views/record.dart b/lib/views/record.dart index e138afb..5218981 100644 --- a/lib/views/record.dart +++ b/lib/views/record.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:food_hub_app/provider/food_provider.dart'; import 'package:food_hub_app/widgets/common/index.dart'; import 'package:food_hub_app/widgets/recipe/calendar.dart'; import 'package:food_hub_app/widgets/recipe/list.dart'; import 'package:food_hub_app/widgets/recipe/timeline.dart'; +import 'package:provider/provider.dart'; enum RecordTab { recipe('菜谱'), @@ -29,7 +31,7 @@ class _RecordPageState extends State { RecipeTimeline(), ]; - Widget buildTabs({ + Widget _buildTabs({ required BuildContext context, required RecordTab currentTab, required ValueChanged onTabChanged, @@ -46,18 +48,35 @@ class _RecordPageState extends State { ); } + void _onTabChange(RecordTab tab, FoodProvider provider) { + setState(() { + _currentTab = tab; + }); + + switch (tab) { + case RecordTab.recipe: + provider.refreshRecipeList(); + break; + case RecordTab.calendar: + provider.refreshRecordList(null, null); + break; + case RecordTab.timeline: + final year = DateTime.now().year; + provider.refreshRecordList("$year-01-01", "$year-12-31"); + break; + } + } + @override Widget build(BuildContext context) { + final provider = context.watch(); + return Column( children: [ - buildTabs( + _buildTabs( context: context, currentTab: _currentTab, - onTabChanged: (tab) { - setState(() { - _currentTab = tab; - }); - }, + onTabChanged: (tab) => _onTabChange(tab, provider), ), Expanded( child: IndexedStack(index: _currentTab.index, children: _tabPages), diff --git a/lib/views/record_form.dart b/lib/views/record_form.dart index d26c2a2..8cc1f76 100644 --- a/lib/views/record_form.dart +++ b/lib/views/record_form.dart @@ -1,7 +1,7 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; -import 'package:food_hub_app/config/app_config.dart'; +import 'package:food_hub_app/apis/recipe.dart'; import 'package:food_hub_app/provider/food_provider.dart'; import 'package:food_hub_app/utils/minio_utils.dart'; import 'package:food_hub_app/widgets/common/form.dart'; @@ -20,41 +20,22 @@ class RecordFormPage extends StatefulWidget { class _RecordFormPageState extends State { final _formKey = GlobalKey(); - final _focusNode = FocusNode(); static const String _nameField = 'name'; static const String _dateField = 'date'; + List _foodNameList = []; @override - void dispose() { - _focusNode.dispose(); - super.dispose(); + void initState() { + super.initState(); + refreshCategoryList(); } - @override - Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; - final provider = context.watch(); + Future refreshCategoryList() async { + final result = await queryFoodNameListApi(); - final String title = provider.isEditing ? '编辑记录' : '新增记录'; - print(provider.recordFormItem.imageUrl); - - return Scaffold( - appBar: AppBar( - title: Text(title, style: TextStyle(color: Colors.white)), - backgroundColor: colors.primary, - leading: IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.white), - onPressed: () => Navigator.pop(context), - ), - ), - backgroundColor: const Color(0xFFF5F5F5), - body: SingleChildScrollView( - child: Padding( - padding: EdgeInsets.all(5), - child: buildCard(context: context, child: _buildFormBuilder()), - ), - ), - ); + setState(() { + _foodNameList = result; + }); } Widget _buildFormBuilder() { @@ -67,34 +48,9 @@ class _RecordFormPageState extends State { children: [ buildFormLabel('菜谱名称', required: true), const SizedBox(height: 8), - FormBuilderTextField( - name: _nameField, - initialValue: provider.recordFormItem.name, - enabled: !provider.isEditing, - focusNode: _focusNode, - onChanged: (value) { - setState(() { - provider.recordFormItem.name = value!; - }); - }, - decoration: buildInputDecoration( - context: context, - hintText: '请输入菜谱名称', - prefixIcon: const Icon( - Icons.title, - size: 20, - color: Color(0xFF86909C), - ), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return '请输入菜谱名称'; - } - return null; - }, - ), - const SizedBox(height: 10), + _buildRecipeAutocomplete(provider), + const SizedBox(height: 10), buildFormLabel('完成时间', required: true), const SizedBox(height: 8), FormBuilderTextField( @@ -130,8 +86,7 @@ class _RecordFormPageState extends State { else buildImagePreviewItem( context: context, - imageUrl: - '${AppConfig.imageBaseUrl}${provider.recordFormItem.imageUrl}', + imageUrl: provider.recordFormItem.imageUrl, onRemoveImage: () => _removeImage(provider), ), ], @@ -147,6 +102,126 @@ class _RecordFormPageState extends State { ); } + Widget _buildRecipeAutocomplete(FoodProvider provider) { + return Autocomplete( + initialValue: TextEditingValue(text: provider.recordFormItem.name), + optionsBuilder: (TextEditingValue textEditingValue) { + if (textEditingValue.text.isEmpty || _foodNameList.isEmpty) { + return const Iterable.empty(); + } + return _foodNameList.where( + (option) => option.toLowerCase().contains( + textEditingValue.text.toLowerCase(), + ), + ); + }, + + onSelected: (String value) { + setState(() { + provider.recordFormItem.name = value; + }); + }, + + optionsViewBuilder: ( + BuildContext context, + AutocompleteOnSelected onSelected, + Iterable options, + ) { + Widget buildOptionItem(String option) { + return InkWell( + onTap: () => onSelected(option), + child: Container( + padding: EdgeInsets.symmetric(vertical: 12, horizontal: 16), + child: Text( + option, + style: TextStyle(fontSize: 14, color: Colors.grey.shade800), + ), + ), + ); + } + + return Align( + alignment: Alignment.topLeft, + child: Material( + elevation: 2, + borderRadius: BorderRadius.circular(8), + child: Container( + constraints: BoxConstraints( + maxHeight: 200, + maxWidth: MediaQuery.of(context).size.width - 32, + ), + child: ListView.builder( + padding: EdgeInsets.zero, + shrinkWrap: true, + itemCount: options.length, + itemBuilder: (BuildContext context, int index) { + return buildOptionItem(options.elementAt(index)); + }, + ), + ), + ), + ); + }, + + fieldViewBuilder: ( + BuildContext context, + TextEditingController controller, + FocusNode focusNode, + VoidCallback onFieldSubmitted, + ) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (provider.recordFormItem.name.isNotEmpty && + controller.text.isEmpty) { + controller.text = provider.recordFormItem.name; + } + }); + + void onClearRecipeName() { + controller.clear(); + setState(() { + provider.recordFormItem.name = ''; + }); + } + + Widget? buildSuffixIcon() { + return controller.text.isNotEmpty + ? IconButton( + icon: Icon(Icons.clear, size: 18), + onPressed: onClearRecipeName, + ) + : null; + } + + return FormBuilderTextField( + name: _nameField, + controller: controller, + focusNode: focusNode, + enabled: !provider.isEditing, + onChanged: (value) { + setState(() { + provider.recordFormItem.name = value ?? ''; + }); + }, + decoration: buildInputDecoration( + context: context, + hintText: '请输入菜谱名称', + prefixIcon: const Icon( + Icons.restaurant_menu, + size: 20, + color: Color(0xFF86909C), + ), + ).copyWith(suffixIcon: buildSuffixIcon()), + validator: (value) { + if (value == null || value.isEmpty) { + return '请输入菜谱名称'; + } + return null; + }, + ); + }, + ); + } + /// 选择日期 Future _onSelectDate(FoodProvider provider) async { final DateTime? picked = await showDatePicker( @@ -202,4 +277,30 @@ class _RecordFormPageState extends State { print(provider.recordFormItem.imageUrl); } } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final provider = context.watch(); + + final String title = provider.isEditing ? '编辑记录' : '新增记录'; + + return Scaffold( + appBar: AppBar( + title: Text(title, style: TextStyle(color: Colors.white)), + backgroundColor: colors.primary, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + ), + backgroundColor: const Color(0xFFF5F5F5), + body: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.all(5), + child: buildCard(context: context, child: _buildFormBuilder()), + ), + ), + ); + } } diff --git a/lib/views/stats.dart b/lib/views/stats.dart index a26d305..e5c0859 100644 --- a/lib/views/stats.dart +++ b/lib/views/stats.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:food_hub_app/apis/stats.dart'; -import 'package:food_hub_app/models/stats.dart'; +import 'package:food_hub_app/provider/food_provider.dart'; import 'package:food_hub_app/widgets/common/chart.dart'; import 'package:food_hub_app/widgets/common/index.dart'; import 'package:food_hub_app/widgets/stats/card.dart'; +import 'package:provider/provider.dart'; class StatsPage extends StatefulWidget { const StatsPage({super.key}); @@ -15,70 +15,17 @@ class StatsPage extends StatefulWidget { class _StatsPage extends State { final double chartHeight = 400; - SummaryStats summaryStats = SummaryStats( - recipeCount: 0, - categoryCount: 0, - workCount: 0, - ); - - late double averageRecordCount = 0; - - List recordStats = []; - List categoryStats = []; - List rankStats = []; - @override void initState() { super.initState(); - refreshStats(); - } - Future refreshStats() async { - final result1 = await queryStatsApi(); - final result2 = await queryRecordStatsApi(); - final result3 = await queryCategoryStatsApi(); - final result4 = await queryRankStatsApi(); - - setState(() { - summaryStats = result1; - recordStats = result2; - categoryStats = result3; - rankStats = result4; - - if (recordStats.isNotEmpty) { - double sumValue = recordStats.fold( - 0.0, - (sum, item) => sum + item.value, - ); - averageRecordCount = sumValue / recordStats.length; - } + // 初始化加载数据 + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().refreshBlogStats(); }); } - @override - Widget build(BuildContext context) { - return SingleChildScrollView( - child: Column( - children: [ - _buildSummaryStats(), - SizedBox( - height: chartHeight, - child: buildCard(context: context, child: _buildRecordStats()), - ), - SizedBox( - height: chartHeight, - child: buildCard(context: context, child: _buildCategoryStats()), - ), - SizedBox( - height: chartHeight, - child: buildCard(context: context, child: _buildRankStats()), - ), - ], - ), - ); - } - - Widget _buildSummaryStats() { + Widget _buildSummaryStats(FoodProvider provider) { return GridView.count( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), @@ -92,40 +39,40 @@ class _StatsPage extends State { icon: Icons.restaurant_menu, color: Colors.blue, title: '菜谱总数', - value: summaryStats.recipeCount, + value: provider.summaryStats.recipeCount, unit: '个', ), StatisticCard( icon: Icons.grid_view_rounded, color: Colors.green, title: '菜谱类别', - value: summaryStats.categoryCount, + value: provider.summaryStats.categoryCount, unit: '种', ), StatisticCard( icon: Icons.flag, color: Colors.orange, title: '做菜次数', - value: summaryStats.workCount, + value: provider.summaryStats.workCount, unit: '个', ), StatisticCard( icon: Icons.show_chart, color: Colors.red, title: '平均次数', - value: double.parse(averageRecordCount.toStringAsFixed(2)), + value: double.parse(provider.averageRecordCount.toStringAsFixed(2)), unit: '次/月', ), ], ); } - Widget _buildRecordStats() { + Widget _buildRecordStats(FoodProvider provider) { return Column( children: [ - _buildTitleSection('记录统计'), + buildChartTitle(context: context, title: '记录统计'), const SizedBox(height: 3), - _buildDivider(), + buildChartDivider(context: context), const SizedBox(height: 3), Expanded( child: lineChart( @@ -133,56 +80,80 @@ class _StatsPage extends State { xAxisName: '日期', yAxisName: '次数', unit: '次', - data: recordStats, + data: provider.recordStats, ), ), ], ); } - Widget _buildCategoryStats() { + Widget _buildCategoryStats(FoodProvider provider) { return Column( children: [ - _buildTitleSection('菜谱统计'), + buildChartTitle(context: context, title: '菜谱统计'), const SizedBox(height: 3), - _buildDivider(), + buildChartDivider(context: context), const SizedBox(height: 3), Expanded( - child: pieChart(context: context, unit: '个', data: categoryStats), + child: pieChart( + context: context, + unit: '个', + data: provider.categoryStats, + ), ), ], ); } - Widget _buildRankStats() { + Widget _buildRankStats(FoodProvider provider) { return Column( children: [ - _buildTitleSection('排行榜'), + buildChartTitle(context: context, title: '排行榜'), const SizedBox(height: 3), - _buildDivider(), + buildChartDivider(context: context), const SizedBox(height: 3), - Expanded(child: rankChart(rankStats)), + Expanded(child: rankChart(data: provider.rankStats, unit: '次')), ], ); } - // 构建标题区域 - Widget _buildTitleSection(String title) { - return Row( + Widget _buildContent(FoodProvider provider) { + return Column( children: [ - Icon(Icons.insert_chart, color: Theme.of(context).primaryColor), - Text(title), + _buildSummaryStats(provider), + SizedBox( + height: chartHeight, + child: buildCard( + context: context, + child: _buildRecordStats(provider), + ), + ), + SizedBox( + height: chartHeight, + child: buildCard( + context: context, + child: _buildCategoryStats(provider), + ), + ), + SizedBox( + height: chartHeight, + child: buildCard(context: context, child: _buildRankStats(provider)), + ), ], ); } - Widget _buildDivider() { - return Divider( - height: 1, - thickness: 1, - color: Theme.of(context).primaryColor, - indent: 0, - endIndent: 0, + @override + Widget build(BuildContext context) { + final provider = context.watch(); + + return Stack( + children: [ + if (provider.isLoading) + buildLoadingIndicator(context) + else + SingleChildScrollView(child: _buildContent(provider)), + ], ); } } diff --git a/lib/widgets/common/chart.dart b/lib/widgets/common/chart.dart index ea4dd2d..cd461fc 100644 --- a/lib/widgets/common/chart.dart +++ b/lib/widgets/common/chart.dart @@ -181,3 +181,73 @@ Widget pieChart({ ], ); } + +Widget buildChartTitle({required BuildContext context, required String title}) { + return Row( + children: [ + Icon(Icons.insert_chart, color: Theme.of(context).colorScheme.primary), + Text(title), + ], + ); +} + +Widget buildChartDivider({required BuildContext context}) { + return Divider( + height: 1, + thickness: 1, + color: Theme.of(context).colorScheme.primary, + indent: 0, + endIndent: 0, + ); +} + +Color _getRankColor(int index) { + switch (index) { + case 0: // 第1名 + return Colors.amber; // 金色 + case 1: // 第2名 + return Colors.grey; // 银色 + case 2: // 第3名 + return Colors.orange[700]!; // 铜色 + default: + return Colors.black; // 普通颜色 + } +} + +Widget rankChart({required String unit, required List data}) { + return ListView.builder( + itemCount: data.length, + itemBuilder: (context, index) { + final item = data[index]; + final rank = index + 1; + return SizedBox( + height: 35, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + width: 25, + alignment: Alignment.center, + child: Text( + '$rank.', + style: TextStyle( + fontWeight: FontWeight.bold, + color: _getRankColor(index), + ), + ), + ), + Text(item.name, style: TextStyle(color: _getRankColor(index))), + ], + ), + Text( + '${item.value.toStringAsFixed(0)} $unit', + style: TextStyle(color: _getRankColor(index)), + ), + ], + ), + ); + }, + ); +} diff --git a/lib/widgets/common/image.dart b/lib/widgets/common/image.dart index 568b50c..d5272ff 100644 --- a/lib/widgets/common/image.dart +++ b/lib/widgets/common/image.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:food_hub_app/config/app_config.dart'; import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view_gallery.dart'; @@ -113,10 +114,13 @@ Widget buildNetworkImage(BuildContext context, String url) { borderRadius: BorderRadius.circular(8), child: GestureDetector( onTap: () { - showFullScreenImage(context, NetworkImage(url)); + showFullScreenImage( + context, + NetworkImage('${AppConfig.imageBaseUrl}$url'), + ); }, child: Image.network( - url, + '${AppConfig.imageBaseUrl}$url', fit: BoxFit.cover, loadingBuilder: (context, child, loadingProgress) { if (loadingProgress == null) return child; diff --git a/lib/widgets/common/index.dart b/lib/widgets/common/index.dart index 0c74cd7..e77fc83 100644 --- a/lib/widgets/common/index.dart +++ b/lib/widgets/common/index.dart @@ -29,7 +29,9 @@ InputDecoration formInputDecoration({ } Widget buildCard({required BuildContext context, required Widget child}) { - final colors = Theme.of(context).colorScheme; + final colors = Theme + .of(context) + .colorScheme; return Card( elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), @@ -64,7 +66,11 @@ Widget buildToggleSwitch({ initialLabelIndex: tabValues.indexOf(currentTab), totalSwitches: tabValues.length, labels: labels, - activeBgColor: [Theme.of(context).colorScheme.primary], + activeBgColor: [Theme + .of(context) + .colorScheme + .primary + ], activeFgColor: Colors.white, inactiveBgColor: Colors.grey.shade200, inactiveFgColor: Colors.grey.shade700, @@ -93,7 +99,10 @@ Widget circleIconButton({ fixedSize: Size(size, size), shape: CircleBorder(), elevation: 0, - backgroundColor: Theme.of(context).colorScheme.primary, + backgroundColor: Theme + .of(context) + .colorScheme + .primary, padding: EdgeInsets.zero, minimumSize: const Size(0, 0), ), @@ -102,7 +111,9 @@ Widget circleIconButton({ } Widget buildTag(BuildContext context, String title) { - final colors = Theme.of(context).colorScheme; + final colors = Theme + .of(context) + .colorScheme; return Container( padding: EdgeInsets.symmetric(horizontal: 10, vertical: 4), @@ -173,3 +184,41 @@ void showErrorToast(String message) { fontSize: 16.0, ); } + +Widget buildLoadingIndicator(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + return Center( + child: Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black12, + blurRadius: 8, + offset: Offset(0, 2), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(colors.primary), + ), + SizedBox(height: 12), + Text( + '加载中', + style: TextStyle( + fontSize: 14, + color: colors.onSurface, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); +} diff --git a/lib/widgets/moment/card.dart b/lib/widgets/moment/card.dart index 1f05d76..0df9ac1 100644 --- a/lib/widgets/moment/card.dart +++ b/lib/widgets/moment/card.dart @@ -100,9 +100,7 @@ class MomentCard extends StatelessWidget { // 生成图片URL列表(用于预览时切换) final List imageUrls = - moment.imageList - .map((path) => '${AppConfig.imageBaseUrl}$path') - .toList(); + moment.imageList.map((path) => path).toList(); void imageTapClick(int index) { Navigator.push( diff --git a/lib/widgets/recipe/calendar.dart b/lib/widgets/recipe/calendar.dart index b9f93fe..f7a7b60 100644 --- a/lib/widgets/recipe/calendar.dart +++ b/lib/widgets/recipe/calendar.dart @@ -1,8 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:food_hub_app/apis/recipe.dart'; import 'package:food_hub_app/models/recipe.dart'; import 'package:food_hub_app/provider/food_provider.dart'; -import 'package:food_hub_app/utils/date_util.dart'; import 'package:food_hub_app/utils/index.dart'; import 'package:food_hub_app/widgets/common/index.dart'; import 'package:table_calendar/table_calendar.dart'; @@ -16,46 +14,7 @@ class RecipeCalendar extends StatefulWidget { } class _RecipeCalendarState extends State { - DateTime _selectedDay = DateTime.now(); - DateTime _focusedDay = DateTime.now(); - - List recordList = []; - List selectRecordList = []; - - @override - void initState() { - super.initState(); - _refreshRecord(); - } - - Future _refreshRecord() async { - final result = await queryRecordApi( - getFirstDayOfMonth(_focusedDay), - getLastDayOfMonth(_focusedDay), - ); - setState(() { - recordList = result; - - if (recordList.isNotEmpty) { - _selectedDay = DateTime.parse(recordList.last.date); - } else { - _selectedDay = _focusedDay; - } - - _refreshSelectRecord(); - }); - } - - void _refreshSelectRecord() { - setState(() { - selectRecordList = - recordList - .where((record) => record.date == formatDateTime(_selectedDay)) - .toList(); - }); - } - - TableCalendar _recipeCalendar() { + TableCalendar _recipeCalendar(FoodProvider provider) { return TableCalendar( locale: 'zh_CN', headerStyle: const HeaderStyle( @@ -64,47 +23,53 @@ class _RecipeCalendarState extends State { ), firstDay: DateTime.utc(2010, 1, 1), lastDay: DateTime.utc(2100, 12, 31), - focusedDay: _focusedDay, - selectedDayPredicate: (day) => isSameDay(_selectedDay, day), + focusedDay: provider.focusedDay, + selectedDayPredicate: (day) => isSameDay(provider.selectedDay, day), onDaySelected: (selectedDay, focusedDay) { - if (!isSameDay(_selectedDay, selectedDay)) { + if (!isSameDay(provider.selectedDay, selectedDay)) { setState(() { - _selectedDay = selectedDay; - _focusedDay = focusedDay; - _refreshSelectRecord(); + provider.selectedDay = selectedDay; + provider.focusedDay = focusedDay; + provider.refreshSelectRecord(); }); } }, onPageChanged: (focusedDay) { - _focusedDay = focusedDay; - _refreshRecord(); + provider.focusedDay = focusedDay; + provider.refreshRecordList(null, null); }, // 自定义日期单元格构建器 calendarBuilders: CalendarBuilders( defaultBuilder: - (context, day, focusedDay) => _buildDateWidget(day, false, false), + (context, day, focusedDay) => + _buildDateItem(day, false, false, provider), selectedBuilder: - (context, day, focusedDay) => _buildDateWidget(day, true, false), + (context, day, focusedDay) => + _buildDateItem(day, true, false, provider), todayBuilder: - (context, day, focusedDay) => _buildDateWidget(day, false, true), + (context, day, focusedDay) => + _buildDateItem(day, false, true, provider), ), ); } - Widget _dailyItem(BuildContext context) { + Widget _dailyItem(BuildContext context, FoodProvider provider) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(formatDateTime(_selectedDay, 'MM月dd日 EEEE')), - if (selectRecordList.isEmpty) + Text(formatDateTime(provider.selectedDay, 'MM月dd日 EEEE')), + if (provider.selectRecordList.isEmpty) buildEmptyData() else ListView.builder( shrinkWrap: true, physics: NeverScrollableScrollPhysics(), - itemCount: selectRecordList.length, + itemCount: provider.selectRecordList.length, itemBuilder: (context, index) { - return _buildRecordItem(context, selectRecordList[index]); + return _buildRecordItem( + context, + provider.selectRecordList[index], + ); }, ), ], @@ -170,9 +135,14 @@ class _RecipeCalendarState extends State { ); } - Widget _buildDateWidget(DateTime day, bool isSelected, bool isToday) { + Widget _buildDateItem( + DateTime day, + bool isSelected, + bool isToday, + FoodProvider provider, + ) { // 检查当前日期是否在需要显示红点的列表中 - bool shouldShowRedDot = recordList.any( + bool shouldShowRedDot = provider.recordList.any( (item) => item.date == formatDateTime(day), ); @@ -214,15 +184,31 @@ class _RecipeCalendarState extends State { ); } - @override - Widget build(BuildContext context) { + Widget _buildContent(FoodProvider provider) { return Column( children: [ - buildCard(context: context, child: _recipeCalendar()), + buildCard(context: context, child: _recipeCalendar(provider)), Expanded( - child: buildCard(context: context, child: _dailyItem(context)), + child: buildCard( + context: context, + child: _dailyItem(context, provider), + ), ), ], ); } + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + + return Stack( + children: [ + if (provider.isLoading) + buildLoadingIndicator(context) + else + _buildContent(provider), + ], + ); + } } diff --git a/lib/widgets/recipe/card.dart b/lib/widgets/recipe/card.dart index 430e1a8..e1c1c3f 100644 --- a/lib/widgets/recipe/card.dart +++ b/lib/widgets/recipe/card.dart @@ -1,6 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:food_hub_app/config/app_config.dart'; - import 'package:food_hub_app/models/recipe.dart'; import 'package:food_hub_app/widgets/common/image.dart'; import 'package:food_hub_app/widgets/common/index.dart'; @@ -86,10 +84,7 @@ class RecipeCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - buildNetworkImage( - context, - '${AppConfig.imageBaseUrl}/$firstImageUrl', - ), + buildNetworkImage(context, firstImageUrl), SizedBox(height: 8), GestureDetector( onTap: () => navigatorToRecipeDetail(context), diff --git a/lib/widgets/recipe/list.dart b/lib/widgets/recipe/list.dart index fb46f56..44f2128 100644 --- a/lib/widgets/recipe/list.dart +++ b/lib/widgets/recipe/list.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:food_hub_app/apis/recipe.dart'; -import 'package:food_hub_app/models/recipe.dart'; +import 'package:food_hub_app/provider/food_provider.dart'; import 'package:food_hub_app/widgets/common/index.dart'; import 'package:food_hub_app/widgets/recipe/card.dart'; +import 'package:provider/provider.dart'; class RecipeList extends StatefulWidget { const RecipeList({super.key}); @@ -12,33 +12,40 @@ class RecipeList extends StatefulWidget { } class _RecipeListState extends State { - List recipeSummaryList = []; - @override void initState() { super.initState(); - refreshRecipeList(); + + // 初始化加载数据 + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().refreshRecipeList(); + }); } - Future refreshRecipeList() async { - final result = await queryRecipeApi(RecipeQuery(category: "")); - - setState(() { - recipeSummaryList = result; - }); + Widget _buildContent(FoodProvider provider) { + if (provider.recipeSummaryList.isEmpty) { + return buildEmptyData(); + } else { + return ListView.builder( + itemCount: provider.recipeSummaryList.length, + itemBuilder: (context, index) { + return RecipeCard(recipe: provider.recipeSummaryList[index]); + }, + ); + } } @override Widget build(BuildContext context) { - if (recipeSummaryList.isEmpty) { - return buildEmptyData(); - } else { - return ListView.builder( - itemCount: recipeSummaryList.length, - itemBuilder: (context, index) { - return RecipeCard(recipe: recipeSummaryList[index]); - } - ); - } + final provider = context.watch(); + + return Stack( + children: [ + if (provider.isLoading) + buildLoadingIndicator(context) + else + _buildContent(provider), + ], + ); } } diff --git a/lib/widgets/recipe/timeline.dart b/lib/widgets/recipe/timeline.dart index c034d1b..278bdd8 100644 --- a/lib/widgets/recipe/timeline.dart +++ b/lib/widgets/recipe/timeline.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; -import 'package:food_hub_app/apis/recipe.dart'; -import 'package:food_hub_app/config/app_config.dart'; import 'package:food_hub_app/models/recipe.dart'; +import 'package:food_hub_app/provider/food_provider.dart'; import 'package:food_hub_app/widgets/common/image.dart'; import 'package:food_hub_app/widgets/common/index.dart'; import 'package:food_hub_app/widgets/common/year_selector.dart'; import 'package:timelines_plus/timelines_plus.dart'; +import 'package:provider/provider.dart'; class RecipeTimeline extends StatefulWidget { const RecipeTimeline({super.key}); @@ -15,24 +15,7 @@ class RecipeTimeline extends StatefulWidget { } class _RecipeTimeline extends State { - List recordList = []; - - @override - void initState() { - super.initState(); - refreshRecord(DateTime.now().year); - } - - Future refreshRecord(int year) async { - final result = await queryRecordApi("$year-01-01", "$year-12-31"); - setState(() { - recordList = result; - }); - } - Widget buildTimelineCard(BuildContext context, FoodRecord record) { - final imageUrl = '${AppConfig.imageBaseUrl}${record.imageUrl}'; - return buildCard( context: context, child: Column( @@ -58,7 +41,7 @@ class _RecipeTimeline extends State { style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), const SizedBox(height: 5), - buildNetworkImage(context, imageUrl), + buildNetworkImage(context, record.imageUrl), ], ), ], @@ -93,8 +76,7 @@ class _RecipeTimeline extends State { } } - @override - Widget build(BuildContext context) { + Widget _buildContent(FoodProvider provider) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -102,9 +84,25 @@ class _RecipeTimeline extends State { initialYear: DateTime.now().year, minYear: 2000, maxYear: 2100, - onYearChanged: (year) => refreshRecord(year), + onYearChanged: + (year) => + provider.refreshRecordList("$year-01-01", "$year-12-31"), ), - Expanded(child: buildTimeline(context, recordList)), + Expanded(child: buildTimeline(context, provider.recordList)), + ], + ); + } + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + + return Stack( + children: [ + if (provider.isLoading) + buildLoadingIndicator(context) + else + _buildContent(provider), ], ); } diff --git a/lib/widgets/stats/card.dart b/lib/widgets/stats/card.dart index e01a314..8fb5023 100644 --- a/lib/widgets/stats/card.dart +++ b/lib/widgets/stats/card.dart @@ -54,58 +54,3 @@ class StatisticCard extends StatelessWidget { ); } } - -// 获取排名对应的颜色 -Color _getRankColor(int index) { - switch (index) { - case 0: // 第1名 - return Colors.amber; // 金色 - case 1: // 第2名 - return Colors.grey; // 银色 - case 2: // 第3名 - return Colors.orange[700]!; // 铜色 - default: - return Colors.black; // 普通颜色 - } -} - -Widget rankChart(List items) { - return ListView.builder( - itemCount: items.length, - itemBuilder: (context, index) { - final item = items[index]; - final rank = index + 1; - return SizedBox( - height: 35, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Container( - width: 25, - alignment: Alignment.center, - child: Text( - '$rank.', - style: TextStyle( - fontWeight: FontWeight.bold, - color: _getRankColor(index), - ), - ), - ), - Text( - item.name, - style: TextStyle(color: _getRankColor(index)), - ), - ], - ), - Text( - '${item.value.toStringAsFixed(0)} 次', - style: TextStyle(color: _getRankColor(index)), - ), - ], - ), - ); - }, - ); -}