Compare commits

...

10 Commits

Author SHA1 Message Date
6bba9a488c feat:更新api模块 2025-11-17 22:36:20 +08:00
504ee073ce feat:新增菜谱详细信息模块 2025-07-31 22:55:39 +08:00
b5dd0ab749 feat:增加食谱详细信息模块 2025-07-28 22:57:45 +08:00
8923d9e63a feat:更新统计模块 2025-07-28 16:22:52 +08:00
34780651a8 feat:更新图表模块 2025-07-27 22:28:04 +08:00
a2ed0f3d98 feat:增加轮播图组件 2025-07-24 23:00:37 +08:00
502bb9df52 feat:增加回到顶部功能 2025-07-24 16:50:50 +08:00
7da3ec9507 feat:更新朋友圈模块 2025-07-23 23:00:30 +08:00
d9f1781277 feat:增加朋友圈组件 2025-07-23 20:03:03 +08:00
5d156d3a02 feat:更新记录UI布局 2025-07-22 22:48:49 +08:00
52 changed files with 2935 additions and 4001 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -1,108 +0,0 @@
import 'package:food_hub_app/models/recipe.dart';
import 'package:food_hub_app/utils/http_util.dart';
import 'package:food_hub_app/utils/index.dart';
Future<Recipe> queryRecipeByIdApi(int id) {
return HttpUtil().get<Recipe>(
"/food-service/food/recipe/$id",
converter: (data) => Recipe.fromJson(data),
);
}
Future<bool> addRecipeApi(Recipe recipe) {
return HttpUtil().post<bool>("/food-service/food/food/recipe", data: recipe);
}
Future<bool> updateRecipeApi(int id, Recipe recipe) {
return HttpUtil().put<bool>(
"/food-service/food/food/recipe/$id",
data: recipe,
);
}
Future<bool> deleteRecipeApi(int id) {
return HttpUtil().delete<bool>("/food-service/food/food/recipe/$id");
}
Future<List<Recipe>> queryRecipeByUserApi(int id) {
return HttpUtil().get<List<Recipe>>(
"/food-service/food/recipe/user/$id",
converter: (data) => convertListResponse<Recipe>(data, Recipe.fromJson),
);
}
Future<List<Recipe>> queryRecipeUserFavouriteApi() {
return HttpUtil().get<List<Recipe>>(
"/food-service/food/recipe/user/favourite",
converter: (data) => convertListResponse<Recipe>(data, Recipe.fromJson),
);
}
Future<List<Recipe>> queryRecipeApi(RecipeQuery recipeQuery) {
return HttpUtil().get<List<Recipe>>(
"/food-service/food/recipe",
queryParameters: recipeQuery.toJson(),
converter: (data) => convertListResponse<Recipe>(data, Recipe.fromJson),
);
}
Future<List<String>> queryFoodNameListApi() {
return HttpUtil().get<List<String>>(
"/food-service/food/recipe/name",
converter:
(data) => convertListResponse<String>(data, (json) => json.toString()),
);
}
Future<bool> addRecordApi(Record record) {
return HttpUtil().post<bool>("/food-service/food/record", data: record);
}
Future<bool> updateRecordApi(int id, Record record) {
return HttpUtil().put<bool>("/food-service/food/record/$id", data: record);
}
Future<bool> deleteRecordApi(int id) {
return HttpUtil().delete<bool>("/food-service/food/record/$id");
}
Future<bool> addRecipeCommentApi(int id, String content) {
return HttpUtil().post<bool>(
"/food-service/food/recipe/$id/comment",
data: content,
);
}
Future<bool> addRecipeLikeApi(int id) {
return HttpUtil().post<bool>("/food-service/food/recipe/$id/like");
}
Future<bool> deleteRecipeLikeApi(int id) {
return HttpUtil().delete<bool>("/food-service/food/recipe/$id/like");
}
Future<bool> addRecipeFavouriteApi(int id) {
return HttpUtil().post<bool>("/food-service/food/recipe/$id/favourite");
}
Future<bool> deleteRecipeFavouriteApi(int id) {
return HttpUtil().delete<bool>("/food-service/food/recipe/$id/like");
}
Future<List<Record>> queryRecordApi(String startDate, String endDate) {
return HttpUtil().get<List<Record>>(
"/food-service/food/record",
queryParameters: {
"startDate": startDate,
"endDate": endDate
},
converter: (data) => convertListResponse<Record>(data, Record.fromJson),
);
}
Future<List<String>> queryCategoryApi() {
return HttpUtil().get<List<String>>(
"/food-service/food/category",
converter: (data) => convertListResponse<String>(data, (json) => json.toString()),
);
}

42
lib/apis/moment.dart Normal file
View File

@@ -0,0 +1,42 @@
import 'package:food_hub_app/models/moment.dart';
import 'package:food_hub_app/utils/http_util.dart';
import 'package:food_hub_app/utils/index.dart';
Future<bool> addMomentApi(Moment moment) {
return HttpUtil().post<bool>("/moment", data: moment);
}
Future<bool> updateMomentApi(int id, Moment moment) {
return HttpUtil().put<bool>("/moment/$id", data: moment);
}
Future<bool> deleteMomentApi(int id) {
return HttpUtil().delete<bool>("/moment/$id");
}
Future<List<Moment>> queryMomentListApi() {
return HttpUtil().get<List<Moment>>(
"/moment",
converter: (data) => convertListResponse<Moment>(data, Moment.fromJson),
);
}
Future<PageMoment> queryMomentByPageApi(int currentPage, int pageSize) {
return HttpUtil().get<PageMoment>(
"/moment/page",
queryParameters: {"currentPage": currentPage, "pageSize": pageSize},
converter: (data) => PageMoment.fromJson(data),
);
}
Future<bool> addMomentCommentApi(int id, String content) {
return HttpUtil().post<bool>("/moment/$id/comment", data: content);
}
Future<bool> addMomentLikeApi(int id) {
return HttpUtil().post<bool>("/food/moment/$id/like");
}
Future<bool> deleteMomentLikeApi(int id) {
return HttpUtil().delete<bool>("/food/moment/$id/like");
}

100
lib/apis/recipe.dart Normal file
View File

@@ -0,0 +1,100 @@
import 'package:food_hub_app/models/recipe.dart';
import 'package:food_hub_app/utils/http_util.dart';
import 'package:food_hub_app/utils/index.dart';
Future<RecipeDetail> queryRecipeByIdApi(int id) {
return HttpUtil().get<RecipeDetail>(
"/food/recipe/$id",
converter: (data) => RecipeDetail.fromJson(data),
);
}
Future<bool> addRecipeApi(Recipe recipe) {
return HttpUtil().post<bool>("/food/recipe", data: recipe);
}
Future<bool> updateRecipeApi(int id, Recipe recipe) {
return HttpUtil().put<bool>("/food/recipe/$id", data: recipe);
}
Future<bool> deleteRecipeApi(int id) {
return HttpUtil().delete<bool>("/food/recipe/$id");
}
Future<List<Recipe>> queryRecipeByUserApi(int id) {
return HttpUtil().get<List<Recipe>>(
"/food/recipe/user/$id",
converter: (data) => convertListResponse<Recipe>(data, Recipe.fromJson),
);
}
Future<List<Recipe>> queryRecipeUserFavouriteApi() {
return HttpUtil().get<List<Recipe>>(
"/food/recipe/user/favourite",
converter: (data) => convertListResponse<Recipe>(data, Recipe.fromJson),
);
}
Future<List<RecipeSummary>> queryRecipeApi(RecipeQuery recipeQuery) {
return HttpUtil().get<List<RecipeSummary>>(
"/food/recipe",
queryParameters: recipeQuery.toJson(),
converter: (data) => convertListResponse<RecipeSummary>(data, RecipeSummary.fromJson),
);
}
Future<List<String>> queryFoodNameListApi() {
return HttpUtil().get<List<String>>(
"/food/recipe/name",
converter:
(data) => convertListResponse<String>(data, (json) => json.toString()),
);
}
Future<bool> addRecordApi(Record record) {
return HttpUtil().post<bool>("/food/record", data: record);
}
Future<bool> updateRecordApi(int id, Record record) {
return HttpUtil().put<bool>("/food/record/$id", data: record);
}
Future<bool> deleteRecordApi(int id) {
return HttpUtil().delete<bool>("/food/record/$id");
}
Future<bool> addRecipeCommentApi(int id, String content) {
return HttpUtil().post<bool>("/food/recipe/$id/comment", data: content);
}
Future<bool> addRecipeLikeApi(int id) {
return HttpUtil().post<bool>("/food/recipe/$id/like");
}
Future<bool> deleteRecipeLikeApi(int id) {
return HttpUtil().delete<bool>("/food/recipe/$id/like");
}
Future<bool> addRecipeFavouriteApi(int id) {
return HttpUtil().post<bool>("/food/recipe/$id/favourite");
}
Future<bool> deleteRecipeFavouriteApi(int id) {
return HttpUtil().delete<bool>("/food/recipe/$id/like");
}
Future<List<Record>> queryRecordApi(String startDate, String endDate) {
return HttpUtil().get<List<Record>>(
"/food/record",
queryParameters: {"startDate": startDate, "endDate": endDate},
converter: (data) => convertListResponse<Record>(data, Record.fromJson),
);
}
Future<List<String>> queryCategoryApi() {
return HttpUtil().get<List<String>>(
"/food/category",
converter:
(data) => convertListResponse<String>(data, (json) => json.toString()),
);
}

View File

@@ -3,7 +3,7 @@ import 'package:food_hub_app/utils/http_util.dart';
Future<Session> loginApi(String username, String password) { Future<Session> loginApi(String username, String password) {
return HttpUtil().post<Session>( return HttpUtil().post<Session>(
"/food-service/session", "/session",
queryParameters: {"username": username, "password": password}, queryParameters: {"username": username, "password": password},
converter: (data) => Session.fromJson(data), converter: (data) => Session.fromJson(data),
); );

31
lib/apis/stats.dart Normal file
View File

@@ -0,0 +1,31 @@
import 'package:food_hub_app/models/stats.dart';
import 'package:food_hub_app/utils/http_util.dart';
import 'package:food_hub_app/utils/index.dart';
Future<SummaryStats> queryStatsApi() {
return HttpUtil().get<SummaryStats>(
"/food/stats",
converter: (data) => SummaryStats.fromJson(data),
);
}
Future<List<ChartData>> queryRecordStatsApi() {
return HttpUtil().get<List<ChartData>>(
"/food/stats/record",
converter: (data) => convertListResponse<ChartData>(data, ChartData.fromJson),
);
}
Future<List<ChartData>> queryCategoryStatsApi() {
return HttpUtil().get<List<ChartData>>(
"/food/stats/category",
converter: (data) => convertListResponse<ChartData>(data, ChartData.fromJson),
);
}
Future<List<ChartData>> queryRankStatsApi() {
return HttpUtil().get<List<ChartData>>(
"/food/stats/rank",
converter: (data) => convertListResponse<ChartData>(data, ChartData.fromJson),
);
}

View File

@@ -0,0 +1,9 @@
/// 应用信息
class AppConfig {
// 网络配置
// http://192.168.1.3:8100
// http://14.103.235.151:81/food-service
// static const String baseApiUrl = "http://14.103.235.151:81/food-service";
// static const String baseApiUrl = "http://192.168.1.3:8100";
static const String baseApiUrl = "https://cxx0822.iepose.cn/food-api";
}

View File

@@ -2,42 +2,30 @@ import 'package:flutter/material.dart';
class NavBar extends StatelessWidget { class NavBar extends StatelessWidget {
final int currentIndex; final int currentIndex;
final List<BottomNavigationBarItem> navItems;
final Function(int) onTap; final Function(int) onTap;
static const List<BottomNavigationBarItem> navItems = [ const NavBar({
BottomNavigationBarItem( super.key,
icon: Icon(Icons.home_outlined), required this.currentIndex,
activeIcon: Icon(Icons.home), required this.onTap,
label: "记录", required this.navItems,
), });
BottomNavigationBarItem(
icon: Icon(Icons.pie_chart_outline),
activeIcon: Icon(Icons.pie_chart),
label: "统计",
),
BottomNavigationBarItem(
icon: Icon(Icons.group_outlined),
activeIcon: Icon(Icons.group),
label: "朋友圈",
),
BottomNavigationBarItem(
icon: Icon(Icons.account_circle_outlined),
activeIcon: Icon(Icons.account_circle),
label: "我的",
),
];
const NavBar({super.key, required this.currentIndex, required this.onTap});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BottomNavigationBar( return Container(
decoration: BoxDecoration(
border: Border(top: BorderSide(color: Theme.of(context).primaryColor)),
),
child: BottomNavigationBar(
currentIndex: currentIndex, currentIndex: currentIndex,
iconSize: 25, iconSize: 25,
type: BottomNavigationBarType.fixed, type: BottomNavigationBarType.fixed,
backgroundColor: Colors.white, backgroundColor: Colors.white,
items: navItems, items: navItems,
onTap: onTap, onTap: onTap,
),
); );
} }
} }
@@ -110,7 +98,7 @@ class SettingsDrawer extends StatelessWidget {
} }
} }
List<StatelessWidget> homeActions() { List<StatelessWidget> homeActions(BuildContext context) {
return [ return [
IconButton( IconButton(
icon: Icon(Icons.search, color: Colors.white), icon: Icon(Icons.search, color: Colors.white),
@@ -118,15 +106,11 @@ List<StatelessWidget> homeActions() {
// 搜索功能 // 搜索功能
}, },
), ),
Builder( // IconButton(
builder: (BuildContext context) { // icon: Icon(Icons.add, color: Colors.white),
return IconButton( // onPressed: () {
icon: Icon(Icons.settings, color: Colors.white), // Navigator.pushNamed(context, '/recordForm');
onPressed: () { // },
Scaffold.of(context).openEndDrawer(); // ),
},
);
},
),
]; ];
} }

