Compare commits
10 Commits
65958d925e
...
6bba9a488c
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bba9a488c | |||
| 504ee073ce | |||
| b5dd0ab749 | |||
| 8923d9e63a | |||
| 34780651a8 | |||
| a2ed0f3d98 | |||
| 502bb9df52 | |||
| 7da3ec9507 | |||
| d9f1781277 | |||
| 5d156d3a02 |
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 4.0 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
android/app/src/main/res/mipmap-ldpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 2.1 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 6.2 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 13 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 19 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
@@ -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
@@ -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
@@ -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()),
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import 'package:food_hub_app/utils/http_util.dart';
|
||||
|
||||
Future<Session> loginApi(String username, String password) {
|
||||
return HttpUtil().post<Session>(
|
||||
"/food-service/session",
|
||||
"/session",
|
||||
queryParameters: {"username": username, "password": password},
|
||||
converter: (data) => Session.fromJson(data),
|
||||
);
|
||||
31
lib/apis/stats.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
9
lib/config/app_config.dart
Normal 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";
|
||||
}
|
||||
@@ -2,42 +2,30 @@ import 'package:flutter/material.dart';
|
||||
|
||||
class NavBar extends StatelessWidget {
|
||||
final int currentIndex;
|
||||
final List<BottomNavigationBarItem> navItems;
|
||||
final Function(int) onTap;
|
||||
|
||||
static const List<BottomNavigationBarItem> navItems = [
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
activeIcon: Icon(Icons.home),
|
||||
label: "记录",
|
||||
),
|
||||
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});
|
||||
const NavBar({
|
||||
super.key,
|
||||
required this.currentIndex,
|
||||
required this.onTap,
|
||||
required this.navItems,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BottomNavigationBar(
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(top: BorderSide(color: Theme.of(context).primaryColor)),
|
||||
),
|
||||
child: BottomNavigationBar(
|
||||
currentIndex: currentIndex,
|
||||
iconSize: 25,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
backgroundColor: Colors.white,
|
||||
items: navItems,
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -110,7 +98,7 @@ class SettingsDrawer extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
List<StatelessWidget> homeActions() {
|
||||
List<StatelessWidget> homeActions(BuildContext context) {
|
||||
return [
|
||||
IconButton(
|
||||
icon: Icon(Icons.search, color: Colors.white),
|
||||
@@ -118,15 +106,11 @@ List<StatelessWidget> homeActions() {
|
||||
// 搜索功能
|
||||
},
|
||||
),
|
||||
Builder(
|
||||
builder: (BuildContext context) {
|
||||
return IconButton(
|
||||
icon: Icon(Icons.settings, color: Colors.white),
|
||||
onPressed: () {
|
||||
Scaffold.of(context).openEndDrawer();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
// IconButton(
|
||||
// icon: Icon(Icons.add, color: Colors.white),
|
||||
// onPressed: () {
|
||||
// Navigator.pushNamed(context, '/recordForm');
|
||||
// },
|
||||
// ),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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/views/home.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:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@@ -46,6 +47,7 @@ class MyApp extends StatelessWidget {
|
||||
routes: {
|
||||
'/home': (context) => HomePage(),
|
||||
'/recordForm': (context) => RecordFormPage(),
|
||||
'/recipeDetail': (context) => RecipeDetailPage(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
16
lib/models/layout.dart
Normal 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
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -4,43 +4,43 @@ part 'recipe.g.dart';
|
||||
|
||||
/// 食材信息
|
||||
@JsonSerializable()
|
||||
class Material {
|
||||
class RecipeMaterial {
|
||||
String type;
|
||||
String name;
|
||||
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) =>
|
||||
_$MaterialFromJson(json);
|
||||
factory RecipeMaterial.fromJson(Map<String, dynamic> json) =>
|
||||
_$RecipeMaterialFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$MaterialToJson(this);
|
||||
Map<String, dynamic> toJson() => _$RecipeMaterialToJson(this);
|
||||
}
|
||||
|
||||
/// 步骤信息
|
||||
@JsonSerializable()
|
||||
class Step {
|
||||
class RecipeStep {
|
||||
int sort;
|
||||
String content;
|
||||
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()
|
||||
class Comment {
|
||||
class RecipeComment {
|
||||
int id;
|
||||
String username;
|
||||
String avatar;
|
||||
String content;
|
||||
String date;
|
||||
|
||||
Comment({
|
||||
RecipeComment({
|
||||
required this.id,
|
||||
required this.username,
|
||||
required this.avatar,
|
||||
@@ -48,10 +48,10 @@ class Comment {
|
||||
required this.date,
|
||||
});
|
||||
|
||||
factory Comment.fromJson(Map<String, dynamic> json) =>
|
||||
_$CommentFromJson(json);
|
||||
factory RecipeComment.fromJson(Map<String, dynamic> json) =>
|
||||
_$RecipeCommentFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$CommentToJson(this);
|
||||
Map<String, dynamic> toJson() => _$RecipeCommentToJson(this);
|
||||
}
|
||||
|
||||
/// 成果信息
|
||||
@@ -94,44 +94,44 @@ class RecipeQuery {
|
||||
/// 菜谱信息
|
||||
@JsonSerializable()
|
||||
class Recipe {
|
||||
int? id;
|
||||
int id;
|
||||
String name;
|
||||
String category;
|
||||
double recommendRate;
|
||||
String? remark;
|
||||
String remark;
|
||||
bool isShare;
|
||||
int? userId;
|
||||
String? username;
|
||||
String? avatar;
|
||||
List<Material>? materialList;
|
||||
List<Step>? stepList;
|
||||
int userId;
|
||||
String username;
|
||||
String avatar;
|
||||
List<RecipeMaterial> materialList;
|
||||
List<RecipeStep> stepList;
|
||||
List<Record> recordList;
|
||||
List<int>? likeList;
|
||||
int? likeCount;
|
||||
List<int>? favouriteList;
|
||||
int? favouriteCount;
|
||||
List<Comment>? commentList;
|
||||
int? commentCount;
|
||||
List<int> likeList;
|
||||
int likeCount;
|
||||
List<int> favouriteList;
|
||||
int favouriteCount;
|
||||
List<RecipeComment> commentList;
|
||||
int commentCount;
|
||||
|
||||
Recipe({
|
||||
this.id,
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.category,
|
||||
required this.recommendRate,
|
||||
this.remark,
|
||||
required this.remark,
|
||||
required this.isShare,
|
||||
this.userId,
|
||||
this.username,
|
||||
this.avatar,
|
||||
this.materialList,
|
||||
this.stepList,
|
||||
required this.userId,
|
||||
required this.username,
|
||||
required this.avatar,
|
||||
required this.materialList,
|
||||
required this.stepList,
|
||||
required this.recordList,
|
||||
this.likeList,
|
||||
this.likeCount,
|
||||
this.favouriteList,
|
||||
this.favouriteCount,
|
||||
this.commentList,
|
||||
this.commentCount,
|
||||
required this.likeList,
|
||||
required this.likeCount,
|
||||
required this.favouriteList,
|
||||
required this.favouriteCount,
|
||||
required this.commentList,
|
||||
required this.commentCount,
|
||||
});
|
||||
|
||||
factory Recipe.fromJson(Map<String, dynamic> json) => _$RecipeFromJson(json);
|
||||
@@ -139,4 +139,80 @@ class Recipe {
|
||||
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 }
|
||||
|
||||
@@ -6,45 +6,50 @@ part of 'recipe.dart';
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
Material _$MaterialFromJson(Map<String, dynamic> json) => Material(
|
||||
RecipeMaterial _$RecipeMaterialFromJson(Map<String, dynamic> json) =>
|
||||
RecipeMaterial(
|
||||
type: json['type'] as String,
|
||||
name: json['name'] 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,
|
||||
'name': instance.name,
|
||||
'amount': instance.amount,
|
||||
};
|
||||
};
|
||||
|
||||
Step _$StepFromJson(Map<String, dynamic> json) => Step(
|
||||
RecipeStep _$RecipeStepFromJson(Map<String, dynamic> json) => RecipeStep(
|
||||
sort: (json['sort'] as num).toInt(),
|
||||
content: json['content'] 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,
|
||||
'content': instance.content,
|
||||
'imageUrl': instance.imageUrl,
|
||||
};
|
||||
};
|
||||
|
||||
Comment _$CommentFromJson(Map<String, dynamic> json) => Comment(
|
||||
RecipeComment _$RecipeCommentFromJson(Map<String, dynamic> json) =>
|
||||
RecipeComment(
|
||||
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>{
|
||||
Map<String, dynamic> _$RecipeCommentToJson(RecipeComment instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'username': instance.username,
|
||||
'avatar': instance.avatar,
|
||||
'content': instance.content,
|
||||
'date': instance.date,
|
||||
};
|
||||
};
|
||||
|
||||
Record _$RecordFromJson(Map<String, dynamic> json) => Record(
|
||||
id: (json['id'] as num?)?.toInt(),
|
||||
@@ -71,42 +76,42 @@ Map<String, dynamic> _$RecipeQueryToJson(RecipeQuery instance) =>
|
||||
<String, dynamic>{'category': instance.category};
|
||||
|
||||
Recipe _$RecipeFromJson(Map<String, dynamic> json) => Recipe(
|
||||
id: (json['id'] as num?)?.toInt(),
|
||||
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?,
|
||||
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?,
|
||||
userId: (json['userId'] as num).toInt(),
|
||||
username: json['username'] as String,
|
||||
avatar: json['avatar'] as String,
|
||||
materialList:
|
||||
(json['materialList'] as List<dynamic>?)
|
||||
?.map((e) => Material.fromJson(e as Map<String, dynamic>))
|
||||
(json['materialList'] as List<dynamic>)
|
||||
.map((e) => RecipeMaterial.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
stepList:
|
||||
(json['stepList'] as List<dynamic>?)
|
||||
?.map((e) => Step.fromJson(e as Map<String, dynamic>))
|
||||
(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())
|
||||
(json['likeList'] as List<dynamic>)
|
||||
.map((e) => (e as num).toInt())
|
||||
.toList(),
|
||||
likeCount: (json['likeCount'] as num?)?.toInt(),
|
||||
likeCount: (json['likeCount'] as num).toInt(),
|
||||
favouriteList:
|
||||
(json['favouriteList'] as List<dynamic>?)
|
||||
?.map((e) => (e as num).toInt())
|
||||
(json['favouriteList'] as List<dynamic>)
|
||||
.map((e) => (e as num).toInt())
|
||||
.toList(),
|
||||
favouriteCount: (json['favouriteCount'] as num?)?.toInt(),
|
||||
favouriteCount: (json['favouriteCount'] as num).toInt(),
|
||||
commentList:
|
||||
(json['commentList'] as List<dynamic>?)
|
||||
?.map((e) => Comment.fromJson(e as Map<String, dynamic>))
|
||||
(json['commentList'] as List<dynamic>)
|
||||
.map((e) => RecipeComment.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
commentCount: (json['commentCount'] as num?)?.toInt(),
|
||||
commentCount: (json['commentCount'] as num).toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$RecipeToJson(Recipe instance) => <String, dynamic>{
|
||||
@@ -129,3 +134,93 @@ Map<String, dynamic> _$RecipeToJson(Recipe instance) => <String, dynamic>{
|
||||
'commentList': instance.commentList,
|
||||
'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
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
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/widgets/common/index.dart';
|
||||
|
||||
@@ -11,7 +11,6 @@ class HttpUtil {
|
||||
factory HttpUtil() => _instance;
|
||||
|
||||
late Dio _dio;
|
||||
String baseUrl = kDebugMode ? "http://172.29.101.108:8100" : "http://14.103.235.151:81";
|
||||
|
||||
// 请求头配置
|
||||
Map<String, dynamic> headers = {
|
||||
@@ -25,7 +24,7 @@ class HttpUtil {
|
||||
HttpUtil._internal() {
|
||||
// 初始化Dio实例
|
||||
BaseOptions options = BaseOptions(
|
||||
baseUrl: baseUrl,
|
||||
baseUrl: AppConfig.baseApiUrl,
|
||||
connectTimeout: Duration(seconds: timeout),
|
||||
receiveTimeout: Duration(seconds: timeout),
|
||||
headers: headers,
|
||||
@@ -81,10 +80,6 @@ class HttpUtil {
|
||||
T Function(dynamic data)? converter,
|
||||
}) async {
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
path = path.replaceFirst('/food-service', '');
|
||||
}
|
||||
|
||||
Response response = await _dio.request(
|
||||
path,
|
||||
data: data,
|
||||
|
||||
3063
lib/views/data.dart
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.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/profile.dart';
|
||||
import 'package:food_hub_app/views/record.dart';
|
||||
@@ -15,15 +16,112 @@ class HomePage extends StatefulWidget {
|
||||
class _HomePage extends State<HomePage> {
|
||||
int _currentIndex = 0;
|
||||
|
||||
final List<Widget> _tabPages = const [
|
||||
RecordPage(),
|
||||
StatsPage(),
|
||||
MomentPage(),
|
||||
ProfilePage(),
|
||||
final List<NavItem> navItems = [
|
||||
NavItem(
|
||||
label: "记录",
|
||||
icon: Icons.home_outlined,
|
||||
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) {
|
||||
return index == 0 || index == 2;
|
||||
List<BottomNavigationBarItem> get bottomNavItems =>
|
||||
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
|
||||
@@ -33,20 +131,29 @@ class _HomePage extends State<HomePage> {
|
||||
centerTitle: true,
|
||||
title: Text('Food Hub', style: TextStyle(color: Colors.white)),
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.menu, color: Colors.white),
|
||||
onPressed: () {
|
||||
// 打开侧边栏或菜单
|
||||
leading: Builder(
|
||||
builder: (context) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.menu),
|
||||
color: Colors.white,
|
||||
onPressed: () => Scaffold.of(context).openDrawer(),
|
||||
);
|
||||
},
|
||||
),
|
||||
actions: homeActions(),
|
||||
actions: homeActions(context),
|
||||
),
|
||||
backgroundColor: Color(0xFFF5F5F5),
|
||||
endDrawer: const SettingsDrawer(),
|
||||
body: _tabPages[_currentIndex],
|
||||
floatingActionButton:
|
||||
isShow(_currentIndex) ? addItemButton(context) : null,
|
||||
drawer: const SettingsDrawer(),
|
||||
body: tabPages[_currentIndex],
|
||||
floatingActionButton: FloatingActionButton(
|
||||
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(
|
||||
navItems: bottomNavItems,
|
||||
currentIndex: _currentIndex,
|
||||
onTap: (index) {
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.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/utils/sp_util.dart';
|
||||
import 'package:food_hub_app/widgets/common/index.dart';
|
||||
|
||||
@@ -1,15 +1,194 @@
|
||||
import 'package:easy_refresh/easy_refresh.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 {
|
||||
const MomentPage({super.key});
|
||||
|
||||
@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
|
||||
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
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
247
lib/views/recipe_detail.dart
Normal 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,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:food_hub_app/widgets/recipe/recipe_calendar.dart';
|
||||
import 'package:food_hub_app/widgets/recipe/recipe_list.dart';
|
||||
import 'package:food_hub_app/widgets/recipe/recipe_timeline.dart';
|
||||
import 'package:food_hub_app/widgets/recipe/calendar.dart';
|
||||
import 'package:food_hub_app/widgets/recipe/list.dart';
|
||||
import 'package:food_hub_app/widgets/recipe/timeline.dart';
|
||||
import 'package:tdesign_flutter/tdesign_flutter.dart';
|
||||
|
||||
|
||||
@@ -39,9 +39,7 @@ class _RecordPageState extends State<RecordPage>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.all(0),
|
||||
child: Column(
|
||||
return Column(
|
||||
children: [
|
||||
TDTabBar(
|
||||
tabs: tabs,
|
||||
@@ -63,7 +61,6 @@ class _RecordPageState extends State<RecordPage>
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:graphic/graphic.dart';
|
||||
|
||||
import 'data.dart';
|
||||
import 'package:food_hub_app/apis/stats.dart';
|
||||
import 'package:food_hub_app/models/stats.dart';
|
||||
import 'package:food_hub_app/widgets/common/chart.dart';
|
||||
import 'package:food_hub_app/widgets/stats/card.dart';
|
||||
|
||||
class StatsPage extends StatefulWidget {
|
||||
const StatsPage({super.key});
|
||||
@@ -11,86 +12,93 @@ class StatsPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _StatsPage extends State<StatsPage> {
|
||||
var _smooth = false;
|
||||
var _stepped = false;
|
||||
final double chartHeight = 400;
|
||||
|
||||
SummaryStats summaryStats = SummaryStats(
|
||||
recipeCount: 0,
|
||||
categoryCount: 0,
|
||||
workCount: 0,
|
||||
);
|
||||
|
||||
late double averageRecordCount = 0;
|
||||
|
||||
List<ChartData> recordStats = [];
|
||||
List<ChartData> categoryStats = [];
|
||||
List<ChartData> rankStats = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
refreshStats();
|
||||
}
|
||||
|
||||
Future<void> refreshStats() async {
|
||||
final result1 = await queryStatsApi();
|
||||
final result2 = await queryRecordStatsApi();
|
||||
final result3 = await queryCategoryStatsApi();
|
||||
final result4 = await queryRankStatsApi();
|
||||
|
||||
setState(() {
|
||||
summaryStats = result1;
|
||||
recordStats = result2;
|
||||
categoryStats = result3;
|
||||
rankStats = result4;
|
||||
|
||||
if (recordStats.isNotEmpty) {
|
||||
double sumValue = recordStats.fold(0.0, (sum, item) => sum + item.value);
|
||||
averageRecordCount = sumValue / recordStats.length;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(5),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 10),
|
||||
width: 350,
|
||||
height: 300,
|
||||
child: Chart(
|
||||
data: basicData,
|
||||
variables: {
|
||||
'genre': Variable(
|
||||
accessor: (Map map) => map['genre'] as String,
|
||||
children: [
|
||||
_buildSummaryStats(),
|
||||
SizedBox(
|
||||
height: chartHeight,
|
||||
child: Card(
|
||||
color: Colors.white,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
'sold': Variable(accessor: (Map map) => map['sold'] as num),
|
||||
},
|
||||
marks: [
|
||||
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)},
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: _buildRecordStats(),
|
||||
),
|
||||
),
|
||||
],
|
||||
axes: [Defaults.horizontalAxis, Defaults.verticalAxis],
|
||||
selections: {'tap': PointSelection(dim: Dim.x)},
|
||||
tooltip: TooltipGuide(),
|
||||
crosshair: CrosshairGuide(),
|
||||
),
|
||||
SizedBox(
|
||||
height: chartHeight,
|
||||
child: Card(
|
||||
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(
|
||||
margin: const EdgeInsets.only(top: 10),
|
||||
width: 350,
|
||||
height: 300,
|
||||
child: Chart(
|
||||
data: basicData,
|
||||
variables: {
|
||||
'genre': Variable(
|
||||
accessor: (Map map) => map['genre'] as String,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: _buildRankStats(),
|
||||
),
|
||||
'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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
183
lib/widgets/common/chart.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
102
lib/widgets/common/form.dart
Normal 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)),
|
||||
);
|
||||
}
|
||||
115
lib/widgets/common/image.dart
Normal 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),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
return ButtonStyle(
|
||||
@@ -51,18 +83,13 @@ Text buttonText({required String text}) {
|
||||
return Text(text, style: TextStyle(color: Colors.white));
|
||||
}
|
||||
|
||||
/// 图片错误显示
|
||||
Widget errorImageContainer(double height) {
|
||||
return Container(
|
||||
height: height,
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
color: Colors.grey[200],
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error, color: Colors.red),
|
||||
const SizedBox(width: 5),
|
||||
const Text("加载失败", style: TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
child: const Icon(Icons.image, color: Colors.grey, size: 30),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
119
lib/widgets/common/year_selector.dart
Normal 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(),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
216
lib/widgets/moment/card.dart
Normal 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]),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
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/utils/date_util.dart';
|
||||
import 'package:food_hub_app/utils/index.dart';
|
||||
import 'package:food_hub_app/widgets/common/index.dart';
|
||||
import 'package:table_calendar/table_calendar.dart';
|
||||
import 'package:tdesign_flutter/tdesign_flutter.dart';
|
||||
|
||||
@@ -145,12 +146,10 @@ class _RecipeCalendarState extends State<RecipeCalendar> {
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10),
|
||||
TDButton(
|
||||
icon: TDIcons.arrow_right,
|
||||
type: TDButtonType.fill,
|
||||
shape: TDButtonShape.circle,
|
||||
theme: TDButtonTheme.primary,
|
||||
circleIconButton(
|
||||
context: context,
|
||||
icon: Icons.chevron_right,
|
||||
onPressed: () => {}
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -171,9 +170,9 @@ class _RecipeCalendarState extends State<RecipeCalendar> {
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
isSelected
|
||||
? Colors.blue
|
||||
? Theme.of(context).primaryColor
|
||||
: isToday
|
||||
? Colors.grey[200]
|
||||
? Colors.grey[300]
|
||||
: Colors.transparent,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
@@ -184,7 +183,7 @@ class _RecipeCalendarState extends State<RecipeCalendar> {
|
||||
// 日期数字
|
||||
Text(
|
||||
day.day.toString(),
|
||||
style: TextStyle(color: isSelected ? Colors.white : Colors.black87),
|
||||
style: TextStyle(color: isSelected ? Colors.white : Colors.black),
|
||||
),
|
||||
// 底部红点 - 只在指定日期显示
|
||||
if (shouldShowRedDot)
|
||||
@@ -207,7 +206,6 @@ class _RecipeCalendarState extends State<RecipeCalendar> {
|
||||
return Column(
|
||||
children: [
|
||||
Card(elevation: 0, color: Colors.white, child: recipeCalendar()),
|
||||
const SizedBox(height: 10),
|
||||
Expanded(child: dailyItem()),
|
||||
],
|
||||
);
|
||||
@@ -1,30 +1,68 @@
|
||||
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/widgets/common/image.dart';
|
||||
import 'package:food_hub_app/widgets/common/index.dart';
|
||||
import 'package:tdesign_flutter/tdesign_flutter.dart';
|
||||
|
||||
class RecipeCard extends StatelessWidget {
|
||||
final Recipe recipe;
|
||||
final RecipeSummary recipe;
|
||||
|
||||
const RecipeCard({super.key, required this.recipe});
|
||||
|
||||
@override
|
||||
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(
|
||||
shape: RoundedRectangleBorder(
|
||||
side: BorderSide(color: Theme.of(context).primaryColor, width: 1.0),
|
||||
borderRadius: BorderRadius.circular(10.0),
|
||||
),
|
||||
elevation: 0,
|
||||
color: Colors.white,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Image.network(
|
||||
'http://172.29.101.108:8100/${recipe.recordList[0].imageUrl}',
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) => errorImageContainer(200),
|
||||
),
|
||||
buildRecipeCarousel(),
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
@@ -58,22 +96,20 @@ class RecipeCard extends StatelessWidget {
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
TDButton(
|
||||
circleIconButton(
|
||||
context: context,
|
||||
icon: Icons.edit,
|
||||
size: TDButtonSize.small,
|
||||
type: TDButtonType.fill,
|
||||
shape: TDButtonShape.circle,
|
||||
theme: TDButtonTheme.primary,
|
||||
onTap: () => {},
|
||||
onPressed: () => {},
|
||||
),
|
||||
SizedBox(width: 10),
|
||||
TDButton(
|
||||
circleIconButton(
|
||||
context: context,
|
||||
icon: Icons.book,
|
||||
size: TDButtonSize.small,
|
||||
type: TDButtonType.fill,
|
||||
shape: TDButtonShape.circle,
|
||||
theme: TDButtonTheme.primary,
|
||||
onTap: () => {},
|
||||
onPressed:
|
||||
() => Navigator.pushNamed(
|
||||
context,
|
||||
'/recipeDetail',
|
||||
arguments: {'id': recipe.id},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1,19 +1,18 @@
|
||||
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/widgets/recipe/recipe_card.dart';
|
||||
import 'package:food_hub_app/widgets/recipe/card.dart';
|
||||
import 'package:tdesign_flutter/tdesign_flutter.dart';
|
||||
|
||||
class RecipeList extends StatefulWidget {
|
||||
const RecipeList({super.key});
|
||||
|
||||
|
||||
@override
|
||||
State<RecipeList> createState() => _RecipeListState();
|
||||
}
|
||||
|
||||
class _RecipeListState extends State<RecipeList> {
|
||||
List<Recipe> recipeList = [];
|
||||
List<RecipeSummary> recipeSummaryList = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -25,23 +24,20 @@ class _RecipeListState extends State<RecipeList> {
|
||||
final result = await queryRecipeApi(RecipeQuery(category: ""));
|
||||
|
||||
setState(() {
|
||||
recipeList = result;
|
||||
recipeSummaryList = result;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (recipeList.isEmpty) {
|
||||
if (recipeSummaryList.isEmpty) {
|
||||
return const TDEmpty(type: TDEmptyType.plain, emptyText: '暂无数据');
|
||||
} else {
|
||||
return ListView.separated(
|
||||
itemCount: recipeList.length,
|
||||
return ListView.builder(
|
||||
itemCount: recipeSummaryList.length,
|
||||
itemBuilder: (context, index) {
|
||||
return RecipeCard(recipe: recipeList[index]);
|
||||
},
|
||||
separatorBuilder: (context, index) {
|
||||
return const SizedBox(height: 10);
|
||||
},
|
||||
return RecipeCard(recipe: recipeSummaryList[index]);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
135
lib/widgets/recipe/timeline.dart
Normal 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
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
42
pubspec.lock
@@ -202,7 +202,7 @@ packages:
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
easy_refresh:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: easy_refresh
|
||||
sha256: "486e30abfcaae66c0f2c2798a10de2298eb9dc5e0bb7e1dba9328308968cae0c"
|
||||
@@ -278,6 +278,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -365,14 +373,6 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -645,6 +645,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -826,6 +834,22 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -40,13 +40,16 @@ dependencies:
|
||||
table_calendar: ^3.1.3
|
||||
flutter_form_builder: ^10.0.0
|
||||
form_builder_validators: ^11.1.2
|
||||
graphic: ^2.6.0
|
||||
intl: ^0.19.0
|
||||
tdesign_flutter: ^0.2.3
|
||||
json_annotation: ^4.9.0
|
||||
fluttertoast: ^8.2.0
|
||||
shared_preferences: ^2.3.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:
|
||||
tdesign_flutter_adaptation: 3.16.0
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace {
|
||||
///
|
||||
/// Redefined in case the developer's machine has a Windows SDK older than
|
||||
/// 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
|
||||
#define DWMWA_USE_IMMERSIVE_DARK_MODE 20
|
||||
#endif
|
||||
|
||||