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

@@ -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<RecordPage> {
RecipeTimeline(),
];
Widget buildTabs({
Widget _buildTabs({
required BuildContext context,
required RecordTab currentTab,
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
Widget build(BuildContext context) {
final provider = context.watch<FoodProvider>();
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),

View File

@@ -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<RecordFormPage> {
final _formKey = GlobalKey<FormBuilderState>();
final _focusNode = FocusNode();
static const String _nameField = 'name';
static const String _dateField = 'date';
List<String> _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<FoodProvider>();
Future<void> 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<RecordFormPage> {
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<RecordFormPage> {
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<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 {
final DateTime? picked = await showDatePicker(
@@ -202,4 +277,30 @@ class _RecordFormPageState extends State<RecordFormPage> {
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: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<StatsPage> {
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
void initState() {
super.initState();
refreshStats();
}
Future<void> 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<FoodProvider>().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<StatsPage> {
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<StatsPage> {
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<FoodProvider>();
return Stack(
children: [
if (provider.isLoading)
buildLoadingIndicator(context)
else
SingleChildScrollView(child: _buildContent(provider)),
],
);
}
}