View File

@@ -3,7 +3,8 @@ import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:food_hub_app/utils/sp_util.dart'; import 'package:food_hub_app/utils/sp_util.dart';
import 'package:food_hub_app/views/home.dart'; import 'package:food_hub_app/views/home.dart';
import 'package:food_hub_app/views/login.dart'; import 'package:food_hub_app/views/login.dart';
import 'package:food_hub_app/views/recordForm.dart'; import 'package:food_hub_app/views/record_form.dart';
import 'package:food_hub_app/views/recipe_detail.dart';
import 'package:form_builder_validators/form_builder_validators.dart'; import 'package:form_builder_validators/form_builder_validators.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -46,6 +47,7 @@ class MyApp extends StatelessWidget {
routes: { routes: {
'/home': (context) => HomePage(), '/home': (context) => HomePage(),
'/recordForm': (context) => RecordFormPage(), '/recordForm': (context) => RecordFormPage(),
'/recipeDetail': (context) => RecipeDetailPage(),
}, },
); );
} }

16
lib/models/layout.dart Normal file
View File

@@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
/// 导航栏
class NavItem {
final String label;
final IconData icon;
final IconData activeIcon;
final Widget page;
const NavItem({
required this.label,
required this.icon,
required this.activeIcon,
required this.page,
});
}

74
lib/models/moment.dart Normal file
View File

@@ -0,0 +1,74 @@
import 'package:json_annotation/json_annotation.dart';
part 'moment.g.dart';
@JsonSerializable()
class Comment {
final int id;
final String username;
final String avatar;
final String content;
final String date;
const Comment({
required this.id,
required this.username,
required this.avatar,
required this.content,
required this.date,
});
factory Comment.fromJson(Map<String, dynamic> json) => _$CommentFromJson(json);
Map<String, dynamic> toJson() => _$CommentToJson(this);
}
@JsonSerializable()
class Moment {
final int? id;
final int? userId;
final String? username;
final String? avatar;
final String content;
final List<String> imageList;
final String? date;
final List<int>? likeList;
final List<Comment>? commentList;
Moment({
this.id,
this.userId,
this.username,
this.avatar,
required this.content,
required this.imageList,
this.date,
this.likeList,
this.commentList,
});
factory Moment.fromJson(Map<String, dynamic> json) => _$MomentFromJson(json);
Map<String, dynamic> toJson() => _$MomentToJson(this);
}
@JsonSerializable()
class PageMoment {
final List<Moment> records;
final int total;
final int size;
final int current;
final int pages;
PageMoment({
required this.records,
required this.total,
required this.size,
required this.current,
required this.pages,
});
factory PageMoment.fromJson(Map<String, dynamic> json) => _$PageMomentFromJson(json);
Map<String, dynamic> toJson() => _$PageMomentToJson(this);
}

74
lib/models/moment.g.dart Normal file
View File

