feat:增加加载中组件

This commit is contained in:
2025-11-19 16:35:20 +08:00
parent 625a872d2f
commit 8acad9c63a
15 changed files with 616 additions and 344 deletions

View File

@@ -39,15 +39,16 @@ Future<List<RecipeSummary>> queryRecipeApi(RecipeQuery recipeQuery) {
return HttpUtil().get<List<RecipeSummary>>( return HttpUtil().get<List<RecipeSummary>>(
"/food/recipe", "/food/recipe",
queryParameters: recipeQuery.toJson(), queryParameters: recipeQuery.toJson(),
converter: (data) => convertListResponse<RecipeSummary>(data, RecipeSummary.fromJson), converter:
(data) =>
convertListResponse<RecipeSummary>(data, RecipeSummary.fromJson),
); );
} }
Future<List<String>> queryFoodNameListApi() { Future<List<String>> queryFoodNameListApi() {
return HttpUtil().get<List<String>>( return HttpUtil().get<List<String>>(
"/food/recipe/name", "/food/recipe/name",
converter: converter: (data) => convertListStringResponse(data),
(data) => convertListResponse<String>(data, (json) => json.toString()),
); );
} }
@@ -87,14 +88,14 @@ Future<List<FoodRecord>> queryRecordApi(String startDate, String endDate) {
return HttpUtil().get<List<FoodRecord>>( return HttpUtil().get<List<FoodRecord>>(
"/food/record", "/food/record",
queryParameters: {"startDate": startDate, "endDate": endDate}, queryParameters: {"startDate": startDate, "endDate": endDate},
converter: (data) => convertListResponse<FoodRecord>(data, FoodRecord.fromJson), converter:
(data) => convertListResponse<FoodRecord>(data, FoodRecord.fromJson),
); );
} }
Future<List<String>> queryCategoryApi() { Future<List<String>> queryCategoryApi() {
return HttpUtil().get<List<String>>( return HttpUtil().get<List<String>>(
"/food/category", "/food/category",
converter: converter: (data) => convertListStringResponse(data),
(data) => convertListResponse<String>(data, (json) => json.toString()),
); );
} }

View File

@@ -1,20 +1,134 @@
import 'package:flutter/material.dart'; 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/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 { class FoodProvider with ChangeNotifier {
late FoodRecord _recordFormItem; late FoodRecord recordFormItem;
late bool isEditing; late bool isEditing;
bool isLoading = false;
String? error;
FoodRecord get recordFormItem => _recordFormItem; late List<RecipeSummary> recipeSummaryList = [];
DateTime selectedDay = DateTime.now();
DateTime focusedDay = DateTime.now();
late List<FoodRecord> recordList = [];
late List<FoodRecord> selectRecordList = [];
late SummaryStats summaryStats = SummaryStats(
recipeCount: 0,
categoryCount: 0,
workCount: 0,
);
late double averageRecordCount = 0;
late List<ChartData> recordStats = [];
late List<ChartData> categoryStats = [];
late List<ChartData> rankStats = [];
void resetRecordForm() { void resetRecordForm() {
_recordFormItem = FoodRecord.getEmpty(); recordFormItem = FoodRecord.getEmpty();
notifyListeners(); notifyListeners();
} }
void initRecordForm(FoodRecord record) { void initRecordForm(FoodRecord record) {
_recordFormItem = record; recordFormItem = record;
notifyListeners(); notifyListeners();
} }
Future<void> 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<void> 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<void> 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();
}
}
} }

View File

@@ -17,9 +17,23 @@ String formatDateTime(DateTime dateTime, [String format = 'yyyy-MM-dd']) {
} }
/// 通用列表转换函数 /// 通用列表转换函数
List<T> convertListResponse<T>(dynamic data, T Function(Map<String, dynamic>) fromJson) { List<T> convertListResponse<T>(
dynamic data,
T Function(Map<String, dynamic>) fromJson,
) {
if (data is List) { if (data is List) {
return data.map((item) => fromJson(item as Map<String, dynamic>)).toList(); return data.map((item) => fromJson(item as Map<String, dynamic>)).toList();
} }
throw FormatException('Expected a list of items for conversion, but got ${data.runtimeType}'); throw FormatException(
'Expected a list of items for conversion, but got ${data.runtimeType}',
);
}
List<String> 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}',
);
} }

