feat:增加加载中组件
This commit is contained in:
@@ -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()),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -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> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
void _onTabChange(RecordTab tab, FoodProvider provider) {
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
buildTabs(
|
|
||||||
context: context,
|
|
||||||
currentTab: _currentTab,
|
|
||||||
onTabChanged: (tab) {
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentTab = tab;
|
_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(
|
||||||
|
context: context,
|
||||||
|
currentTab: _currentTab,
|
||||||
|
onTabChanged: (tab) => _onTabChange(tab, provider),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: IndexedStack(index: _currentTab.index, children: _tabPages),
|
child: IndexedStack(index: _currentTab.index, children: _tabPages),
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user