@@ -0,0 +1,74 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'moment.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
Comment _$CommentFromJson(Map<String, dynamic> json) => Comment(
id: (json['id'] as num).toInt(),
username: json['username'] as String,
avatar: json['avatar'] as String,
content: json['content'] as String,
date: json['date'] as String,
);
Map<String, dynamic> _$CommentToJson(Comment instance) => <String, dynamic>{
'id': instance.id,
'username': instance.username,
'avatar': instance.avatar,
'content': instance.content,
'date': instance.date,
};
Moment _$MomentFromJson(Map<String, dynamic> json) => Moment(
id: (json['id'] as num?)?.toInt(),
userId: (json['userId'] as num?)?.toInt(),
username: json['username'] as String?,
avatar: json['avatar'] as String?,
content: json['content'] as String,
imageList:
(json['imageList'] as List<dynamic>).map((e) => e as String).toList(),
date: json['date'] as String?,
likeList:
(json['likeList'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList(),
commentList:
(json['commentList'] as List<dynamic>?)
?.map((e) => Comment.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$MomentToJson(Moment instance) => <String, dynamic>{
'id': instance.id,
'userId': instance.userId,
'username': instance.username,
'avatar': instance.avatar,
'content': instance.content,
'imageList': instance.imageList,
'date': instance.date,
'likeList': instance.likeList,
'commentList': instance.commentList,
};
PageMoment _$PageMomentFromJson(Map<String, dynamic> json) => PageMoment(
records:
(json['records'] as List<dynamic>)
.map((e) => Moment.fromJson(e as Map<String, dynamic>))
.toList(),
total: (json['total'] as num).toInt(),
size: (json['size'] as num).toInt(),
current: (json['current'] as num).toInt(),
pages: (json['pages'] as num).toInt(),
);
Map<String, dynamic> _$PageMomentToJson(PageMoment instance) =>
<String, dynamic>{
'records': instance.records,
'total': instance.total,
'size': instance.size,
'current': instance.current,
'pages': instance.pages,
};

View File

@@ -4,43 +4,43 @@ part 'recipe.g.dart';
/// 食材信息 /// 食材信息
@JsonSerializable() @JsonSerializable()
class Material { class RecipeMaterial {
String type; String type;
String name; String name;
String amount; String amount;
Material({required this.type, required this.name, required this.amount}); RecipeMaterial({required this.type, required this.name, required this.amount});
factory Material.fromJson(Map<String, dynamic> json) => factory RecipeMaterial.fromJson(Map<String, dynamic> json) =>
_$MaterialFromJson(json); _$RecipeMaterialFromJson(json);
Map<String, dynamic> toJson() => _$MaterialToJson(this); Map<String, dynamic> toJson() => _$RecipeMaterialToJson(this);
} }
/// 步骤信息 /// 步骤信息
@JsonSerializable() @JsonSerializable()
class Step { class RecipeStep {
int sort; int sort;
String content; String content;
String imageUrl; String imageUrl;
Step({required this.sort, required this.content, required this.imageUrl}); RecipeStep({required this.sort, required this.content, required this.imageUrl});
factory Step.fromJson(Map<String, dynamic> json) => _$StepFromJson(json); factory RecipeStep.fromJson(Map<String, dynamic> json) => _$RecipeStepFromJson(json);
Map<String, dynamic> toJson() => _$StepToJson(this); Map<String, dynamic> toJson() => _$RecipeStepToJson(this);
} }
/// 评论信息 /// 评论信息
@JsonSerializable() @JsonSerializable()
class Comment { class RecipeComment {
int id; int id;
String username; String username;
String avatar; String avatar;
String content; String content;
String date; String date;
Comment({ RecipeComment({
required this.id, required this.id,
required this.username, required this.username,
required this.avatar, required this.avatar,
@@ -48,10 +48,10 @@ class Comment {
required this.date, required this.date,
}); });
factory Comment.fromJson(Map<String, dynamic> json) => factory RecipeComment.fromJson(Map<String, dynamic> json) =>
_$CommentFromJson(json); _$RecipeCommentFromJson(json);
Map<String, dynamic> toJson() => _$CommentToJson(this); Map<String, dynamic> toJson() => _$RecipeCommentToJson(this);
} }
/// 成果信息 /// 成果信息
@@ -94,44 +94,44 @@ class RecipeQuery {
/// 菜谱信息 /// 菜谱信息
@JsonSerializable() @JsonSerializable()
class Recipe { class Recipe {
int? id; int id;
String name; String name;
String category; String category;
double recommendRate; double recommendRate;
String? remark; String remark;
bool isShare; bool isShare;
int? userId; int userId;
String? username; String username;
String? avatar; String avatar;
List<Material>? materialList; List<RecipeMaterial> materialList;
List<Step>? stepList; List<RecipeStep> stepList;
List<Record> recordList; List<Record> recordList;
List<int>? likeList; List<int> likeList;
int? likeCount; int likeCount;
List<int>? favouriteList; List<int> favouriteList;
int? favouriteCount; int favouriteCount;
List<Comment>? commentList; List<RecipeComment> commentList;
int? commentCount; int commentCount;
Recipe({ Recipe({
this.id, required this.id,
required this.name, required this.name,
required this.category, required this.category,
required this.recommendRate, required this.recommendRate,
this.remark, required this.remark,
required this.isShare, required this.isShare,
this.userId, required this.userId,
this.username, required this.username,
this.avatar, required this.avatar,
this.materialList, required this.materialList,
this.stepList, required this.stepList,
required this.recordList, required this.recordList,
this.likeList, required this.likeList,
this.likeCount, required this.likeCount,
this.favouriteList, required this.favouriteList,
this.favouriteCount, required this.favouriteCount,
this.commentList, required this.commentList,
this.commentCount, required this.commentCount,
}); });
factory Recipe.fromJson(Map<String, dynamic> json) => _$RecipeFromJson(json); factory Recipe.fromJson(Map<String, dynamic> json) => _$RecipeFromJson(json);
@@ -139,4 +139,80 @@ class Recipe {
Map<String, dynamic> toJson() => _$RecipeToJson(this); Map<String, dynamic> toJson() => _$RecipeToJson(this);
} }
@JsonSerializable()
class RecipeSummary {
int id;
String name;
String category;
double recommendRate;
bool isShare;
List<Record> recordList;
int likeCount;
int favouriteCount;
int commentCount;
int userId;
String username;
String avatar;
RecipeSummary({
required this.id,
required this.name,
required this.category,
required this.recommendRate,
required this.isShare,
required this.userId,
required this.username,
required this.avatar,
required this.recordList,
required this.likeCount,
required this.favouriteCount,
required this.commentCount,
});
factory RecipeSummary.fromJson(Map<String, dynamic> json) => _$RecipeSummaryFromJson(json);
Map<String, dynamic> toJson() => _$RecipeSummaryToJson(this);
}
@JsonSerializable()
class RecipeDetail {
int id;
String name;
String category;
double recommendRate;
String remark;
bool isShare;
int userId;
String username;
String avatar;
List<RecipeMaterial> materialList;
List<RecipeStep> stepList;
List<Record> recordList;
List<int> likeList;
List<int> favouriteList;
List<RecipeComment> commentList;
RecipeDetail({
required this.id,
required this.name,
required this.category,
required this.recommendRate,
required this.remark,
required this.isShare,
required this.userId,
required this.username,
required this.avatar,
required this.materialList,
required this.stepList,
required this.recordList,
required this.likeList,
required this.favouriteList,
required this.commentList,
});
factory RecipeDetail.fromJson(Map<String, dynamic> json) => _$RecipeDetailFromJson(json);
Map<String, dynamic> toJson() => _$RecipeDetailToJson(this);
}
enum ViewType { recipe, calendar, timeline } enum ViewType { recipe, calendar, timeline }

View File

@@ -6,45 +6,50 @@ part of 'recipe.dart';
// JsonSerializableGenerator // JsonSerializableGenerator
// ************************************************************************** // **************************************************************************
Material _$MaterialFromJson(Map<String, dynamic> json) => Material( RecipeMaterial _$RecipeMaterialFromJson(Map<String, dynamic> json) =>
RecipeMaterial(
type: json['type'] as String, type: json['type'] as String,
name: json['name'] as String, name: json['name'] as String,
amount: json['amount'] as String, amount: json['amount'] as String,
); );
Map<String, dynamic> _$MaterialToJson(Material instance) => <String, dynamic>{ Map<String, dynamic> _$RecipeMaterialToJson(RecipeMaterial instance) =>
<String, dynamic>{
'type': instance.type, 'type': instance.type,
'name': instance.name, 'name': instance.name,
'amount': instance.amount, 'amount': instance.amount,
}; };
Step _$StepFromJson(Map<String, dynamic> json) => Step( RecipeStep _$RecipeStepFromJson(Map<String, dynamic> json) => RecipeStep(
sort: (json['sort'] as num).toInt(), sort: (json['sort'] as num).toInt(),
content: json['content'] as String, content: json['content'] as String,
imageUrl: json['imageUrl'] as String, imageUrl: json['imageUrl'] as String,
); );
Map<String, dynamic> _$StepToJson(Step instance) => <String, dynamic>{ Map<String, dynamic> _$RecipeStepToJson(RecipeStep instance) =>
<String, dynamic>{
'sort': instance.sort, 'sort': instance.sort,
'content': instance.content, 'content': instance.content,
'imageUrl': instance.imageUrl, 'imageUrl': instance.imageUrl,
}; };
Comment _$CommentFromJson(Map<String, dynamic> json) => Comment( RecipeComment _$RecipeCommentFromJson(Map<String, dynamic> json) =>
RecipeComment(
id: (json['id'] as num).toInt(), id: (json['id'] as num).toInt(),
username: json['username'] as String, username: json['username'] as String,
avatar: json['avatar'] as String, avatar: json['avatar'] as String,
content: json['content'] as String, content: json['content'] as String,
date: json['date'] as String, date: json['date'] as String,
); );
Map<String, dynamic> _$CommentToJson(Comment instance) => <String, dynamic>{ Map<String, dynamic> _$RecipeCommentToJson(RecipeComment instance) =>
<String, dynamic>{
'id': instance.id, 'id': instance.id,
'username': instance.username, 'username': instance.username,
'avatar': instance.avatar, 'avatar': instance.avatar,
'content': instance.content, 'content': instance.content,
'date': instance.date, 'date': instance.date,
}; };
Record _$RecordFromJson(Map<String, dynamic> json) => Record( Record _$RecordFromJson(Map<String, dynamic> json) => Record(
id: (json['id'] as num?)?.toInt(), id: (json['id'] as num?)?.toInt(),
@@ -71,42 +76,42 @@ Map<String, dynamic> _$RecipeQueryToJson(RecipeQuery instance) =>
<String, dynamic>{'category': instance.category}; <String, dynamic>{'category': instance.category};
Recipe _$RecipeFromJson(Map<String, dynamic> json) => Recipe( Recipe _$RecipeFromJson(Map<String, dynamic> json) => Recipe(
id: (json['id'] as num?)?.toInt(), id: (json['id'] as num).toInt(),
name: json['name'] as String, name: json['name'] as String,
category: json['category'] as String, category: json['category'] as String,
recommendRate: (json['recommendRate'] as num).toDouble(), recommendRate: (json['recommendRate'] as num).toDouble(),
remark: json['remark'] as String?, remark: json['remark'] as String,
isShare: json['isShare'] as bool, isShare: json['isShare'] as bool,
userId: (json['userId'] as num?)?.toInt(), userId: (json['userId'] as num).toInt(),
username: json['username'] as String?, username: json['username'] as String,
avatar: json['avatar'] as String?, avatar: json['avatar'] as String,
materialList: materialList:
(json['materialList'] as List<dynamic>?) (json['materialList'] as List<dynamic>)
?.map((e) => Material.fromJson(e as Map<String, dynamic>)) .map((e) => RecipeMaterial.fromJson(e as Map<String, dynamic>))
.toList(), .toList(),
stepList: stepList:
(json['stepList'] as List<dynamic>?) (json['stepList'] as List<dynamic>)
?.map((e) => Step.fromJson(e as Map<String, dynamic>)) .map((e) => RecipeStep.fromJson(e as Map<String, dynamic>))
.toList(), .toList(),
recordList: recordList:
(json['recordList'] as List<dynamic>) (json['recordList'] as List<dynamic>)
.map((e) => Record.fromJson(e as Map<String, dynamic>)) .map((e) => Record.fromJson(e as Map<String, dynamic>))
.toList(), .toList(),
likeList: likeList:
(json['likeList'] as List<dynamic>?) (json['likeList'] as List<dynamic>)
?.map((e) => (e as num).toInt()) .map((e) => (e as num).toInt())
.toList(), .toList(),
likeCount: (json['likeCount'] as num?)?.toInt(), likeCount: (json['likeCount'] as num).toInt(),
favouriteList: favouriteList:
(json['favouriteList'] as List<dynamic>?) (json['favouriteList'] as List<dynamic>)
?.map((e) => (e as num).toInt()) .map((e) => (e as num).toInt())
.toList(), .toList(),
favouriteCount: (json['favouriteCount'] as num?)?.toInt(), favouriteCount: (json['favouriteCount'] as num).toInt(),
commentList: commentList:
(json['commentList'] as List<dynamic>?) (json['commentList'] as List<dynamic>)
?.map((e) => Comment.fromJson(e as Map<String, dynamic>)) .map((e) => RecipeComment.fromJson(e as Map<String, dynamic>))
.toList(), .toList(),
commentCount: (json['commentCount'] as num?)?.toInt(), commentCount: (json['commentCount'] as num).toInt(),
); );
Map<String, dynamic> _$RecipeToJson(Recipe instance) => <String, dynamic>{ Map<String, dynamic> _$RecipeToJson(Recipe instance) => <String, dynamic>{
@@ -129,3 +134,93 @@ Map<String, dynamic> _$RecipeToJson(Recipe instance) => <String, dynamic>{
'commentList': instance.commentList, 'commentList': instance.commentList,
'commentCount': instance.commentCount, 'commentCount': instance.commentCount,
}; };
RecipeSummary _$RecipeSummaryFromJson(Map<String, dynamic> json) =>
RecipeSummary(
id: (json['id'] as num).toInt(),
name: json['name'] as String,
category: json['category'] as String,
recommendRate: (json['recommendRate'] as num).toDouble(),
isShare: json['isShare'] as bool,
userId: (json['userId'] as num).toInt(),
username: json['username'] as String,
avatar: json['avatar'] as String,
recordList:
(json['recordList'] as List<dynamic>)
.map((e) => Record.fromJson(e as Map<String, dynamic>))
.toList(),
likeCount: (json['likeCount'] as num).toInt(),
favouriteCount: (json['favouriteCount'] as num).toInt(),
commentCount: (json['commentCount'] as num).toInt(),
);
Map<String, dynamic> _$RecipeSummaryToJson(RecipeSummary instance) =>
<String, dynamic>{
'id': instance.id,
'name': instance.name,
'category': instance.category,
'recommendRate': instance.recommendRate,
'isShare': instance.isShare,
'recordList': instance.recordList,
'likeCount': instance.likeCount,
'favouriteCount': instance.favouriteCount,
'commentCount': instance.commentCount,
'userId': instance.userId,
'username': instance.username,
'avatar': instance.avatar,
};
RecipeDetail _$RecipeDetailFromJson(Map<String, dynamic> json) => RecipeDetail(
id: (json['id'] as num).toInt(),
name: json['name'] as String,
category: json['category'] as String,
recommendRate: (json['recommendRate'] as num).toDouble(),
remark: json['remark'] as String,
isShare: json['isShare'] as bool,
userId: (json['userId'] as num).toInt(),
username: json['username'] as String,
avatar: json['avatar'] as String,
materialList:
(json['materialList'] as List<dynamic>)
.map((e) => RecipeMaterial.fromJson(e as Map<String, dynamic>))
.toList(),
stepList:
(json['stepList'] as List<dynamic>)
.map((e) => RecipeStep.fromJson(e as Map<String, dynamic>))
.toList(),
recordList:
(json['recordList'] as List<dynamic>)
.map((e) => Record.fromJson(e as Map<String, dynamic>))
.toList(),
likeList:
(json['likeList'] as List<dynamic>)
.map((e) => (e as num).toInt())
.toList(),
favouriteList:
(json['favouriteList'] as List<dynamic>)
.map((e) => (e as num).toInt())
.toList(),
commentList:
(json['commentList'] as List<dynamic>)
.map((e) => RecipeComment.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$RecipeDetailToJson(RecipeDetail instance) =>
<String, dynamic>{
'id': instance.id,
'name': instance.name,
'category': instance.category,
'recommendRate': instance.recommendRate,
'remark': instance.remark,
'isShare': instance.isShare,
'userId': instance.userId,
'username': instance.username,
'avatar': instance.avatar,
'materialList': instance.materialList,
'stepList': instance.stepList,
'recordList': instance.recordList,
'likeList': instance.likeList,
'favouriteList': instance.favouriteList,
'commentList': instance.commentList,
};

34
lib/models/stats.dart Normal file
View File

@@ -0,0 +1,34 @@
import 'package:json_annotation/json_annotation.dart';
part 'stats.g.dart';
@JsonSerializable()
class SummaryStats {
final int recipeCount;
final int categoryCount;
final int workCount;
const SummaryStats({
required this.recipeCount,
required this.categoryCount,
required this.workCount,
});
factory SummaryStats.fromJson(Map<String, dynamic> json) =>
_$SummaryStatsFromJson(json);
Map<String, dynamic> toJson() => _$SummaryStatsToJson(this);
}
@JsonSerializable()
class ChartData {
final String name;
final double value;
const ChartData({required this.name, required this.value});
factory ChartData.fromJson(Map<String, dynamic> json) =>
_$ChartDataFromJson(json);
Map<String, dynamic> toJson() => _$ChartDataToJson(this);
}

30
lib/models/stats.g.dart Normal file
View File

@@ -0,0 +1,30 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'stats.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SummaryStats _$SummaryStatsFromJson(Map<String, dynamic> json) => SummaryStats(
recipeCount: (json['recipeCount'] as num).toInt(),
categoryCount: (json['categoryCount'] as num).toInt(),
workCount: (json['workCount'] as num).toInt(),
);
Map<String, dynamic> _$SummaryStatsToJson(SummaryStats instance) =>
<String, dynamic>{
'recipeCount': instance.recipeCount,
'categoryCount': instance.categoryCount,
'workCount': instance.workCount,
};
ChartData _$ChartDataFromJson(Map<String, dynamic> json) => ChartData(
name: json['name'] as String,
value: (json['value'] as num).toDouble(),
);
Map<String, dynamic> _$ChartDataToJson(ChartData instance) => <String, dynamic>{
'name': instance.name,
'value': instance.value,
};

View File

@@ -1,5 +1,5 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart'; import 'package:food_hub_app/config/app_config.dart';
import 'package:food_hub_app/utils/sp_util.dart'; import 'package:food_hub_app/utils/sp_util.dart';
import 'package:food_hub_app/widgets/common/index.dart'; import 'package:food_hub_app/widgets/common/index.dart';
@@ -11,7 +11,6 @@ class HttpUtil {
factory HttpUtil() => _instance; factory HttpUtil() => _instance;
late Dio _dio; late Dio _dio;
String baseUrl = kDebugMode ? "http://172.29.101.108:8100" : "http://14.103.235.151:81";
// 请求头配置 // 请求头配置
Map<String, dynamic> headers = { Map<String, dynamic> headers = {
@@ -25,7 +24,7 @@ class HttpUtil {
HttpUtil._internal() { HttpUtil._internal() {
// 初始化Dio实例 // 初始化Dio实例
BaseOptions options = BaseOptions( BaseOptions options = BaseOptions(
baseUrl: baseUrl, baseUrl: AppConfig.baseApiUrl,
connectTimeout: Duration(seconds: timeout), connectTimeout: Duration(seconds: timeout),
receiveTimeout: Duration(seconds: timeout), receiveTimeout: Duration(seconds: timeout),
headers: headers, headers: headers,
@@ -81,10 +80,6 @@ class HttpUtil {
T Function(dynamic data)? converter, T Function(dynamic data)? converter,
}) async { }) async {
try { try {
if (kDebugMode) {
path = path.replaceFirst('/food-service', '');
}
Response response = await _dio.request( Response response = await _dio.request(
path, path,
data: data, data: data,

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:food_hub_app/layout/index.dart'; import 'package:food_hub_app/layout/index.dart';
import 'package:food_hub_app/models/layout.dart';
import 'package:food_hub_app/views/moment.dart'; import 'package:food_hub_app/views/moment.dart';
import 'package:food_hub_app/views/profile.dart'; import 'package:food_hub_app/views/profile.dart';
import 'package:food_hub_app/views/record.dart'; import 'package:food_hub_app/views/record.dart';
@@ -15,15 +16,112 @@ class HomePage extends StatefulWidget {
class _HomePage extends State<HomePage> { class _HomePage extends State<HomePage> {
int _currentIndex = 0; int _currentIndex = 0;
final List<Widget> _tabPages = const [ final List<NavItem> navItems = [
RecordPage(), NavItem(
StatsPage(), label: "记录",
MomentPage(), icon: Icons.home_outlined,
ProfilePage(), activeIcon: Icons.home,
page: RecordPage(),
),
NavItem(
label: "统计",
icon: Icons.pie_chart_outline,
activeIcon: Icons.pie_chart,
page: StatsPage(),
),
NavItem(
label: "朋友圈",
icon: Icons.group_outlined,
activeIcon: Icons.group,
page: MomentPage(),
),
NavItem(
label: "我的",
icon: Icons.account_circle_outlined,
activeIcon: Icons.account_circle,
page: ProfilePage(),
),
]; ];
bool isShow(int index) { List<BottomNavigationBarItem> get bottomNavItems =>
return index == 0 || index == 2; navItems
.map(
(item) => BottomNavigationBarItem(
icon: Icon(item.icon),
activeIcon: Icon(item.activeIcon),
label: item.label,
),
)
.toList();
List<Widget> get tabPages => navItems.map((item) => item.page).toList();
// 显示底部弹窗
void _showBottomSheet() {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(16),
),
),
builder: (context) => Container(
padding: const EdgeInsets.all(10),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'请选择操作',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 10),
ListTile(
leading: Icon(Icons.book, color: Theme.of(context).primaryColor),
title: const Text('新增菜谱'),
onTap: () {
Navigator.pop(context);
_handleAddRecipe();
},
),
ListTile(
leading: Icon(Icons.note_add, color: Theme.of(context).primaryColor),
title: const Text('新增记录'),
onTap: () {
Navigator.pop(context);
Navigator.pushNamed(context, "/recordForm");
},
),
ListTile(
leading: Icon(Icons.group, color: Theme.of(context).primaryColor),
title: const Text('发布朋友圈'),
onTap: () {
Navigator.pop(context);
_handleAddRecord();
},
)
],
),
),
);
}
// 处理添加菜谱
void _handleAddRecipe() {
// 这里添加跳转或处理添加菜谱的逻辑
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('添加菜谱功能')),
);
}
// 处理添加记录
void _handleAddRecord() {
// 这里添加跳转或处理添加记录的逻辑
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('添加记录功能')),
);
} }
@override @override
@@ -33,20 +131,29 @@ class _HomePage extends State<HomePage> {
centerTitle: true, centerTitle: true,
title: Text('Food Hub', style: TextStyle(color: Colors.white)), title: Text('Food Hub', style: TextStyle(color: Colors.white)),
backgroundColor: Theme.of(context).primaryColor, backgroundColor: Theme.of(context).primaryColor,
leading: IconButton( leading: Builder(
icon: Icon(Icons.menu, color: Colors.white), builder: (context) {
onPressed: () { return IconButton(
// 打开侧边栏或菜单 icon: const Icon(Icons.menu),
color: Colors.white,
onPressed: () => Scaffold.of(context).openDrawer(),
);
}, },
), ),
actions: homeActions(), actions: homeActions(context),
), ),
backgroundColor: Color(0xFFF5F5F5), backgroundColor: Color(0xFFF5F5F5),
endDrawer: const SettingsDrawer(), drawer: const SettingsDrawer(),
body: _tabPages[_currentIndex], body: tabPages[_currentIndex],
floatingActionButton: floatingActionButton: FloatingActionButton(
isShow(_currentIndex) ? addItemButton(context) : null, backgroundColor: Theme.of(context).primaryColor,
onPressed: _showBottomSheet,
shape: const CircleBorder(),
child: const Icon(Icons.add, color: Colors.white, size: 30),
),
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
bottomNavigationBar: NavBar( bottomNavigationBar: NavBar(
navItems: bottomNavItems,
currentIndex: _currentIndex, currentIndex: _currentIndex,
onTap: (index) { onTap: (index) {
setState(() { setState(() {
@@ -57,17 +164,3 @@ class _HomePage extends State<HomePage> {
); );
} }
} }
Widget addItemButton(BuildContext context) {
void onAddItemClick(BuildContext context) {
Navigator.pushNamed(context, '/recordForm');
}
return FloatingActionButton(
mini: true,
onPressed: () => onAddItemClick(context),
backgroundColor: Theme.of(context).primaryColor,
shape: const CircleBorder(),
child: Icon(Icons.add, color: Colors.white),
);
}

View File

@@ -1,6 +1,6 @@
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/api/session.dart'; import 'package:food_hub_app/apis/session.dart';
import 'package:food_hub_app/models/session.dart'; import 'package:food_hub_app/models/session.dart';
import 'package:food_hub_app/utils/sp_util.dart'; import 'package:food_hub_app/utils/sp_util.dart';
import 'package:food_hub_app/widgets/common/index.dart'; import 'package:food_hub_app/widgets/common/index.dart';

View File

@@ -1,15 +1,194 @@
import 'package:easy_refresh/easy_refresh.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:food_hub_app/apis/moment.dart';
import 'package:food_hub_app/models/moment.dart';
import 'package:food_hub_app/widgets/moment/card.dart';
import 'package:tdesign_flutter/tdesign_flutter.dart';
class MomentPage extends StatefulWidget { class MomentPage extends StatefulWidget {
const MomentPage({super.key}); const MomentPage({super.key});
@override @override
State<MomentPage> createState() => _MomentPage(); State<MomentPage> createState() => _MomentPageState();
} }
class _MomentPage extends State<MomentPage>{ class _MomentPageState extends State<MomentPage> {
List<Moment> momentList = [];
int _currentPage = 1;
final int _pageSize = 3;
bool _hasMore = true;
// 初始化EasyRefresh控制器
final EasyRefreshController _freshController = EasyRefreshController(
controlFinishRefresh: true,
controlFinishLoad: true,
);
bool _showScrollToTop = false;
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
// 初始加载数据
_loadData(isRefresh: true);
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_freshController.dispose();
_scrollController.dispose();
super.dispose();
}
// 统一的数据加载方法
Future<void> _loadData({required bool isRefresh}) async {
try {
// 如果是刷新,重置页码
if (isRefresh) {
_currentPage = 1;
}
final result = await queryMomentByPageApi(_currentPage, _pageSize);
setState(() {
if (isRefresh) {
// 刷新时直接替换数据
momentList = result.records;
} else {
// 加载更多时追加数据
momentList.addAll(result.records);
}
// 判断是否还有更多数据
_hasMore = result.current < result.pages;
// 如果有更多数据,准备加载下一页
if (_hasMore) {
_currentPage++;
}
});
} catch (e) {
// 处理错误
debugPrint('加载数据失败: $e');
} finally {
_freshController.finishRefresh();
_freshController.resetFooter();
}
}
// 下拉刷新
Future<void> _onRefresh() async {
await _loadData(isRefresh: true);
}
// 上拉加载
Future<void> _onLoad() async {
if (_hasMore) {
await _loadData(isRefresh: false);
_freshController.finishLoad(IndicatorResult.success);
} else {
_freshController.finishLoad(IndicatorResult.noMore);
}
}
void _onScroll() {
// 当滚动距离超过300时显示返回顶部按钮
if (_scrollController.offset > 300) {
if (!_showScrollToTop) {
setState(() {
_showScrollToTop = true;
});
}
} else {
if (_showScrollToTop) {
setState(() {
_showScrollToTop = false;
});
}
}
}
// 滚动到顶部
void _scrollToTop() {
_scrollController.animateTo(
0,
duration: const Duration(milliseconds: 500), // 滚动动画时长
curve: Curves.easeInOut, // 滚动动画曲线
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return const Center(child: Text("朋友圈")); // 空状态显示
if (momentList.isEmpty) {
return EasyRefresh(
controller: _freshController,
onRefresh: _onRefresh,
child: const TDEmpty(type: TDEmptyType.plain, emptyText: '暂无数据'),
);
}
// 有数据时显示列表
return Stack(
children: [
Padding(
padding: const EdgeInsets.all(5),
child: EasyRefresh(
controller: _freshController,
header: ClassicHeader(
dragText: '下拉刷新',
armedText: '释放刷新',
readyText: '准备刷新',
processingText: '刷新中...',
processedText: '刷新完成',
failedText: '刷新失败',
noMoreText: '没有更多数据',
showText: true,
messageText: '更新于 %T',
showMessage: true,
),
footer: ClassicFooter(
dragText: '上拉加载',
armedText: '释放加载',
readyText: '准备加载',
processingText: '加载中...',
processedText: '加载完成',
failedText: '加载失败',
noMoreText: '没有更多数据',
showText: true,
messageText: '更新于 %T',
showMessage: true,
),
onRefresh: _onRefresh,
onLoad: _onLoad,
child: ListView.builder(
controller: _scrollController,
itemCount: momentList.length,
itemBuilder: (context, index) {
return MomentCard(moment: momentList[index]);
},
),
),
),
// 返回顶部按钮
if (_showScrollToTop)
Positioned(
right: 10,
bottom: 20,
child: FloatingActionButton(
onPressed: _scrollToTop,
backgroundColor: Colors.white,
elevation: 5,
mini: true,
child: const Icon(
Icons.arrow_upward
),
),
),
],
);
} }
} }

View File

@@ -0,0 +1,247 @@
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/widgets/common/index.dart';
class RecipeDetailPage extends StatefulWidget {
const RecipeDetailPage({super.key});
@override
State<RecipeDetailPage> createState() => _RecipeDetailState();
}
class _RecipeDetailState extends State<RecipeDetailPage> {
RecipeDetail recipe = RecipeDetail(
id: 0,
name: '',
category: '',
recommendRate: 0,
remark: '',
isShare: false,
userId: 0,
username: '',
avatar: '',
materialList: [],
stepList: [],
recordList: [],
likeList: [],
favouriteList: [],
commentList: [],
);
// 定义三个分类列表
List<RecipeMaterial> mainMaterialList = []; // 主料
List<RecipeMaterial> auxiliaryMaterialList = []; // 配料
List<RecipeMaterial> accessoryMaterialList = []; // 辅料
@override
void didChangeDependencies() {
super.didChangeDependencies();
refreshRecipeDetail();
}
Future<void> refreshRecipeDetail() async {
// 获取参数
final args = ModalRoute.of(context)?.settings.arguments as Map;
final recipeId = args['id'];
final result = await queryRecipeByIdApi(recipeId);
setState(() {
recipe = result;
getRecipeMaterial();
});
}
void getRecipeMaterial() {
for (var material in recipe.materialList) {
switch (material.type) {
case '主料':
mainMaterialList.add(material);
break;
case '配料':
auxiliaryMaterialList.add(material);
break;
case '辅料':
accessoryMaterialList.add(material);
break;
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('菜谱信息', style: TextStyle(color: Colors.white)),
backgroundColor: Theme.of(context).primaryColor,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
),
backgroundColor: Color(0xFFF5F5F5),
body: SingleChildScrollView(
child: Padding(padding: EdgeInsets.all(5), child: _buildRecipeDetail()),
),
);
}
Widget _buildRecipeDetail() {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildTitleSection(),
SizedBox(height: 10),
_buildInfoCard(
context,
icon: Icons.info,
title: "基础信息",
content: _buildTag(context, title: recipe.category),
),
_buildInfoCard(
context,
icon: Icons.shopping_cart,
title: "食材信息",
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildMaterialSection(context, '主料', mainMaterialList),
_buildMaterialSection(context, '辅料', auxiliaryMaterialList),
_buildMaterialSection(context, '调料', accessoryMaterialList),
],
),
),
_buildInfoCard(
context,
icon: Icons.list,
title: "步骤信息",
content: Column(
children:
recipe.stepList.map((step) => _buildStepSection(step)).toList(),
),
),
_buildInfoCard(
context,
icon: Icons.note,
title: "其他信息",
content: Text(
'备注:${recipe.remark.isEmpty ? '' : recipe.remark}',
style: const TextStyle(height: 1.5),
),
),
],
);
}
Widget _buildTag(BuildContext context, {required String title}) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Color.lerp(Theme.of(context).primaryColor, Colors.white, 0.8),
borderRadius: BorderRadius.circular(10),
),
child: Text(
title,
style: TextStyle(color: Theme.of(context).primaryColor),
),
);
}
Widget _buildTitleSection() {
return Text(
recipe.name,
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
);
}
Widget _buildMaterialSection(
BuildContext context,
String title,
List<RecipeMaterial> materials,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8, top: 12),
child: Text(title, style: TextStyle(fontWeight: FontWeight.bold)),
),
if (materials.isEmpty)
_buildTag(context, title: '')
else
Wrap(
spacing: 8.0,
runSpacing: 8.0,
children:
materials
.map(
(m) => _buildTag(context, title: '${m.name} ${m.amount}'),
)
.toList(),
),
],
);
}
Widget _buildStepSection(RecipeStep step) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("${step.sort + 1}"),
SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
step.content,
style: const TextStyle(
height: 1.4,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
if (step.imageUrl.isNotEmpty)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
step.imageUrl,
width: double.infinity,
height: 180,
fit: BoxFit.contain,
),
),
],
),
),
],
);
}
Widget _buildInfoCard(
BuildContext context, {
required IconData icon,
required String title,
required Widget content,
}) {
return cardContainer(
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, color: Theme.of(context).primaryColor),
const SizedBox(width: 5),
Text(
title,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
],
),
SizedBox(height: 5),
content,
],
),
);
}
}

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:food_hub_app/widgets/recipe/recipe_calendar.dart'; import 'package:food_hub_app/widgets/recipe/calendar.dart';
import 'package:food_hub_app/widgets/recipe/recipe_list.dart'; import 'package:food_hub_app/widgets/recipe/list.dart';
import 'package:food_hub_app/widgets/recipe/recipe_timeline.dart'; import 'package:food_hub_app/widgets/recipe/timeline.dart';
import 'package:tdesign_flutter/tdesign_flutter.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart';
@@ -39,9 +39,7 @@ class _RecordPageState extends State<RecordPage>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Column(
padding: EdgeInsets.all(0),
child: Column(
children: [ children: [
TDTabBar( TDTabBar(
tabs: tabs, tabs: tabs,
@@ -63,7 +61,6 @@ class _RecordPageState extends State<RecordPage>
), ),
), ),
], ],
),
); );
} }
} }