View File

@@ -1,8 +1,10 @@
import 'package:flutter/material.dart'; 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/common/index.dart';
import 'package:food_hub_app/widgets/recipe/calendar.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/list.dart';
import 'package:food_hub_app/widgets/recipe/timeline.dart'; import 'package:food_hub_app/widgets/recipe/timeline.dart';
import 'package:provider/provider.dart';
enum RecordTab { enum RecordTab {
recipe('菜谱'), recipe('菜谱'),
@@ -29,7 +31,7 @@ class _RecordPageState extends State<RecordPage> {
RecipeTimeline(), RecipeTimeline(),
]; ];
Widget buildTabs({ Widget _buildTabs({
required BuildContext context, required BuildContext context,
required RecordTab currentTab, required RecordTab currentTab,
required ValueChanged<RecordTab> onTabChanged, required ValueChanged<RecordTab> onTabChanged,
@@ -46,18 +48,35 @@ class _RecordPageState extends State<RecordPage> {
); );
} }
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final provider = context.watch<FoodProvider>();
return Column( return Column(
children: [ children: [
buildTabs( _buildTabs(
context: context, context: context,
currentTab: _currentTab, currentTab: _currentTab,
onTabChanged: (tab) { onTabChanged: (tab) => _onTabChange(tab, provider),
setState(() {
_currentTab = tab;
});
},
), ),
Expanded( Expanded(
child: IndexedStack(index: _currentTab.index, children: _tabPages), child: IndexedStack(index: _currentTab.index, children: _tabPages),

View File

@@ -1,7 +1,7 @@
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.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/provider/food_provider.dart';
import 'package:food_hub_app/utils/minio_utils.dart'; import 'package:food_hub_app/utils/minio_utils.dart';
import 'package:food_hub_app/widgets/common/form.dart'; import 'package:food_hub_app/widgets/common/form.dart';
@@ -20,41 +20,22 @@ class RecordFormPage extends StatefulWidget {
class _RecordFormPageState extends State<RecordFormPage> { class _RecordFormPageState extends State<RecordFormPage> {
final _formKey = GlobalKey<FormBuilderState>(); final _formKey = GlobalKey<FormBuilderState>();
final _focusNode = FocusNode();
static const String _nameField = 'name'; static const String _nameField = 'name';
static const String _dateField = 'date'; static const String _dateField = 'date';
List<String> _foodNameList = [];
@override @override
void dispose() { void initState() {
_focusNode.dispose(); super.initState();
super.dispose(); refreshCategoryList();
} }
@override Future<void> refreshCategoryList() async {
Widget build(BuildContext context) { final result = await queryFoodNameListApi();
final colors = Theme.of(context).colorScheme;
final provider = context.watch<FoodProvider>();
final String title = provider.isEditing ? '编辑记录' : '新增记录'; setState(() {
print(provider.recordFormItem.imageUrl); _foodNameList = result;
});
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()),
),
),
);
} }
Widget _buildFormBuilder() { Widget _buildFormBuilder() {
@@ -67,34 +48,9 @@ class _RecordFormPageState extends State<RecordFormPage> {
children: [ children: [
buildFormLabel('菜谱名称', required: true), buildFormLabel('菜谱名称', required: true),
const SizedBox(height: 8), const SizedBox(height: 8),
FormBuilderTextField( _buildRecipeAutocomplete(provider),
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),
const SizedBox(height: 10),
buildFormLabel('完成时间', required: true), buildFormLabel('完成时间', required: true),
const SizedBox(height: 8), const SizedBox(height: 8),
FormBuilderTextField( FormBuilderTextField(
@@ -130,8 +86,7 @@ class _RecordFormPageState extends State<RecordFormPage> {
else else
buildImagePreviewItem( buildImagePreviewItem(
context: context, context: context,
imageUrl: imageUrl: provider.recordFormItem.imageUrl,
'${AppConfig.imageBaseUrl}${provider.recordFormItem.imageUrl}',
onRemoveImage: () => _removeImage(provider), onRemoveImage: () => _removeImage(provider),
), ),
], ],
@@ -147,6 +102,126 @@ class _RecordFormPageState extends State<RecordFormPage> {
); );
} }
Widget _buildRecipeAutocomplete(FoodProvider provider) {
return Autocomplete<String>(
initialValue: TextEditingValue(text: provider.recordFormItem.name),
optionsBuilder: (TextEditingValue textEditingValue) {
if (textEditingValue.text.isEmpty || _foodNameList.isEmpty) {
return const Iterable<String>.empty();
}
return _foodNameList.where(
(option) => option.toLowerCase().contains(
textEditingValue.text.toLowerCase(),
),
);
},
onSelected: (String value) {
setState(() {
provider.recordFormItem.name = value;
});
},
optionsViewBuilder: (
BuildContext context,
AutocompleteOnSelected<String> onSelected,
Iterable<String> 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<void> _onSelectDate(FoodProvider provider) async { Future<void> _onSelectDate(FoodProvider provider) async {
final DateTime? picked = await showDatePicker( final DateTime? picked = await showDatePicker(
@@ -202,4 +277,30 @@ class _RecordFormPageState extends State<RecordFormPage> {
print(provider.recordFormItem.imageUrl); print(provider.recordFormItem.imageUrl);
} }
} }
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final provider = context.watch<FoodProvider>();
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()),
),
),
);
}
} }

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:food_hub_app/apis/stats.dart'; import 'package:food_hub_app/provider/food_provider.dart';
import 'package:food_hub_app/models/stats.dart';
import 'package:food_hub_app/widgets/common/chart.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/common/index.dart';
import 'package:food_hub_app/widgets/stats/card.dart'; import 'package:food_hub_app/widgets/stats/card.dart';
import 'package:provider/provider.dart';
class StatsPage extends StatefulWidget { class StatsPage extends StatefulWidget {
const StatsPage({super.key}); const StatsPage({super.key});
@@ -15,70 +15,17 @@ class StatsPage extends StatefulWidget {
class _StatsPage extends State<StatsPage> { class _StatsPage extends State<StatsPage> {
final double chartHeight = 400; final double chartHeight = 400;
SummaryStats summaryStats = SummaryStats(
recipeCount: 0,
categoryCount: 0,
workCount: 0,
);
late double averageRecordCount = 0;
List<ChartData> recordStats = [];
List<ChartData> categoryStats = [];
List<ChartData> rankStats = [];
@override @override
void initState() { void initState() {
super.initState(); super.initState();
refreshStats();
}
Future<void> refreshStats() async { // 初始化加载数据
final result1 = await queryStatsApi(); WidgetsBinding.instance.addPostFrameCallback((_) {
final result2 = await queryRecordStatsApi(); context.read<FoodProvider>().refreshBlogStats();
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;
}
}); });
} }
@override Widget _buildSummaryStats(FoodProvider provider) {
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() {
return GridView.count( return GridView.count(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
@@ -92,40 +39,40 @@ class _StatsPage extends State<StatsPage> {
icon: Icons.restaurant_menu, icon: Icons.restaurant_menu,
color: Colors.blue, color: Colors.blue,
title: '菜谱总数', title: '菜谱总数',
value: summaryStats.recipeCount, value: provider.summaryStats.recipeCount,
unit: '', unit: '',
), ),
StatisticCard( StatisticCard(
icon: Icons.grid_view_rounded, icon: Icons.grid_view_rounded,
color: Colors.green, color: Colors.green,
title: '菜谱类别', title: '菜谱类别',
value: summaryStats.categoryCount, value: provider.summaryStats.categoryCount,
unit: '', unit: '',
), ),
StatisticCard( StatisticCard(
icon: Icons.flag, icon: Icons.flag,
color: Colors.orange, color: Colors.orange,
title: '做菜次数', title: '做菜次数',
value: summaryStats.workCount, value: provider.summaryStats.workCount,
unit: '', unit: '',
), ),
StatisticCard( StatisticCard(
icon: Icons.show_chart, icon: Icons.show_chart,
color: Colors.red, color: Colors.red,
title: '平均次数', title: '平均次数',
value: double.parse(averageRecordCount.toStringAsFixed(2)), value: double.parse(provider.averageRecordCount.toStringAsFixed(2)),
unit: '次/月', unit: '次/月',
), ),
], ],
); );
} }
Widget _buildRecordStats() { Widget _buildRecordStats(FoodProvider provider) {
return Column( return Column(
children: [ children: [
_buildTitleSection('记录统计'), buildChartTitle(context: context, title: '记录统计'),
const SizedBox(height: 3), const SizedBox(height: 3),
_buildDivider(), buildChartDivider(context: context),
const SizedBox(height: 3), const SizedBox(height: 3),
Expanded( Expanded(
child: lineChart( child: lineChart(
@@ -133,56 +80,80 @@ class _StatsPage extends State<StatsPage> {
xAxisName: '日期', xAxisName: '日期',
yAxisName: '次数', yAxisName: '次数',
unit: '', unit: '',
data: recordStats, data: provider.recordStats,
), ),
), ),
], ],
); );
} }
Widget _buildCategoryStats() { Widget _buildCategoryStats(FoodProvider provider) {
return Column( return Column(
children: [ children: [
_buildTitleSection('菜谱统计'), buildChartTitle(context: context, title: '菜谱统计'),
const SizedBox(height: 3), const SizedBox(height: 3),
_buildDivider(), buildChartDivider(context: context),
const SizedBox(height: 3), const SizedBox(height: 3),
Expanded( Expanded(
child: pieChart(context: context, unit: '', data: categoryStats), child: pieChart(
context: context,
unit: '',
data: provider.categoryStats,
),
), ),
], ],
); );
} }
Widget _buildRankStats() { Widget _buildRankStats(FoodProvider provider) {
return Column( return Column(
children: [ children: [
_buildTitleSection('排行榜'), buildChartTitle(context: context, title: '排行榜'),
const SizedBox(height: 3), const SizedBox(height: 3),
_buildDivider(), buildChartDivider(context: context),
const SizedBox(height: 3), const SizedBox(height: 3),
Expanded(child: rankChart(rankStats)), Expanded(child: rankChart(data: provider.rankStats, unit: '')),
], ],
); );
} }
// 构建标题区域 Widget _buildContent(FoodProvider provider) {
Widget _buildTitleSection(String title) { return Column(
return Row(
children: [ children: [
Icon(Icons.insert_chart, color: Theme.of(context).primaryColor), _buildSummaryStats(provider),
Text(title), 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() { @override
return Divider( Widget build(BuildContext context) {
height: 1, final provider = context.watch<FoodProvider>();
thickness: 1,
color: Theme.of(context).primaryColor, return Stack(
indent: 0, children: [
endIndent: 0, if (provider.isLoading)
buildLoadingIndicator(context)
else
SingleChildScrollView(child: _buildContent(provider)),
],
); );
} }
} }

View File

@@ -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<ChartData> 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)),
),
],
),
);
},
);
}

View File

@@ -1,6 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; 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.dart';
import 'package:photo_view/photo_view_gallery.dart'; import 'package:photo_view/photo_view_gallery.dart';
@@ -113,10 +114,13 @@ Widget buildNetworkImage(BuildContext context, String url) {
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: GestureDetector( child: GestureDetector(
onTap: () { onTap: () {
showFullScreenImage(context, NetworkImage(url)); showFullScreenImage(
context,
NetworkImage('${AppConfig.imageBaseUrl}$url'),
);
}, },
child: Image.network( child: Image.network(
url, '${AppConfig.imageBaseUrl}$url',
fit: BoxFit.cover, fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) { loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child; if (loadingProgress == null) return child;

View File

@@ -29,7 +29,9 @@ InputDecoration formInputDecoration({
} }
Widget buildCard({required BuildContext context, required Widget child}) { Widget buildCard({required BuildContext context, required Widget child}) {
final colors = Theme.of(context).colorScheme; final colors = Theme
.of(context)
.colorScheme;
return Card( return Card(
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
@@ -64,7 +66,11 @@ Widget buildToggleSwitch<T extends Enum>({
initialLabelIndex: tabValues.indexOf(currentTab), initialLabelIndex: tabValues.indexOf(currentTab),
totalSwitches: tabValues.length, totalSwitches: tabValues.length,
labels: labels, labels: labels,
activeBgColor: [Theme.of(context).colorScheme.primary], activeBgColor: [Theme
.of(context)
.colorScheme
.primary
],
activeFgColor: Colors.white, activeFgColor: Colors.white,
inactiveBgColor: Colors.grey.shade200, inactiveBgColor: Colors.grey.shade200,
inactiveFgColor: Colors.grey.shade700, inactiveFgColor: Colors.grey.shade700,
@@ -93,7 +99,10 @@ Widget circleIconButton({
fixedSize: Size(size, size), fixedSize: Size(size, size),
shape: CircleBorder(), shape: CircleBorder(),
elevation: 0, elevation: 0,
backgroundColor: Theme.of(context).colorScheme.primary, backgroundColor: Theme
.of(context)
.colorScheme
.primary,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
minimumSize: const Size(0, 0), minimumSize: const Size(0, 0),
), ),
@@ -102,7 +111,9 @@ Widget circleIconButton({
} }
Widget buildTag(BuildContext context, String title) { Widget buildTag(BuildContext context, String title) {
final colors = Theme.of(context).colorScheme; final colors = Theme
.of(context)
.colorScheme;
return Container( return Container(
padding: EdgeInsets.symmetric(horizontal: 10, vertical: 4), padding: EdgeInsets.symmetric(horizontal: 10, vertical: 4),
@@ -173,3 +184,41 @@ void showErrorToast(String message) {
fontSize: 16.0, 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<Color>(colors.primary),
),
SizedBox(height: 12),
Text(
'加载中',
style: TextStyle(
fontSize: 14,
color: colors.onSurface,
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}

View File

@@ -100,9 +100,7 @@ class MomentCard extends StatelessWidget {
// 生成图片URL列表用于预览时切换 // 生成图片URL列表用于预览时切换
final List<String> imageUrls = final List<String> imageUrls =
moment.imageList moment.imageList.map((path) => path).toList();
.map((path) => '${AppConfig.imageBaseUrl}$path')
.toList();
void imageTapClick(int index) { void imageTapClick(int index) {
Navigator.push( Navigator.push(

View File

@@ -1,8 +1,6 @@
import 'package:flutter/material.dart'; 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/models/recipe.dart';
import 'package:food_hub_app/provider/food_provider.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/utils/index.dart';
import 'package:food_hub_app/widgets/common/index.dart'; import 'package:food_hub_app/widgets/common/index.dart';
import 'package:table_calendar/table_calendar.dart'; import 'package:table_calendar/table_calendar.dart';
@@ -16,46 +14,7 @@ class RecipeCalendar extends StatefulWidget {
} }
class _RecipeCalendarState extends State<RecipeCalendar> { class _RecipeCalendarState extends State<RecipeCalendar> {
DateTime _selectedDay = DateTime.now(); TableCalendar _recipeCalendar(FoodProvider provider) {
DateTime _focusedDay = DateTime.now();
List<FoodRecord> recordList = [];
List<FoodRecord> selectRecordList = [];
@override
void initState() {
super.initState();
_refreshRecord();
}
Future<void> _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() {
return TableCalendar( return TableCalendar(
locale: 'zh_CN', locale: 'zh_CN',
headerStyle: const HeaderStyle( headerStyle: const HeaderStyle(
@@ -64,47 +23,53 @@ class _RecipeCalendarState extends State<RecipeCalendar> {
), ),
firstDay: DateTime.utc(2010, 1, 1), firstDay: DateTime.utc(2010, 1, 1),
lastDay: DateTime.utc(2100, 12, 31), lastDay: DateTime.utc(2100, 12, 31),
focusedDay: _focusedDay, focusedDay: provider.focusedDay,
selectedDayPredicate: (day) => isSameDay(_selectedDay, day), selectedDayPredicate: (day) => isSameDay(provider.selectedDay, day),
onDaySelected: (selectedDay, focusedDay) { onDaySelected: (selectedDay, focusedDay) {
if (!isSameDay(_selectedDay, selectedDay)) { if (!isSameDay(provider.selectedDay, selectedDay)) {
setState(() { setState(() {
_selectedDay = selectedDay; provider.selectedDay = selectedDay;
_focusedDay = focusedDay; provider.focusedDay = focusedDay;
_refreshSelectRecord(); provider.refreshSelectRecord();
}); });
} }
}, },
onPageChanged: (focusedDay) { onPageChanged: (focusedDay) {
_focusedDay = focusedDay; provider.focusedDay = focusedDay;
_refreshRecord(); provider.refreshRecordList(null, null);
}, },
// 自定义日期单元格构建器 // 自定义日期单元格构建器
calendarBuilders: CalendarBuilders( calendarBuilders: CalendarBuilders(
defaultBuilder: defaultBuilder:
(context, day, focusedDay) => _buildDateWidget(day, false, false), (context, day, focusedDay) =>
_buildDateItem(day, false, false, provider),
selectedBuilder: selectedBuilder:
(context, day, focusedDay) => _buildDateWidget(day, true, false), (context, day, focusedDay) =>
_buildDateItem(day, true, false, provider),
todayBuilder: 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( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(formatDateTime(_selectedDay, 'MM月dd日 EEEE')), Text(formatDateTime(provider.selectedDay, 'MM月dd日 EEEE')),
if (selectRecordList.isEmpty) if (provider.selectRecordList.isEmpty)
buildEmptyData() buildEmptyData()
else else
ListView.builder( ListView.builder(
shrinkWrap: true, shrinkWrap: true,
physics: NeverScrollableScrollPhysics(), physics: NeverScrollableScrollPhysics(),
itemCount: selectRecordList.length, itemCount: provider.selectRecordList.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
return _buildRecordItem(context, selectRecordList[index]); return _buildRecordItem(
context,
provider.selectRecordList[index],
);
}, },
), ),
], ],
@@ -170,9 +135,14 @@ class _RecipeCalendarState extends State<RecipeCalendar> {
); );
} }
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), (item) => item.date == formatDateTime(day),
); );
@@ -214,15 +184,31 @@ class _RecipeCalendarState extends State<RecipeCalendar> {
); );
} }
@override Widget _buildContent(FoodProvider provider) {
Widget build(BuildContext context) {
return Column( return Column(
children: [ children: [
buildCard(context: context, child: _recipeCalendar()), buildCard(context: context, child: _recipeCalendar(provider)),
Expanded( 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<FoodProvider>();
return Stack(
children: [
if (provider.isLoading)
buildLoadingIndicator(context)
else
_buildContent(provider),
],
);
}
} }

View File

@@ -1,6 +1,4 @@
import 'package:flutter/material.dart'; 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/models/recipe.dart';
import 'package:food_hub_app/widgets/common/image.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/index.dart';
@@ -86,10 +84,7 @@ class RecipeCard extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
buildNetworkImage( buildNetworkImage(context, firstImageUrl),
context,
'${AppConfig.imageBaseUrl}/$firstImageUrl',
),
SizedBox(height: 8), SizedBox(height: 8),
GestureDetector( GestureDetector(
onTap: () => navigatorToRecipeDetail(context), onTap: () => navigatorToRecipeDetail(context),

View File

@@ -1,8 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:food_hub_app/apis/recipe.dart'; import 'package:food_hub_app/provider/food_provider.dart';
import 'package:food_hub_app/models/recipe.dart';
import 'package:food_hub_app/widgets/common/index.dart'; import 'package:food_hub_app/widgets/common/index.dart';
import 'package:food_hub_app/widgets/recipe/card.dart'; import 'package:food_hub_app/widgets/recipe/card.dart';
import 'package:provider/provider.dart';
class RecipeList extends StatefulWidget { class RecipeList extends StatefulWidget {
const RecipeList({super.key}); const RecipeList({super.key});
@@ -12,33 +12,40 @@ class RecipeList extends StatefulWidget {
} }
class _RecipeListState extends State<RecipeList> { class _RecipeListState extends State<RecipeList> {
List<RecipeSummary> recipeSummaryList = [];
@override @override
void initState() { void initState() {
super.initState(); super.initState();
refreshRecipeList();
// 初始化加载数据
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<FoodProvider>().refreshRecipeList();
});
} }
Future<void> refreshRecipeList() async { Widget _buildContent(FoodProvider provider) {
final result = await queryRecipeApi(RecipeQuery(category: "")); if (provider.recipeSummaryList.isEmpty) {
return buildEmptyData();
setState(() { } else {
recipeSummaryList = result; return ListView.builder(
}); itemCount: provider.recipeSummaryList.length,
itemBuilder: (context, index) {
return RecipeCard(recipe: provider.recipeSummaryList[index]);
},
);
}
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (recipeSummaryList.isEmpty) { final provider = context.watch<FoodProvider>();
return buildEmptyData();
} else { return Stack(
return ListView.builder( children: [
itemCount: recipeSummaryList.length, if (provider.isLoading)
itemBuilder: (context, index) { buildLoadingIndicator(context)
return RecipeCard(recipe: recipeSummaryList[index]); else
} _buildContent(provider),
); ],
} );
} }
} }

View File

@@ -1,11 +1,11 @@
import 'package:flutter/material.dart'; 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/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/image.dart';
import 'package:food_hub_app/widgets/common/index.dart'; import 'package:food_hub_app/widgets/common/index.dart';
import 'package:food_hub_app/widgets/common/year_selector.dart'; import 'package:food_hub_app/widgets/common/year_selector.dart';
import 'package:timelines_plus/timelines_plus.dart'; import 'package:timelines_plus/timelines_plus.dart';
import 'package:provider/provider.dart';
class RecipeTimeline extends StatefulWidget { class RecipeTimeline extends StatefulWidget {
const RecipeTimeline({super.key}); const RecipeTimeline({super.key});
@@ -15,24 +15,7 @@ class RecipeTimeline extends StatefulWidget {
} }
class _RecipeTimeline extends State<RecipeTimeline> { class _RecipeTimeline extends State<RecipeTimeline> {
List<FoodRecord> recordList = [];
@override
void initState() {
super.initState();
refreshRecord(DateTime.now().year);
}
Future<void> refreshRecord(int year) async {
final result = await queryRecordApi("$year-01-01", "$year-12-31");
setState(() {
recordList = result;
});
}
Widget buildTimelineCard(BuildContext context, FoodRecord record) { Widget buildTimelineCard(BuildContext context, FoodRecord record) {
final imageUrl = '${AppConfig.imageBaseUrl}${record.imageUrl}';
return buildCard( return buildCard(
context: context, context: context,
child: Column( child: Column(
@@ -58,7 +41,7 @@ class _RecipeTimeline extends State<RecipeTimeline> {
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 5), const SizedBox(height: 5),
buildNetworkImage(context, imageUrl), buildNetworkImage(context, record.imageUrl),
], ],
), ),
], ],
@@ -93,8 +76,7 @@ class _RecipeTimeline extends State<RecipeTimeline> {
} }
} }
@override Widget _buildContent(FoodProvider provider) {
Widget build(BuildContext context) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -102,9 +84,25 @@ class _RecipeTimeline extends State<RecipeTimeline> {
initialYear: DateTime.now().year, initialYear: DateTime.now().year,
minYear: 2000, minYear: 2000,
maxYear: 2100, 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<FoodProvider>();
return Stack(
children: [
if (provider.isLoading)
buildLoadingIndicator(context)
else
_buildContent(provider),
], ],
); );
} }

View File

@@ -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<ChartData> 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)),
),
],
),
);
},
);
}