View File

@@ -1,243 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:food_hub_app/utils/index.dart';
import 'package:food_hub_app/widgets/common/index.dart';
import 'package:image_picker/image_picker.dart';
import 'package:tdesign_flutter/tdesign_flutter.dart';
class RecordFormPage extends StatefulWidget {
const RecordFormPage({super.key});
@override
State<RecordFormPage> createState() => _RecordFormPage();
}
class _RecordFormPage extends State<RecordFormPage> {
final _formKey = GlobalKey<FormBuilderState>();
List<TextEditingController> _controller = [];
FormController _formController = FormController();
String _selected_1 = '';
String _selected_2 = '';
String? _initLocalData;
final ImagePicker _picker = ImagePicker();
XFile? _selectedImage;
/// 整个表单存放的数据
Map<String, dynamic> _formData = {"name": '', "date": '', "photo": ''};
/// 定义整个校验规则
final Map<String, TDFormValidation> _validationRules = {
'name': TDFormValidation(
validate: (value) => value == null || value.isEmpty ? 'empty' : null,
errorMessage: '输入不能为空',
type: TDFormItemType.input,
),
"birth": TDFormValidation(
validate: (value) => value == null || value.isEmpty ? 'empty' : null,
errorMessage: '不能为空',
type: TDFormItemType.dateTimePicker,
),
"photo": TDFormValidation(
validate: (value) => value == null || value.isEmpty ? 'empty' : null,
errorMessage: '不能为空',
type: TDFormItemType.upLoadImg,
),
};
List<TDUploadFile> files = [];
List<TDUploadFile> _onValueChanged(
List<TDUploadFile> fileList,
List<TDUploadFile> value,
TDUploadType event,
) {
switch (event) {
case TDUploadType.add:
fileList.addAll(value);
break;
case TDUploadType.remove:
fileList.removeWhere((element) => element.key == value[0].key);
break;
case TDUploadType.replace:
final firstReplaceFile = value.first;
final index = fileList.indexWhere(
(file) => file.key == firstReplaceFile.key,
);
if (index != -1) {
fileList[index] = firstReplaceFile;
}
break;
}
return fileList;
}
@override
void initState() {
super.initState();
}
@override
void dispose() {
super.dispose();
}
void _confirmClick(BuildContext context) {
if (_formKey.currentState?.saveAndValidate() ?? false) {
// showSuccessToast("新增记录成功");
print(_formData);
} else {
TDMessage.showMessage(
context: context,
visible: true,
icon: true,
content: "请先输入信息",
theme: MessageTheme.error,
duration: 3000,
);
}
}
TDFormItem buildNameItem() {
return TDFormItem(
label: '菜谱名称',
name: 'name',
type: TDFormItemType.input,
labelWidth: 82.0,
showErrorMessage: true,
requiredMark: true,
child: TDInput(
leftContentSpace: 0,
inputDecoration: InputDecoration(
hintText: "请输入菜谱名称",
border: InputBorder.none,
hintStyle: TextStyle(color: TDTheme.of(context).grayColor6),
),
backgroundColor: Colors.white,
additionInfoColor: TDTheme.of(context).errorColor6,
showBottomDivider: false,
onChanged: (value) {
_formData['name'] = value;
},
),
);
}
TDFormItem buildDateItem() {
return TDFormItem(
label: '完成时间',
name: 'date',
labelWidth: 82.0,
type: TDFormItemType.dateTimePicker,
contentAlign: TextAlign.left,
tipAlign: TextAlign.left,
hintText: '请选择完成时间',
select: _formData['date'],
selectFn: (BuildContext context) {
DateTime now = DateTime.now();
TDPicker.showDatePicker(
context,
title: '选择时间',
onConfirm: (selected) {
setState(() {
_formData['date'] = parseDatePickerSelected(selected);
});
Navigator.of(context).pop();
},
dateStart: [2000, 01, 01],
dateEnd: [2100, 12, 31],
initialDate: [now.year, now.month, now.day],
);
},
);
}
TDFormItem buildImageItem() {
return TDFormItem(
label: '上传图片',
name: 'photo',
labelWidth: 82.0,
type: TDFormItemType.upLoadImg,
child: TDUpload(
files: files,
onError: print,
onValidate: print,
onChange: ((imgList, type) {
files = _onValueChanged(files ?? [], imgList, type);
List imgs = files.map((e) => e.remotePath ?? e.assetPath).toList();
setState(() {});
}),
),
);
}
Widget buildFormBtnGroup() {
return Container(
decoration: BoxDecoration(color: TDTheme.of(context).whiteColor1),
child: Padding(
padding: const EdgeInsets.all(10),
child: Row(
children: [
Expanded(
child: TDButton(
text: '取消',
type: TDButtonType.fill,
theme: TDButtonTheme.light,
shape: TDButtonShape.rectangle,
onTap: () => Navigator.pop(context),
),
),
SizedBox(width: 20),
Expanded(
child: TDButton(
text: '提交',
type: TDButtonType.fill,
theme: TDButtonTheme.primary,
shape: TDButtonShape.rectangle,
onTap: () => _confirmClick(context),
),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('新增记录', style: TextStyle(color: Colors.white)),
backgroundColor: Theme.of(context).primaryColor,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context)
),
),
backgroundColor: Color(0xFFF5F5F5),
body: Padding(
padding: const EdgeInsets.all(10),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: SingleChildScrollView(
child: TDForm(
formController: _formController,
data: _formData,
rules: _validationRules,
formContentAlign: TextAlign.left,
formShowErrorMessage: true,
onSubmit: () => {},
items: [buildNameItem(), buildDateItem(), buildImageItem()],
btnGroup: [buildFormBtnGroup()],
),
),
),
),
);
}
}

333
lib/views/record_form.dart Normal file
View File

@@ -0,0 +1,333 @@
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:food_hub_app/widgets/common/form.dart';
import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.dart';
import 'dart:io';
class RecordFormPage extends StatefulWidget {
const RecordFormPage({super.key});
@override
State<RecordFormPage> createState() => _RecordFormPageState();
}
class _RecordFormPageState extends State<RecordFormPage> {
final _formKey = GlobalKey<FormBuilderState>();
final _focusNode = FocusNode();
static const String _nameField = 'name';
static const String _dateField = 'date';
final ImagePicker _picker = ImagePicker();
File? imageUrl; // 改为单张图片变量
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('新增记录', style: TextStyle(color: Colors.white)),
backgroundColor: theme.primaryColor,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
),
backgroundColor: const Color(0xFFF5F5F5),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(5),
child: Card(
elevation: 0,
color: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
child: Padding(
padding: const EdgeInsets.all(10),
child: _buildFormBuilder(),
),
),
),
),
);
}
Widget _buildFormBuilder() {
return FormBuilder(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildFormLabel('菜谱名称', required: true),
const SizedBox(height: 8),
_buildNameTextField(),
const SizedBox(height: 10),
buildFormLabel('完成时间', required: true),
const SizedBox(height: 8),
_buildDateTextField(),
const SizedBox(height: 10),
buildFormLabel('上传图片', required: true),
const SizedBox(height: 10),
_buildImageUploadArea(),
const SizedBox(height: 10),
buildFormButtonGroup(context: context, onConfirm: () => _submitForm),
],
),
);
}
/// 菜谱名称输入框
Widget _buildNameTextField() {
return FormBuilderTextField(
name: _nameField,
focusNode: _focusNode,
decoration: buildInputDecoration(context: context, hintText: '请输入菜谱名称'),
validator: (value) {
if (value == null || value.isEmpty) {
return '请输入菜谱名称';
}
return null;
},
);
}
/// 完成时间选择框
Widget _buildDateTextField() {
return FormBuilderTextField(
name: _dateField,
readOnly: true,
decoration: buildInputDecoration(
context: context,
hintText: '请选择完成时间',
prefixIcon: const Icon(
Icons.calendar_month,
size: 20,
color: Color(0xFF86909C),
),
),
onTap: () => _onSelectDate(),
validator: (value) {
if (value == null || value.isEmpty) {
return '请选择完成时间';
}
return null;
},
);
}
/// 图片上传区域(预览+上传按钮)
Widget _buildImageUploadArea() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (imageUrl != null)
_buildImagePreviewItem()
else
_buildImageUploadButton(),
],
);
}
Widget _buildImagePreviewItem() {
return Container(
decoration: BoxDecoration(borderRadius: BorderRadius.circular(8)),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Stack(
children: [
GestureDetector(
onTap: () => _showImagePreview(),
child: Image(
image: FileImage(imageUrl!),
width: 120,
height: 120,
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Container(
width: 120,
height: 120,
color: Colors.grey[100],
child: const Center(
child: CircularProgressIndicator(strokeWidth: 2),
),
);
},
),
),
Positioned(
top: 6,
right: 6,
child: GestureDetector(
onTap: _removeImage,
child: Container(
width: 24,
height: 24,
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black26,
blurRadius: 2,
spreadRadius: 0,
),
],
),
child: const Icon(Icons.close, color: Colors.white, size: 16),
),
),
),
],
),
),
);
}
void _showImagePreview() {
showDialog(
context: context,
barrierColor: Colors.black87, // 半透明黑色背景
builder:
(context) => Dialog(
backgroundColor: Colors.transparent,
elevation: 0,
insetPadding: const EdgeInsets.all(16),
child: GestureDetector(
onTap: () => Navigator.pop(context), // 点击空白处关闭
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
// 预览图添加轻微阴影
boxShadow: [BoxShadow(color: Colors.black38, blurRadius: 10)],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(
imageUrl!,
fit: BoxFit.contain, // 保持图片比例
height: MediaQuery.of(context).size.height * 0.7, // 限制最大高度
),
),
),
),
),
);
}
/// 图片上传按钮
Widget _buildImageUploadButton() {
return InkWell(
onTap: _pickImage,
borderRadius: BorderRadius.circular(8),
child: Container(
width: 96,
height: 96,
decoration: BoxDecoration(
color: const Color(0xFFF2F3F5),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFDCDFE6), width: 1),
),
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.add, color: Color(0xFF86909C), size: 24),
SizedBox(height: 6),
Text(
'添加图片',
style: TextStyle(color: Color(0xFF86909C), fontSize: 13),
),
],
),
),
);
}
/// 选择日期
Future<void> _onSelectDate() async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(2100),
builder:
(context, child) => Theme(
data: ThemeData.light().copyWith(
primaryColor: Theme.of(context).primaryColor,
colorScheme: ColorScheme.light(
primary: Theme.of(context).primaryColor,
),
buttonTheme: const ButtonThemeData(
textTheme: ButtonTextTheme.primary,
),
),
child: child!,
),
);
if (picked != null) {
_formKey.currentState?.fields[_dateField]?.didChange(
DateFormat('yyyy-MM-dd').format(picked),
);
}
}
/// 选择图片(限制单张)
Future<void> _pickImage() async {
final XFile? image = await _picker.pickImage(source: ImageSource.gallery);
if (image != null) {
setState(() {
imageUrl = File(image.path); // 直接覆盖现有图片
});
}
}
/// 移除图片
void _removeImage() {
setState(() {
imageUrl = null;
});
}
/// 提交表单
void _submitForm() {
if (_formKey.currentState?.saveAndValidate() ?? false) {
if (imageUrl == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('请上传图片'),
backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
),
);
return;
}
final formData = {
..._formKey.currentState!.value,
'imageUrl': imageUrl?.path, // 单张图片路径
};
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('提交成功!'),
behavior: SnackBarBehavior.floating,
),
);
print('表单数据: $formData');
}
}
}

View File

@@ -1,7 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:graphic/graphic.dart'; import 'package:food_hub_app/apis/stats.dart';
import 'package:food_hub_app/models/stats.dart';
import 'data.dart'; import 'package:food_hub_app/widgets/common/chart.dart';
import 'package:food_hub_app/widgets/stats/card.dart';
class StatsPage extends StatefulWidget { class StatsPage extends StatefulWidget {
const StatsPage({super.key}); const StatsPage({super.key});
@@ -11,86 +12,93 @@ class StatsPage extends StatefulWidget {
} }
class _StatsPage extends State<StatsPage> { class _StatsPage extends State<StatsPage> {
var _smooth = false; final double chartHeight = 400;
var _stepped = false;
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;
}
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SingleChildScrollView( return SingleChildScrollView(
child: Center( child: Padding(
padding: const EdgeInsets.all(5),
child: Column( child: Column(
children: <Widget>[ children: [
Container( _buildSummaryStats(),
margin: const EdgeInsets.only(top: 10), SizedBox(
width: 350, height: chartHeight,
height: 300, child: Card(
child: Chart( color: Colors.white,
data: basicData, elevation: 0,
variables: { shape: RoundedRectangleBorder(
'genre': Variable( borderRadius: BorderRadius.circular(10),
accessor: (Map map) => map['genre'] as String,
), ),
'sold': Variable(accessor: (Map map) => map['sold'] as num), child: Padding(
}, padding: const EdgeInsets.all(10),
marks: [ child: _buildRecordStats(),
IntervalMark(
label: LabelEncode(
encoder: (tuple) => Label(tuple['sold'].toString()),
),
elevation: ElevationEncode(
value: 0,
updaters: {
'tap': {true: (_) => 5},
},
),
color: ColorEncode(
value: Defaults.primaryColor,
updaters: {
'tap': {false: (color) => color.withAlpha(100)},
},
), ),
), ),
], ),
axes: [Defaults.horizontalAxis, Defaults.verticalAxis], SizedBox(
selections: {'tap': PointSelection(dim: Dim.x)}, height: chartHeight,
tooltip: TooltipGuide(), child: Card(
crosshair: CrosshairGuide(), color: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
child: Padding(
padding: const EdgeInsets.all(10),
child: _buildCategoryStats(),
), ),
), ),
Container(
padding: const EdgeInsets.fromLTRB(20, 40, 20, 5),
child: const Text(
'Transposed Bar Chart',
style: TextStyle(fontSize: 20),
), ),
SizedBox(
height: chartHeight,
child: Card(
color: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
), ),
Container( child: Padding(
margin: const EdgeInsets.only(top: 10), padding: const EdgeInsets.all(10),
width: 350, child: _buildRankStats(),
height: 300,
child: Chart(
data: basicData,
variables: {
'genre': Variable(
accessor: (Map map) => map['genre'] as String,
), ),
'sold': Variable(accessor: (Map map) => map['sold'] as num),
},
transforms: [Proportion(variable: 'sold', as: 'percent')],
marks: [
IntervalMark(
position: Varset('percent') / Varset('genre'),
label: LabelEncode(
encoder: (tuple) => Label(tuple['sold'].toString()),
),
color: ColorEncode(
variable: 'genre',
values: Defaults.colors10,
),
modifiers: [StackModifier()],
),
],
coord: PolarCoord(transposed: true, dimCount: 1, dimFill: 1.05),
), ),
), ),
], ],
@@ -98,4 +106,112 @@ class _StatsPage extends State<StatsPage> {
), ),
); );
} }
Widget _buildSummaryStats() {
return GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
// 禁用网格自身滚动
crossAxisCount: 2,
// crossAxisSpacing: 5,
// mainAxisSpacing: 5,
childAspectRatio: 2,
children: [
StatisticCard(
icon: Icons.restaurant_menu,
color: Colors.blue,
title: '菜谱总数',
value: summaryStats.recipeCount,
unit: '',
),
StatisticCard(
icon: Icons.grid_view_rounded,
color: Colors.green,
title: '菜谱类别',
value: summaryStats.categoryCount,
unit: '',
),
StatisticCard(
icon: Icons.flag,
color: Colors.orange,
title: '做菜次数',
value: summaryStats.workCount,
unit: '',
),
StatisticCard(
icon: Icons.show_chart,
color: Colors.red,
title: '平均次数',
value: double.parse(averageRecordCount.toStringAsFixed(2)),
unit: '次/月',
),
],
);
}
Widget _buildRecordStats() {
return Column(
children: [
_buildTitleSection('记录统计'),
const SizedBox(height: 3),
_buildDivider(),
const SizedBox(height: 3),
Expanded(
child: lineChart(
context: context,
xAxisName: '日期',
yAxisName: '次数',
unit: '',
data: recordStats,
),
),
],
);
}
Widget _buildCategoryStats() {
return Column(
children: [
_buildTitleSection('菜谱统计'),
const SizedBox(height: 3),
_buildDivider(),
const SizedBox(height: 3),
Expanded(
child: pieChart(context: context, unit: '', data: categoryStats),
),
],
);
}
Widget _buildRankStats() {
return Column(
children: [
_buildTitleSection('排行榜'),
const SizedBox(height: 3),
_buildDivider(),
const SizedBox(height: 3),
Expanded(child: rankChart(rankStats)),
],
);
}
// 构建标题区域
Widget _buildTitleSection(String title) {
return Row(
children: [
Icon(Icons.insert_chart, color: Theme.of(context).primaryColor),
Text(title),
],
);
}
Widget _buildDivider() {
return Divider(
height: 1,
thickness: 1,
color: Theme.of(context).primaryColor,
indent: 0,
endIndent: 0,
);
}
} }

View File

@@ -0,0 +1,183 @@
import 'package:flutter/material.dart';
import 'package:food_hub_app/models/stats.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
Widget lineChart({
required BuildContext context,
required String xAxisName,
required String yAxisName,
required String unit,
required List<ChartData> data,
}) {
return SfCartesianChart(
// 图表标题
// title: ChartTitle(text: '2023年上半年销售额万元'),
// X轴配置类别轴
primaryXAxis: CategoryAxis(majorGridLines: MajorGridLines(width: 0)),
// Y轴配置数值轴
// primaryYAxis: NumericAxis(title: AxisTitle(text: '$yAxisName$unit')),
// 启用图例
// legend: const Legend(isVisible: true),
// 启用交互提示(点击数据点显示详情)
tooltipBehavior: TooltipBehavior(
enable: true,
format: 'point.x: point.y $unit',
),
// 折线图数据系列
series: [
LineSeries<ChartData, String>(
dataSource: data,
// X轴数据映射
xValueMapper: (ChartData chart, _) => chart.name,
// Y轴数据映射
yValueMapper: (ChartData chart, _) => chart.value,
// 线条颜色
color: Theme.of(context).primaryColor,
// 线条宽度
width: 3,
// 数据点样式
markerSettings: const MarkerSettings(
isVisible: true,
color: Colors.white,
shape: DataMarkerType.circle,
height: 6,
width: 6,
),
// 折线名称(会显示在图例中)
name: yAxisName,
// 启用数据标签(直接显示数值)
dataLabelSettings: const DataLabelSettings(
isVisible: true,
color: Colors.white,
opacity: 0,
),
// 动画效果
animationDuration: 2000, // 动画时长(毫秒)
),
],
);
}
Widget barChart({
required BuildContext context,
required String xAxisName,
required String yAxisName,
required String unit,
required List<ChartData> data,
}) {
return SfCartesianChart(
// X轴配置类别轴
primaryXAxis: CategoryAxis(
majorGridLines: MajorGridLines(width: 0),
title: AxisTitle(text: xAxisName),
),
// Y轴配置数值轴
primaryYAxis: NumericAxis(title: AxisTitle(text: '$yAxisName$unit')),
// 启用交互提示
tooltipBehavior: TooltipBehavior(
enable: true,
format: 'point.x: point.y $unit',
),
// 柱状图数据系列
series: [
ColumnSeries<ChartData, String>(
dataSource: data,
// X轴数据映射
xValueMapper: (ChartData chart, _) => chart.name,
// Y轴数据映射
yValueMapper: (ChartData chart, _) => chart.value,
// 名称
name: yAxisName,
// 柱子颜色
color: Theme.of(context).primaryColor,
// 柱子宽度0-1之间1表示占满类别间隔
width: 0.6,
// 柱子边框
borderWidth: 1,
borderColor: Colors.black12,
// 数据标签
dataLabelSettings: const DataLabelSettings(
isVisible: true,
color: Colors.white,
opacity: 0,
alignment: ChartAlignment.center,
),
// 动画效果
animationDuration: 2000,
),
],
);
}
Widget pieChart({
required BuildContext context,
required String unit,
required List<ChartData> data,
}) {
// 计算 value 的总和
double sumValue = data.fold(0.0, (sum, item) => sum + item.value);
return SfCircularChart(
// 饼图标题
// title: ChartTitle(text: '菜谱类别占比分布'),
// 启用图例
legend: const Legend(isVisible: true, position: LegendPosition.right),
// 启用交互提示(点击扇区显示详情)
tooltipBehavior: TooltipBehavior(
enable: true,
format: 'point.x: point.y $unit',
),
// 饼图系列配置
series: [
PieSeries<ChartData, String>(
dataSource: data,
// 类别映射(饼图扇区名称)
xValueMapper: (ChartData data, _) => data.name,
// 数值映射(扇区大小占比)
yValueMapper: (ChartData data, _) => data.value,
// 扇区半径0-1之间1表示充满容器
// radius: '50%',
// 启用扇区分离效果
explode: true,
// 指定分离的扇区索引(这里分离第一个扇区)
explodeIndex: 0,
// 分离距离
explodeOffset: '5%',
dataLabelMapper: (ChartData data, _) {
final percentage = (data.value / sumValue * 100).toStringAsFixed(0);
return '$percentage%';
},
// 数据标签(显示在扇区上的文本)
dataLabelSettings: DataLabelSettings(isVisible: true),
// 动画效果
animationDuration: 2000,
),
],
);
}

View File

@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
/// 通用输入框样式
InputDecoration buildInputDecoration({
required BuildContext context,
required String hintText,
Widget? prefixIcon,
}) {
return InputDecoration(
hintText: hintText,
hintStyle: const TextStyle(
color: Color(0xFF999999),
fontSize: 15,
height: 1.2,
),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderSide: const BorderSide(color: Color(0xFFE5E7EB)),
borderRadius: BorderRadius.circular(8),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 1.5),
borderRadius: BorderRadius.circular(8),
),
errorBorder: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.red, width: 1.5),
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 15, vertical: 14),
errorStyle: const TextStyle(fontSize: 12, height: 1, color: Colors.red),
prefixIcon: prefixIcon,
prefixIconConstraints: const BoxConstraints(minWidth: 40),
isDense: true,
);
}
Widget buildFormLabel(String text, {bool required = false}) {
return RichText(
text: TextSpan(
text: text,
style: const TextStyle(
color: Color(0xFF1D2129),
fontSize: 16,
fontWeight: FontWeight.w500,
fontFamily: 'CustomFont',
height: 1.2,
),
children: [
if (required)
const TextSpan(
text: ' *',
style: TextStyle(color: Colors.red, fontSize: 16),
),
],
),
);
}
/// 表单底部按钮组组件
Widget buildFormButtonGroup({
required BuildContext context,
required VoidCallback onConfirm,
}) {
return Row(
children: [
Expanded(child: _buildCancelButton(context)),
const SizedBox(width: 16),
Expanded(child: _buildSubmitButton(context, onConfirm)),
],
);
}
/// 取消按钮
Widget _buildCancelButton(BuildContext context) {
return ElevatedButton(
onPressed: () => Navigator.pop(context), // 直接使用传入的context返回
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF4E5969),
side: const BorderSide(color: Color(0xFFDCDFE6)),
padding: const EdgeInsets.symmetric(vertical: 15),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
elevation: 0,
),
child: const Text('取消'),
);
}
/// 提交按钮
Widget _buildSubmitButton(BuildContext context, VoidCallback onConfirm) {
return ElevatedButton(
onPressed: onConfirm,
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).primaryColor, // 使用传入的context获取主题
padding: const EdgeInsets.symmetric(vertical: 15),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
elevation: 0,
),
child: const Text('提交', style: TextStyle(color: Colors.white)),
);
}

View File

@@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart';
import 'package:photo_view/photo_view_gallery.dart';
class ImagePreviewPage extends StatefulWidget {
final List<String> images;
final int initialIndex;
const ImagePreviewPage({
super.key,
required this.images,
required this.initialIndex,
}) : assert(initialIndex >= 0 && initialIndex < images.length);
@override
State<ImagePreviewPage> createState() => _ImagePreviewPageState();
}
class _ImagePreviewPageState extends State<ImagePreviewPage> {
// 声明 PageController 并初始化初始索引
late PageController _pageController;
// 记录当前显示的图片索引(用于更新页码)
int _currentIndex = 0;
@override
void initState() {
super.initState();
// 初始化控制器,设置初始页面
_pageController = PageController(initialPage: widget.initialIndex);
// 初始化当前索引为初始索引
_currentIndex = widget.initialIndex;
// 监听页面切换事件
_pageController.addListener(() {
// 取当前页面的整数索引(避免滑动过程中的小数)
final currentPage = _pageController.page?.round() ?? 0;
// 只有当索引变化时才更新状态
if (currentPage != _currentIndex) {
setState(() {
_currentIndex = currentPage;
});
}
});
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black54,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
// 显示实时更新的页码(当前索引+1 / 总数量)
title: Text(
'${_currentIndex + 1}/${widget.images.length}',
style: const TextStyle(color: Colors.white),
),
centerTitle: true,
),
body: PhotoViewGallery(
pageOptions: widget.images.map((url) {
return PhotoViewGalleryPageOptions(
imageProvider: NetworkImage(url),
minScale: PhotoViewComputedScale.contained,
maxScale: PhotoViewComputedScale.covered * 2,
// 点击空白处关闭预览
onTapDown: (context, details, controllerValue) {
Navigator.pop(context);
},
);
}).toList(),
pageController: _pageController,
scrollDirection: Axis.horizontal,
),
);
}
}
Widget networkImage(String url) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
url,
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
// 加载中显示占位符
if (loadingProgress == null) return child;
return Center(
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null,
),
);
},
errorBuilder: (context, error, stackTrace) {
return Container(
color: Colors.grey[200],
child: const Icon(Icons.image, color: Colors.grey, size: 30),
);
},
),
);
}

View File

@@ -27,6 +27,38 @@ InputDecoration formInputDecoration({
); );
} }
Widget cardContainer(Widget content) {
return Card(
elevation: 0,
color: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
child: Padding(
padding: const EdgeInsets.all(10),
child: content,
),
);
}
/// 圆形图标按钮
Widget circleIconButton({
required IconData icon,
required VoidCallback onPressed,
required BuildContext context,
}) {
return ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
fixedSize: Size(32, 32),
shape: CircleBorder(),
elevation: 0,
backgroundColor: Theme.of(context).primaryColor,
padding: EdgeInsets.zero,
minimumSize: const Size(0, 0),
),
child: Icon(icon, color: Colors.white, size: 18),
);
}
/// 确认按钮样式 /// 确认按钮样式
ButtonStyle primaryButtonStyle() { ButtonStyle primaryButtonStyle() {
return ButtonStyle( return ButtonStyle(
@@ -51,18 +83,13 @@ Text buttonText({required String text}) {
return Text(text, style: TextStyle(color: Colors.white)); return Text(text, style: TextStyle(color: Colors.white));
} }
/// 图片错误显示
Widget errorImageContainer(double height) { Widget errorImageContainer(double height) {
return Container( return Container(
height: height, height: 200,
width: double.infinity,
color: Colors.grey[200], color: Colors.grey[200],
child: Row( child: const Icon(Icons.image, color: Colors.grey, size: 30),
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error, color: Colors.red),
const SizedBox(width: 5),
const Text("加载失败", style: TextStyle(color: Colors.red)),
],
),
); );
} }

View File

@@ -0,0 +1,119 @@
import 'package:flutter/material.dart';
import 'package:food_hub_app/widgets/common/index.dart';
class YearSelector extends StatefulWidget {
final int initialYear;
final int? minYear;
final int? maxYear;
final Function(int) onYearChanged;
const YearSelector({
super.key,
required this.initialYear,
required this.onYearChanged,
this.minYear,
this.maxYear,
});
@override
State<YearSelector> createState() => _YearSelectorState();
}
// SingleTickerProviderStateMixin 动画控制器
class _YearSelectorState extends State<YearSelector>
with SingleTickerProviderStateMixin {
late int _currentYear;
// 用于动画效果
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_currentYear = widget.initialYear;
// 初始化动画控制器
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
);
// 缩放动画
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.1).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
/// 切换到上一年
void _previousYear() {
if (widget.minYear == null || _currentYear > widget.minYear!) {
_animateYearChange(() {
setState(() {
_currentYear--;
});
widget.onYearChanged(_currentYear);
});
}
}
/// 切换到下一年
void _nextYear() {
if (widget.maxYear == null || _currentYear < widget.maxYear!) {
_animateYearChange(() {
setState(() {
_currentYear++;
});
widget.onYearChanged(_currentYear);
});
}
}
/// 年份变化时的动画效果
void _animateYearChange(VoidCallback onComplete) {
_animationController.forward().then((_) {
onComplete();
_animationController.reverse();
});
}
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
circleIconButton(
context: context,
icon: Icons.chevron_left,
onPressed: () => _previousYear(),
),
// 年份显示
AnimatedBuilder(
animation: _scaleAnimation,
builder: (context, child) {
return Transform.scale(scale: _scaleAnimation.value, child: child);
},
child: Text(
'$_currentYear',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
),
),
circleIconButton(
context: context,
icon: Icons.chevron_right,
onPressed: () => _nextYear(),
)
],
);
}
}

View File

@@ -0,0 +1,216 @@
import 'package:flutter/material.dart';
import 'package:food_hub_app/config/app_config.dart';
import 'package:food_hub_app/models/moment.dart';
import 'package:food_hub_app/widgets/common/image.dart';
import 'package:tdesign_flutter/tdesign_flutter.dart';
class MomentCard extends StatelessWidget {
final Moment moment;
const MomentCard({super.key, required this.moment});
@override
Widget build(BuildContext context) {
return Card(
elevation: 0,
color: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
child: Padding(
padding: const EdgeInsets.all(10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildAvatar(context),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildNickname(),
const SizedBox(height: 5),
_buildContent(),
if (moment.imageList.isNotEmpty) ...[
const SizedBox(height: 8),
_buildPostImages(context),
],
const SizedBox(height: 10),
_buildTime(),
const SizedBox(height: 5),
_buildActionButtons(),
],
),
),
],
),
),
);
}
// 构建头像组件
Widget _buildAvatar(BuildContext context) {
if (moment.avatar!.isEmpty) {
return TDAvatar(
size: TDAvatarSize.medium,
type: TDAvatarType.customText,
shape: TDAvatarShape.circle,
backgroundColor: Theme.of(context).primaryColor,
text: moment.username?[0],
);
} else {
return TDAvatar(
size: TDAvatarSize.medium,
type: TDAvatarType.normal,
fit: BoxFit.contain,
avatarUrl: '${AppConfig.baseApiUrl}/${moment.avatar}',
);
}
}
// 构建昵称组件
Widget _buildNickname() {
return Text(
moment.username ?? "",
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
);
}
// 构建内容组件
Widget _buildContent() {
return Text(
moment.content,
style: const TextStyle(fontSize: 15, height: 1.3),
);
}
// 构建朋友圈图片列表(网格布局)
Widget _buildPostImages(BuildContext context) {
final imageCount = moment.imageList.length;
// 计算网格列数
int crossAxisCount;
if (imageCount == 1) {
crossAxisCount = 1;
} else if (imageCount == 2 || imageCount == 4) {
crossAxisCount = 2;
} else {
crossAxisCount = 3;
}
// 计算宽高比
double itemAspectRatio = 1.0;
if (imageCount == 1) {
itemAspectRatio = 1.5;
}
// 生成图片URL列表用于预览时切换
final List<String> imageUrls =
moment.imageList
.map((path) => '${AppConfig.baseApiUrl}/$path')
.toList();
void imageTapClick(int index) {
Navigator.push(
context,
MaterialPageRoute(
builder:
(context) =>
ImagePreviewPage(images: imageUrls, initialIndex: index),
),
);
}
return GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: crossAxisCount,
crossAxisSpacing: 4,
mainAxisSpacing: 4,
childAspectRatio: itemAspectRatio,
children: List.generate(imageCount, (index) {
// 单个图片项:添加点击事件
return GestureDetector(
// 点击图片时,跳转到预览页面
onTap: () => imageTapClick(index),
// 原图片组件
child: networkImage(imageUrls[index]),
);
}),
);
}
// 构建时间组件
Widget _buildTime() {
return Text(
moment.date ?? "",
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
);
}
// 构建点赞和评论按钮
Widget _buildActionButtons() {
return Row(
children: [
SizedBox(
width: 42,
height: 36,
child: Stack(
alignment: Alignment.bottomLeft,
children: [
Icon(Icons.thumb_up_alt_outlined),
Positioned(
left: 20,
bottom: 15,
child: TDBadge(
TDBadgeType.message,
count: (moment.likeList?.length ?? 0).toString(),
),
),
],
),
),
SizedBox(
width: 42,
height: 36,
child: Stack(
alignment: Alignment.bottomLeft,
children: [
Icon(Icons.comment_outlined),
Positioned(
left: 20,
bottom: 15,
child: TDBadge(
TDBadgeType.message,
count: (moment.commentList?.length ?? 0).toString(),
),
),
],
),
),
// _buildActionButton(
// icon: Icons.thumb_up_alt_outlined,
// count: moment.likeList?.length ?? 0,
// ),
// const SizedBox(width: 20),
// _buildActionButton(
// icon: Icons.comment_outlined,
// count: moment.commentList?.length ?? 0,
// ),
],
);
}
// 构建带数字标记的动作按钮
Widget _buildActionButton({required IconData icon, required int count}) {
return Row(
children: [
Icon(icon, color: Colors.grey[500], size: 18),
const SizedBox(width: 4),
if (count > 0)
Text(
count.toString(),
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
),
],
);
}
}

View File

@@ -1,8 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:food_hub_app/api/recipe.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/utils/date_util.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:table_calendar/table_calendar.dart'; import 'package:table_calendar/table_calendar.dart';
import 'package:tdesign_flutter/tdesign_flutter.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart';
@@ -145,12 +146,10 @@ class _RecipeCalendarState extends State<RecipeCalendar> {
], ],
), ),
), ),
SizedBox(width: 10), circleIconButton(
TDButton( context: context,
icon: TDIcons.arrow_right, icon: Icons.chevron_right,
type: TDButtonType.fill, onPressed: () => {}
shape: TDButtonShape.circle,
theme: TDButtonTheme.primary,
), ),
], ],
), ),
@@ -171,9 +170,9 @@ class _RecipeCalendarState extends State<RecipeCalendar> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: color:
isSelected isSelected
? Colors.blue ? Theme.of(context).primaryColor
: isToday : isToday
? Colors.grey[200] ? Colors.grey[300]
: Colors.transparent, : Colors.transparent,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
@@ -184,7 +183,7 @@ class _RecipeCalendarState extends State<RecipeCalendar> {
// //
Text( Text(
day.day.toString(), day.day.toString(),
style: TextStyle(color: isSelected ? Colors.white : Colors.black87), style: TextStyle(color: isSelected ? Colors.white : Colors.black),
), ),
// - // -
if (shouldShowRedDot) if (shouldShowRedDot)
@@ -207,7 +206,6 @@ class _RecipeCalendarState extends State<RecipeCalendar> {
return Column( return Column(
children: [ children: [
Card(elevation: 0, color: Colors.white, child: recipeCalendar()), Card(elevation: 0, color: Colors.white, child: recipeCalendar()),
const SizedBox(height: 10),
Expanded(child: dailyItem()), Expanded(child: dailyItem()),
], ],
); );

View File

@@ -1,30 +1,68 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_carousel_widget/flutter_carousel_widget.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/index.dart'; import 'package:food_hub_app/widgets/common/index.dart';
import 'package:tdesign_flutter/tdesign_flutter.dart';
class RecipeCard extends StatelessWidget { class RecipeCard extends StatelessWidget {
final Recipe recipe; final RecipeSummary recipe;
const RecipeCard({super.key, required this.recipe}); const RecipeCard({super.key, required this.recipe});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final List<String> imageUrls =
recipe.recordList
.map((item) => '${AppConfig.baseApiUrl}/${item.imageUrl}')
.toList();
Widget buildCarouselItem(String url) {
return Builder(
builder: (BuildContext context) {
return AspectRatio(
aspectRatio: 2,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: networkImage(url),
),
);
},
);
}
Widget buildRecipeCarousel() {
return FlutterCarousel(
//
items:
imageUrls.map((url) {
return buildCarouselItem(url);
}).toList(),
//
options: FlutterCarouselOptions(
height: 300,
autoPlay: imageUrls.length > 1,
enableInfiniteScroll: imageUrls.length > 1,
autoPlayInterval: const Duration(seconds: 3),
viewportFraction: 0.9,
showIndicator: true,
),
);
}
return Card( return Card(
shape: RoundedRectangleBorder(
side: BorderSide(color: Theme.of(context).primaryColor, width: 1.0),
borderRadius: BorderRadius.circular(10.0),
),
elevation: 0, elevation: 0,
color: Colors.white, color: Colors.white,
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Image.network( buildRecipeCarousel(),
'http://172.29.101.108:8100/${recipe.recordList[0].imageUrl}',
height: 200,
width: double.infinity,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) => errorImageContainer(200),
),
Divider( Divider(
height: 1, height: 1,
thickness: 1, thickness: 1,
@@ -58,22 +96,20 @@ class RecipeCard extends StatelessWidget {
), ),
Row( Row(
children: [ children: [
TDButton( circleIconButton(
context: context,
icon: Icons.edit, icon: Icons.edit,
size: TDButtonSize.small, onPressed: () => {},
type: TDButtonType.fill,
shape: TDButtonShape.circle,
theme: TDButtonTheme.primary,
onTap: () => {},
), ),
SizedBox(width: 10), circleIconButton(
TDButton( context: context,
icon: Icons.book, icon: Icons.book,
size: TDButtonSize.small, onPressed:
type: TDButtonType.fill, () => Navigator.pushNamed(
shape: TDButtonShape.circle, context,
theme: TDButtonTheme.primary, '/recipeDetail',
onTap: () => {}, arguments: {'id': recipe.id},
),
), ),
], ],
), ),

View File

@@ -1,19 +1,18 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:food_hub_app/api/recipe.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/widgets/recipe/recipe_card.dart'; import 'package:food_hub_app/widgets/recipe/card.dart';
import 'package:tdesign_flutter/tdesign_flutter.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart';
class RecipeList extends StatefulWidget { class RecipeList extends StatefulWidget {
const RecipeList({super.key}); const RecipeList({super.key});
@override @override
State<RecipeList> createState() => _RecipeListState(); State<RecipeList> createState() => _RecipeListState();
} }
class _RecipeListState extends State<RecipeList> { class _RecipeListState extends State<RecipeList> {
List<Recipe> recipeList = []; List<RecipeSummary> recipeSummaryList = [];
@override @override
void initState() { void initState() {
@@ -25,23 +24,20 @@ class _RecipeListState extends State<RecipeList> {
final result = await queryRecipeApi(RecipeQuery(category: "")); final result = await queryRecipeApi(RecipeQuery(category: ""));
setState(() { setState(() {
recipeList = result; recipeSummaryList = result;
}); });
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (recipeList.isEmpty) { if (recipeSummaryList.isEmpty) {
return const TDEmpty(type: TDEmptyType.plain, emptyText: '暂无数据'); return const TDEmpty(type: TDEmptyType.plain, emptyText: '暂无数据');
} else { } else {
return ListView.separated( return ListView.builder(
itemCount: recipeList.length, itemCount: recipeSummaryList.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
return RecipeCard(recipe: recipeList[index]); return RecipeCard(recipe: recipeSummaryList[index]);
}, }
separatorBuilder: (context, index) {
return const SizedBox(height: 10);
},
); );
} }
} }

View File

@@ -1,248 +0,0 @@
import 'package:flutter/material.dart';
import 'package:food_hub_app/api/recipe.dart';
import 'package:food_hub_app/models/recipe.dart';
import 'package:food_hub_app/widgets/common/index.dart';
import 'package:tdesign_flutter/tdesign_flutter.dart';
import 'package:timelines_plus/timelines_plus.dart';
class RecipeTimeline extends StatefulWidget {
const RecipeTimeline({super.key});
@override
State<StatefulWidget> createState() => _RecipeTimeline();
}
class _RecipeTimeline extends State<RecipeTimeline> {
List<Record> recordList = [];
@override
void initState() {
super.initState();
refreshRecord();
}
Future<void> refreshRecord() async {
final result = await queryRecordApi("2025-01-01", "2025-07-08");
setState(() {
recordList = result;
});
}
@override
Widget build(BuildContext context) {
return Column(
children: [
YearSelector(
initialYear: DateTime.now().year,
minYear: 2000,
maxYear: 2100,
onYearChanged: (year) {
print('选中的年份: $year');
},
accentColor: Colors.blue,
),
Expanded(child: TimelineContainer(recordList))
],
);
}
}
Widget TimelineContainer(List<Record> recordList) {
if (recordList.isEmpty) {
return const TDEmpty(type: TDEmptyType.plain, emptyText: '暂无数据');
} else {
return Padding(
padding: EdgeInsets.all(10),
child: Timeline.tileBuilder(
theme: TimelineThemeData(nodePosition: 0, indicatorPosition: 0),
builder: TimelineTileBuilder.connected(
itemCount: recordList.length,
connectorBuilder:
(context, index, type) => Connector.solidLine(thickness: 2),
indicatorBuilder: (context, index) {
return Indicator.dot(size: 12.0);
},
contentsBuilder: (context, index) {
return TimelineCard(record: recordList[index]);
},
),
),
);
}
}
class TimelineCard extends StatelessWidget {
final Record record;
const TimelineCard({super.key, required this.record});
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.only(left: 10, bottom: 5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
record.date,
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
Card(
elevation: 0,
color: Colors.white,
child: Padding(
padding: EdgeInsets.all(10),
child: Column(
children: [
Text(record.name, style: TextStyle(fontSize: 16)),
const SizedBox(height: 5),
Image.network(
'http://172.29.101.108:8100/${record.imageUrl}',
width: double.infinity,
height: 250,
fit: BoxFit.contain,
errorBuilder:
(context, error, stackTrace) =>
errorImageContainer(250),
),
],
),
),
),
],
),
),
);
}
}
class YearSelector extends StatefulWidget {
final int initialYear;
final int? minYear;
final int? maxYear;
final Function(int) onYearChanged;
final Color? accentColor;
const YearSelector({
super.key,
required this.initialYear,
required this.onYearChanged,
this.minYear,
this.maxYear,
this.accentColor,
});
@override
State<YearSelector> createState() => _YearSelectorState();
}
class _YearSelectorState extends State<YearSelector>
with SingleTickerProviderStateMixin {
late int _currentYear;
late Color _accentColor;
// 用于动画效果
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_currentYear = widget.initialYear;
_accentColor = widget.accentColor ?? Theme
.of(context)
.primaryColor;
// 初始化动画控制器
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
);
// 缩放动画
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.1).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
/// 切换到上一年
void _previousYear() {
if (widget.minYear == null || _currentYear > widget.minYear!) {
_animateYearChange(() {
setState(() {
_currentYear--;
});
widget.onYearChanged(_currentYear);
});
}
}
/// 切换到下一年
void _nextYear() {
if (widget.maxYear == null || _currentYear < widget.maxYear!) {
_animateYearChange(() {
setState(() {
_currentYear++;
});
widget.onYearChanged(_currentYear);
});
}
}
/// 年份变化时的动画效果
void _animateYearChange(VoidCallback onComplete) {
_animationController.forward().then((_) {
onComplete();
_animationController.reverse();
});
}
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TDButton(
icon: Icons.chevron_left,
size: TDButtonSize.small,
type: TDButtonType.fill,
shape: TDButtonShape.circle,
theme: TDButtonTheme.primary,
onTap: () => _previousYear(),
),
SizedBox(width: 10),
// 年份显示
AnimatedBuilder(
animation: _scaleAnimation,
builder: (context, child) {
return Transform.scale(scale: _scaleAnimation.value, child: child);
},
child: Text(
'$_currentYear',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: _accentColor,
),
),
),
TDButton(
icon: Icons.chevron_right,
size: TDButtonSize.small,
type: TDButtonType.fill,
shape: TDButtonShape.circle,
theme: TDButtonTheme.primary,
onTap: () => _nextYear(),
),
],
);
}
}

View File

@@ -0,0 +1,135 @@
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/widgets/common/image.dart';
import 'package:food_hub_app/widgets/common/year_selector.dart';
import 'package:tdesign_flutter/tdesign_flutter.dart';
import 'package:timelines_plus/timelines_plus.dart';
class RecipeTimeline extends StatefulWidget {
const RecipeTimeline({super.key});
@override
State<StatefulWidget> createState() => _RecipeTimeline();
}
class _RecipeTimeline extends State<RecipeTimeline> {
List<Record> 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;
});
}
@override
Widget build(BuildContext context) {
return Column(
children: [
YearSelector(
initialYear: DateTime.now().year,
minYear: 2000,
maxYear: 2100,
onYearChanged: (year) => refreshRecord(year),
),
Expanded(child: timelineContainer(recordList)),
],
);
}
}
Widget timelineContainer(List<Record> recordList) {
if (recordList.isEmpty) {
return const TDEmpty(type: TDEmptyType.plain, emptyText: '暂无数据');
} else {
return Timeline.tileBuilder(
theme: TimelineThemeData(nodePosition: 0, indicatorPosition: 0),
builder: TimelineTileBuilder.connected(
itemCount: recordList.length,
connectorBuilder:
(context, index, type) => Connector.solidLine(
thickness: 2,
color: Theme.of(context).primaryColor,
),
indicatorBuilder: (context, index) {
return Indicator.dot(
size: 12.0,
color: Theme.of(context).primaryColor,
);
},
contentsBuilder: (context, index) {
return TimelineCard(record: recordList[index]);
},
),
);
}
}
class TimelineCard extends StatelessWidget {
final Record record;
const TimelineCard({super.key, required this.record});
Widget cardContent(BuildContext context) {
final imageUrls = ['${AppConfig.baseApiUrl}/${record.imageUrl}'];
void imageTapClick() {
Navigator.push(
context,
MaterialPageRoute(
builder:
(context) => ImagePreviewPage(images: imageUrls, initialIndex: 0),
),
);
}
return Card(
elevation: 0,
color: Colors.white,
child: Padding(
padding: EdgeInsets.all(10),
child: Column(
children: [
Text(record.name, style: TextStyle(fontSize: 16)),
const SizedBox(height: 5),
GestureDetector(
onTap: () => imageTapClick(),
child: AspectRatio(
aspectRatio: 1.5,
child: networkImage(imageUrls[0]),
),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.only(left: 10, bottom: 5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
record.date,
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
cardContent(context),
],
),
),
);
}
}

115
lib/widgets/stats/card.dart Normal file
View File

@@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import 'package:food_hub_app/models/stats.dart';
class StatisticCard extends StatelessWidget {
final IconData icon;
final Color color;
final String title;
final num value;
final String unit;
const StatisticCard({
super.key,
required this.icon,
required this.color,
required this.title,
required this.value,
required this.unit,
});
@override
Widget build(BuildContext context) {
return Card(
color: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
child: Padding(
padding: const EdgeInsets.all(10.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 40,
height: 40,
child: Icon(icon, color: color, size: 40),
),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(title, style: TextStyle(fontSize: 20, color: color)),
const SizedBox(height: 4),
Row(
children: [
Text(
value.toString(),
style: TextStyle(fontSize: 20, color: color),
),
const SizedBox(width: 5),
Text(unit, style: TextStyle(fontSize: 20, color: color)),
],
),
],
),
],
),
),
);
}
}
// 获取排名对应的颜色
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)),
),
],
),
);
},
);
}

View File

@@ -202,7 +202,7 @@ packages:
source: hosted source: hosted
version: "2.1.1" version: "2.1.1"
easy_refresh: easy_refresh:
dependency: transitive dependency: "direct main"
description: description:
name: easy_refresh name: easy_refresh
sha256: "486e30abfcaae66c0f2c2798a10de2298eb9dc5e0bb7e1dba9328308968cae0c" sha256: "486e30abfcaae66c0f2c2798a10de2298eb9dc5e0bb7e1dba9328308968cae0c"
@@ -278,6 +278,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_carousel_widget:
dependency: "direct main"
description:
name: flutter_carousel_widget
sha256: "6473e6df04bfafea70efd58251fe5945d5aa8d19461575c1b9d83643f08e0c77"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.0"
flutter_form_builder: flutter_form_builder:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -365,14 +373,6 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "2.1.3" version: "2.1.3"
graphic:
dependency: "direct main"
description:
name: graphic
sha256: af3a5a967d95ce2c2c9f7dee83c21f8bd6e6a458341820920cf15c0311cdaddc
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.6.0"
graphs: graphs:
dependency: transitive dependency: transitive
description: description:
@@ -645,6 +645,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "2.3.0" version: "2.3.0"
photo_view:
dependency: "direct main"
description:
name: photo_view
sha256: "1fc3d970a91295fbd1364296575f854c9863f225505c28c46e0a03e48960c75e"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.15.0"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@@ -826,6 +834,22 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.4.1" version: "1.4.1"
syncfusion_flutter_charts:
dependency: "direct main"
description:
name: syncfusion_flutter_charts
sha256: c58ca79e072680af6f0554f7c4b91886c1d8808f2522b49d557f16fb5cb3bb04
url: "https://pub.flutter-io.cn"
source: hosted
version: "30.1.41"
syncfusion_flutter_core:
dependency: transitive
description:
name: syncfusion_flutter_core
sha256: "536753489e168f49659261d0abd02b101b34c0b1bde27898f2869aab05f1cbb6"
url: "https://pub.flutter-io.cn"
source: hosted
version: "30.1.41"
table_calendar: table_calendar:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@@ -40,13 +40,16 @@ dependencies:
table_calendar: ^3.1.3 table_calendar: ^3.1.3
flutter_form_builder: ^10.0.0 flutter_form_builder: ^10.0.0
form_builder_validators: ^11.1.2 form_builder_validators: ^11.1.2
graphic: ^2.6.0
intl: ^0.19.0 intl: ^0.19.0
tdesign_flutter: ^0.2.3 tdesign_flutter: ^0.2.3
json_annotation: ^4.9.0 json_annotation: ^4.9.0
fluttertoast: ^8.2.0 fluttertoast: ^8.2.0
shared_preferences: ^2.3.0 shared_preferences: ^2.3.0
logger: ^2.6.0 logger: ^2.6.0
photo_view: ^0.15.0
flutter_carousel_widget: ^3.1.0
easy_refresh: ^3.4.0
syncfusion_flutter_charts: ^30.1.41
dependency_overrides: dependency_overrides:
tdesign_flutter_adaptation: 3.16.0 tdesign_flutter_adaptation: 3.16.0

View File

@@ -11,7 +11,7 @@ namespace {
/// ///
/// Redefined in case the developer's machine has a Windows SDK older than /// Redefined in case the developer's machine has a Windows SDK older than
/// version 10.0.22000.0. /// version 10.0.22000.0.
/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute /// See: https://docs.microsoft.com/windows/win32/apis/dwmapi/ne-dwmapi-dwmwindowattribute
#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE #ifndef DWMWA_USE_IMMERSIVE_DARK_MODE
#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 #define DWMWA_USE_IMMERSIVE_DARK_MODE 20
#endif #endif