Compare commits

...

28 Commits

Author SHA1 Message Date
a5b3053920 feat:更新用户表单 2026-07-05 20:41:06 +08:00
780526ddf4 feat:增加用户信息编辑功能 2026-07-05 14:14:43 +08:00
9d3a3f2e80 feat:更新编辑菜谱功能 2026-06-30 23:26:49 +08:00
613e50b0da feat:更新菜谱表单 2026-06-28 23:17:00 +08:00
c10e8e5ffb feat:更新好友菜谱功能 2026-06-28 19:54:32 +08:00
a340ba424e feat:重构模块 2026-06-27 17:12:38 +08:00
a5e788eee6 feat:更新菜谱表单 2026-06-23 20:02:17 +08:00
ed0ceb29d3 feat:增加查询朋友朋友圈功能 2025-11-25 19:16:53 +08:00
8cff5228ae feat:更新个人主页模块 2025-11-24 19:56:13 +08:00
97e7a6f44b feat:增加朋友圈表单功能 2025-11-23 23:19:17 +08:00
245edd9d27 feat:更新主题色适配 2025-11-23 15:27:50 +08:00
6308169013 feat:更新美食记录表单 2025-11-23 00:02:41 +08:00
1ac504f3d7 feat:增加主题色模块 2025-11-21 17:15:40 +08:00
41fe9d6a24 feat:更新朋友圈表单组件 2025-11-20 22:54:44 +08:00
515021781f feat:增加朋友圈评论显示模块 2025-11-20 19:55:44 +08:00
4f033a4d0a feat:增加分享图片功能 2025-11-20 18:43:09 +08:00
a9f2e102c5 feat:调整部分组件UI 2025-11-20 14:50:15 +08:00
eb4f7a3637 factor:布局颜色重构 2025-11-20 14:25:31 +08:00
e6ee934a3e feat:更新Card样式 2025-11-19 22:48:25 +08:00
5151430132 feat:增加朋友圈表单功能 2025-11-19 20:04:45 +08:00
07180965cc feat:更新组件UI 2025-11-19 19:53:05 +08:00
8acad9c63a feat:增加加载中组件 2025-11-19 16:35:20 +08:00
625a872d2f feat:更新图片url路径 2025-11-19 10:40:02 +08:00
7ede1c2d89 feat:更新上传记录表单 2025-11-18 23:18:24 +08:00
b6995d8dd1 feat:增加Minio工具类 2025-11-18 20:03:25 +08:00
9a8e1a7419 feat:更新菜谱显示模块 2025-11-18 15:57:00 +08:00
df26790389 feat:更新TabUI模块 2025-11-18 11:46:21 +08:00
21ff0ad94f feat:重构UI模块 2025-11-18 11:01:53 +08:00
64 changed files with 5010 additions and 2764 deletions

View File

@@ -15,21 +15,6 @@ migration:
- platform: root
create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
- platform: android
create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
- platform: ios
create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
- platform: linux
create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
- platform: macos
create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
- platform: web
create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
- platform: windows
create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1

View File

@@ -1,6 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:label="food_hub_app"
android:label="食光集"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity

View File

@@ -27,7 +27,7 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.7.0" apply false
id("org.jetbrains.kotlin.android") version "1.8.22" apply false
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
}
include(":app")

View File

@@ -1,28 +1,38 @@
import 'package:flutter_common/utils/convert_utils.dart';
import 'package:flutter_common/utils/http_utils.dart';
import 'package:food_hub_app/config/app_config.dart';
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';
final httpUtil = HttpUtil(baseUrl: AppConfig.baseApiUrl);
Future<bool> addMomentApi(Moment moment) {
return HttpUtil().post<bool>("/moment", data: moment);
return httpUtil.post<bool>("/moment", data: moment);
}
Future<bool> updateMomentApi(int id, Moment moment) {
return HttpUtil().put<bool>("/moment/$id", data: moment);
return httpUtil.put<bool>("/moment/$id", data: moment);
}
Future<bool> deleteMomentApi(int id) {
return HttpUtil().delete<bool>("/moment/$id");
return httpUtil.delete<bool>("/moment/$id");
}
Future<List<Moment>> queryMomentListApi() {
return HttpUtil().get<List<Moment>>(
return httpUtil.get<List<Moment>>(
"/moment",
converter: (data) => convertListResponse<Moment>(data, Moment.fromJson),
converter: (data) => convertList<Moment>(data, Moment.fromJson),
);
}
Future<List<Moment>> queryMomentListByUserIdApi(int userId) {
return httpUtil.get<List<Moment>>(
"/moment/$userId",
converter: (data) => convertList<Moment>(data, Moment.fromJson),
);
}
Future<PageMoment> queryMomentByPageApi(int currentPage, int pageSize) {
return HttpUtil().get<PageMoment>(
return httpUtil.get<PageMoment>(
"/moment/page",
queryParameters: {"currentPage": currentPage, "pageSize": pageSize},
converter: (data) => PageMoment.fromJson(data),
@@ -30,13 +40,13 @@ Future<PageMoment> queryMomentByPageApi(int currentPage, int pageSize) {
}
Future<bool> addMomentCommentApi(int id, String content) {
return HttpUtil().post<bool>("/moment/$id/comment", data: content);
return httpUtil.post<bool>("/moment/$id/comment", data: content);
}
Future<bool> addMomentLikeApi(int id) {
return HttpUtil().post<bool>("/food/moment/$id/like");
return httpUtil.post<bool>("/food/moment/$id/like");
}
Future<bool> deleteMomentLikeApi(int id) {
return HttpUtil().delete<bool>("/food/moment/$id/like");
return httpUtil.delete<bool>("/food/moment/$id/like");
}

View File

@@ -1,100 +1,103 @@
import 'package:flutter_common/utils/convert_utils.dart';
import 'package:flutter_common/utils/http_utils.dart';
import 'package:food_hub_app/config/app_config.dart';
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>(
final httpUtil = HttpUtil(baseUrl: AppConfig.baseApiUrl);
Future<Recipe> queryRecipeByIdApi(int id) {
return httpUtil.get<Recipe>(
"/food/recipe/$id",
converter: (data) => RecipeDetail.fromJson(data),
converter: (data) => Recipe.fromJson(data),
);
}
Future<bool> addRecipeApi(Recipe recipe) {
return HttpUtil().post<bool>("/food/recipe", data: 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);
return httpUtil.put<bool>("/food/recipe/$id", data: recipe);
}
Future<bool> deleteRecipeApi(int id) {
return HttpUtil().delete<bool>("/food/recipe/$id");
return httpUtil.delete<bool>("/food/recipe/$id");
}
Future<List<Recipe>> queryRecipeByUserApi(int id) {
return HttpUtil().get<List<Recipe>>(
Future<List<RecipeSummary>> queryRecipeByUserApi(int id) {
return httpUtil.get<List<RecipeSummary>>(
"/food/recipe/user/$id",
converter: (data) => convertListResponse<Recipe>(data, Recipe.fromJson),
converter: (data) => convertList<RecipeSummary>(data, RecipeSummary.fromJson),
);
}
Future<List<Recipe>> queryRecipeUserFavouriteApi() {
return HttpUtil().get<List<Recipe>>(
return httpUtil.get<List<Recipe>>(
"/food/recipe/user/favourite",
converter: (data) => convertListResponse<Recipe>(data, Recipe.fromJson),
converter: (data) => convertList<Recipe>(data, Recipe.fromJson),
);
}
Future<List<RecipeSummary>> queryRecipeApi(RecipeQuery recipeQuery) {
return HttpUtil().get<List<RecipeSummary>>(
return httpUtil.get<List<RecipeSummary>>(
"/food/recipe",
queryParameters: recipeQuery.toJson(),
converter: (data) => convertListResponse<RecipeSummary>(data, RecipeSummary.fromJson),
converter:
(data) => convertList<RecipeSummary>(data, RecipeSummary.fromJson),
);
}
Future<List<String>> queryFoodNameListApi() {
return HttpUtil().get<List<String>>(
return httpUtil.get<List<String>>(
"/food/recipe/name",
converter:
(data) => convertListResponse<String>(data, (json) => json.toString()),
converter: (data) => convertStringList(data),
);
}
Future<bool> addRecordApi(Record record) {
return HttpUtil().post<bool>("/food/record", data: record);
Future<bool> addRecordApi(FoodRecord 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> updateRecordApi(int id, FoodRecord record) {
return httpUtil.put<bool>("/food/record/$id", data: record);
}
Future<bool> deleteRecordApi(int id) {
return HttpUtil().delete<bool>("/food/record/$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);
return httpUtil.post<bool>("/food/recipe/$id/comment", data: content);
}
Future<bool> addRecipeLikeApi(int id) {
return HttpUtil().post<bool>("/food/recipe/$id/like");
return httpUtil.post<bool>("/food/recipe/$id/like");
}
Future<bool> deleteRecipeLikeApi(int id) {
return HttpUtil().delete<bool>("/food/recipe/$id/like");
return httpUtil.delete<bool>("/food/recipe/$id/like");
}
Future<bool> addRecipeFavouriteApi(int id) {
return HttpUtil().post<bool>("/food/recipe/$id/favourite");
return httpUtil.post<bool>("/food/recipe/$id/favourite");
}
Future<bool> deleteRecipeFavouriteApi(int id) {
return HttpUtil().delete<bool>("/food/recipe/$id/like");
return httpUtil.delete<bool>("/food/recipe/$id/like");
}
Future<List<Record>> queryRecordApi(String startDate, String endDate) {
return HttpUtil().get<List<Record>>(
Future<List<FoodRecord>> queryRecordApi(String startDate, String endDate) {
return httpUtil.get<List<FoodRecord>>(
"/food/record",
queryParameters: {"startDate": startDate, "endDate": endDate},
converter: (data) => convertListResponse<Record>(data, Record.fromJson),
converter: (data) => convertList<FoodRecord>(data, FoodRecord.fromJson),
);
}
Future<List<String>> queryCategoryApi() {
return HttpUtil().get<List<String>>(
return httpUtil.get<List<String>>(
"/food/category",
converter:
(data) => convertListResponse<String>(data, (json) => json.toString()),
converter: (data) => convertListStringResponse(data),
);
}

View File

@@ -1,8 +1,11 @@
import 'package:flutter_common/utils/http_utils.dart';
import 'package:food_hub_app/config/app_config.dart';
import 'package:food_hub_app/models/session.dart';
import 'package:food_hub_app/utils/http_util.dart';
final httpUtil = HttpUtil(baseUrl: AppConfig.baseApiUrl);
Future<Session> loginApi(String username, String password) {
return HttpUtil().post<Session>(
return httpUtil.post<Session>(
"/session",
queryParameters: {"username": username, "password": password},
converter: (data) => Session.fromJson(data),

View File

@@ -1,31 +1,35 @@
import 'package:flutter_common/models/common_model.dart';
import 'package:flutter_common/utils/convert_utils.dart';
import 'package:flutter_common/utils/http_utils.dart';
import 'package:food_hub_app/config/app_config.dart';
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';
final httpUtil = HttpUtil(baseUrl: AppConfig.baseApiUrl);
Future<SummaryStats> queryStatsApi() {
return HttpUtil().get<SummaryStats>(
return httpUtil.get<SummaryStats>(
"/food/stats",
converter: (data) => SummaryStats.fromJson(data),
);
}
Future<List<ChartData>> queryRecordStatsApi() {
return HttpUtil().get<List<ChartData>>(
return httpUtil.get<List<ChartData>>(
"/food/stats/record",
converter: (data) => convertListResponse<ChartData>(data, ChartData.fromJson),
converter: (data) => convertList<ChartData>(data, ChartData.fromJson),
);
}
Future<List<ChartData>> queryCategoryStatsApi() {
return HttpUtil().get<List<ChartData>>(
return httpUtil.get<List<ChartData>>(
"/food/stats/category",
converter: (data) => convertListResponse<ChartData>(data, ChartData.fromJson),
converter: (data) => convertList<ChartData>(data, ChartData.fromJson),
);
}
Future<List<ChartData>> queryRankStatsApi() {
return HttpUtil().get<List<ChartData>>(
return httpUtil.get<List<ChartData>>(
"/food/stats/rank",
converter: (data) => convertListResponse<ChartData>(data, ChartData.fromJson),
converter: (data) => convertList<ChartData>(data, ChartData.fromJson),
);
}

16
lib/apis/user.dart Normal file
View File

@@ -0,0 +1,16 @@
import 'package:flutter_common/utils/http_utils.dart';
import 'package:food_hub_app/config/app_config.dart';
import 'package:food_hub_app/models/session.dart';
final httpUtil = HttpUtil(baseUrl: AppConfig.baseApiUrl);
Future<User> queryUserApi(int number) {
return httpUtil.get<User>(
"/user/$number",
converter: (data) => User.fromJson(data),
);
}
Future<bool> updateUserApi(int id, User user) {
return httpUtil.put<bool>("/user/$id", data: user);
}

View File

@@ -1,9 +1,14 @@
/// 应用信息
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";
// static const String baseApiUrl = "http://192.168.1.4:8083";
static const String baseApiUrl = "https://cxx0822.s.3q.hair/food-api";
static const String rustfsIp = '43.248.188.28';
static const int rustfsPort = 23125;
static const String rustfsAccessKey = 'sZuAg1WCo5i4kD936Tqh';
static const String rustfsSecretKey = 'g2fEyk6bslGrHepBvjhu0OLNF4qCRziPJIx1KAS8';
static const String rustfsFileUrl = 'http://$rustfsIp:$rustfsPort';
static const String bucketName = 'food';
static const String imageBaseUrl = '$rustfsFileUrl/$bucketName/';
// static const String imageBaseUrl = '$baseApiUrl/';
}

105
lib/layout/app_actions.dart Normal file
View File

@@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import 'package:food_hub_app/provider/food_provider.dart';
import 'package:provider/provider.dart';
class AppActions extends StatefulWidget {
final int pageIndex;
const AppActions({super.key, required this.pageIndex});
@override
State<StatefulWidget> createState() => AppActionsState();
}
class AppActionsState extends State<AppActions> {
Widget _buildBottomSheetBody(FoodProvider provider) {
final colors = Theme.of(context).colorScheme;
final provider = context.watch<FoodProvider>();
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
const Text(
'请选择操作',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 10),
if (widget.pageIndex == 0)
ListTile(
leading: Icon(Icons.book, color: colors.primary),
title: const Text('新增菜谱'),
onTap: () => _handleAddRecipe(provider),
),
if (widget.pageIndex == 0)
ListTile(
leading: Icon(Icons.note_add, color: colors.primary),
title: const Text('新增记录'),
onTap: () => _handleAddRecord(provider),
),
if (widget.pageIndex == 2)
ListTile(
leading: Icon(Icons.group, color: colors.primary),
title: const Text('发布朋友圈'),
onTap: () => _handleAddMoment(provider),
),
],
);
}
void _onPressAdd(FoodProvider provider) {
if (widget.pageIndex == 0) {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => _buildBottomSheetBody(provider),
);
}
if (widget.pageIndex == 2) {
_handleAddMoment(provider);
}
}
void _handleAddRecipe(FoodProvider provider) {
// provider.resetRecordForm();
Navigator.pop(context);
Navigator.pushNamed(context, "/recipeForm");
}
void _handleAddRecord(FoodProvider provider) {
provider.resetRecordForm();
provider.isEditing = false;
Navigator.pop(context);
Navigator.pushNamed(context, "/recordForm");
}
void _handleAddMoment(FoodProvider provider) {
provider.resetMomentForm();
provider.isEditing = false;
Navigator.pushNamed(context, "/momentForm");
}
@override
Widget build(BuildContext context) {
final provider = context.watch<FoodProvider>();
return Row(
children: [
IconButton(
icon: Icon(Icons.search, color: Colors.white),
onPressed: () {
// 搜索功能
},
),
if (widget.pageIndex == 0 || widget.pageIndex == 2)
IconButton(
icon: Icon(Icons.add, color: Colors.white),
onPressed: () => _onPressAdd(provider),
),
],
);
}
}

View File

@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:flutter_common/layout/theme_layout.dart';
class AppDrawer extends StatefulWidget {
const AppDrawer({super.key});
@override
State<StatefulWidget> createState() => AppDrawerState();
}
class AppDrawerState extends State<AppDrawer> {
@override
Widget build(BuildContext context) {
// final themeProvider = Provider.of<ThemeProvider>(context);
return Drawer(
child: ListView(
padding: EdgeInsets.zero,
children: [
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 100),
child: DrawerHeader(
decoration: BoxDecoration(color: Theme.of(context).primaryColor),
child: const Text(
'设置',
style: TextStyle(color: Colors.white, fontSize: 24),
),
),
),
ThemeLayout(),
const Divider(),
ListTile(
leading: const Icon(Icons.info),
title: const Text('关于我们'),
onTap: () {
Navigator.pop(context); // 关闭抽屉
// 跳转到关于页面
},
),
],
),
);
}
}

View File

@@ -1,116 +0,0 @@
import 'package:flutter/material.dart';
class NavBar extends StatelessWidget {
final int currentIndex;
final List<BottomNavigationBarItem> navItems;
final Function(int) onTap;
const NavBar({
super.key,
required this.currentIndex,
required this.onTap,
required this.navItems,
});
@override
Widget build(BuildContext context) {
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,
),
);
}
}
class SettingsDrawer extends StatelessWidget {
const SettingsDrawer({super.key});
@override
Widget build(BuildContext context) {
// final themeProvider = Provider.of<ThemeProvider>(context);
return Drawer(
child: ListView(
padding: EdgeInsets.zero,
children: [
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 100),
child: DrawerHeader(
decoration: BoxDecoration(color: Theme.of(context).primaryColor),
child: const Text(
'设置',
style: TextStyle(color: Colors.white, fontSize: 24),
),
),
),
ListTile(
leading: const Icon(Icons.brightness_4),
title: const Text('夜间模式'),
// trailing: Switch(
// value: themeProvider.isDarkMode,
// onChanged: (bool value) {
// themeProvider.toggleTheme();
// },
// ),
// onTap: () => themeProvider.toggleTheme(),
),
// 其他设置选项
ListTile(
leading: const Icon(Icons.notifications),
title: const Text('通知设置'),
onTap: () {
Navigator.pop(context); // 关闭抽屉
// 跳转到通知设置页面
},
),
ListTile(
leading: const Icon(Icons.language),
title: const Text('语言'),
onTap: () {
Navigator.pop(context); // 关闭抽屉
// 跳转到语言设置页面
},
),
// 底部关于
const Divider(),
ListTile(
leading: const Icon(Icons.info),
title: const Text('关于我们'),
onTap: () {
Navigator.pop(context); // 关闭抽屉
// 跳转到关于页面
},
),
],
),
);
}
}
List<StatelessWidget> homeActions(BuildContext context) {
return [
IconButton(
icon: Icon(Icons.search, color: Colors.white),
onPressed: () {
// 搜索功能
},
),
// IconButton(
// icon: Icon(Icons.add, color: Colors.white),
// onPressed: () {
// Navigator.pushNamed(context, '/recordForm');
// },
// ),
];
}

View File

@@ -1,18 +1,48 @@
import 'package:flutter/material.dart';
import 'package:flutter_common/provider/theme_provider.dart';
import 'package:flutter_common/utils/log_utils.dart';
import 'package:flutter_common/utils/sp_utils.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:food_hub_app/utils/sp_util.dart';
import 'package:food_hub_app/provider/app_provider.dart';
import 'package:food_hub_app/provider/food_provider.dart';
import 'package:food_hub_app/provider/user_provider.dart';
import 'package:food_hub_app/views/home.dart';
import 'package:food_hub_app/views/login.dart';
import 'package:food_hub_app/views/moment.dart';
import 'package:food_hub_app/views/moment_form.dart';
import 'package:food_hub_app/views/moment_user.dart';
import 'package:food_hub_app/views/profile_form.dart';
import 'package:food_hub_app/views/profile_user.dart';
import 'package:food_hub_app/views/recipe_form.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';
import 'package:liquid_glass_widgets/liquid_glass_setup.dart';
import 'package:provider/provider.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
FormBuilderLocalizations.delegate.load(const Locale('zh', 'CN'));
await SPUtil.init();
runApp(const MyApp());
await initLogger();
// 预编译 shader防止首帧白闪
await LiquidGlassWidgets.initialize();
// wrap() 安装无障碍桥接、全局主题、自适应质量
runApp(
LiquidGlassWidgets.wrap(
child: MultiProvider(
providers: [
ChangeNotifierProvider(create: (context) => FoodProvider()),
ChangeNotifierProvider(create: (context) => ThemeProvider()),
ChangeNotifierProvider(create: (context) => UserProvider()),
ChangeNotifierProvider(create: (context) => AppProvider()),
],
child: const MyApp(),
),
),
);
}
class MyApp extends StatelessWidget {
@@ -21,9 +51,53 @@ class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
final themeProvider = context.watch<ThemeProvider>();
final brightness = MediaQuery.of(context).platformBrightness;
themeProvider.isDarkMode = brightness == Brightness.dark;
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(fontFamily: 'CustomFont'),
theme: ThemeData(
scaffoldBackgroundColor: const Color(0xFFFAF6F0),
colorScheme: ColorScheme(
brightness: Brightness.light,
primary: const Color(0xFFE89F71),
secondary: const Color(0xFFE89F71),
surface: const Color(0xFFFFFFFF),
error: const Color(0xFFE76F51),
onPrimary: Colors.white,
onSecondary: Colors.white,
onSurface: const Color(0xFF3A332C),
onError: Colors.white,
),
textTheme: TextTheme(
titleLarge: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: const Color(0xFF3A332C),
),
titleMedium: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: const Color(0xFF3A332C),
),
bodyLarge: TextStyle(
fontSize: 15,
color: const Color(0xFF3A332C),
),
bodyMedium: TextStyle(
fontSize: 14,
color: const Color(0xFF8C8379),
),
bodySmall: TextStyle(
fontSize: 12,
color: const Color(0xFF8C8379),
),
),
fontFamily: 'CustomFont',
useMaterial3: true,
),
supportedLocales: const [
Locale('en', 'US'), // 英语
Locale('zh', 'CN'), // 中文
@@ -46,8 +120,14 @@ class MyApp extends StatelessWidget {
home: LoginPage(),
routes: {
'/home': (context) => HomePage(),
'/recordForm': (context) => RecordFormPage(),
'/recordForm': (context) => RecordForm(),
'/recipeForm': (context) => RecipeForm(),
'/recipeDetail': (context) => RecipeDetailPage(),
'/momentForm': (context) => MomentForm(),
'/moment': (context) => MomentPage(),
'/momentUser': (context) => MomentUserPage(),
'/profileUser': (context) => ProfileUserPage(),
'/profileForm': (context) => ProfileForm(),
},
);
}

View File

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

View File

@@ -4,13 +4,13 @@ part 'moment.g.dart';
@JsonSerializable()
class Comment {
final int id;
final String username;
final String avatar;
final String content;
final String date;
int id;
String username;
String avatar;
String content;
String date;
const Comment({
Comment({
required this.id,
required this.username,
required this.avatar,
@@ -25,15 +25,15 @@ class Comment {
@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;
int? id;
int? userId;
String? username;
String? avatar;
String content;
List<String> imageList;
String? date;
List<int>? likeList;
List<Comment>? commentList;
Moment({
this.id,
@@ -50,6 +50,14 @@ class Moment {
factory Moment.fromJson(Map<String, dynamic> json) => _$MomentFromJson(json);
Map<String, dynamic> toJson() => _$MomentToJson(this);
static Moment getEmpty() {
return Moment(
id: 0,
content: '',
imageList: []
);
}
}
@JsonSerializable()

View File

@@ -5,11 +5,17 @@ part 'recipe.g.dart';
/// 食材信息
@JsonSerializable()
class RecipeMaterial {
int id;
String type;
String name;
String amount;
RecipeMaterial({required this.type, required this.name, required this.amount});
RecipeMaterial({
required this.id,
required this.type,
required this.name,
required this.amount,
});
factory RecipeMaterial.fromJson(Map<String, dynamic> json) =>
_$RecipeMaterialFromJson(json);
@@ -20,13 +26,20 @@ class RecipeMaterial {
/// 步骤信息
@JsonSerializable()
class RecipeStep {
int id;
int sort;
String content;
String imageUrl;
RecipeStep({required this.sort, required this.content, required this.imageUrl});
RecipeStep({
required this.id,
required this.sort,
required this.content,
required this.imageUrl,
});
factory RecipeStep.fromJson(Map<String, dynamic> json) => _$RecipeStepFromJson(json);
factory RecipeStep.fromJson(Map<String, dynamic> json) =>
_$RecipeStepFromJson(json);
Map<String, dynamic> toJson() => _$RecipeStepToJson(this);
}
@@ -56,7 +69,7 @@ class RecipeComment {
/// 成果信息
@JsonSerializable()
class Record {
class FoodRecord {
int? id;
String name;
String category;
@@ -64,7 +77,7 @@ class Record {
String date;
String imageUrl;
Record({
FoodRecord({
this.id,
required this.name,
required this.category,
@@ -73,20 +86,31 @@ class Record {
required this.imageUrl,
});
factory Record.fromJson(Map<String, dynamic> json) => _$RecordFromJson(json);
factory FoodRecord.fromJson(Map<String, dynamic> json) =>
_$FoodRecordFromJson(json);
Map<String, dynamic> toJson() => _$RecordToJson(this);
Map<String, dynamic> toJson() => _$FoodRecordToJson(this);
static FoodRecord getEmpty() {
return FoodRecord(
id: 0,
name: '',
category: '',
person: 0,
date: '',
imageUrl: '',
);
}
}
@JsonSerializable()
class RecipeQuery {
String category;
RecipeQuery({
required this.category
});
RecipeQuery({required this.category});
factory RecipeQuery.fromJson(Map<String, dynamic> json) => _$RecipeQueryFromJson(json);
factory RecipeQuery.fromJson(Map<String, dynamic> json) =>
_$RecipeQueryFromJson(json);
Map<String, dynamic> toJson() => _$RecipeQueryToJson(this);
}
@@ -102,16 +126,12 @@ class Recipe {
bool isShare;
int userId;
String username;
String avatar;
List<RecipeMaterial> materialList;
List<RecipeStep> stepList;
List<Record> recordList;
List<FoodRecord> recordList;
List<int> likeList;
int likeCount;
List<int> favouriteList;
int favouriteCount;
List<RecipeComment> commentList;
int commentCount;
Recipe({
required this.id,
@@ -122,21 +142,36 @@ class Recipe {
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.likeCount,
required this.favouriteList,
required this.favouriteCount,
required this.commentList,
required this.commentCount,
});
factory Recipe.fromJson(Map<String, dynamic> json) => _$RecipeFromJson(json);
Map<String, dynamic> toJson() => _$RecipeToJson(this);
static Recipe getEmpty() {
return Recipe(
id: 0,
name: '',
category: '家常菜',
recommendRate: 3,
remark: '',
isShare: true,
userId: 0,
username: '',
materialList: [],
stepList: [],
recordList: [],
likeList: [],
favouriteList: [],
commentList: []
);
}
}
@JsonSerializable()
@@ -146,13 +181,12 @@ class RecipeSummary {
String category;
double recommendRate;
bool isShare;
List<Record> recordList;
List<FoodRecord> recordList;
int likeCount;
int favouriteCount;
int commentCount;
int userId;
String username;
String avatar;
RecipeSummary({
required this.id,
@@ -162,14 +196,14 @@ class RecipeSummary {
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);
factory RecipeSummary.fromJson(Map<String, dynamic> json) =>
_$RecipeSummaryFromJson(json);
Map<String, dynamic> toJson() => _$RecipeSummaryToJson(this);
}
@@ -187,7 +221,7 @@ class RecipeDetail {
String avatar;
List<RecipeMaterial> materialList;
List<RecipeStep> stepList;
List<Record> recordList;
List<FoodRecord> recordList;
List<int> likeList;
List<int> favouriteList;
List<RecipeComment> commentList;
@@ -210,9 +244,30 @@ class RecipeDetail {
required this.commentList,
});
factory RecipeDetail.fromJson(Map<String, dynamic> json) => _$RecipeDetailFromJson(json);
factory RecipeDetail.fromJson(Map<String, dynamic> json) =>
_$RecipeDetailFromJson(json);
Map<String, dynamic> toJson() => _$RecipeDetailToJson(this);
static RecipeDetail getEmpty() {
return RecipeDetail(
id: 0,
name: '',
category: '',
recommendRate: 0,
remark: '',
isShare: false,
userId: 0,
username: '',
avatar: '',
materialList: [],
stepList: [],
recordList: [],
likeList: [],
favouriteList: [],
commentList: [],
);
}
}
enum ViewType { recipe, calendar, timeline }

View File

@@ -8,6 +8,7 @@ part of 'recipe.dart';
RecipeMaterial _$RecipeMaterialFromJson(Map<String, dynamic> json) =>
RecipeMaterial(
id: (json['id'] as num).toInt(),
type: json['type'] as String,
name: json['name'] as String,
amount: json['amount'] as String,
@@ -15,12 +16,14 @@ RecipeMaterial _$RecipeMaterialFromJson(Map<String, dynamic> json) =>
Map<String, dynamic> _$RecipeMaterialToJson(RecipeMaterial instance) =>
<String, dynamic>{
'id': instance.id,
'type': instance.type,
'name': instance.name,
'amount': instance.amount,
};
RecipeStep _$RecipeStepFromJson(Map<String, dynamic> json) => RecipeStep(
id: (json['id'] as num).toInt(),
sort: (json['sort'] as num).toInt(),
content: json['content'] as String,
imageUrl: json['imageUrl'] as String,
@@ -28,6 +31,7 @@ RecipeStep _$RecipeStepFromJson(Map<String, dynamic> json) => RecipeStep(
Map<String, dynamic> _$RecipeStepToJson(RecipeStep instance) =>
<String, dynamic>{
'id': instance.id,
'sort': instance.sort,
'content': instance.content,
'imageUrl': instance.imageUrl,
@@ -51,7 +55,7 @@ Map<String, dynamic> _$RecipeCommentToJson(RecipeComment instance) =>
'date': instance.date,
};
Record _$RecordFromJson(Map<String, dynamic> json) => Record(
FoodRecord _$FoodRecordFromJson(Map<String, dynamic> json) => FoodRecord(
id: (json['id'] as num?)?.toInt(),
name: json['name'] as String,
category: json['category'] as String,
@@ -60,7 +64,8 @@ Record _$RecordFromJson(Map<String, dynamic> json) => Record(
imageUrl: json['imageUrl'] as String,
);
Map<String, dynamic> _$RecordToJson(Record instance) => <String, dynamic>{
Map<String, dynamic> _$FoodRecordToJson(FoodRecord instance) =>
<String, dynamic>{
'id': instance.id,
'name': instance.name,
'category': instance.category,
@@ -84,7 +89,6 @@ Recipe _$RecipeFromJson(Map<String, dynamic> json) => Recipe(
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>))
@@ -95,23 +99,20 @@ Recipe _$RecipeFromJson(Map<String, dynamic> json) => Recipe(
.toList(),
recordList:
(json['recordList'] as List<dynamic>)
.map((e) => Record.fromJson(e as Map<String, dynamic>))
.map((e) => FoodRecord.fromJson(e as Map<String, dynamic>))
.toList(),
likeList:
(json['likeList'] as List<dynamic>)
.map((e) => (e as num).toInt())
.toList(),
likeCount: (json['likeCount'] as num).toInt(),
favouriteList:
(json['favouriteList'] as List<dynamic>)
.map((e) => (e as num).toInt())
.toList(),
favouriteCount: (json['favouriteCount'] as num).toInt(),
commentList:
(json['commentList'] as List<dynamic>)
.map((e) => RecipeComment.fromJson(e as Map<String, dynamic>))
.toList(),
commentCount: (json['commentCount'] as num).toInt(),
);
Map<String, dynamic> _$RecipeToJson(Recipe instance) => <String, dynamic>{
@@ -123,16 +124,12 @@ Map<String, dynamic> _$RecipeToJson(Recipe instance) => <String, dynamic>{
'isShare': instance.isShare,
'userId': instance.userId,
'username': instance.username,
'avatar': instance.avatar,
'materialList': instance.materialList,
'stepList': instance.stepList,
'recordList': instance.recordList,
'likeList': instance.likeList,
'likeCount': instance.likeCount,
'favouriteList': instance.favouriteList,
'favouriteCount': instance.favouriteCount,
'commentList': instance.commentList,
'commentCount': instance.commentCount,
};
RecipeSummary _$RecipeSummaryFromJson(Map<String, dynamic> json) =>
@@ -144,10 +141,9 @@ RecipeSummary _$RecipeSummaryFromJson(Map<String, dynamic> json) =>
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>))
.map((e) => FoodRecord.fromJson(e as Map<String, dynamic>))
.toList(),
likeCount: (json['likeCount'] as num).toInt(),
favouriteCount: (json['favouriteCount'] as num).toInt(),
@@ -167,7 +163,6 @@ Map<String, dynamic> _$RecipeSummaryToJson(RecipeSummary instance) =>
'commentCount': instance.commentCount,
'userId': instance.userId,
'username': instance.username,
'avatar': instance.avatar,
};
RecipeDetail _$RecipeDetailFromJson(Map<String, dynamic> json) => RecipeDetail(
@@ -190,7 +185,7 @@ RecipeDetail _$RecipeDetailFromJson(Map<String, dynamic> json) => RecipeDetail(
.toList(),
recordList:
(json['recordList'] as List<dynamic>)
.map((e) => Record.fromJson(e as Map<String, dynamic>))
.map((e) => FoodRecord.fromJson(e as Map<String, dynamic>))
.toList(),
likeList:
(json['likeList'] as List<dynamic>)

View File

@@ -30,14 +30,16 @@ class SaTokenInfo {
this.tag,
});
factory SaTokenInfo.fromJson(Map<String, dynamic> json) => _$SaTokenInfoFromJson(json);
factory SaTokenInfo.fromJson(Map<String, dynamic> json) =>
_$SaTokenInfoFromJson(json);
Map<String, dynamic> toJson() => _$SaTokenInfoToJson(this);
}
@JsonSerializable()
class User {
int? id;
String? username;
String username;
int? gender;
String? phoneNumber;
String? email;
@@ -51,7 +53,7 @@ class User {
User({
this.id,
this.username,
required this.username,
this.gender,
this.phoneNumber,
this.email,
@@ -65,7 +67,24 @@ class User {
});
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
static User getEmpty() {
return User(
username: '',
gender: 0,
phoneNumber: '',
email: '',
birthDate: null,
avatar: '',
area: [],
address: '',
job: '',
tags: [],
description: '',
);
}
}
@JsonSerializable()
@@ -75,6 +94,8 @@ class Session {
Session({required this.saToken, required this.userInfo});
factory Session.fromJson(Map<String, dynamic> json) => _$SessionFromJson(json);
factory Session.fromJson(Map<String, dynamic> json) =>
_$SessionFromJson(json);
Map<String, dynamic> toJson() => _$SessionToJson(this);
}

View File

@@ -37,7 +37,7 @@ Map<String, dynamic> _$SaTokenInfoToJson(SaTokenInfo instance) =>
User _$UserFromJson(Map<String, dynamic> json) => User(
id: (json['id'] as num?)?.toInt(),
username: json['username'] as String?,
username: json['username'] as String,
gender: (json['gender'] as num?)?.toInt(),
phoneNumber: json['phoneNumber'] as String?,
email: json['email'] as String?,

View File

@@ -19,16 +19,3 @@ class SummaryStats {
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);
}

View File

@@ -18,13 +18,3 @@ Map<String, dynamic> _$SummaryStatsToJson(SummaryStats instance) =>
'categoryCount': instance.categoryCount,
'workCount': instance.workCount,
};
ChartData _$ChartDataFromJson(Map<String, dynamic> json) => ChartData(
name: json['name'] as String,
value: (json['value'] as num).toDouble(),
);
Map<String, dynamic> _$ChartDataToJson(ChartData instance) => <String, dynamic>{
'name': instance.name,
'value': instance.value,
};

View File

@@ -0,0 +1,8 @@
import 'package:flutter/material.dart';
class AppProvider with ChangeNotifier {
String appVersion = '1.0.0';
String buildNumber = '1';
String get fullVersion => '$appVersion+$buildNumber';
}

View File

@@ -0,0 +1,517 @@
import 'package:flutter/material.dart';
import 'package:flutter_common/models/common_model.dart';
import 'package:food_hub_app/apis/moment.dart';
import 'package:food_hub_app/apis/recipe.dart';
import 'package:food_hub_app/apis/stats.dart';
import 'package:food_hub_app/models/moment.dart';
import 'package:food_hub_app/models/recipe.dart';
import 'package:food_hub_app/models/stats.dart';
import 'package:food_hub_app/utils/date_util.dart';
import 'package:food_hub_app/utils/index.dart';
import 'package:food_hub_app/views/record.dart';
class FoodProvider with ChangeNotifier {
late RecordTab currentRecordTab = RecordTab.recipe;
late FoodRecord recordFormItem;
late Recipe recipeFormItem;
late Moment momentFormItem;
late bool isEditing;
bool isLoading = false;
String? error;
String queryCategory = '全部菜系';
late List<String> categoryList = [];
late List<RecipeSummary> recipeSummaryList = [];
int selectYear = DateTime.now().year;
DateTime selectedDay = DateTime.now();
DateTime focusedDay = DateTime.now();
late List<FoodRecord> recordList = [];
late List<FoodRecord> selectRecordList = [];
late SummaryStats summaryStats = SummaryStats(
recipeCount: 0,
categoryCount: 0,
workCount: 0,
);
late double averageRecordCount = 0;
late List<ChartData> recordStats = [];
late List<ChartData> categoryStats = [];
late List<ChartData> rankStats = [];
List<Moment> momentList = [];
int currentPage = 1;
final int pageSize = 5;
bool hasMore = true;
void resetRecordForm() {
recordFormItem = FoodRecord.getEmpty();
notifyListeners();
}
void initRecordForm(FoodRecord record) {
recordFormItem = record;
notifyListeners();
}
void resetRecipeForm() {
recipeFormItem = Recipe.getEmpty();
notifyListeners();
}
void initRecipeForm(Recipe recipe) {
recipeFormItem = recipe;
notifyListeners();
}
void updateRecipeFormItem({
String? name,
String? category,
double? recommendRate,
String? remark,
bool? isShare,
}) {
if (name != null) recipeFormItem.name = name;
if (category != null) recipeFormItem.category = category;
if (recommendRate != null) recipeFormItem.recommendRate = recommendRate;
if (remark != null) recipeFormItem.remark = remark;
if (isShare != null) recipeFormItem.isShare = isShare;
notifyListeners();
}
void addRecipeMaterial() {
recipeFormItem.materialList.add(
RecipeMaterial(
id: DateTime.now().microsecondsSinceEpoch,
type: '主料',
name: '',
amount: '',
),
);
notifyListeners();
}
void updateRecipeMaterial(
int index, {
String? type,
String? name,
String? amount,
}) {
final material = recipeFormItem.materialList[index];
recipeFormItem.materialList[index] = RecipeMaterial(
id: material.id,
type: type ?? material.type,
name: name ?? material.name,
amount: amount ?? material.amount,
);
notifyListeners();
}
void removeRecipeMaterial(int index) {
recipeFormItem.materialList.removeAt(index);
notifyListeners();
}
void addRecipeStep() {
recipeFormItem.stepList.add(
RecipeStep(
id: DateTime.now().microsecondsSinceEpoch,
sort: recipeFormItem.stepList.length,
content: '',
imageUrl: '',
),
);
notifyListeners();
}
void updateRecipeStep(int index, {String? content, String? imageUrl}) {
final step = recipeFormItem.stepList[index];
recipeFormItem.stepList[index] = RecipeStep(
id: step.id,
sort: step.sort,
content: content ?? step.content,
imageUrl: imageUrl ?? step.imageUrl,
);
}
void removeRecipeStep(int index) {
recipeFormItem.stepList.removeAt(index);
for (int i = 0; i < recipeFormItem.stepList.length; i++) {
recipeFormItem.stepList[i].sort = i;
}
notifyListeners();
}
void updateRecordFormItem({String? name, String? date, String? imageUrl}) {
if (name != null) recordFormItem.name = name;
if (date != null) recordFormItem.date = date;
if (imageUrl != null) recordFormItem.imageUrl = imageUrl;
notifyListeners();
}
void updateMomentFormItem({String? content}) {
if (content != null) momentFormItem.content = content;
notifyListeners();
}
void addMomentFormImage(List<String> imageUrls) {
momentFormItem.imageList.addAll(imageUrls);
notifyListeners();
}
void removeMomentFormImage(int index) {
momentFormItem.imageList.removeAt(index);
notifyListeners();
}
void resetMomentForm() {
momentFormItem = Moment.getEmpty();
notifyListeners();
}
void initMomentForm(Moment moment) {
momentFormItem = moment;
notifyListeners();
}
Future<void> onRecordTabChange() async {
switch (currentRecordTab) {
case RecordTab.recipe:
await refreshRecipeList();
break;
case RecordTab.calendar:
case RecordTab.timeline:
await refreshRecordList();
break;
}
}
Future<void> queryCategoryList() async {
try {
error = null;
notifyListeners();
final result = await queryCategoryApi();
categoryList.clear();
categoryList.add('全部菜系');
categoryList.addAll(result);
} catch (e) {
error = '加载数据失败: $e';
debugPrint('加载数据失败: $e');
} finally {
notifyListeners();
}
}
Future<void> queryRecipeByUserId(int userId) async {
if (isLoading) return;
try {
isLoading = true;
error = null;
notifyListeners();
final result = await queryRecipeByUserApi(userId);
recipeSummaryList = result;
notifyListeners();
} catch (e) {
error = '加载数据失败: $e';
debugPrint('加载数据失败: $e');
} finally {
isLoading = false;
notifyListeners();
}
}
Future<void> refreshRecipeList() async {
if (isLoading) return;
try {
isLoading = true;
error = null;
notifyListeners();
final category = queryCategory == '全部菜系' ? '' : queryCategory;
final result = await queryRecipeApi(RecipeQuery(category: category));
recipeSummaryList = result;
} catch (e) {
error = '加载数据失败: $e';
debugPrint('加载数据失败: $e');
} finally {
isLoading = false;
notifyListeners();
}
}
Future<bool> handleRecipe() async {
if (isLoading) return true;
try {
isLoading = true;
error = null;
notifyListeners();
if (isEditing) {
await updateRecipeApi(recipeFormItem.id!, recipeFormItem);
} else {
await addRecipeApi(recipeFormItem);
}
return true;
} catch (e) {
error = '处理数据失败: $e';
debugPrint('处理数据失败: $e');
return false;
} finally {
isLoading = false;
notifyListeners();
}
}
Future<void> refreshRecordList() async {
if (isLoading) return;
if (currentRecordTab == RecordTab.recipe) return;
try {
isLoading = true;
error = null;
notifyListeners();
late String startDate;
late String endDate;
switch (currentRecordTab) {
case RecordTab.calendar:
startDate = getFirstDayOfMonth(focusedDay);
endDate = getLastDayOfMonth(focusedDay);
break;
case RecordTab.timeline:
startDate = '$selectYear-01-01';
endDate = '$selectYear-12-31';
break;
case RecordTab.recipe:
break;
}
final result = await queryRecordApi(startDate, endDate);
recordList = result;
if (recordList.isNotEmpty) {
selectedDay = DateTime.parse(recordList.last.date);
} else {
selectedDay = focusedDay;
}
refreshSelectRecord();
} catch (e) {
error = '加载数据失败: $e';
debugPrint('加载数据失败: $e');
} finally {
isLoading = false;
notifyListeners();
}
}
Future<bool> handleRecord() async {
if (isLoading) return true;
try {
isLoading = true;
error = null;
notifyListeners();
if (isEditing) {
await updateRecordApi(recordFormItem.id!, recordFormItem);
} else {
await addRecordApi(recordFormItem);
}
return true;
} catch (e) {
error = '处理数据失败: $e';
debugPrint('处理数据失败: $e');
return false;
} finally {
isLoading = false;
notifyListeners();
}
}
Future<bool> deleteRecord() async {
if (isLoading) return true;
try {
isLoading = true;
error = null;
notifyListeners();
await deleteRecordApi(recordFormItem.id!);
return true;
} catch (e) {
error = '处理数据失败: $e';
debugPrint('处理数据失败: $e');
return false;
} finally {
isLoading = false;
notifyListeners();
}
}
void refreshSelectRecord() {
selectRecordList =
recordList
.where((record) => record.date == formatDateTime(selectedDay))
.toList();
}
Future<void> refreshFoodStats() async {
if (isLoading) return;
try {
isLoading = true;
error = null;
notifyListeners();
final statsResult = await queryStatsApi();
final recordStatsResult = await queryRecordStatsApi();
final categoryStatsResult = await queryCategoryStatsApi();
final rankStatsResult = await queryRankStatsApi();
summaryStats = statsResult;
recordStats = recordStatsResult;
categoryStats = categoryStatsResult;
rankStats = rankStatsResult.take(15).toList();
if (recordStats.isNotEmpty) {
double sumValue = recordStats.fold(
0.0,
(sum, item) => sum + item.value,
);
averageRecordCount = sumValue / recordStats.length;
}
} catch (e) {
error = '加载数据失败: $e';
debugPrint('加载数据失败: $e');
} finally {
isLoading = false;
notifyListeners();
}
}
Future<void> queryMomentByPage({bool isRefresh = true}) async {
if (isLoading) return;
try {
if (currentPage == 1) {
isLoading = true;
}
error = null;
notifyListeners();
// 如果是刷新,重置页码
if (isRefresh) {
currentPage = 1;
}
final result = await queryMomentByPageApi(currentPage, pageSize);
// 更新数据
if (isRefresh) {
momentList = result.records;
} else {
momentList.addAll(result.records);
}
// 判断是否还有更多数据
hasMore = result.current < result.pages;
if (hasMore) {
currentPage++;
}
} catch (e) {
error = '加载数据失败: $e';
debugPrint('加载数据失败: $e');
} finally {
isLoading = false;
notifyListeners();
}
}
Future<void> queryMomentByUserId(int userId) async {
if (isLoading) return;
try {
isLoading = true;
error = null;
notifyListeners();
final result = await queryMomentListByUserIdApi(userId);
momentList = result;
notifyListeners();
} catch (e) {
error = '加载数据失败: $e';
debugPrint('加载数据失败: $e');
} finally {
isLoading = false;
notifyListeners();
}
}
Future<void> loadMoreMomentList() async {
if (hasMore && !isLoading) {
await queryMomentByPage(isRefresh: false);
}
}
Future<void> refreshMomentList() async {
await queryMomentByPage(isRefresh: true);
}
Future<bool> handleMoment() async {
if (isLoading) return true;
try {
isLoading = true;
error = null;
notifyListeners();
if (isEditing) {
await updateMomentApi(momentFormItem.id!, momentFormItem);
} else {
await addMomentApi(momentFormItem);
}
return true;
} catch (e) {
error = '处理数据失败: $e';
debugPrint('处理数据失败: $e');
return false;
} finally {
isLoading = false;
notifyListeners();
}
}
Future<bool> deleteMoment() async {
if (isLoading) return true;
try {
isLoading = true;
error = null;
notifyListeners();
await deleteMomentApi(momentFormItem.id!);
return true;
} catch (e) {
error = '处理数据失败: $e';
debugPrint('处理数据失败: $e');
return false;
} finally {
isLoading = false;
notifyListeners();
}
}
}

View File

@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'package:food_hub_app/apis/moment.dart';
import 'package:food_hub_app/apis/user.dart';
import 'package:food_hub_app/models/moment.dart';
import 'package:food_hub_app/models/session.dart';
class UserProvider with ChangeNotifier {
late User currentUser = User.getEmpty();
bool isLoading = false;
String? error = '';
List<Moment> momentList = [];
late User userFormItem;
Future<void> refreshUser(int id) async {
if (isLoading) return;
try {
isLoading = true;
error = '';
notifyListeners();
final result = await queryUserApi(id);
currentUser = result;
} catch (e) {
error = '加载数据失败: $e';
debugPrint('加载数据失败: $e');
} finally {
isLoading = false;
notifyListeners();
}
}
Future<void> queryMomentByUserId(int userId) async {
if (isLoading) return;
try {
isLoading = true;
error = '';
notifyListeners();
final result = await queryMomentListByUserIdApi(userId);
momentList = result;
notifyListeners();
} catch (e) {
error = '加载数据失败: $e';
debugPrint('加载数据失败: $e');
} finally {
isLoading = false;
notifyListeners();
}
}
void initUserForm(User user) {
userFormItem = user;
notifyListeners();
}
void updateUserFormItem({
String? username,
int? gender,
String? phoneNumber,
String? email,
DateTime? birthDate,
String? avatar,
List<String>? area,
String? address,
String? job,
List<String>? tags,
String? description
}) {
if (username != null) userFormItem.username = username;
if (gender != null) userFormItem.gender = gender;
if (phoneNumber != null) userFormItem.phoneNumber = phoneNumber;
if (email != null) userFormItem.email = email;
if (birthDate != null) userFormItem.birthDate = birthDate;
if (avatar != null) userFormItem.avatar = avatar;
if (area != null) userFormItem.area = area;
if (address != null) userFormItem.address = address;
if (job != null) userFormItem.job = job;
if (tags != null) userFormItem.tags = tags;
if (description != null) userFormItem.description = description;
notifyListeners();
}
Future<bool> handleUser() async {
if (isLoading) return true;
try {
isLoading = true;
error = null;
notifyListeners();
await updateUserApi(userFormItem.id!, userFormItem);
return true;
} catch (e) {
error = '处理数据失败: $e';
debugPrint('处理数据失败: $e');
return false;
} finally {
isLoading = false;
notifyListeners();
}
}
}

View File

@@ -1,245 +0,0 @@
import 'package:dio/dio.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';
import 'log_util.dart';
class HttpUtil {
static final HttpUtil _instance = HttpUtil._internal();
factory HttpUtil() => _instance;
late Dio _dio;
// 请求头配置
Map<String, dynamic> headers = {
'Content-Type': 'application/json;charset=UTF-8',
'Accept': 'application/json',
};
// 超时时间
final int timeout = 5;
HttpUtil._internal() {
// 初始化Dio实例
BaseOptions options = BaseOptions(
baseUrl: AppConfig.baseApiUrl,
connectTimeout: Duration(seconds: timeout),
receiveTimeout: Duration(seconds: timeout),
headers: headers,
);
_dio = Dio(options);
// 添加请求拦截器
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
logger.d("请求URL: ${options.uri}");
if (options.data != null) {
logger.d("请求参数: ${options.data}");
}
if (SPUtil.getString('token').isNotEmpty) {
options.headers['satoken'] = SPUtil.getString('token');
}
return handler.next(options);
},
),
);
// 添加响应拦截器
_dio.interceptors.add(
InterceptorsWrapper(
onResponse: (response, handler) {
logger.d("响应状态码: ${response.statusCode}");
// logger.d("响应数据: ${response.data}");
return handler.next(response);
},
),
);
// 添加错误拦截器
_dio.interceptors.add(
InterceptorsWrapper(
onError: (DioException e, handler) {
_handleError(e);
return handler.next(e);
},
),
);
}
// 基础请求方法(处理所有类型的请求)
Future<T> _request<T>(
String path, {
required String method,
dynamic data,
Map<String, dynamic>? queryParameters,
T Function(dynamic data)? converter,
}) async {
try {
Response response = await _dio.request(
path,
data: data,
queryParameters: queryParameters,
options: Options(method: method),
);
// 处理响应数据
if (converter != null) {
return converter(response.data);
}
// 没有转换器时尝试直接返回(可能不安全,建议提供转换器)
return response.data;
} catch (e) {
rethrow;
}
}
// GET请求
Future<T> get<T>(
String path, {
Map<String, dynamic>? queryParameters,
T Function(dynamic data)? converter,
}) => _request(
path,
method: "GET",
queryParameters: queryParameters,
converter: converter,
);
// POST请求
Future<T> post<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
T Function(dynamic data)? converter,
}) => _request(
path,
method: "POST",
data: data,
queryParameters: queryParameters,
converter: converter,
);
// PUT请求
Future<T> put<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
T Function(dynamic data)? converter,
}) => _request(
path,
method: "PUT",
data: data,
queryParameters: queryParameters,
converter: converter,
);
// DELETE请求
Future<T> delete<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
T Function(dynamic data)? converter,
}) => _request(
path,
method: "DELETE",
data: data,
queryParameters: queryParameters,
converter: converter,
);
// 文件上传
Future<dynamic> upload(
String path,
String filePath, {
Map<String, dynamic>? queryParameters,
}) async {
try {
FormData formData = FormData.fromMap({
'file': await MultipartFile.fromFile(filePath),
});
Response response = await _dio.post(
path,
data: formData,
queryParameters: queryParameters,
);
return response.data;
} catch (e) {
_handleError(e);
rethrow;
}
}
// 错误处理
void _handleError(dynamic error) {
String errorMessage = '未知错误';
if (error is DioException) {
switch (error.type) {
case DioExceptionType.connectionTimeout:
errorMessage = '连接超时,请检查网络连接';
break;
case DioExceptionType.sendTimeout:
errorMessage = '发送超时,请检查网络连接';
break;
case DioExceptionType.receiveTimeout:
errorMessage = '接收超时,请检查网络连接';
break;
case DioExceptionType.cancel:
errorMessage = '请求已取消';
break;
case DioExceptionType.badCertificate:
errorMessage = '证书验证失败';
break;
case DioExceptionType.badResponse:
// 处理HTTP响应错误4xx, 5xx
final statusCode = error.response?.statusCode ?? 0;
final responseData = error.response?.data;
if (responseData is Map<String, dynamic> && responseData.containsKey('message')) {
// 服务器返回了自定义错误消息
errorMessage = responseData['message']?.toString() ?? '未知错误';
} else {
errorMessage = _getHttpErrorMessage(statusCode);
}
break;
case DioExceptionType.connectionError:
errorMessage = '网络连接错误,请检查网络设置';
break;
case DioExceptionType.unknown:
if (error.error != null) {
errorMessage = '未知错误: ${error.error.toString()}';
}
errorMessage = '发生未知错误';
break;
}
} else {
errorMessage = '非Dio错误: $error';
}
print(errorMessage);
showErrorToast(errorMessage);
}
String _getHttpErrorMessage(int statusCode) {
// 根据HTTP状态码返回对应的错误消息
switch (statusCode) {
case 400: return '错误请求,请检查参数';
case 401: return '未授权,请登录';
case 403: return '禁止访问,权限不足';
case 404: return '资源不存在';
case 405: return '方法不允许';
case 408: return '请求超时';
case 500: return '服务器内部错误';
case 502: return '网关错误';
case 503: return '服务不可用';
case 504: return '网关超时';
default: return 'HTTP错误: $statusCode';
}
}
}

View File

@@ -16,10 +16,13 @@ String formatDateTime(DateTime dateTime, [String format = 'yyyy-MM-dd']) {
return Intl.withLocale('zh_CN', () => formatter.format(dateTime));
}
/// 通用列表转换函数
List<T> convertListResponse<T>(dynamic data, T Function(Map<String, dynamic>) fromJson) {
List<String> convertListStringResponse(dynamic data) {
if (data is List) {
return data.map((item) => fromJson(item as Map<String, dynamic>)).toList();
return data.map((item) => item.toString()).toList();
}
throw FormatException('Expected a list of items for conversion, but got ${data.runtimeType}');
throw FormatException(
'Expected a list of items for conversion, but got ${data.runtimeType}',
);
}

View File

@@ -1,12 +0,0 @@
import 'package:logger/logger.dart';
// 全局日志实例,在整个项目中共享
final Logger logger = Logger(
printer: PrettyPrinter(
methodCount: 1,
colors: true,
dateTimeFormat: DateTimeFormat.dateAndTime,
),
// 可选:配置输出到文件(需配合文件操作库)
// output: FileOutput(file: File('logs/app.log')),
);

View File

@@ -0,0 +1,43 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter_common/utils/file_utils.dart';
import 'package:food_hub_app/config/app_config.dart';
import 'package:minio/io.dart';
import 'package:minio/minio.dart';
class MinIOHelper {
static final MinIOHelper _instance = MinIOHelper._internal();
factory MinIOHelper() => _instance;
final String ip = AppConfig.rustfsIp;
MinIOHelper._internal() {
_minio = Minio(
endPoint: AppConfig.rustfsIp,
port: AppConfig.rustfsPort,
accessKey: AppConfig.rustfsAccessKey,
secretKey: AppConfig.rustfsSecretKey,
useSSL: false,
);
}
late Minio _minio;
Future<String> uploadFile({
required PlatformFile file,
required String bucketName,
Function(double)? onProgress,
}) async {
try {
String hashName = await generateMD5HashName(file.path!);
String fileName = '$hashName${getFileExtension(file.name)}';
await _minio.fPutObject(bucketName, fileName, file.path!);
return fileName;
} catch (e) {
throw Exception('文件上传失败: $e');
}
}
}

View File

@@ -0,0 +1,124 @@
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_common/utils/toast_util.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
/// 截图工具类
class ScreenshotUtil {
static double pixelRatio = 3.0;
static Duration delay = const Duration(milliseconds: 50);
static String watermarkText = '来自 食光集';
// 截图功能
static Future<Uint8List?> captureImage({required GlobalKey globalKey}) async {
// 等待一帧确保UI已渲染
await Future.delayed(delay);
final RenderObject? render = globalKey.currentContext!.findRenderObject();
final RenderRepaintBoundary boundary = render as RenderRepaintBoundary;
final ui.Image image = await boundary.toImage(pixelRatio: pixelRatio);
// 添加水印
return await _addWatermarkToImage(image);
}
// 添加水印到图片
static Future<Uint8List> _addWatermarkToImage(ui.Image image) async {
// 创建画布
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
// 绘制原始图片
canvas.drawImage(image, Offset.zero, Paint());
// 添加文字水印
_drawTextWatermark(canvas, image);
// 生成最终图片
final picture = recorder.endRecording();
final watermarkedImage = await picture.toImage(image.width, image.height);
final ByteData? byteData = await watermarkedImage.toByteData(
format: ui.ImageByteFormat.png,
);
return byteData!.buffer.asUint8List();
}
// 绘制文字水印
static void _drawTextWatermark(Canvas canvas, ui.Image image) {
final textPainter = TextPainter(
text: TextSpan(
text: watermarkText,
style: TextStyle(
color: Color(0x99FFFFFF),
fontSize: 28,
fontFamily: 'CustomFont',
fontWeight: FontWeight.w600,
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout(maxWidth: image.width.toDouble());
// 计算水印位置(右下角,带边距)
final double x = image.width - textPainter.width - 40;
final double y = image.height - textPainter.height - 40;
// 绘制文字背景
final backgroundPaint =
Paint()
..color = const ui.Color(0x4D000000)
..style = PaintingStyle.fill;
// 绘制圆角矩形背景
final backgroundRect = RRect.fromRectAndRadius(
Rect.fromLTWH(
x - 12,
y - 6,
textPainter.width + 24,
textPainter.height + 12,
),
Radius.circular(6),
);
canvas.drawRRect(backgroundRect, backgroundPaint);
// 绘制文字
textPainter.paint(canvas, Offset(x, y));
}
// 保存图片到临时文件
static Future<File> saveImageToFile(Uint8List bytes, String fileName) async {
final directory = await getTemporaryDirectory();
final File imageFile = File('${directory.path}/$fileName');
await imageFile.writeAsBytes(bytes);
return imageFile;
}
static Future<void> captureAndShare({
required GlobalKey globalKey,
required String fileName,
required String shareText,
}) async {
try {
final imageBytes = await captureImage(globalKey: globalKey);
if (imageBytes != null) {
final imageFile = await saveImageToFile(imageBytes, fileName);
await SharePlus.instance.share(
ShareParams(files: [XFile(imageFile.path)], text: shareText),
);
// 分享后删除临时文件
await imageFile.delete();
} else {
ToastUtil.error('生成图片失败,请重试');
}
} catch (e) {
ToastUtil.error('分享失败: $e');
}
}
}

View File

@@ -1,61 +0,0 @@
import 'package:shared_preferences/shared_preferences.dart';
class SPUtil {
static late SharedPreferences _prefs;
/// 初始化
static Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
}
/// 存储数据
static Future<bool> set(String key, dynamic value) {
if (value is String) return _prefs.setString(key, value);
if (value is int) return _prefs.setInt(key, value);
if (value is bool) return _prefs.setBool(key, value);
if (value is double) return _prefs.setDouble(key, value);
if (value is List<String>) return _prefs.setStringList(key, value);
throw ArgumentError('Unsupported value type: ${value.runtimeType}');
}
/// 获取数据
static dynamic get(String key, [dynamic defaultValue]) {
if (!_prefs.containsKey(key)) return defaultValue;
return _prefs.get(key) ?? defaultValue;
}
/// 获取字符串
static String getString(String key, [String defaultValue = '']) {
return _prefs.getString(key) ?? defaultValue;
}
/// 获取布尔值
static bool getBool(String key, [bool defaultValue = false]) {
return _prefs.getBool(key) ?? defaultValue;
}
/// 获取整数
static int getInt(String key, [int defaultValue = 0]) {
return _prefs.getInt(key) ?? defaultValue;
}
/// 获取浮点数
static double getDouble(String key, [double defaultValue = 0.0]) {
return _prefs.getDouble(key) ?? defaultValue;
}
/// 获取字符串列表
static List<String> getStringList(String key, [List<String> defaultValue = const []]) {
return _prefs.getStringList(key) ?? defaultValue;
}
/// 删除数据
static Future<bool> remove(String key) {
return _prefs.remove(key);
}
/// 清空所有数据
static Future<bool> clear() {
return _prefs.clear();
}
}

View File

@@ -1,10 +1,14 @@
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';
import 'package:food_hub_app/views/stats.dart';
import 'package:liquid_glass_widgets/liquid_glass_widgets.dart';
import 'package:liquid_glass_widgets/widgets/shared/glass_page.dart';
import 'package:liquid_glass_widgets/widgets/surfaces/glass_app_bar.dart';
import 'package:liquid_glass_widgets/widgets/surfaces/glass_bottom_bar.dart';
import 'package:liquid_glass_widgets/widgets/surfaces/glass_scaffold.dart';
import 'moment.dart';
class HomePage extends StatefulWidget {
const HomePage({super.key});
@@ -16,151 +20,52 @@ class HomePage extends StatefulWidget {
class _HomePage extends State<HomePage> {
int _currentIndex = 0;
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(),
),
final _pages = [
const RecordPage(),
const StatsPage(),
const MomentPage(),
const ProfilePage(),
];
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
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
centerTitle: true,
title: Text('Food Hub', style: TextStyle(color: Colors.white)),
backgroundColor: Theme.of(context).primaryColor,
leading: Builder(
builder: (context) {
return IconButton(
icon: const Icon(Icons.menu),
color: Colors.white,
onPressed: () => Scaffold.of(context).openDrawer(),
);
},
final colors = Theme.of(context).colorScheme;
return GlassScaffold(
statusBarStyle: GlassStatusBarStyle.auto,
bottomBar: GlassBottomBar(
settings: LiquidGlassSettings(
glassColor: colors.surface
),
actions: homeActions(context),
selectedIndex: _currentIndex,
onTabSelected: (i) => setState(() => _currentIndex = i),
indicatorColor: colors.primary,
selectedIconColor: colors.surface,
unselectedIconColor: Color(0xFF000000),
tabs: const [
GlassBottomBarTab(
icon: Icon(Icons.home_outlined),
activeIcon: Icon(Icons.home),
label: '记录',
),
backgroundColor: Color(0xFFF5F5F5),
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),
GlassBottomBarTab(
icon: Icon(Icons.pie_chart_outline),
activeIcon: Icon(Icons.pie_chart),
label: '统计',
),
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
bottomNavigationBar: NavBar(
navItems: bottomNavItems,
currentIndex: _currentIndex,
onTap: (index) {
setState(() {
_currentIndex = index;
});
},
GlassBottomBarTab(
icon: Icon(Icons.group_outlined),
activeIcon: Icon(Icons.group),
label: '朋友圈',
),
GlassBottomBarTab(
icon: Icon(Icons.account_circle_outlined),
activeIcon: Icon(Icons.account_circle),
label: '我的',
),
],
),
body: _pages[_currentIndex],
);
}
}

View File

@@ -1,11 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter_common/utils/sp_utils.dart';
import 'package:flutter_common/utils/toast_util.dart';
import 'package:flutter_common/widget/loading_widget.dart';
import 'package:flutter_form_builder/flutter_form_builder.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/form.dart';
import 'package:food_hub_app/widgets/common/index.dart';
import 'package:form_builder_validators/form_builder_validators.dart';
import 'package:tdesign_flutter/tdesign_flutter.dart';
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@@ -20,6 +22,7 @@ class _LoginPage extends State<LoginPage> {
String _username = "", _password = "";
bool _isRemember = false;
bool _isObscure = true;
bool _isLoading = false;
Color _eyeColor = Colors.grey;
final List _loginMethod = [
{"title": "phone", "icon": Icons.phone_android},
@@ -34,13 +37,22 @@ class _LoginPage extends State<LoginPage> {
}
Future<void> _loadRememberState() async {
bool? isRemember = await SPUtil.getBool('isRemember');
if (isRemember) {
bool? isRemember = SPUtil.getBool('isRemember');
if (isRemember == true) {
final username = SPUtil.getString('username');
final password = SPUtil.getString('password');
if (username.isNotEmpty && password.isNotEmpty) {
setState(() {
_isRemember = true;
_username = SPUtil.getString('username');
_password = SPUtil.getString('password');
_username = username;
_password = password;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
loginClick();
});
}
}
}
@@ -60,39 +72,53 @@ class _LoginPage extends State<LoginPage> {
SPUtil.set('token', token);
}
void loginClick(BuildContext context) async {
void handleUserInfo(User user) {
SPUtil.set('userId', user.id);
}
void loginClick() async {
setState(() {
_isLoading = true;
});
// 表单校验通过才会继续执行
if ((_formKey.currentState as FormState).validate()) {
(_formKey.currentState as FormState).save();
Session session = await loginApi(_username, _password);
showSuccessToast('登录成功');
try {
LoadingDialog.show(context, message: '登录中');
Session session = await loginApi(_username, _password);
ToastUtil.success('登录成功');
handleRememberState();
handleTokenState(session.saToken.tokenValue);
handleUserInfo(session.userInfo);
LoadingDialog.hide(context);
Navigator.pushNamed(context, '/home');
} else {
showErrorToast('请先输入信息');
} catch (e) {
LoadingDialog.hide(context);
ToastUtil.error(e.toString());
}
}
setState(() {
_isLoading = false;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.grey[100],
body: Form(
key: _formKey,
// autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(
children: [
// 可滚动的主要内容区域
Expanded(child: buildLoginForm()),
// 固定在底部的其他登录选项区域
Expanded(child: buildLoginForm(context)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 50),
child: Column(
children: [
buildOtherLoginText(),
buildOtherLoginText(context),
const SizedBox(height: 15),
buildOtherMethod(context),
const SizedBox(height: 20),
@@ -114,42 +140,36 @@ class _LoginPage extends State<LoginPage> {
);
}
Widget buildLoginForm() {
Widget buildLoginForm(BuildContext context) {
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 20),
children: [
const SizedBox(height: kToolbarHeight),
buildTitle(),
const SizedBox(height: 60),
buildUsernameTextField(),
buildUsernameTextField(context),
const SizedBox(height: 20),
buildPasswordTextField(context),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
buildRememberPasswordCheckbox(context), // 记住密码(左对齐)
buildForgetPasswordText(context),
],
),
const SizedBox(height: 20), // 调整与登录按钮的间距
const SizedBox(height: 10),
buildRememberPasswordCheckbox(context),
const SizedBox(height: 20),
buildLoginButton(context),
],
);
}
Widget buildUsernameTextField() {
Widget buildUsernameTextField(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
formLabelText(labelText: "账号:"),
Text("账号:", style: Theme.of(context).textTheme.bodyLarge),
const SizedBox(height: 10),
FormBuilderTextField(
name: 'username',
initialValue: _username,
keyboardType: TextInputType.text,
decoration: formInputDecoration(
decoration: buildInputDecoration(
context: context,
hintText: "请输入账号",
prefixIcon: Icons.person,
),
@@ -164,7 +184,7 @@ class _LoginPage extends State<LoginPage> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
formLabelText(labelText: "密码:"),
Text("密码:", style: Theme.of(context).textTheme.bodyLarge),
const SizedBox(height: 10),
FormBuilderTextField(
name: 'password',
@@ -173,22 +193,18 @@ class _LoginPage extends State<LoginPage> {
obscureText: _isObscure,
onSaved: (v) => _password = v!,
validator: FormBuilderValidators.required(),
decoration: InputDecoration(
prefixIcon: Icon(Icons.lock),
decoration: buildInputDecoration(
context: context,
hintText: "请输入密码",
hintStyle: TextStyle(color: Colors.grey),
border: OutlineInputBorder(),
filled: true,
fillColor: Colors.white,
prefixIcon: Icons.lock,
).copyWith(
suffixIcon: IconButton(
icon: Icon(Icons.remove_red_eye, color: _eyeColor),
onPressed: () {
final colors = Theme.of(context).colorScheme;
setState(() {
_isObscure = !_isObscure;
_eyeColor =
(_isObscure
? Colors.grey
: Theme.of(context).iconTheme.color)!;
_eyeColor = (_isObscure ? Colors.grey : colors.surface);
});
},
),
@@ -202,6 +218,8 @@ class _LoginPage extends State<LoginPage> {
return Row(
children: [
Checkbox(
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
value: _isRemember,
onChanged: (bool? value) {
setState(() {
@@ -209,48 +227,45 @@ class _LoginPage extends State<LoginPage> {
});
},
),
const Text('记住密码', style: TextStyle(color: Colors.grey, fontSize: 14)),
Text('记住密码', style: Theme.of(context).textTheme.bodyMedium),
],
);
}
Widget buildForgetPasswordText(BuildContext context) {
return Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {
print("忘记密码");
},
child: const Text(
"忘记密码?",
style: TextStyle(fontSize: 14, color: Colors.grey),
),
),
);
}
Widget buildLoginButton(BuildContext context) {
return Align(
child: SizedBox(
return SizedBox(
height: 45,
width: double.infinity,
child: TDButton(
text: '登录',
size: TDButtonSize.large,
type: TDButtonType.fill,
shape: TDButtonShape.rectangle,
theme: TDButtonTheme.primary,
onTap: () => loginClick(context),
child: ElevatedButton(
onPressed: _isLoading ? null : loginClick,
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: const Text(
'登录',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
),
);
}
Widget buildOtherLoginText() {
return TDDivider(text: '其他方式登录', alignment: TextAlignment.center);
Widget buildOtherLoginText(BuildContext context) {
return Row(
children: [
Expanded(child: Divider(color: Colors.grey[300], thickness: 1)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text('其他方式登录', style: Theme.of(context).textTheme.bodyMedium),
),
Expanded(child: Divider(color: Colors.grey[300], thickness: 1)),
],
);
}
Widget buildOtherMethod(context) {
Widget buildOtherMethod(BuildContext context) {
return OverflowBar(
alignment: MainAxisAlignment.center,
children:
@@ -283,16 +298,19 @@ class _LoginPage extends State<LoginPage> {
);
}
Widget buildRegisterText(context) {
Widget buildRegisterText(BuildContext context) {
return Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('没有账号?', style: TextStyle(fontSize: 18)),
Text('没有账号?', style: Theme.of(context).textTheme.bodyMedium),
GestureDetector(
child: const Text(
child: Text(
'点击注册',
style: TextStyle(fontSize: 18, color: Colors.green),
style: TextStyle(
fontSize: 14,
color: Theme.of(context).colorScheme.primary,
),
),
onTap: () {
print("点击注册");

View File

@@ -1,9 +1,10 @@
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:flutter_common/widget/common_widget.dart';
import 'package:food_hub_app/provider/food_provider.dart';
import 'package:food_hub_app/widgets/common/easy_refresh.dart';
import 'package:food_hub_app/widgets/moment/card.dart';
import 'package:tdesign_flutter/tdesign_flutter.dart';
import 'package:provider/provider.dart';
class MomentPage extends StatefulWidget {
const MomentPage({super.key});
@@ -13,26 +14,23 @@ class MomentPage extends StatefulWidget {
}
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();
bool _showScrollToTop = false;
@override
void initState() {
super.initState();
// 初始加载数据
_loadData(isRefresh: true);
_scrollController.addListener(_onScroll);
// 初始化加载数据
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<FoodProvider>().refreshMomentList();
});
}
@override
@@ -43,50 +41,17 @@ class _MomentPageState extends State<MomentPage> {
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);
await context.read<FoodProvider>().refreshMomentList();
_freshController.finishRefresh();
}
// 上拉加载
Future<void> _onLoad() async {
if (_hasMore) {
await _loadData(isRefresh: false);
final provider = context.read<FoodProvider>();
if (provider.hasMore) {
await provider.loadMoreMomentList();
_freshController.finishLoad(IndicatorResult.success);
} else {
_freshController.finishLoad(IndicatorResult.noMore);
@@ -94,8 +59,8 @@ class _MomentPageState extends State<MomentPage> {
}
void _onScroll() {
// 当滚动距离超过300时显示返回顶部按钮
if (_scrollController.offset > 300) {
// 当滚动距离超过时显示返回顶部按钮
if (_scrollController.offset > 100) {
if (!_showScrollToTop) {
setState(() {
_showScrollToTop = true;
@@ -111,84 +76,62 @@ class _MomentPageState extends State<MomentPage> {
}
// 滚动到顶部
void _scrollToTop() {
_scrollController.animateTo(
0,
duration: const Duration(milliseconds: 500), // 滚动动画时长
curve: Curves.easeInOut, // 滚动动画曲线
void _scrollToTop() => scrollToTopAnimateTo(_scrollController);
Widget _buildMomentList(FoodProvider provider) {
return ListView.separated(
padding: const EdgeInsets.only(bottom: 80),
controller: _scrollController,
itemCount: provider.momentList.length,
separatorBuilder: (context, index) => SizedBox(height: 8),
itemBuilder: (context, index) {
return MomentCard(moment: provider.momentList[index], isUser: false);
},
);
}
@override
Widget build(BuildContext context) {
final provider = context.watch<FoodProvider>();
// 空状态显示
if (momentList.isEmpty) {
return EasyRefresh(
controller: _freshController,
onRefresh: _onRefresh,
child: const TDEmpty(type: TDEmptyType.plain, emptyText: '暂无数据'),
);
if (provider.momentList.isEmpty) {
return buildEmptyData();
}
// 有数据时显示列表
return Stack(
return Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
title: Text('朋友圈', style: Theme.of(context).textTheme.titleLarge),
centerTitle: true,
automaticallyImplyLeading: false,
),
body: Padding(
padding: EdgeInsetsGeometry.all(10),
child: 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,
),
buildEasyRefresh(
freshController: _freshController,
onRefresh: _onRefresh,
onLoad: _onLoad,
child: ListView.builder(
controller: _scrollController,
itemCount: momentList.length,
itemBuilder: (context, index) {
return MomentCard(moment: momentList[index]);
},
),
body: Stack(
children: [
if (provider.isLoading)
buildLoadingIndicator()
else
_buildMomentList(provider),
],
),
),
// 返回顶部按钮
if (_showScrollToTop)
Positioned(
right: 10,
bottom: 20,
child: FloatingActionButton(
onPressed: _scrollToTop,
backgroundColor: Colors.white,
elevation: 5,
mini: true,
child: const Icon(
Icons.arrow_upward
),
),
),
buildScrollToTop(context: context, scrollToTop: _scrollToTop),
],
),
),
);
}
}

227
lib/views/moment_form.dart Normal file
View File

@@ -0,0 +1,227 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_common/utils/log_utils.dart';
import 'package:flutter_common/utils/toast_util.dart';
import 'package:flutter_common/widget/dialog_widget.dart';
import 'package:flutter_common/widget/loading_widget.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:food_hub_app/config/app_config.dart';
import 'package:food_hub_app/provider/food_provider.dart';
import 'package:food_hub_app/utils/minio_utils.dart';
import 'package:food_hub_app/widgets/common/form.dart';
import 'package:food_hub_app/widgets/common/image.dart';
import 'package:provider/provider.dart';
class MomentForm extends StatefulWidget {
const MomentForm({super.key});
@override
State<MomentForm> createState() => _MomentFormState();
}
class _MomentFormState extends State<MomentForm> {
final _formKey = GlobalKey<FormBuilderState>();
final _focusNode = FocusNode();
static const String _contentField = 'content';
static const int maxContentLength = 200;
static const int maxImageCount = 9; // 最大图片数量
Widget _buildContentField(FoodProvider provider) {
final colors = Theme.of(context).colorScheme;
return FormBuilderTextField(
name: _contentField,
initialValue: provider.momentFormItem.content,
focusNode: _focusNode,
maxLines: 8,
minLines: 5,
maxLength: maxContentLength,
buildCounter: (
context, {
required currentLength,
required isFocused,
maxLength,
}) {
return Text(
'$currentLength/$maxContentLength',
style: TextStyle(
color: currentLength > maxLength! ? Colors.red : colors.primary,
),
);
},
onChanged: (value) {
provider.updateMomentFormItem(content: value);
},
decoration: buildInputDecoration(context: context, hintText: '请输入朋友圈内容'),
validator: (value) {
if (value == null || value.isEmpty) {
return '请输入朋友圈内容';
}
if (value.length > maxContentLength) {
return '内容不能超过$maxContentLength字';
}
return null;
},
);
}
Widget _buildImageField(FoodProvider provider) {
final imageUrls = provider.momentFormItem.imageList;
final totalItems =
imageUrls.length + (imageUrls.length < maxImageCount ? 1 : 0);
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 4 / 3,
),
itemCount: totalItems,
itemBuilder: (context, index) {
if (index < imageUrls.length) {
return ImagePreview(
imageUrls: imageUrls,
index: index,
onRemoveImage: () => _removeImage(provider, index),
);
}
// 上传按钮(最后一个)
else {
return buildImageUploadButton(
context: context,
onTap: () => _pickImages(provider),
);
}
},
);
}
Widget _buildFormBuilder(FoodProvider provider) {
return FormBuilder(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildFormLabel(context: context, text: '朋友圈内容', isRequired: true),
const SizedBox(height: 8),
_buildContentField(provider),
const SizedBox(height: 8),
buildFormLabel(
context: context,
text: '上传图片最多9张',
isRequired: false,
),
const SizedBox(height: 8),
_buildImageField(provider),
const SizedBox(height: 8),
buildFormButtonGroup(
context: context,
isShowDelete: provider.isEditing,
onConfirm: () => _submitForm(provider),
onDelete: () {},
),
],
),
);
}
/// 选择多张图片
Future<void> _pickImages(FoodProvider provider) async {
final currentCount = provider.momentFormItem.imageList.length;
final remainingCount = maxImageCount - currentCount;
if (remainingCount <= 0) {
ToastUtil.error('最多只能上传$maxImageCount张图片');
return;
}
FilePickerResult? result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['jpg', 'jpeg', 'png'],
allowMultiple: true,
);
if (result != null) {
// 限制选择数量
final filesToUpload = result.files.take(remainingCount).toList();
LoadingDialog.show(context, message: '上传中');
try {
final newImageUrls = <String>[];
// 逐个上传图片
for (final file in filesToUpload) {
final fileName = await MinIOHelper().uploadFile(
bucketName: AppConfig.bucketName,
file: file,
);
logger.i('图片名称:$fileName');
newImageUrls.add(fileName);
}
// 更新图片列表
provider.addMomentFormImage(newImageUrls);
} catch (e) {
ToastUtil.error(e.toString());
} finally {
LoadingDialog.hide(context);
}
}
}
/// 移除单张图片
void _removeImage(FoodProvider provider, int index) {
provider.removeMomentFormImage(index);
}
/// 提交表单
void _submitForm(FoodProvider provider) async {
if (_formKey.currentState?.saveAndValidate() ?? false) {
LoadingDialog.show(context, message: '提交中');
final result = await provider.handleMoment();
if (result == true) {
await provider.refreshMomentList();
LoadingDialog.hide(context);
if (provider.isEditing) {
showSuccessTip(context, '更新朋友圈成功');
} else {
showSuccessTip(context, '新增朋友圈成功');
}
Future.delayed(Duration(milliseconds: 1500), () {
if (mounted) {
Navigator.pop(context);
}
});
} else {
LoadingDialog.hide(context);
if (provider.isEditing) {
showErrorTip(context, '更新朋友圈失败');
} else {
showErrorTip(context, '新增朋友圈失败');
}
}
}
}
@override
Widget build(BuildContext context) {
final provider = context.watch<FoodProvider>();
return AnimatedPadding(
duration: const Duration(milliseconds: 200),
padding: MediaQuery.of(context).viewInsets,
child: SingleChildScrollView(
child: Padding(
padding: EdgeInsets.all(16),
child: _buildFormBuilder(provider),
),
),
);
}
}

View File

@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:flutter_common/utils/sp_utils.dart';
import 'package:flutter_common/widget/common_widget.dart';
import 'package:food_hub_app/provider/food_provider.dart';
import 'package:food_hub_app/widgets/moment/list.dart';
import 'package:provider/provider.dart';
class MomentUserPage extends StatefulWidget {
const MomentUserPage({super.key});
@override
State<MomentUserPage> createState() => _MomentUserPageState();
}
class _MomentUserPageState extends State<MomentUserPage> {
@override
void initState() {
super.initState();
// 初始化加载数据
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<FoodProvider>().queryMomentByUserId(SPUtil.getInt('userId'));
});
}
@override
Widget build(BuildContext context) {
final provider = context.watch<FoodProvider>();
// 空状态显示
if (provider.momentList.isEmpty) {
return buildEmptyData();
}
return Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
title: Text('我的朋友圈', style: Theme.of(context).textTheme.titleLarge),
centerTitle: true,
automaticallyImplyLeading: false,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
),
body:
provider.isLoading
? buildLoadingIndicator()
: Padding(
padding: const EdgeInsets.all(10),
child: MomentList(
momentList: provider.momentList,
isUser: true,
),
),
);
}
}

View File

@@ -1,15 +1,182 @@
import 'package:flutter/material.dart';
import 'package:flutter_common/utils/sp_utils.dart';
import 'package:flutter_common/widget/common_widget.dart';
import 'package:flutter_common/widget/dialog_widget.dart';
import 'package:food_hub_app/provider/app_provider.dart';
import 'package:food_hub_app/provider/user_provider.dart';
import 'package:food_hub_app/views/profile_form.dart';
import 'package:food_hub_app/widgets/profile/basic_info.dart';
import 'package:liquid_glass_widgets/liquid_glass_widgets.dart';
import 'package:liquid_glass_widgets/widgets/containers/glass_card.dart';
import 'package:provider/provider.dart';
class ProfilePage extends StatefulWidget {
const ProfilePage({super.key});
@override
State<ProfilePage> createState() => _ProfilePage();
State<ProfilePage> createState() => ProfilePageState();
}
class ProfilePageState extends State<ProfilePage> {
@override
void initState() {
super.initState();
// 初始化加载数据
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<UserProvider>().refreshUser(SPUtil.getInt('userId'));
});
}
Widget _buildFunctionButton({
required IconData icon,
required String text,
required VoidCallback onTap,
}) {
final colors = Theme.of(context).colorScheme;
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(icon, color: colors.primary),
title: Text(text),
trailing: Icon(Icons.arrow_forward_ios, size: 16, color: colors.primary),
onTap: onTap,
);
}
void _onTapInfo(UserProvider provider) {
provider.initUserForm(provider.currentUser);
Navigator.pushNamed(context, '/profileForm');
// showModalBottomSheet(
// context: context,
// backgroundColor: Theme.of(context).scaffoldBackgroundColor,
// isScrollControlled: true,
// shape: const RoundedRectangleBorder(
// borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
// ),
// builder: (context) => ProfileForm(),
// );
}
void _onTapMoment() {
Navigator.pushNamed(context, '/momentUser');
}
Widget _buildFunctionButtons(BuildContext context) {
final provider = context.watch<UserProvider>();
final colors = Theme.of(context).colorScheme;
return GlassCard(
padding: EdgeInsetsGeometry.symmetric(vertical: 0, horizontal: 10),
useOwnLayer: true,
settings: LiquidGlassSettings(glassColor: colors.surface),
child: Column(
children: [
_buildFunctionButton(
icon: Icons.account_circle,
text: '基本信息',
onTap: () => _onTapInfo(provider),
),
const Divider(height: 1, thickness: 0.2),
_buildFunctionButton(
icon: Icons.group_outlined,
text: '朋友圈',
onTap: _onTapMoment,
),
const Divider(height: 1, thickness: 0.2),
_buildFunctionButton(
icon: Icons.favorite,
text: '我的收藏',
onTap: () {
// 跳转到浏览历史页面
// LogUtils.i('点击浏览历史');
},
),
const Divider(height: 1, thickness: 0.2),
_buildFunctionButton(
icon: Icons.lock,
text: '更改密码',
onTap: () {
// 跳转到帮助与反馈页面
// LogUtils.i('点击帮助与反馈');
},
),
const Divider(height: 1, thickness: 0.2),
_buildFunctionButton(icon: Icons.message, text: '反馈意见', onTap: () {}),
const Divider(height: 1, thickness: 0.2),
_buildFunctionButton(icon: Icons.refresh, text: '检查更新', onTap: () {}),
const Divider(height: 1, thickness: 0.2),
_buildFunctionButton(
icon: Icons.exit_to_app,
text: '退出登录',
onTap: _onTapLogout,
),
],
),
);
}
void _onTapLogout() async {
final result = await showConfirmDialog(context, '确认要退出吗?');
if (result == true) {
SPUtil.remove('token');
Navigator.pop(context);
Navigator.pushNamed(context, '/login');
}
}
Widget _buildVersionInfo() {
final provider = context.read<AppProvider>();
return Text(
'版本 v${provider.fullVersion}',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.primary,
),
);
}
Widget _buildContent(UserProvider provider, BuildContext context) {
final user = provider.currentUser;
return Column(
children: [
SizedBox(width: double.infinity, child: ProfileInfo(user: user)),
const SizedBox(height: 16),
_buildFunctionButtons(context),
const SizedBox(height: 8),
_buildVersionInfo(),
],
);
}
class _ProfilePage extends State<ProfilePage>{
@override
Widget build(BuildContext context) {
return const Center(child: Text("个人主页"));
final provider = context.watch<UserProvider>();
return Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
title: Text('我的', style: Theme.of(context).textTheme.titleLarge),
centerTitle: true,
automaticallyImplyLeading: false,
),
body: Padding(
padding: EdgeInsetsGeometry.all(10),
child: Stack(
children: [
if (provider.isLoading)
buildLoadingIndicator()
else
SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 90),
child: _buildContent(provider, context),
),
],
),
),
);
}
}

254
lib/views/profile_form.dart Normal file
View File

@@ -0,0 +1,254 @@
import 'package:flutter/material.dart';
import 'package:flutter_common/widget/dialog_widget.dart';
import 'package:flutter_common/widget/loading_widget.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_input_chips/flutter_input_chips.dart';
import 'package:form_builder_validators/form_builder_validators.dart';
import 'package:food_hub_app/provider/user_provider.dart';
import 'package:food_hub_app/widgets/common/form.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
class ProfileForm extends StatefulWidget {
const ProfileForm({super.key});
@override
State<ProfileForm> createState() => _ProfileFormState();
}
class _ProfileFormState extends State<ProfileForm> {
final _formKey = GlobalKey<FormBuilderState>();
@override
void initState() {
super.initState();
}
Widget _buildFormBuilder(UserProvider provider) {
final double sizeBoxHeight = 8;
return FormBuilder(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildFormLabel(context: context, text: '用户名', isRequired: true),
SizedBox(height: sizeBoxHeight),
FormBuilderTextField(
name: 'username',
initialValue: provider.userFormItem.username,
onChanged: (value) {
provider.updateUserFormItem(username: value);
},
decoration: buildInputDecoration(
context: context,
hintText: '请输入用户名',
),
validator: FormBuilderValidators.required(errorText: '请输入用户名'),
),
SizedBox(height: sizeBoxHeight),
buildFormLabel(context: context, text: '性别', isRequired: true),
SizedBox(height: sizeBoxHeight),
FormBuilderDropdown<int>(
name: 'gender',
initialValue: provider.userFormItem.gender,
decoration: buildInputDecoration(
context: context,
hintText: '请选择性别',
),
items: const [
DropdownMenuItem(value: 1, child: Text('')),
DropdownMenuItem(value: 2, child: Text('')),
],
onChanged: (value) {
provider.updateUserFormItem(gender: value);
},
),
SizedBox(height: sizeBoxHeight),
buildFormLabel(context: context, text: '手机号', isRequired: true),
SizedBox(height: sizeBoxHeight),
FormBuilderTextField(
name: 'phoneNumber',
initialValue: provider.userFormItem.phoneNumber,
keyboardType: TextInputType.phone,
decoration: buildInputDecoration(
context: context,
hintText: '请输入手机号',
),
onChanged: (value) {
provider.updateUserFormItem(phoneNumber: value);
},
validator: FormBuilderValidators.compose([
FormBuilderValidators.required(errorText: '请输入手机号'),
FormBuilderValidators.match(
RegExp(r'^1[3-9]\d{9}$'),
errorText: '请输入正确的11位手机号',
),
]),
),
SizedBox(height: sizeBoxHeight),
buildFormLabel(context: context, text: '邮箱', isRequired: true),
SizedBox(height: sizeBoxHeight),
FormBuilderTextField(
name: 'email',
initialValue: provider.userFormItem.email,
keyboardType: TextInputType.emailAddress,
decoration: buildInputDecoration(
context: context,
hintText: '请输入邮箱',
),
onChanged: (value) {
provider.updateUserFormItem(email: value);
},
validator: FormBuilderValidators.compose([
FormBuilderValidators.email(errorText: '邮箱格式不正确'),
]),
),
SizedBox(height: sizeBoxHeight),
buildFormLabel(context: context, text: '出生日期', isRequired: true),
SizedBox(height: sizeBoxHeight),
FormBuilderDateTimePicker(
name: 'birthDate',
initialValue: provider.userFormItem.birthDate,
inputType: InputType.date,
format: DateFormat('yyyy-MM-dd'),
firstDate: DateTime(1900),
lastDate: DateTime.now(),
decoration: buildInputDecoration(
context: context,
hintText: '请选择出生日期',
),
onChanged: (value) {
provider.updateUserFormItem(birthDate: value);
},
),
SizedBox(height: sizeBoxHeight),
buildFormLabel(context: context, text: '详细地址', isRequired: false),
SizedBox(height: sizeBoxHeight),
FormBuilderTextField(
name: 'address',
initialValue: provider.userFormItem.address,
decoration: buildInputDecoration(
context: context,
hintText: '请输入详细地址',
),
onChanged: (value) {
provider.updateUserFormItem(address: value);
},
),
SizedBox(height: sizeBoxHeight),
buildFormLabel(context: context, text: '职业', isRequired: false),
SizedBox(height: sizeBoxHeight),
FormBuilderTextField(
name: 'job',
initialValue: provider.userFormItem.job,
decoration: buildInputDecoration(
context: context,
hintText: '请输入职业',
),
onChanged: (value) {
provider.updateUserFormItem(job: value);
},
),
SizedBox(height: sizeBoxHeight),
buildFormLabel(context: context, text: '标签', isRequired: false),
SizedBox(height: sizeBoxHeight),
FormBuilderField<List<String>>(
name: 'tags',
initialValue: provider.userFormItem.tags,
builder: (FormFieldState<List<String>> field) {
return FlutterInputChips(
padding: EdgeInsets.all(0),
initialValue: field.value ?? [],
onChanged: (value) {
provider.updateUserFormItem(tags: value);
},
inputDecoration: buildInputDecoration(
context: context,
hintText: '请输入标签,多个标签用回车键隔开',
),
);
},
),
SizedBox(height: sizeBoxHeight),
buildFormLabel(context: context, text: '简介', isRequired: false),
SizedBox(height: sizeBoxHeight),
FormBuilderTextField(
name: 'description',
initialValue: provider.userFormItem.description,
decoration: buildInputDecoration(
context: context,
hintText: '请输入简介',
),
onChanged: (value) {
provider.updateUserFormItem(description: value);
},
),
SizedBox(height: sizeBoxHeight),
buildFormButtonGroup(
context: context,
isShowDelete: false,
onConfirm: () => _submitForm(provider),
onDelete: () => {},
),
],
),
);
}
/// 提交表单
void _submitForm(UserProvider provider) async {
if (_formKey.currentState?.saveAndValidate() ?? false) {
LoadingDialog.show(context, message: '提交中');
final result = await provider.handleUser();
if (result == true) {
LoadingDialog.hide(context);
showSuccessTip(context, '更新信息成功');
Future.delayed(Duration(milliseconds: 1500), () {
if (mounted) {
Navigator.pop(context);
}
});
} else {
LoadingDialog.hide(context);
showErrorTip(context, '更新信息失败');
}
}
}
@override
Widget build(BuildContext context) {
final provider = context.watch<UserProvider>();
final colors = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: Text('基本信息', style: Theme.of(context).textTheme.titleLarge),
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: colors.primary),
onPressed: () => Navigator.pop(context),
),
),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16),
child: _buildFormBuilder(provider),
),
),
);
}
}

109
lib/views/profile_user.dart Normal file
View File

@@ -0,0 +1,109 @@
import 'package:flutter/material.dart';
import 'package:flutter_common/widget/common_widget.dart';
import 'package:food_hub_app/provider/user_provider.dart';
import 'package:food_hub_app/widgets/common/index.dart';
import 'package:food_hub_app/widgets/moment/list.dart';
import 'package:food_hub_app/widgets/profile/basic_info.dart';
import 'package:food_hub_app/widgets/recipe/list.dart';
import 'package:provider/provider.dart';
enum ProfileTab {
profile('基础信息'),
moment('朋友圈'),
recipe('菜谱');
final String label;
const ProfileTab(this.label);
}
class ProfileUserPage extends StatefulWidget {
const ProfileUserPage({super.key});
@override
State<ProfileUserPage> createState() => ProfileUserPageState();
}
class ProfileUserPageState extends State<ProfileUserPage> {
late ProfileTab currentTab = ProfileTab.profile;
void _onTabChange(ProfileTab tab) {
setState(() {
currentTab = tab;
});
// provider.onRecordTabChange();
}
Widget _buildTabs({
required BuildContext context,
required ProfileTab currentTab,
required ValueChanged<ProfileTab> onTabChanged,
}) {
return Container(
padding: EdgeInsets.all(8),
child: buildToggleSwitch(
context: context,
currentTab: currentTab,
tabValues: ProfileTab.values,
labels: ProfileTab.values.map((e) => e.label).toList(),
onTabChanged: onTabChanged,
),
);
}
Widget _buildContent(UserProvider provider) {
return Column(
children: [
_buildTabs(
context: context,
currentTab: currentTab,
onTabChanged: (tab) => _onTabChange(tab),
),
Expanded(
child: IndexedStack(
index: currentTab.index,
children: [
SizedBox(
width: double.infinity, child: ProfileInfo(user: provider.currentUser)
),
MomentList(momentList: provider.momentList, isUser: false),
RecipeList(userId: provider.currentUser.id!),
],
),
),
],
);
}
@override
Widget build(BuildContext context) {
final provider = context.watch<UserProvider>();
final colors = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: Text(
'${provider.currentUser.username}的个人信息',
style: Theme.of(context).textTheme.titleLarge,
),
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: colors.primary),
onPressed: () => Navigator.pop(context),
),
),
body: Padding(
padding: const EdgeInsets.all(10),
child: Stack(
children: [
if (provider.isLoading)
buildLoadingIndicator()
else
_buildContent(provider),
],
),
),
);
}
}

View File

@@ -1,7 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_common/widget/common_widget.dart';
import 'package:food_hub_app/apis/recipe.dart';
import 'package:food_hub_app/models/recipe.dart';
import 'package:food_hub_app/provider/food_provider.dart';
import 'package:food_hub_app/utils/screenshot_util.dart';
import 'package:food_hub_app/views/recipe_form.dart';
import 'package:food_hub_app/widgets/common/index.dart';
import 'package:provider/provider.dart';
class RecipeDetailPage extends StatefulWidget {
const RecipeDetailPage({super.key});
@@ -11,28 +16,11 @@ class RecipeDetailPage extends StatefulWidget {
}
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: [],
);
Recipe recipe = Recipe.getEmpty();
final GlobalKey _recipeDetailKey = GlobalKey();
bool _isSharing = false;
// 定义三个分类列表
List<RecipeMaterial> mainMaterialList = []; // 主料
List<RecipeMaterial> auxiliaryMaterialList = []; // 配料
List<RecipeMaterial> accessoryMaterialList = []; // 辅料
List<RecipeMaterial> materialList = [];
@override
void didChangeDependencies() {
@@ -43,8 +31,7 @@ class _RecipeDetailState extends State<RecipeDetailPage> {
Future<void> refreshRecipeDetail() async {
// 获取参数
final args = ModalRoute.of(context)?.settings.arguments as Map;
final recipeId = args['id'];
final result = await queryRecipeByIdApi(recipeId);
final result = await queryRecipeByIdApi(args['id']);
setState(() {
recipe = result;
@@ -53,35 +40,78 @@ class _RecipeDetailState extends State<RecipeDetailPage> {
}
void getRecipeMaterial() {
materialList.clear();
for (var material in recipe.materialList) {
switch (material.type) {
case '主料':
mainMaterialList.add(material);
break;
case '配料':
auxiliaryMaterialList.add(material);
break;
case '辅料':
accessoryMaterialList.add(material);
break;
materialList.add(material);
}
}
void _handleEditRecipe(FoodProvider provider) {
provider.initRecipeForm(recipe);
provider.isEditing = true;
showModalBottomSheet(
context: context,
constraints: BoxConstraints(minHeight: 600),
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => RecipeForm(),
);
}
// 分享图片
Future<void> _shareRecipeAsImage() async {
if (_isSharing) return;
setState(() {
_isSharing = true;
});
final timestamp = DateTime.now().millisecondsSinceEpoch;
final imageName = '${recipe.name}_$timestamp.png';
await ScreenshotUtil.captureAndShare(
globalKey: _recipeDetailKey,
fileName: imageName,
shareText: '分享菜谱: ${recipe.name}',
);
setState(() {
_isSharing = false;
});
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final provider = context.watch<FoodProvider>();
return Scaffold(
appBar: AppBar(
title: Text('菜谱信息', style: TextStyle(color: Colors.white)),
backgroundColor: Theme.of(context).primaryColor,
title: Text('菜谱信息', style: Theme.of(context).textTheme.titleLarge),
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: Colors.white),
icon: Icon(Icons.arrow_back, color: colors.primary),
onPressed: () => Navigator.pop(context),
),
),
backgroundColor: Color(0xFFF5F5F5),
body: SingleChildScrollView(
child: Padding(padding: EdgeInsets.all(5), child: _buildRecipeDetail()),
child: Column(
children: [
// 用RepaintBoundary包裹要截图的部分
RepaintBoundary(
key: _recipeDetailKey,
child: Padding(
padding: EdgeInsets.all(10),
child: _buildRecipeDetail(),
),
),
_buildButtons(provider),
],
),
),
);
}
@@ -91,35 +121,47 @@ class _RecipeDetailState extends State<RecipeDetailPage> {
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildTitleSection(),
SizedBox(height: 10),
SizedBox(height: 8),
_buildInfoCard(
context,
icon: Icons.info,
title: "基础信息",
content: _buildTag(context, title: recipe.category),
content: Text(recipe.category),
),
SizedBox(height: 8),
_buildInfoCard(
context,
icon: Icons.star,
title: "推荐指数",
content: Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(5, (index) {
return Icon(
index < recipe.recommendRate ? Icons.star : Icons.star_border,
color: Theme.of(context).primaryColor,
size: 20,
);
}),
),
),
SizedBox(height: 8),
_buildInfoCard(
context,
icon: Icons.shopping_cart,
title: "食材信息",
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildMaterialSection(context, '主料', mainMaterialList),
_buildMaterialSection(context, '辅料', auxiliaryMaterialList),
_buildMaterialSection(context, '调料', accessoryMaterialList),
],
children: [_buildMaterialSection(materialList)],
),
),
SizedBox(height: 8),
_buildInfoCard(
context,
icon: Icons.list,
title: "步骤信息",
content: Column(
children:
recipe.stepList.map((step) => _buildStepSection(step)).toList(),
),
content: Column(children: [_buildStepSection(recipe.stepList)]),
),
SizedBox(height: 8),
_buildInfoCard(
context,
icon: Icons.note,
@@ -133,20 +175,6 @@ class _RecipeDetailState extends State<RecipeDetailPage> {
);
}
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,
@@ -154,36 +182,43 @@ class _RecipeDetailState extends State<RecipeDetailPage> {
);
}
Widget _buildMaterialSection(
BuildContext context,
String title,
List<RecipeMaterial> materials,
) {
Widget _buildMaterialSection(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: '')
buildTag(context, '')
else
Wrap(
Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8.0,
runSpacing: 8.0,
children:
materials
.map(
(m) => _buildTag(context, title: '${m.name} ${m.amount}'),
)
.toList(),
children: materials.map((m) => _buildMaterialItem(m)).toList(),
),
],
);
}
Widget _buildStepSection(RecipeStep step) {
Widget _buildMaterialItem(RecipeMaterial material) {
return Text('${material.name} ${material.amount}');
}
Widget _buildStepSection(List<RecipeStep> steps) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (steps.isEmpty)
buildTag(context, '')
else
Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8.0,
children: steps.map((m) => _buildStepItem(m)).toList(),
),
],
);
}
Widget _buildStepItem(RecipeStep step) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -201,16 +236,6 @@ class _RecipeDetailState extends State<RecipeDetailPage> {
),
),
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,
),
),
],
),
),
@@ -224,13 +249,13 @@ class _RecipeDetailState extends State<RecipeDetailPage> {
required String title,
required Widget content,
}) {
return cardContainer(
Column(
return CommonCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, color: Theme.of(context).primaryColor),
Icon(icon, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 5),
Text(
title,
@@ -244,4 +269,23 @@ class _RecipeDetailState extends State<RecipeDetailPage> {
),
);
}
Widget _buildButtons(FoodProvider provider) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
circleIconButton(
context: context,
icon: Icons.edit,
onPressed: () => _handleEditRecipe(provider),
),
SizedBox(width: 8),
circleIconButton(
context: context,
icon: _isSharing ? Icons.hourglass_top : Icons.share,
onPressed: _shareRecipeAsImage,
),
],
);
}
}

569
lib/views/recipe_form.dart Normal file
View File

@@ -0,0 +1,569 @@
import 'package:flutter/material.dart';
import 'package:flutter_common/widget/dialog_widget.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:food_hub_app/models/recipe.dart';
import 'package:food_hub_app/provider/food_provider.dart';
import 'package:food_hub_app/widgets/common/form.dart';
import 'package:food_hub_app/widgets/common/index.dart';
import 'package:form_builder_validators/form_builder_validators.dart';
import 'package:flutter_common/utils/toast_util.dart';
import 'package:flutter_common/widget/loading_widget.dart';
import 'package:provider/provider.dart';
class RecipeForm extends StatefulWidget {
const RecipeForm({super.key});
@override
State<RecipeForm> createState() => _RecipeFormState();
}
class _RecipeFormState extends State<RecipeForm> {
final _formKey = GlobalKey<FormBuilderState>();
int _currentStep = 0;
// 表单字段名称常量
static const String _nameField = 'name';
static const String _categoryField = 'category';
static const String _recommendRateField = 'recommendRate';
static const String _remarkField = 'remark';
static const String _isShareField = 'isShare';
// 分类选项
final List<String> _categoryOptions = [
'家常菜',
'川菜',
'粤菜',
'湘菜',
'西餐',
'甜品',
'汤羹',
'其他',
];
@override
void initState() {
super.initState();
}
// 构建步骤内容
Widget _buildStepContent(FoodProvider provider) {
switch (_currentStep) {
case 0:
return _buildInfo(provider);
case 1:
return _buildMaterials(provider);
case 2:
return _buildSteps(provider);
default:
return _buildInfo(provider);
}
}
// 第一步:基本信息
Widget _buildInfo(FoodProvider provider) {
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildFormLabel(context: context, text: '菜谱名称', isRequired: true),
const SizedBox(height: 8),
FormBuilderTextField(
name: _nameField,
initialValue: provider.recipeFormItem.name,
decoration: buildInputDecoration(
context: context,
hintText: '请输入菜谱名称',
prefixIcon: Icons.restaurant_menu,
),
onChanged: (value) {
provider.updateRecipeFormItem(name: value ?? '');
},
validator: FormBuilderValidators.compose([
FormBuilderValidators.required(errorText: '菜谱名称不能为空'),
FormBuilderValidators.maxLength(50, errorText: '名称不能超过50个字符'),
]),
),
const SizedBox(height: 16),
buildFormLabel(context: context, text: '分类', isRequired: true),
const SizedBox(height: 8),
FormBuilderDropdown(
name: _categoryField,
initialValue: provider.recipeFormItem.category,
decoration: buildInputDecoration(
context: context,
hintText: '请选择分类',
prefixIcon: Icons.widgets,
),
items:
_categoryOptions
.map(
(category) => DropdownMenuItem(
value: category,
child: Text(category),
),
)
.toList(),
onChanged: (value) {
provider.updateRecipeFormItem(category: value ?? '');
},
validator: FormBuilderValidators.required(errorText: '请选择分类'),
),
const SizedBox(height: 16),
buildFormLabel(context: context, text: '推荐评分', isRequired: false),
const SizedBox(height: 8),
FormBuilderSlider(
name: _recommendRateField,
initialValue: provider.recipeFormItem.recommendRate,
min: 1,
max: 5,
divisions: 4,
activeColor: Theme.of(context).colorScheme.primary,
decoration: const InputDecoration(border: InputBorder.none),
onChanged: (value) {
provider.updateRecipeFormItem(recommendRate: value ?? 0);
},
),
const SizedBox(height: 16),
buildFormLabel(context: context, text: '备注', isRequired: false),
const SizedBox(height: 8),
FormBuilderTextField(
name: _remarkField,
initialValue: provider.recipeFormItem.remark,
maxLines: 3,
decoration: buildInputDecoration(
context: context,
hintText: '请输入菜谱的特别说明或小贴士',
),
onChanged: (value) {
provider.updateRecipeFormItem(remark: value ?? '');
},
),
const SizedBox(height: 16),
FormBuilderCheckbox(
name: _isShareField,
initialValue: provider.recipeFormItem.isShare,
title: const Text('公开分享此菜谱'),
onChanged: (value) {
provider.updateRecipeFormItem(isShare: value ?? false);
},
),
],
),
);
}
// 第二步:食材清单
Widget _buildMaterials(FoodProvider provider) {
final materials = provider.recipeFormItem.materialList;
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('食材清单', style: Theme.of(context).textTheme.titleMedium),
const Spacer(),
IconButton(
icon: Icon(
Icons.add,
color: Theme.of(context).colorScheme.primary,
),
onPressed: () => provider.addRecipeMaterial(),
tooltip: '添加食材',
),
],
),
if (materials.isEmpty)
Container(
color: Theme.of(context).colorScheme.surface,
margin: EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(10),
child: Column(
children: [
Icon(Icons.inventory_2, size: 48, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'暂无食材,请点击添加按钮添加食材',
style: TextStyle(color: Colors.grey[600]),
),
],
),
)
else
Column(
children:
materials.asMap().entries.map((entry) {
final index = entry.key;
final material = entry.value;
return Column(
children: [
_buildMaterialItem(index, material, provider),
const SizedBox(height: 8),
],
);
}).toList(),
),
],
),
);
}
Widget _buildMaterialItem(
int index,
RecipeMaterial material,
FoodProvider provider,
) {
return Column(
key: ValueKey(material.id),
children: [
Row(
children: [
Expanded(
child: TextFormField(
initialValue: material.name,
decoration: buildInputDecoration(
context: context,
hintText: '请输入名称',
prefixIcon: Icons.content_paste,
),
onChanged: (value) {
provider.updateRecipeMaterial(index, name: value);
},
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
initialValue: material.amount,
decoration: buildInputDecoration(
context: context,
hintText: '请输入用量',
prefixIcon: Icons.scale,
),
onChanged: (value) {
provider.updateRecipeMaterial(index, amount: value);
},
),
),
IconButton(
icon: const Icon(Icons.delete, color: Colors.red, size: 20),
onPressed: () => provider.removeRecipeMaterial(index),
),
],
),
],
);
}
// 第三步:制作步骤
Widget _buildSteps(FoodProvider provider) {
final steps = provider.recipeFormItem.stepList;
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('制作步骤', style: Theme.of(context).textTheme.titleMedium),
const Spacer(),
IconButton(
icon: Icon(
Icons.add,
color: Theme.of(context).colorScheme.primary,
),
onPressed: () => provider.addRecipeStep(),
tooltip: '添加步骤',
),
],
),
if (steps.isEmpty)
Container(
color: Theme.of(context).colorScheme.surface,
margin: EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(10),
child: Column(
children: [
Icon(Icons.list_alt, size: 48, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'暂无步骤,请点击添加按钮添加制作步骤',
style: TextStyle(color: Colors.grey[600]),
),
],
),
)
else
Column(
children:
steps.asMap().entries.map((entry) {
final index = entry.key;
final step = entry.value;
return Column(
children: [
_buildStepItem(index, step, provider),
const SizedBox(height: 8),
],
);
}).toList(),
),
],
),
);
}
Widget _buildStepItem(int index, RecipeStep step, FoodProvider provider) {
return Row(
key: ValueKey(step.id),
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// 步骤编号
Container(
width: 28,
height: 28,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
),
child: Text(
'${index + 1}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
const SizedBox(width: 12),
// 步骤内容
Expanded(
child: TextFormField(
initialValue: step.content,
decoration: buildInputDecoration(
context: context,
hintText: '请输入内容',
prefixIcon: Icons.content_paste,
),
onChanged: (value) {
provider.updateRecipeStep(index, content: value);
},
),
),
// 删除按钮
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () => provider.removeRecipeStep(index),
),
],
);
}
// 步骤指示器
Widget _buildStepIndicator() {
return Container(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildStepCircle(0),
SizedBox(width: 10),
_buildStepConnector(0),
SizedBox(width: 10),
_buildStepCircle(1),
SizedBox(width: 10),
_buildStepConnector(1),
SizedBox(width: 10),
_buildStepCircle(2),
],
),
Container(
margin: const EdgeInsets.only(top: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildStepTitle(0, '基本信息'),
SizedBox(width: 40),
_buildStepTitle(1, '食材清单'),
SizedBox(width: 40),
_buildStepTitle(2, '制作步骤'),
],
),
),
],
),
);
}
// 只构建圆圈,不包含标题文字
Widget _buildStepCircle(int stepIndex) {
final isActive = _currentStep == stepIndex;
final isCompleted = _currentStep > stepIndex;
final boxColor =
isActive || isCompleted
? Theme.of(context).colorScheme.primary
: Colors.grey;
return Container(
width: 32,
height: 32,
decoration: BoxDecoration(color: boxColor, shape: BoxShape.circle),
child: Center(
child:
isCompleted
? const Icon(Icons.check, color: Colors.white, size: 16)
: Text(
'${stepIndex + 1}',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
);
}
// 箭头连接器
Widget _buildStepConnector(int stepIndex) {
final isActive = _currentStep > stepIndex;
return SizedBox(
width: 40,
child: Icon(
Icons.arrow_forward,
size: 20,
color: isActive ? Theme.of(context).colorScheme.primary : Colors.grey,
),
);
}
Widget _buildStepTitle(int stepIndex, String title) {
final isActive = _currentStep == stepIndex;
final isCompleted = _currentStep > stepIndex;
final textColor =
isActive || isCompleted
? Theme.of(context).colorScheme.primary
: Colors.grey;
return Text(
title,
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
color: textColor,
fontSize: 12,
),
);
}
// 导航按钮
Widget _buildNavigationButtons(FoodProvider provider) {
final isLastStep = _currentStep == 2;
final isFirstStep = _currentStep == 0;
return Row(
children: [
if (!isFirstStep)
Expanded(
child: buildInfoButton(
context: context,
text: '上一步',
onPressed: _previousStep,
),
),
if (!isFirstStep) const SizedBox(width: 12),
Expanded(
child: buildPrimaryButton(
context: context,
text: isLastStep ? '提交' : '下一步',
onPressed: () => _nextStep(provider),
),
),
],
);
}
void _nextStep(FoodProvider provider) {
if (_currentStep < 2) {
if (_formKey.currentState?.saveAndValidate() ?? false) {
setState(() {
_currentStep++;
});
}
} else {
_submitForm(provider);
}
}
void _previousStep() {
if (_currentStep > 0) {
setState(() {
_currentStep--;
});
}
}
void _submitForm(FoodProvider provider) async {
if (_formKey.currentState?.saveAndValidate() ?? false) {
LoadingDialog.show(context, message: '提交中');
final result = await provider.handleRecipe();
if (result == true) {
LoadingDialog.hide(context);
if (provider.isEditing) {
showSuccessTip(context, '更新菜谱成功');
} else {
showSuccessTip(context, '新增菜谱成功');
}
Future.delayed(Duration(milliseconds: 1500), () {
if (mounted) {
Navigator.pop(context);
}
});
} else {
LoadingDialog.hide(context);
if (provider.isEditing) {
showErrorTip(context, '更新菜谱失败');
} else {
showErrorTip(context, '新增菜谱失败');
}
}
} else {
ToastUtil.error('请检查表单填写是否正确');
}
}
@override
Widget build(BuildContext context) {
final provider = context.watch<FoodProvider>();
return AnimatedPadding(
duration: const Duration(milliseconds: 200),
padding: MediaQuery.of(context).viewInsets,
child: SingleChildScrollView(
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
children: [
_buildStepIndicator(),
FormBuilder(key: _formKey, child: _buildStepContent(provider)),
_buildNavigationButtons(provider),
],
),
),
),
);
}
}

View File

@@ -1,9 +1,23 @@
import 'package:flutter/material.dart';
import 'package:food_hub_app/provider/food_provider.dart';
import 'package:food_hub_app/views/moment_form.dart';
import 'package:food_hub_app/views/recipe_form.dart';
import 'package:food_hub_app/views/record_form.dart';
import 'package:food_hub_app/widgets/common/index.dart';
import 'package:food_hub_app/widgets/recipe/calendar.dart';
import 'package:food_hub_app/widgets/recipe/list.dart';
import 'package:food_hub_app/widgets/recipe/timeline.dart';
import 'package:tdesign_flutter/tdesign_flutter.dart';
import 'package:provider/provider.dart';
enum RecordTab {
recipe('菜谱'),
calendar('日历'),
timeline('时间轴');
final String label;
const RecordTab(this.label);
}
class RecordPage extends StatefulWidget {
const RecordPage({super.key});
@@ -12,18 +26,11 @@ class RecordPage extends StatefulWidget {
State<StatefulWidget> createState() => _RecordPageState();
}
class _RecordPageState extends State<RecordPage>
with SingleTickerProviderStateMixin {
late final TabController _tabController = TabController(
length: 3,
vsync: this,
);
final List<TDTab> tabs = [
const TDTab(text: '菜谱', icon: Icon(Icons.book)),
const TDTab(text: '日历', icon: Icon(Icons.calendar_month)),
const TDTab(text: '时间轴', icon: Icon(Icons.timeline)),
class _RecordPageState extends State<RecordPage> {
final List<Widget> _tabPages = [
RecipeList(userId: 0),
RecipeCalendar(),
RecipeTimeline(),
];
@override
@@ -33,34 +40,176 @@ class _RecordPageState extends State<RecordPage>
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
Widget _buildTabs({
required BuildContext context,
required RecordTab currentTab,
required ValueChanged<RecordTab> onTabChanged,
}) {
return Container(
padding: EdgeInsets.all(8),
child: buildToggleSwitch(
context: context,
currentTab: currentTab,
tabValues: RecordTab.values,
labels: RecordTab.values.map((e) => e.label).toList(),
onTabChanged: onTabChanged,
),
);
}
void _onTabChange(RecordTab tab, FoodProvider provider) {
provider.currentRecordTab = tab;
provider.onRecordTabChange();
}
void _handleAddRecord(FoodProvider provider) {
provider.resetRecordForm();
provider.isEditing = false;
Navigator.pop(context);
showModalBottomSheet(
context: context,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => RecordForm(),
);
}
void _handleAddRecipe(FoodProvider provider) {
provider.resetRecipeForm();
provider.isEditing = false;
Navigator.pop(context);
showModalBottomSheet(
context: context,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => RecipeForm(),
);
}
void _handleAddMoment(FoodProvider provider) {
provider.resetMomentForm();
provider.isEditing = false;
Navigator.pop(context);
showModalBottomSheet(
context: context,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => MomentForm(),
);
}
void _onPressAdd(FoodProvider provider) {
showModalBottomSheet(
context: context,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder:
(context) => Padding(
padding: EdgeInsetsGeometry.symmetric(horizontal: 0, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
Text('请选择操作', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 10),
ListTile(
leading: Icon(
Icons.book,
color: Theme.of(context).colorScheme.primary,
),
title: Text(
'新增菜谱',
style: Theme.of(context).textTheme.bodyLarge,
),
onTap: () => _handleAddRecipe(provider),
),
ListTile(
leading: Icon(
Icons.note_add,
color: Theme.of(context).colorScheme.primary,
),
title: Text(
'新增记录',
style: Theme.of(context).textTheme.bodyLarge,
),
onTap: () => _handleAddRecord(provider),
),
ListTile(
leading: Icon(
Icons.group,
color: Theme.of(context).colorScheme.primary,
),
title: Text(
'发布朋友圈',
style: Theme.of(context).textTheme.bodyLarge,
),
onTap: () => _handleAddMoment(provider),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TDTabBar(
tabs: tabs,
controller: _tabController,
backgroundColor: Colors.white,
showIndicator: true,
final provider = context.watch<FoodProvider>();
return Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
elevation: 0,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
title: Text('记录', style: Theme.of(context).textTheme.titleLarge),
centerTitle: true,
leading: Builder(
builder:
(context) => IconButton(
icon: Icon(Icons.menu, color: Theme.of(context).primaryColor),
onPressed: () => {},
),
Expanded(
child: Padding(
padding: EdgeInsets.all(5),
child: TabBarView(
controller: _tabController,
),
actions: [
IconButton(
icon: Icon(Icons.search, color: Theme.of(context).primaryColor),
onPressed: () {},
),
IconButton(
icon: Icon(Icons.add, color: Theme.of(context).primaryColor),
onPressed: () => _onPressAdd(provider),
),
],
),
body: Padding(
padding: const EdgeInsets.all(10),
child: Column(
children: [
RecipeList(),
RecipeCalendar(),
RecipeTimeline(),
_buildTabs(
context: context,
currentTab: provider.currentRecordTab,
onTabChanged: (tab) => _onTabChange(tab, provider),
),
Expanded(child: _tabPages[provider.currentRecordTab.index]),
],
),
),
),
],
);
}
}

View File

@@ -1,99 +1,180 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_common/utils/toast_util.dart';
import 'package:flutter_common/widget/dialog_widget.dart';
import 'package:flutter_common/widget/loading_widget.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:food_hub_app/apis/recipe.dart';
import 'package:food_hub_app/config/app_config.dart';
import 'package:food_hub_app/provider/food_provider.dart';
import 'package:food_hub_app/utils/minio_utils.dart';
import 'package:food_hub_app/widgets/common/form.dart';
import 'package:image_picker/image_picker.dart';
import 'package:food_hub_app/widgets/common/image.dart';
import 'package:intl/intl.dart';
import 'dart:io';
class RecordFormPage extends StatefulWidget {
const RecordFormPage({super.key});
import 'package:provider/provider.dart';
class RecordForm extends StatefulWidget {
const RecordForm({super.key});
@override
State<RecordFormPage> createState() => _RecordFormPageState();
State<RecordForm> createState() => _RecordFormState();
}
class _RecordFormPageState extends State<RecordFormPage> {
class _RecordFormState extends State<RecordForm> {
final _formKey = GlobalKey<FormBuilderState>();
final _focusNode = FocusNode();
static const String _nameField = 'name';
static const String _dateField = 'date';
final ImagePicker _picker = ImagePicker();
File? imageUrl; // 改为单张图片变量
List<String> _foodNameList = [];
@override
void dispose() {
_focusNode.dispose();
super.dispose();
void initState() {
super.initState();
refreshCategoryList();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
Future<void> refreshCategoryList() async {
final result = await queryFoodNameListApi();
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(),
),
),
),
),
);
setState(() {
_foodNameList = result;
});
}
Widget _buildFormBuilder() {
Widget _buildFormBuilder(FoodProvider provider) {
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(context: context, text: '菜谱名称', isRequired: true),
const SizedBox(height: 12),
_buildRecipeField(provider),
const SizedBox(height: 12),
buildFormLabel('完成时间', required: true),
const SizedBox(height: 8),
_buildDateTextField(),
const SizedBox(height: 10),
buildFormLabel(context: context, text: '完成时间', isRequired: true),
const SizedBox(height: 12),
_buildDateField(provider),
const SizedBox(height: 12),
buildFormLabel('上传图片', required: true),
const SizedBox(height: 10),
_buildImageUploadArea(),
buildFormLabel(context: context, text: '上传图片', isRequired: true),
const SizedBox(height: 12),
_buildImageField(provider),
const SizedBox(height: 12),
const SizedBox(height: 10),
buildFormButtonGroup(context: context, onConfirm: () => _submitForm),
buildFormButtonGroup(
context: context,
isShowDelete: provider.isEditing,
onConfirm: () => _submitForm(provider),
onDelete: () => _deleteForm(provider),
),
],
),
);
}
/// 菜谱名称输入框
Widget _buildNameTextField() {
Widget _buildRecipeField(FoodProvider provider) {
return Autocomplete<String>(
initialValue: TextEditingValue(text: provider.recordFormItem.name),
optionsBuilder: (TextEditingValue textEditingValue) {
if (textEditingValue.text.isEmpty || _foodNameList.isEmpty) {
return const Iterable<String>.empty();
}
return _foodNameList.where(
(option) => option.toLowerCase().contains(
textEditingValue.text.toLowerCase(),
),
);
},
onSelected: (String value) {
provider.updateRecordFormItem(name: value);
},
optionsViewBuilder: (
BuildContext context,
AutocompleteOnSelected<String> onSelected,
Iterable<String> options,
) {
Widget buildOptionItem(String option) {
return InkWell(
onTap: () => onSelected(option),
child: Container(
padding: EdgeInsets.symmetric(vertical: 12, horizontal: 16),
child: Text(
option,
style: TextStyle(fontSize: 14, color: Colors.grey.shade800),
),
),
);
}
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 2,
borderRadius: BorderRadius.circular(8),
child: Container(
color: Theme.of(context).colorScheme.surface,
constraints: BoxConstraints(
maxHeight: 200,
maxWidth: MediaQuery.of(context).size.width - 58,
),
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: options.length,
itemBuilder: (BuildContext context, int index) {
return buildOptionItem(options.elementAt(index));
},
),
),
),
);
},
fieldViewBuilder: (
BuildContext context,
TextEditingController controller,
FocusNode focusNode,
VoidCallback onFieldSubmitted,
) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (provider.recordFormItem.name.isNotEmpty &&
controller.text.isEmpty) {
controller.text = provider.recordFormItem.name;
}
});
void onClearRecipeName() {
controller.clear();
provider.updateRecordFormItem(name: '');
}
Widget? buildSuffixIcon() {
final isShow = controller.text.isNotEmpty && !provider.isEditing;
return isShow
? IconButton(
icon: Icon(Icons.clear, size: 18),
onPressed: onClearRecipeName,
)
: null;
}
return FormBuilderTextField(
name: _nameField,
focusNode: _focusNode,
decoration: buildInputDecoration(context: context, hintText: '请输入菜谱名称'),
controller: controller,
focusNode: focusNode,
enabled: !provider.isEditing,
onChanged: (value) {
provider.updateRecordFormItem(name: value ?? '');
},
decoration: buildInputDecoration(
context: context,
hintText: '请输入菜谱名称',
prefixIcon: Icons.restaurant_menu,
).copyWith(suffixIcon: buildSuffixIcon()),
validator: (value) {
if (value == null || value.isEmpty) {
return '请输入菜谱名称';
@@ -101,23 +182,21 @@ class _RecordFormPageState extends State<RecordFormPage> {
return null;
},
);
},
);
}
/// 完成时间选择框
Widget _buildDateTextField() {
Widget _buildDateField(FoodProvider provider) {
return FormBuilderTextField(
name: _dateField,
readOnly: true,
initialValue: provider.recordFormItem.date,
decoration: buildInputDecoration(
context: context,
hintText: '请选择完成时间',
prefixIcon: const Icon(
Icons.calendar_month,
size: 20,
color: Color(0xFF86909C),
prefixIcon: Icons.calendar_month,
),
),
onTap: () => _onSelectDate(),
onTap: () => _onSelectDate(provider),
validator: (value) {
if (value == null || value.isEmpty) {
return '请选择完成时间';
@@ -127,207 +206,141 @@ class _RecordFormPageState extends State<RecordFormPage> {
);
}
/// 图片上传区域(预览+上传按钮)
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 {
Future<void> _onSelectDate(FoodProvider provider) 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),
);
final String date = DateFormat('yyyy-MM-dd').format(picked);
_formKey.currentState?.fields[_dateField]?.didChange(date);
provider.updateRecordFormItem(date: date);
}
}
Widget _buildImageField(FoodProvider provider) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (provider.recordFormItem.imageUrl.isEmpty)
buildImageUploadButton(
context: context,
onTap: () => _pickImage(provider),
)
else
ImagePreview(
imageUrls: [provider.recordFormItem.imageUrl],
index: 0,
onRemoveImage: () => _removeImage(provider),
),
],
);
}
/// 选择图片(限制单张)
Future<void> _pickImage() async {
final XFile? image = await _picker.pickImage(source: ImageSource.gallery);
if (image != null) {
setState(() {
imageUrl = File(image.path); // 直接覆盖现有图片
});
Future<void> _pickImage(FoodProvider provider) async {
FilePickerResult? result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['jpg', 'jpeg', 'png'],
allowMultiple: false,
);
if (result != null) {
PlatformFile file = result.files.first;
LoadingDialog.show(context, message: '上传中');
try {
final fileName = await MinIOHelper().uploadFile(
bucketName: AppConfig.bucketName,
file: file,
);
ToastUtil.success('上传成功');
provider.updateRecordFormItem(imageUrl: fileName);
} catch (e) {
ToastUtil.error(e.toString());
} finally {
LoadingDialog.hide(context);
}
}
}
/// 移除图片
void _removeImage() {
setState(() {
imageUrl = null;
});
void _removeImage(FoodProvider provider) {
provider.updateRecordFormItem(imageUrl: '');
}
/// 提交表单
void _submitForm() {
void _submitForm(FoodProvider provider) async {
if (_formKey.currentState?.saveAndValidate() ?? false) {
if (imageUrl == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('请上传图片'),
backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
),
);
if (provider.recordFormItem.imageUrl.isEmpty) {
ToastUtil.error('请上传图片');
return;
}
final formData = {
..._formKey.currentState!.value,
'imageUrl': imageUrl?.path, // 单张图片路径
};
LoadingDialog.show(context, message: '提交中');
final result = await provider.handleRecord();
if (result == true) {
await provider.onRecordTabChange();
LoadingDialog.hide(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('提交成功!'),
behavior: SnackBarBehavior.floating,
if (provider.isEditing) {
showSuccessTip(context, '更新记录成功');
} else {
showSuccessTip(context, '新增记录成功');
}
Future.delayed(Duration(milliseconds: 1500), () {
if (mounted) {
Navigator.pop(context);
}
});
} else {
LoadingDialog.hide(context);
if (provider.isEditing) {
showErrorTip(context, '更新记录失败');
} else {
showErrorTip(context, '新增记录失败');
}
}
}
}
void _deleteForm(FoodProvider provider) async {
final result = await showConfirmDialog(context, '确认删除该记录?');
if (result == true) {
LoadingDialog.show(context, message: '删除中');
final isDelete = await provider.deleteRecord();
if (isDelete) {
await provider.onRecordTabChange();
LoadingDialog.hide(context);
showSuccessTip(context, '删除记录成功');
Future.delayed(Duration(milliseconds: 1500), () {
if (mounted) {
Navigator.pop(context);
}
});
}
}
}
@override
Widget build(BuildContext context) {
final provider = context.watch<FoodProvider>();
return AnimatedPadding(
duration: const Duration(milliseconds: 200),
padding: MediaQuery.of(context).viewInsets,
child: SingleChildScrollView(
child: Padding(
padding: EdgeInsets.all(16),
child: _buildFormBuilder(provider),
),
),
);
print('表单数据: $formData');
}
}
}

View File

@@ -1,8 +1,9 @@
import 'package:flutter/material.dart';
import 'package:food_hub_app/apis/stats.dart';
import 'package:food_hub_app/models/stats.dart';
import 'package:food_hub_app/widgets/common/chart.dart';
import 'package:food_hub_app/widgets/stats/card.dart';
import 'package:flutter_common/widget/chart.dart';
import 'package:flutter_common/widget/common_widget.dart';
import 'package:food_hub_app/provider/food_provider.dart';
import 'package:liquid_glass_widgets/liquid_glass_widgets.dart';
import 'package:provider/provider.dart';
class StatsPage extends StatefulWidget {
const StatsPage({super.key});
@@ -14,204 +15,146 @@ class StatsPage extends StatefulWidget {
class _StatsPage extends State<StatsPage> {
final double chartHeight = 400;
SummaryStats summaryStats = SummaryStats(
recipeCount: 0,
categoryCount: 0,
workCount: 0,
);
late double averageRecordCount = 0;
List<ChartData> recordStats = [];
List<ChartData> categoryStats = [];
List<ChartData> rankStats = [];
@override
void initState() {
super.initState();
refreshStats();
}
Future<void> refreshStats() async {
final result1 = await queryStatsApi();
final result2 = await queryRecordStatsApi();
final result3 = await queryCategoryStatsApi();
final result4 = await queryRankStatsApi();
setState(() {
summaryStats = result1;
recordStats = result2;
categoryStats = result3;
rankStats = result4;
if (recordStats.isNotEmpty) {
double sumValue = recordStats.fold(0.0, (sum, item) => sum + item.value);
averageRecordCount = sumValue / recordStats.length;
}
// 初始化加载数据
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<FoodProvider>().refreshFoodStats();
});
}
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(5),
child: Column(
children: [
_buildSummaryStats(),
SizedBox(
height: chartHeight,
child: Card(
color: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
child: Padding(
padding: const EdgeInsets.all(10),
child: _buildRecordStats(),
),
),
),
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(),
),
),
),
SizedBox(
height: chartHeight,
child: Card(
color: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
child: Padding(
padding: const EdgeInsets.all(10),
child: _buildRankStats(),
),
),
),
],
),
),
);
}
Widget _buildSummaryStats(BuildContext context, FoodProvider provider) {
final colors = Theme.of(context).colorScheme;
Widget _buildSummaryStats() {
return GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
// 禁用网格自身滚动
crossAxisCount: 2,
// crossAxisSpacing: 5,
// mainAxisSpacing: 5,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 2,
children: [
StatisticCard(
GlassCard(
useOwnLayer: true,
settings: LiquidGlassSettings(glassColor: colors.surface),
padding: EdgeInsetsGeometry.all(10),
child: StatsCard(
icon: Icons.restaurant_menu,
color: Colors.blue,
title: '菜谱总数',
value: summaryStats.recipeCount,
value: provider.summaryStats.recipeCount.toString(),
unit: '',
),
StatisticCard(
),
GlassCard(
useOwnLayer: true,
settings: LiquidGlassSettings(glassColor: colors.surface),
padding: EdgeInsetsGeometry.all(10),
child: StatsCard(
icon: Icons.grid_view_rounded,
color: Colors.green,
title: '菜谱类别',
value: summaryStats.categoryCount,
value: provider.summaryStats.categoryCount.toString(),
unit: '',
),
StatisticCard(
),
GlassCard(
useOwnLayer: true,
settings: LiquidGlassSettings(glassColor: colors.surface),
padding: EdgeInsetsGeometry.all(10),
child: StatsCard(
icon: Icons.flag,
color: Colors.orange,
title: '做菜次数',
value: summaryStats.workCount,
value: provider.summaryStats.workCount.toString(),
unit: '',
),
StatisticCard(
),
GlassCard(
useOwnLayer: true,
settings: LiquidGlassSettings(glassColor: colors.surface),
padding: EdgeInsetsGeometry.all(10),
child: StatsCard(
icon: Icons.show_chart,
color: Colors.red,
title: '平均次数',
value: double.parse(averageRecordCount.toStringAsFixed(2)),
value: provider.averageRecordCount.toStringAsFixed(2),
unit: '次/月',
),
),
],
);
}
Widget _buildRecordStats() {
Widget _buildContent(BuildContext context, FoodProvider provider) {
final colors = Theme.of(context).colorScheme;
return Column(
children: [
_buildTitleSection('记录统计'),
const SizedBox(height: 3),
_buildDivider(),
const SizedBox(height: 3),
Expanded(
child: lineChart(
context: context,
_buildSummaryStats(context, provider),
SizedBox(height: 8),
SizedBox(
height: chartHeight,
child: GlassCard(
useOwnLayer: true,
settings: LiquidGlassSettings(glassColor: colors.surface),
child: LineChart(
title: '记录统计',
xAxisName: '日期',
yAxisName: '次数',
unit: '',
data: recordStats,
data: provider.recordStats,
),
),
),
SizedBox(height: 8),
SizedBox(
height: chartHeight,
child: GlassCard(
useOwnLayer: true,
settings: LiquidGlassSettings(glassColor: colors.surface),
child: PieChart(
title: '菜谱统计',
unit: '',
data: provider.categoryStats,
),
),
),
SizedBox(height: 8),
GlassCard(
useOwnLayer: true,
settings: LiquidGlassSettings(glassColor: colors.surface),
child: RankChart(title: '排行榜', data: provider.rankStats, unit: ''),
),
],
);
}
Widget _buildCategoryStats() {
return Column(
@override
Widget build(BuildContext context) {
final provider = context.watch<FoodProvider>();
return Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
title: Text('统计', style: Theme.of(context).textTheme.titleLarge),
centerTitle: true,
automaticallyImplyLeading: false,
),
body: Padding(
padding: EdgeInsetsGeometry.all(10),
child: Stack(
children: [
_buildTitleSection('菜谱统计'),
const SizedBox(height: 3),
_buildDivider(),
const SizedBox(height: 3),
Expanded(
child: pieChart(context: context, unit: '', data: categoryStats),
if (provider.isLoading)
buildLoadingIndicator()
else
SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 90),
child: _buildContent(context, provider),
),
],
);
}
Widget _buildRankStats() {
return Column(
children: [
_buildTitleSection('排行榜'),
const SizedBox(height: 3),
_buildDivider(),
const SizedBox(height: 3),
Expanded(child: rankChart(rankStats)),
],
);
}
// 构建标题区域
Widget _buildTitleSection(String title) {
return Row(
children: [
Icon(Icons.insert_chart, color: Theme.of(context).primaryColor),
Text(title),
],
);
}
Widget _buildDivider() {
return Divider(
height: 1,
thickness: 1,
color: Theme.of(context).primaryColor,
indent: 0,
endIndent: 0,
),
),
);
}
}

View File

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

View File

@@ -0,0 +1,67 @@
import 'package:easy_refresh/easy_refresh.dart';
import 'package:flutter/material.dart';
Widget buildEasyRefresh({
required EasyRefreshController freshController,
required Future<void> Function()? onRefresh,
required Future<void> Function()? onLoad,
required Widget body,
}) {
return 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: body,
);
}
Widget buildScrollToTop({
required BuildContext context,
required VoidCallback scrollToTop,
}) {
final colors = Theme.of(context).colorScheme;
return Positioned(
right: 0,
bottom: 80,
child: FloatingActionButton(
onPressed: scrollToTop,
backgroundColor: colors.primary,
elevation: 2,
mini: true,
child: Icon(Icons.arrow_upward, color: colors.surface),
),
);
}
void scrollToTopAnimateTo(ScrollController controller) {
controller.animateTo(
0,
duration: const Duration(milliseconds: 500), // 滚动动画时长
curve: Curves.easeInOut, // 滚动动画曲线
);
}

View File

@@ -1,53 +1,62 @@
import 'package:flutter/material.dart';
import 'index.dart';
/// 通用输入框样式
InputDecoration buildInputDecoration({
required BuildContext context,
required String hintText,
Widget? prefixIcon,
IconData? prefixIcon,
}) {
final colors = Theme.of(context).colorScheme;
return InputDecoration(
hintText: hintText,
hintStyle: const TextStyle(
color: Color(0xFF999999),
fontSize: 15,
height: 1.2,
),
hintStyle: Theme.of(context).textTheme.bodyMedium,
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderSide: const BorderSide(color: Color(0xFFE5E7EB)),
borderRadius: BorderRadius.circular(8),
fillColor: colors.surface,
enabledBorder: OutlineInputBorder(
borderSide: BorderSide.none,
borderRadius: BorderRadius.circular(10),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 1.5),
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: colors.primary, width: 2),
borderRadius: BorderRadius.circular(10),
),
errorBorder: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.red, width: 1.5),
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Colors.red, width: 2),
borderRadius: BorderRadius.circular(10),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 15, vertical: 14),
errorStyle: const TextStyle(fontSize: 12, height: 1, color: Colors.red),
prefixIcon: prefixIcon,
prefixIcon:
prefixIcon != null
? Icon(prefixIcon, size: 20, color: colors.primary)
: null,
prefixIconConstraints: const BoxConstraints(minWidth: 40),
isDense: true,
);
}
Widget buildFormLabel(String text, {bool required = false}) {
Widget buildFormLabel({
required BuildContext context,
required String text,
required bool isRequired,
}) {
final colors = Theme.of(context).colorScheme;
return RichText(
text: TextSpan(
text: text,
style: const TextStyle(
color: Color(0xFF1D2129),
style: TextStyle(
color: colors.onSurface,
fontSize: 16,
fontWeight: FontWeight.w500,
fontFamily: 'CustomFont',
height: 1.2,
),
children: [
if (required)
if (isRequired)
const TextSpan(
text: ' *',
style: TextStyle(color: Colors.red, fontSize: 16),
@@ -60,12 +69,19 @@ Widget buildFormLabel(String text, {bool required = false}) {
/// 表单底部按钮组组件
Widget buildFormButtonGroup({
required BuildContext context,
required bool isShowDelete,
required VoidCallback onConfirm,
required VoidCallback onDelete,
}) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(child: _buildCancelButton(context)),
const SizedBox(width: 16),
if (isShowDelete) ...[
SizedBox(width: 16),
Expanded(child: _buildDeleteButton(context, onDelete)),
],
SizedBox(width: 16),
Expanded(child: _buildSubmitButton(context, onConfirm)),
],
);
@@ -73,30 +89,19 @@ Widget buildFormButtonGroup({
/// 取消按钮
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('取消'),
return buildInfoButton(
context: context,
text: '取消',
onPressed: () => Navigator.pop(context),
);
}
/// 提交按钮
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)),
);
return buildPrimaryButton(context: context, text: '提交', onPressed: onConfirm);
}
/// 删除按钮
Widget _buildDeleteButton(BuildContext context, VoidCallback onDelete) {
return buildErrorButton(text: '删除', onPressed: onDelete);
}

View File

@@ -1,4 +1,6 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:food_hub_app/config/app_config.dart';
import 'package:photo_view/photo_view.dart';
import 'package:photo_view/photo_view_gallery.dart';
@@ -19,6 +21,7 @@ class ImagePreviewPage extends StatefulWidget {
class _ImagePreviewPageState extends State<ImagePreviewPage> {
// 声明 PageController 并初始化初始索引
late PageController _pageController;
// 记录当前显示的图片索引(用于更新页码)
int _currentIndex = 0;
@@ -51,6 +54,8 @@ class _ImagePreviewPageState extends State<ImagePreviewPage> {
@override
Widget build(BuildContext context) {
final index = '${_currentIndex + 1}/${widget.images.length}';
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
@@ -61,16 +66,14 @@ class _ImagePreviewPageState extends State<ImagePreviewPage> {
onPressed: () => Navigator.pop(context),
),
// 显示实时更新的页码(当前索引+1 / 总数量)
title: Text(
'${_currentIndex + 1}/${widget.images.length}',
style: const TextStyle(color: Colors.white),
),
title: Text(index, style: const TextStyle(color: Colors.white)),
centerTitle: true,
),
body: PhotoViewGallery(
pageOptions: widget.images.map((url) {
pageOptions:
widget.images.map((url) {
return PhotoViewGalleryPageOptions(
imageProvider: NetworkImage(url),
imageProvider: NetworkImage('${AppConfig.imageBaseUrl}$url'),
minScale: PhotoViewComputedScale.contained,
maxScale: PhotoViewComputedScale.covered * 2,
// 点击空白处关闭预览
@@ -86,30 +89,132 @@ class _ImagePreviewPageState extends State<ImagePreviewPage> {
}
}
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) {
class CommonImage extends StatelessWidget {
final List<String> imageUrls;
final int index;
const CommonImage({super.key, required this.imageUrls, required this.index});
Widget _buildErrorImage() {
return Container(
color: Colors.grey[200],
child: const Icon(Icons.image, color: Colors.grey, size: 30),
);
}
void _navigateToImagePreview(
BuildContext context,
List<String> imageUrls,
int index,
) {
final route = MaterialPageRoute(
builder:
(context) => ImagePreviewPage(images: imageUrls, initialIndex: index),
);
Navigator.push(context, route);
}
@override
Widget build(BuildContext context) {
return AspectRatio(
aspectRatio: 4 / 3,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: GestureDetector(
onTap: () {
_navigateToImagePreview(context, imageUrls, index);
},
child: CachedNetworkImage(
imageUrl: '${AppConfig.imageBaseUrl}${imageUrls[index]}',
fit: BoxFit.cover,
progressIndicatorBuilder:
(context, url, downloadProgress) => CircularProgressIndicator(
strokeWidth: 1.5,
padding: EdgeInsetsGeometry.all(20),
value: downloadProgress.progress,
),
errorWidget: (context, error, stackTrace) => _buildErrorImage(),
),
),
),
);
}
}
class ImagePreview extends StatelessWidget {
final List<String> imageUrls;
final int index;
final VoidCallback onRemoveImage;
const ImagePreview({
super.key,
required this.imageUrls,
required this.index,
required this.onRemoveImage,
});
Widget _buildDeleteImage() {
return Positioned(
top: 0,
right: 0,
child: GestureDetector(
onTap: onRemoveImage,
child: Container(
width: 24,
height: 24,
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
child: const Icon(Icons.close, color: Colors.white, size: 16),
),
),
);
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(borderRadius: BorderRadius.circular(8)),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Stack(
children: [
CommonImage(imageUrls: imageUrls, index: index),
_buildDeleteImage(),
],
),
),
);
}
}
Widget buildImageUploadButton({
required BuildContext context,
required VoidCallback onTap,
}) {
final colors = Theme.of(context).colorScheme;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Container(
width: double.infinity,
height: 180,
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: colors.surface),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.camera_alt, color: colors.onSurface.withAlpha(100)),
SizedBox(height: 6),
Text('点击上传美食图片', style: Theme.of(context).textTheme.bodyMedium),
],
),
),
);
}

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:toggle_switch/toggle_switch.dart';
Widget formLabelText({required String labelText, bool isRequired = false}) {
return Text.rich(
@@ -14,7 +14,7 @@ Widget formLabelText({required String labelText, bool isRequired = false}) {
}
InputDecoration formInputDecoration({
required String hintText, // 使用命名参数并标记为必填
required String hintText,
IconData? prefixIcon,
}) {
return InputDecoration(
@@ -27,15 +27,32 @@ 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 buildToggleSwitch<T extends Enum>({
required BuildContext context,
required T currentTab,
required List<T> tabValues,
required List<String> labels,
required ValueChanged<T> onTabChanged,
}) {
final colors = Theme.of(context).colorScheme;
return ToggleSwitch(
minWidth: 90.0,
minHeight: 40.0,
initialLabelIndex: tabValues.indexOf(currentTab),
totalSwitches: tabValues.length,
labels: labels,
activeBgColor: [colors.primary],
activeFgColor: colors.surface,
inactiveBgColor: colors.surface,
inactiveFgColor: colors.onSurface,
cornerRadius: 12.0,
customTextStyles: [TextStyle(fontSize: 12, fontWeight: FontWeight.w500)],
onToggle: (index) {
if (index != null && index < tabValues.length) {
onTabChanged(tabValues[index]);
}
},
);
}
@@ -44,43 +61,82 @@ Widget circleIconButton({
required IconData icon,
required VoidCallback onPressed,
required BuildContext context,
bool? isSmall,
}) {
final double size = isSmall == true ? 24 : 36;
return ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
fixedSize: Size(32, 32),
fixedSize: Size(size, size),
shape: CircleBorder(),
elevation: 0,
backgroundColor: Theme.of(context).primaryColor,
backgroundColor: Theme.of(context).colorScheme.primary,
padding: EdgeInsets.zero,
minimumSize: const Size(0, 0),
),
child: Icon(icon, color: Colors.white, size: 18),
child: Icon(icon, color: Colors.white),
);
}
/// 确认按钮样式
ButtonStyle primaryButtonStyle() {
return ButtonStyle(
backgroundColor: WidgetStateProperty.all(Colors.green),
shape: WidgetStateProperty.all(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
Widget buildTag(BuildContext context, String title) {
final colors = Theme.of(context).colorScheme;
return Container(
padding: EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: colors.primary,
borderRadius: BorderRadius.circular(10),
),
child: Text(title, style: TextStyle(color: colors.surface)),
);
}
/// 取消按钮样式
ButtonStyle cancelButtonStyle() {
return ButtonStyle(
backgroundColor: WidgetStateProperty.all(Colors.grey),
shape: WidgetStateProperty.all(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
Widget buildPrimaryButton({
required BuildContext context,
required String text,
required VoidCallback onPressed,
}) {
return ElevatedButton(
onPressed: () => onPressed(),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
elevation: 0,
),
child: Text(text, style: TextStyle(color: Colors.white)),
);
}
Text buttonText({required String text}) {
return Text(text, style: TextStyle(color: Colors.white));
Widget buildErrorButton({
required String text,
required VoidCallback onPressed,
}) {
return ElevatedButton(
onPressed: () => onPressed(),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
elevation: 0,
),
child: Text(text, style: TextStyle(color: Colors.white)),
);
}
Widget buildInfoButton({
required BuildContext context,
required String text,
required VoidCallback onPressed,
}) {
return ElevatedButton(
onPressed: () => onPressed(),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
elevation: 0,
),
child: Text(text),
);
}
/// 图片错误显示
@@ -92,29 +148,3 @@ Widget errorImageContainer(double height) {
child: const Icon(Icons.image, color: Colors.grey, size: 30),
);
}
/// 成功消息
void showSuccessToast(String message) {
Fluttertoast.showToast(
msg: message,
toastLength: Toast.LENGTH_SHORT,
gravity: ToastGravity.CENTER,
timeInSecForIosWeb: 1,
backgroundColor: Colors.green,
textColor: Colors.white,
fontSize: 16.0,
);
}
/// 错误消息
void showErrorToast(String message) {
Fluttertoast.showToast(
msg: message,
toastLength: Toast.LENGTH_SHORT,
gravity: ToastGravity.CENTER,
timeInSecForIosWeb: 1,
backgroundColor: Colors.red,
textColor: Colors.white,
fontSize: 16.0,
);
}

View File

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

View File

@@ -1,90 +1,111 @@
import 'package:flutter/material.dart';
import 'package:flutter_common/flutter_common.dart';
import 'package:flutter_common/widget/common_widget.dart';
import 'package:food_hub_app/config/app_config.dart';
import 'package:food_hub_app/models/moment.dart';
import 'package:food_hub_app/provider/food_provider.dart';
import 'package:food_hub_app/provider/user_provider.dart';
import 'package:food_hub_app/widgets/common/image.dart';
import 'package:tdesign_flutter/tdesign_flutter.dart';
import 'package:provider/provider.dart';
class MomentCard extends StatelessWidget {
class MomentCard extends StatefulWidget {
final Moment moment;
final bool isUser;
const MomentCard({super.key, required this.moment});
const MomentCard({super.key, required this.moment, required this.isUser});
@override
State<StatefulWidget> createState() => MomentCardState();
}
class MomentCardState extends State<MomentCard> {
@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),
return CommonCard(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildAvatar(context),
InkWell(
onTap: () => _onTapUser(context),
child: _buildAvatar(context),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildNickname(),
_buildNickname(context),
const SizedBox(height: 5),
_buildContent(),
if (moment.imageList.isNotEmpty) ...[
_buildContent(context),
if (widget.moment.imageList.isNotEmpty) ...[
const SizedBox(height: 8),
_buildPostImages(context),
_buildPostImages(),
],
const SizedBox(height: 10),
_buildTime(),
_buildTime(context),
const SizedBox(height: 5),
_buildActionButtons(),
if (widget.moment.commentList!.isNotEmpty) ...[
const SizedBox(height: 8),
_buildCommentList(),
],
],
),
),
],
),
),
);
}
void _onTapUser(BuildContext context) async {
final provider = context.read<UserProvider>();
if (widget.moment.userId != SPUtil.getInt('userId')) {
await provider.refreshUser(widget.moment.userId!);
await provider.queryMomentByUserId(widget.moment.userId!);
if (mounted) {
Navigator.pushNamed(context, '/profileUser');
}
}
}
// 构建头像组件
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],
if (widget.moment.avatar!.isEmpty) {
return CircleAvatar(
radius: 18,
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.surface,
child: Text(widget.moment.username![0], style: TextStyle(fontSize: 14)),
);
} else {
return TDAvatar(
size: TDAvatarSize.medium,
type: TDAvatarType.normal,
fit: BoxFit.contain,
avatarUrl: '${AppConfig.baseApiUrl}/${moment.avatar}',
return CircleAvatar(
radius: 18,
backgroundImage: NetworkImage('${AppConfig.imageBaseUrl}/${widget.moment.avatar}')
);
}
}
// 构建昵称组件
Widget _buildNickname() {
Widget _buildNickname(BuildContext context) {
return Text(
moment.username ?? "",
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
widget.moment.username ?? "",
style: Theme.of(context).textTheme.titleMedium,
);
}
// 构建内容组件
Widget _buildContent() {
Widget _buildContent(BuildContext context) {
return Text(
moment.content,
style: const TextStyle(fontSize: 15, height: 1.3),
widget.moment.content,
style: Theme.of(context).textTheme.bodyMedium,
);
}
// 构建朋友圈图片列表(网格布局)
Widget _buildPostImages(BuildContext context) {
final imageCount = moment.imageList.length;
Widget _buildPostImages() {
final imageCount = widget.moment.imageList.length;
// 计算网格列数
int crossAxisCount;
@@ -104,20 +125,7 @@ class MomentCard extends StatelessWidget {
// 生成图片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),
),
);
}
widget.moment.imageList.map((path) => path).toList();
return GridView.count(
shrinkWrap: true,
@@ -127,29 +135,41 @@ class MomentCard extends StatelessWidget {
mainAxisSpacing: 4,
childAspectRatio: itemAspectRatio,
children: List.generate(imageCount, (index) {
// 单个图片项:添加点击事件
return GestureDetector(
// 点击图片时,跳转到预览页面
onTap: () => imageTapClick(index),
// 原图片组件
child: networkImage(imageUrls[index]),
);
return CommonImage(imageUrls: imageUrls, index: index);
}),
);
}
// 构建时间组件
Widget _buildTime() {
Widget _buildTime(BuildContext context) {
return Text(
moment.date ?? "",
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
widget.moment.date ?? "",
style: Theme.of(context).textTheme.bodySmall,
);
}
void _onTapEdit() {
final provider = Provider.of<FoodProvider>(context, listen: false);
provider.isEditing = true;
provider.momentFormItem = widget.moment;
Navigator.pushNamed(context, '/momentForm');
}
// 构建点赞和评论按钮
Widget _buildActionButtons() {
return Row(
children: [
if (widget.isUser)
SizedBox(
width: 42,
height: 36,
child: Stack(
alignment: Alignment.bottomLeft,
children: [
InkWell(onTap: () => _onTapEdit(), child: Icon(Icons.edit)),
],
),
),
SizedBox(
width: 42,
height: 36,
@@ -157,14 +177,14 @@ class MomentCard extends StatelessWidget {
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(),
),
),
// Positioned(
// left: 20,
// bottom: 15,
// child: TDBadge(
// TDBadgeType.message,
// count: (widget.moment.likeList?.length ?? 0).toString(),
// ),
// ),
],
),
),
@@ -175,42 +195,69 @@ class MomentCard extends StatelessWidget {
alignment: Alignment.bottomLeft,
children: [
Icon(Icons.comment_outlined),
Positioned(
left: 20,
bottom: 15,
child: TDBadge(
TDBadgeType.message,
count: (moment.commentList?.length ?? 0).toString(),
),
),
// Positioned(
// left: 20,
// bottom: 15,
// child: TDBadge(
// TDBadgeType.message,
// count: (widget.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(
Widget _buildCommentItem(Comment comment) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
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]),
RichText(
text: TextSpan(
children: [
TextSpan(
text: comment.username,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
fontFamily: 'CustomFont',
color: Colors.black,
),
),
const TextSpan(text: ': ', style: TextStyle(color: Colors.black)),
TextSpan(
text: comment.content,
style: const TextStyle(
fontSize: 14,
fontFamily: 'CustomFont',
color: Colors.black87,
),
),
],
),
),
],
);
}
Widget _buildCommentList() {
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: widget.moment.commentList!.length,
separatorBuilder: (context, index) => SizedBox(height: 8),
itemBuilder: (context, index) {
return _buildCommentItem(widget.moment.commentList![index]);
},
),
);
}
}

View File

@@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
import 'package:food_hub_app/models/moment.dart';
import 'package:food_hub_app/widgets/moment/card.dart';
class MomentList extends StatelessWidget {
final List<Moment> momentList;
final bool isUser;
const MomentList({super.key, required this.momentList, required this.isUser});
@override
Widget build(BuildContext context) {
return ListView.separated(
itemCount: momentList.length,
separatorBuilder: (context, index) => SizedBox(height: 8),
itemBuilder: (context, index) {
return MomentCard(moment: momentList[index], isUser: isUser);
},
);
}
}

View File

@@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import 'package:flutter_common/utils/date_utils.dart';
import 'package:food_hub_app/config/app_config.dart';
import 'package:food_hub_app/models/session.dart';
import 'package:food_hub_app/widgets/common/index.dart';
import 'package:liquid_glass_widgets/liquid_glass_widgets.dart';
import 'package:liquid_glass_widgets/widgets/containers/glass_card.dart';
class ProfileInfo extends StatelessWidget {
final User user;
const ProfileInfo({super.key, required this.user});
Widget _buildAvatar(BuildContext context, User user) {
if (user.avatar == null) {
return CircleAvatar(
radius: 24,
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.surface,
child: Text(
user.username.isNotEmpty ? user.username[0] : '?',
style: TextStyle(fontSize: 14),
),
);
} else {
return CircleAvatar(
radius: 24,
backgroundImage: NetworkImage(
'${AppConfig.imageBaseUrl}/${user.avatar}',
),
);
}
}
Widget _buildInfoItem({
required BuildContext context,
required IconData icon,
required String text,
}) {
final colors = Theme.of(context).colorScheme;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: colors.primary),
const SizedBox(width: 6),
Text(text, style: Theme.of(context).textTheme.bodySmall),
],
);
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return GlassCard(
padding: EdgeInsetsGeometry.all(10),
useOwnLayer: true,
settings: LiquidGlassSettings(glassColor: colors.surface),
child: Column(
children: [
_buildAvatar(context, user),
const SizedBox(height: 8),
Text(user.username, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
if (user.tags != null && user.tags!.isNotEmpty)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children:
user.tags!.map((tag) => buildTag(context, tag)).toList(),
),
if (user.tags != null && user.tags!.isNotEmpty)
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (user.area != null && user.area!.length >= 2)
_buildInfoItem(
context: context,
icon: Icons.location_on,
text: '${user.area![0]}-${user.area![1]}',
),
if (user.birthDate != null)
_buildInfoItem(
context: context,
icon: Icons.cake,
text: formatDate(user.birthDate!),
),
if (user.job != null && user.job!.isNotEmpty)
_buildInfoItem(
context: context,
icon: Icons.work,
text: user.job!,
),
if (user.phoneNumber != null && user.phoneNumber!.isNotEmpty)
_buildInfoItem(
context: context,
icon: Icons.phone_android,
text: user.phoneNumber!,
),
if (user.email != null && user.email!.isNotEmpty)
_buildInfoItem(
context: context,
icon: Icons.email,
text: user.email!,
),
],
),
if (user.description != null && user.description!.isNotEmpty)
const SizedBox(height: 8),
if (user.description != null && user.description!.isNotEmpty)
Text(user.description!),
],
),
);
}
}

View File

@@ -1,11 +1,12 @@
import 'package:flutter/material.dart';
import 'package:food_hub_app/apis/recipe.dart';
import 'package:flutter_common/widget/common_widget.dart';
import 'package:food_hub_app/models/recipe.dart';
import 'package:food_hub_app/utils/date_util.dart';
import 'package:food_hub_app/provider/food_provider.dart';
import 'package:food_hub_app/utils/index.dart';
import 'package:food_hub_app/views/record_form.dart';
import 'package:food_hub_app/widgets/common/index.dart';
import 'package:table_calendar/table_calendar.dart';
import 'package:tdesign_flutter/tdesign_flutter.dart';
import 'package:provider/provider.dart';
class RecipeCalendar extends StatefulWidget {
const RecipeCalendar({super.key});
@@ -15,141 +16,129 @@ class RecipeCalendar extends StatefulWidget {
}
class _RecipeCalendarState extends State<RecipeCalendar> {
DateTime _selectedDay = DateTime.now();
DateTime _focusedDay = DateTime.now();
List<Record> recordList = [];
List<Record> selectRecordList = [];
@override
void initState() {
super.initState();
refreshRecord();
}
Future<void> refreshRecord() async {
final result = await queryRecordApi(
getFirstDayOfMonth(_focusedDay),
getLastDayOfMonth(_focusedDay),
);
setState(() {
recordList = result;
if (recordList.isNotEmpty) {
_selectedDay = DateTime.parse(recordList.last.date);
} else {
_selectedDay = _focusedDay;
}
refreshSelectRecord();
});
}
void refreshSelectRecord() {
setState(() {
selectRecordList =
recordList
.where((record) => record.date == formatDateTime(_selectedDay))
.toList();
});
}
TableCalendar recipeCalendar() {
TableCalendar _recipeCalendar(FoodProvider provider) {
return TableCalendar(
locale: 'zh_CN',
headerStyle: const HeaderStyle(
headerPadding: EdgeInsets.symmetric(vertical: 0),
formatButtonVisible: false,
titleCentered: true,
),
firstDay: DateTime.utc(2010, 1, 1),
lastDay: DateTime.utc(2100, 12, 31),
focusedDay: _focusedDay,
selectedDayPredicate: (day) => isSameDay(_selectedDay, day),
focusedDay: provider.focusedDay,
selectedDayPredicate: (day) => isSameDay(provider.selectedDay, day),
onDaySelected: (selectedDay, focusedDay) {
if (!isSameDay(_selectedDay, selectedDay)) {
if (!isSameDay(provider.selectedDay, selectedDay)) {
setState(() {
_selectedDay = selectedDay;
_focusedDay = focusedDay;
refreshSelectRecord();
provider.selectedDay = selectedDay;
provider.focusedDay = focusedDay;
provider.refreshSelectRecord();
});
}
},
onPageChanged: (focusedDay) {
_focusedDay = focusedDay;
refreshRecord();
provider.focusedDay = focusedDay;
provider.refreshRecordList();
},
// 自定义日期单元格构建器
calendarBuilders: CalendarBuilders(
defaultBuilder:
(context, day, focusedDay) => _buildDateWidget(day, false, false),
(context, day, focusedDay) =>
_buildDateItem(day, false, false, provider),
selectedBuilder:
(context, day, focusedDay) => _buildDateWidget(day, true, false),
(context, day, focusedDay) =>
_buildDateItem(day, true, false, provider),
todayBuilder:
(context, day, focusedDay) => _buildDateWidget(day, false, true),
(context, day, focusedDay) =>
_buildDateItem(day, false, true, provider),
),
);
}
Widget dailyItem() {
return Card(
elevation: 0,
color: Colors.white,
child: Padding(
padding: EdgeInsets.all(5),
child: Column(
Widget _dailyItem(BuildContext context, FoodProvider provider) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(formatDateTime(_selectedDay, 'MM月dd日 EEEE')),
if (selectRecordList.isEmpty)
TDEmpty(type: TDEmptyType.plain, emptyText: '暂无数据')
Text(formatDateTime(provider.selectedDay, 'MM月dd日 EEEE')),
SizedBox(height: 4),
if (provider.selectRecordList.isEmpty)
SizedBox(height: 100, child: buildEmptyData())
else
ListView.builder(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
itemCount: selectRecordList.length,
itemBuilder: (context, index) {
return recipeRecordItem(selectRecordList[index]);
},
Column(
children:
provider.selectRecordList.map((record) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _buildRecordItem(context, record),
);
}).toList(),
),
],
);
}
Widget _buildRecordItemAvatar(String name, Color color) {
return CircleAvatar(
radius: 20,
backgroundColor: color,
child: Text(
name,
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
);
}
Widget recipeRecordItem(Record record) {
Widget _buildRecordItemBody(String name, String category) {
return Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name, style: TextStyle(fontWeight: FontWeight.bold)),
SizedBox(height: 5),
Text(category, style: TextStyle(color: Colors.grey)),
],
);
}
void _handleEditRecord(BuildContext context, FoodRecord record) {
final provider = context.read<FoodProvider>();
provider.initRecordForm(record);
provider.isEditing = true;
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => RecordForm(),
);
}
Widget _buildRecordItem(BuildContext context, FoodRecord record) {
final colors = Theme.of(context).colorScheme;
return Card(
elevation: 0,
color: Color(0xFFF5F5DC),
color: colors.primary.withAlpha(50),
margin: EdgeInsets.zero,
child: Padding(
padding: EdgeInsets.all(10),
child: Row(
children: [
TDAvatar(
size: TDAvatarSize.medium,
type: TDAvatarType.customText,
text: record.category[0],
),
_buildRecordItemAvatar(record.category[0], colors.primary),
SizedBox(width: 10),
Expanded(
// 使用Expanded让文本区域占据剩余空间
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
record.name,
style: TextStyle(fontWeight: FontWeight.bold),
),
SizedBox(height: 5),
Text(record.category, style: TextStyle(color: Colors.grey)),
],
),
),
Expanded(child: _buildRecordItemBody(record.name, record.category)),
circleIconButton(
context: context,
icon: Icons.chevron_right,
onPressed: () => {}
onPressed: () => _handleEditRecord(context, record),
),
],
),
@@ -157,38 +146,47 @@ class _RecipeCalendarState extends State<RecipeCalendar> {
);
}
Widget _buildDateWidget(DateTime day, bool isSelected, bool isToday) {
Widget _buildDateItem(
DateTime day,
bool isSelected,
bool isToday,
FoodProvider provider,
) {
final colors = Theme.of(context).colorScheme;
// 检查当前日期是否在需要显示红点的列表中
bool shouldShowRedDot = recordList.any(
bool shouldShowRedDot = provider.recordList.any(
(item) => item.date == formatDateTime(day),
);
late Color boxColor;
if (isSelected) {
boxColor = colors.secondary;
} else {
boxColor = isToday ? colors.primary : Colors.transparent;
}
late Color textColor;
if (isSelected) {
textColor = colors.surface;
} else {
textColor = isToday ? colors.surface : colors.onSurface;
}
return Container(
width: 40,
height: 40,
margin: EdgeInsets.all(2),
decoration: BoxDecoration(
color:
isSelected
? Theme.of(context).primaryColor
: isToday
? Colors.grey[300]
: Colors.transparent,
shape: BoxShape.circle,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
decoration: BoxDecoration(color: boxColor, shape: BoxShape.circle),
child: Stack(
alignment: Alignment.center,
children: [
// 日期数字
Text(
day.day.toString(),
style: TextStyle(color: isSelected ? Colors.white : Colors.black),
),
// 底部红点 - 只在指定日期显示
// 日期数字始终居中
Text(day.day.toString(), style: TextStyle(color: textColor)),
// 红点
if (shouldShowRedDot)
Container(
margin: EdgeInsets.only(top: 2),
Align(
alignment: Alignment(0, 0.8),
child: Container(
width: 6,
height: 6,
decoration: BoxDecoration(
@@ -196,6 +194,20 @@ class _RecipeCalendarState extends State<RecipeCalendar> {
shape: BoxShape.circle,
),
),
),
],
),
);
}
Widget _buildContent(FoodProvider provider) {
return SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 80),
child: Column(
children: [
CommonCard(child: _recipeCalendar(provider)),
const SizedBox(height: 8),
CommonCard(child: _dailyItem(context, provider)),
],
),
);
@@ -203,11 +215,12 @@ class _RecipeCalendarState extends State<RecipeCalendar> {
@override
Widget build(BuildContext context) {
return Column(
children: [
Card(elevation: 0, color: Colors.white, child: recipeCalendar()),
Expanded(child: dailyItem()),
],
);
final provider = context.watch<FoodProvider>();
if (provider.isLoading) {
return buildLoadingIndicator();
}
return _buildContent(provider);
}
}

View File

@@ -1,77 +1,36 @@
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:flutter_common/widget/common_widget.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:liquid_glass_widgets/liquid_glass_widgets.dart';
class RecipeCard extends StatelessWidget {
final RecipeSummary recipe;
const RecipeCard({super.key, required this.recipe});
void navigatorToRecipeDetail(BuildContext context) {
Navigator.pushNamed(context, '/recipeDetail', arguments: {'id': recipe.id});
}
@override
Widget build(BuildContext context) {
final List<String> imageUrls =
recipe.recordList
.map((item) => '${AppConfig.baseApiUrl}/${item.imageUrl}')
.toList();
recipe.recordList.reversed.map((record) => record.imageUrl).toList();
Widget buildCarouselItem(String url) {
return Builder(
builder: (BuildContext context) {
return AspectRatio(
aspectRatio: 2,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: networkImage(url),
),
);
},
);
}
final colors = Theme.of(context).colorScheme;
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,
return GlassCard(
padding: EdgeInsetsGeometry.all(10),
useOwnLayer: true,
settings: LiquidGlassSettings(glassColor: colors.surface),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
buildRecipeCarousel(),
Divider(
height: 1,
thickness: 1,
color: Theme.of(context).primaryColor,
indent: 0,
endIndent: 0,
),
Padding(
padding: const EdgeInsets.all(10),
CommonImage(imageUrls: imageUrls, index: 0),
InkWell(
onTap: () => navigatorToRecipeDetail(context),
child: _buildRecipeContent(context),
),
],
@@ -80,81 +39,43 @@ class RecipeCard extends StatelessWidget {
}
Widget _buildRecipeContent(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
Text(
recipe.name,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
maxLines: 1, // 限制单行,避免挤压按钮
style: textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Row(
children: [
circleIconButton(
context: context,
icon: Icons.edit,
onPressed: () => {},
),
circleIconButton(
context: context,
icon: Icons.book,
onPressed:
() => Navigator.pushNamed(
context,
'/recipeDetail',
arguments: {'id': recipe.id},
),
_buildIconText(
icon: Icons.star,
text: recipe.recommendRate.toString(),
color: colors.primary,
),
],
),
],
),
const SizedBox(height: 5),
SizedBox(height: 5),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Row(
children: [
_buildIconText(
icon: Icons.food_bank,
text: recipe.category,
color: Theme.of(context).primaryColor,
color: colors.primary,
),
const SizedBox(width: 12),
_buildIconText(
icon: Icons.thumb_up_alt_outlined,
text: recipe.likeCount.toString(),
color: Colors.red,
),
const SizedBox(width: 12),
_buildIconText(
icon: Icons.star_outline,
text: recipe.favouriteCount.toString(),
color: Colors.blue,
),
const SizedBox(width: 12),
_buildIconText(
icon: Icons.comment_outlined,
text: recipe.commentCount.toString(),
color: Colors.deepOrange,
),
],
),
Row(
children: [
_buildIconText(
icon: Icons.date_range,
text: recipe.recordList[0].date,
color: Colors.red,
),
],
icon: Icons.note_add,
text: '${recipe.recordList.length.toString()}',
color: colors.primary,
),
],
),
@@ -166,10 +87,10 @@ class RecipeCard extends StatelessWidget {
Widget _buildIconText({
required IconData icon,
required String text,
Color color = Colors.grey, // 默认灰色
Color color = Colors.grey,
double iconSize = 16,
double textSize = 16,
double spacing = 2, // 图标与文本间距
double spacing = 2,
}) {
return Row(
children: [

View File

@@ -1,44 +1,72 @@
import 'package:flutter/material.dart';
import 'package:food_hub_app/apis/recipe.dart';
import 'package:food_hub_app/models/recipe.dart';
import 'package:flutter_common/widget/common_widget.dart';
import 'package:food_hub_app/provider/food_provider.dart';
import 'package:food_hub_app/widgets/recipe/card.dart';
import 'package:tdesign_flutter/tdesign_flutter.dart';
import 'package:provider/provider.dart';
class RecipeList extends StatefulWidget {
const RecipeList({super.key});
final int userId;
const RecipeList({super.key, required this.userId});
@override
State<RecipeList> createState() => _RecipeListState();
}
class _RecipeListState extends State<RecipeList> {
List<RecipeSummary> recipeSummaryList = [];
@override
void initState() {
super.initState();
refreshRecipeList();
// 初始化加载数据
WidgetsBinding.instance.addPostFrameCallback((_) {
if (widget.userId == 0) {
context.read<FoodProvider>().refreshRecipeList();
} else {
context.read<FoodProvider>().queryRecipeByUserId(widget.userId);
}
});
}
Future<void> refreshRecipeList() async {
final result = await queryRecipeApi(RecipeQuery(category: ""));
Widget _buildRecipeList(FoodProvider provider) {
return GridView.builder(
padding: const EdgeInsets.only(bottom: 80),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 0.85,
),
itemCount: provider.recipeSummaryList.length,
itemBuilder: (context, index) {
return RecipeCard(recipe: provider.recipeSummaryList[index]);
},
);
}
setState(() {
recipeSummaryList = result;
});
Widget _buildContent(FoodProvider provider) {
if (provider.recipeSummaryList.isEmpty) {
return buildEmptyData();
} else {
return Column(
children: [
Expanded(child: _buildRecipeList(provider)),
],
);
}
}
@override
Widget build(BuildContext context) {
if (recipeSummaryList.isEmpty) {
return const TDEmpty(type: TDEmptyType.plain, emptyText: '暂无数据');
} else {
return ListView.builder(
itemCount: recipeSummaryList.length,
itemBuilder: (context, index) {
return RecipeCard(recipe: recipeSummaryList[index]);
}
final provider = context.watch<FoodProvider>();
return Stack(
children: [
if (provider.isLoading)
buildLoadingIndicator()
else
_buildContent(provider),
],
);
}
}
}

View File

@@ -1,11 +1,11 @@
import 'package:flutter/material.dart';
import 'package:food_hub_app/apis/recipe.dart';
import 'package:food_hub_app/config/app_config.dart';
import 'package:flutter_common/widget/common_widget.dart';
import 'package:flutter_common/widget/year_selector.dart';
import 'package:food_hub_app/models/recipe.dart';
import 'package:food_hub_app/provider/food_provider.dart';
import 'package:food_hub_app/widgets/common/image.dart';
import 'package:food_hub_app/widgets/common/year_selector.dart';
import 'package:tdesign_flutter/tdesign_flutter.dart';
import 'package:timelines_plus/timelines_plus.dart';
import 'package:provider/provider.dart';
class RecipeTimeline extends StatefulWidget {
const RecipeTimeline({super.key});
@@ -15,121 +15,101 @@ class RecipeTimeline extends StatefulWidget {
}
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) {
Widget buildTimelineCard(BuildContext context, FoodRecord record) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
YearSelector(
initialYear: DateTime.now().year,
minYear: 2000,
maxYear: 2100,
onYearChanged: (year) => refreshRecord(year),
Row(
children: [
Icon(
Icons.date_range,
color: Theme.of(context).colorScheme.primary,
),
Expanded(child: timelineContainer(recordList)),
SizedBox(width: 5),
Text(
record.date,
style: Theme.of(context).textTheme.titleMedium,
),
],
),
SizedBox(height: 8),
CommonCard(
child: Column(
children: [
Text(
record.name,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 5),
CommonImage(imageUrls: [record.imageUrl], index: 0),
],
),
),
SizedBox(height: 20)
],
);
}
}
Widget timelineContainer(List<Record> recordList) {
Widget buildTimeline(BuildContext context, List<FoodRecord> recordList) {
if (recordList.isEmpty) {
return const TDEmpty(type: TDEmptyType.plain, emptyText: '暂无数据');
return buildEmptyData();
} else {
final colors = Theme.of(context).colorScheme;
return Timeline.tileBuilder(
padding: const EdgeInsets.only(bottom: 80),
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,
),
(context, index, type) =>
Connector.solidLine(thickness: 2, color: colors.primary),
indicatorBuilder: (context, index) {
return Indicator.dot(
size: 12.0,
color: Theme.of(context).primaryColor,
);
return Indicator.dot(size: 12.0, color: colors.primary);
},
contentsBuilder: (context, index) {
return TimelineCard(record: recordList[index]);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 8),
child: buildTimelineCard(context, 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(
Widget _buildContent(FoodProvider provider) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(record.name, style: TextStyle(fontSize: 16)),
const SizedBox(height: 5),
GestureDetector(
onTap: () => imageTapClick(),
child: AspectRatio(
aspectRatio: 1.5,
child: networkImage(imageUrls[0]),
YearSelector(
currentYear: provider.selectYear,
onYearChanged: (year) {
provider.selectYear = year;
provider.refreshRecordList();
},
),
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 0, horizontal: 10),
child: buildTimeline(context, provider.recordList),
),
),
],
),
),
);
}
@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,
final provider = context.watch<FoodProvider>();
return Stack(
children: [
Text(
record.date,
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
cardContent(context),
if (provider.isLoading)
buildLoadingIndicator()
else
_buildContent(provider),
],
),
),
);
}
}

View File

@@ -1,115 +0,0 @@
import 'package:flutter/material.dart';
import 'package:food_hub_app/models/stats.dart';
class StatisticCard extends StatelessWidget {
final IconData icon;
final Color color;
final String title;
final num value;
final String unit;
const StatisticCard({
super.key,
required this.icon,
required this.color,
required this.title,
required this.value,
required this.unit,
});
@override
Widget build(BuildContext context) {
return Card(
color: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
child: Padding(
padding: const EdgeInsets.all(10.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 40,
height: 40,
child: Icon(icon, color: color, size: 40),
),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(title, style: TextStyle(fontSize: 20, color: color)),
const SizedBox(height: 4),
Row(
children: [
Text(
value.toString(),
style: TextStyle(fontSize: 20, color: color),
),
const SizedBox(width: 5),
Text(unit, style: TextStyle(fontSize: 20, color: color)),
],
),
],
),
],
),
),
);
}
}
// 获取排名对应的颜色
Color _getRankColor(int index) {
switch (index) {
case 0: // 第1名
return Colors.amber; // 金色
case 1: // 第2名
return Colors.grey; // 银色
case 2: // 第3名
return Colors.orange[700]!; // 铜色
default:
return Colors.black; // 普通颜色
}
}
Widget rankChart(List<ChartData> items) {
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
final rank = index + 1;
return SizedBox(
height: 35,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
width: 25,
alignment: Alignment.center,
child: Text(
'$rank.',
style: TextStyle(
fontWeight: FontWeight.bold,
color: _getRankColor(index),
),
),
),
Text(
item.name,
style: TextStyle(color: _getRankColor(index)),
),
],
),
Text(
'${item.value.toStringAsFixed(0)}',
style: TextStyle(color: _getRankColor(index)),
),
],
),
);
},
);
}

View File

@@ -6,10 +6,14 @@
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <rive_native/rive_native_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) rive_native_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "RiveNativePlugin");
rive_native_plugin_register_with_registrar(rive_native_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
}

View File

@@ -3,7 +3,8 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
rive_native
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@@ -5,10 +5,20 @@
import FlutterMacOS
import Foundation
import file_selector_macos
import file_picker
import flutter_image_compress_macos
import path_provider_foundation
import rive_native
import share_plus
import shared_preferences_foundation
import sqflite_darwin
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
RiveNativePlugin.register(with: registry.registrar(forPlugin: "RiveNativePlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
}

View File

@@ -33,6 +33,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.12.0"
awesome_dialog:
dependency: transitive
description:
name: awesome_dialog
sha256: "4c5821a0a637ceee022084e78c1b8237dd4b8bfca4dd24ac2484662a56707338"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.3.0"
boolean_selector:
dependency: transitive
description:
@@ -41,6 +49,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.2"
buffer:
dependency: transitive
description:
name: buffer
sha256: "389da2ec2c16283c8787e0adaede82b1842102f8c8aae2f49003a766c5c6b3d1"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.2.3"
build:
dependency: transitive
description:
@@ -105,6 +121,30 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "8.10.1"
cached_network_image:
dependency: "direct main"
description:
name: cached_network_image
sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.4.1"
cached_network_image_platform_interface:
dependency: transitive
description:
name: cached_network_image_platform_interface
sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.1.1"
cached_network_image_web:
dependency: transitive
description:
name: cached_network_image_web
sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.3.1"
characters:
dependency: transitive
description:
@@ -165,10 +205,10 @@ packages:
dependency: transitive
description:
name: crypto
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.6"
version: "3.0.7"
cupertino_icons:
dependency: "direct main"
description:
@@ -185,22 +225,30 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.0"
dio:
dependency: "direct main"
dbus:
dependency: transitive
description:
name: dio
sha256: "253a18bbd4851fecba42f7343a1df3a9a4c1d31a2c1b37e221086b4fa8c8dbc9"
name: dbus
sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c"
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.8.0+1"
version: "0.7.11"
dio:
dependency: transitive
description:
name: dio
sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.9.2"
dio_web_adapter:
dependency: transitive
description:
name: dio_web_adapter
sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78"
sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.1"
version: "2.1.2"
easy_refresh:
dependency: "direct main"
description:
@@ -209,14 +257,22 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.4.0"
equatable:
dependency: transitive
description:
name: equatable
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.8"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc"
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.3.2"
version: "1.3.3"
ffi:
dependency: transitive
description:
@@ -233,6 +289,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.0.1"
file_picker:
dependency: "direct main"
description:
name: file_picker
sha256: f8f4ea435f791ab1f817b4e338ed958cb3d04ba43d6736ffc39958d950754967
url: "https://pub.flutter-io.cn"
source: hosted
version: "10.3.6"
file_selector_linux:
dependency: transitive
description:
@@ -278,6 +342,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_cache_manager:
dependency: transitive
description:
name: flutter_cache_manager
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.4.1"
flutter_carousel_widget:
dependency: "direct main"
description:
@@ -286,6 +358,13 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.0"
flutter_common:
dependency: "direct main"
description:
path: "../flutter_common"
relative: true
source: path
version: "1.0.0+1"
flutter_form_builder:
dependency: "direct main"
description:
@@ -294,6 +373,62 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "10.0.1"
flutter_image_compress:
dependency: transitive
description:
name: flutter_image_compress
sha256: "51d23be39efc2185e72e290042a0da41aed70b14ef97db362a6b5368d0523b27"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.0"
flutter_image_compress_common:
dependency: transitive
description:
name: flutter_image_compress_common
sha256: c5c5d50c15e97dd7dc72ff96bd7077b9f791932f2076c5c5b6c43f2c88607bfb
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.6"
flutter_image_compress_macos:
dependency: transitive
description:
name: flutter_image_compress_macos
sha256: "20019719b71b743aba0ef874ed29c50747461e5e8438980dfa5c2031898f7337"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.3"
flutter_image_compress_ohos:
dependency: transitive
description:
name: flutter_image_compress_ohos
sha256: e76b92bbc830ee08f5b05962fc78a532011fcd2041f620b5400a593e96da3f51
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.0.3"
flutter_image_compress_platform_interface:
dependency: transitive
description:
name: flutter_image_compress_platform_interface
sha256: "579cb3947fd4309103afe6442a01ca01e1e6f93dc53bb4cbd090e8ce34a41889"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.5"
flutter_image_compress_web:
dependency: transitive
description:
name: flutter_image_compress_web
sha256: b9b141ac7c686a2ce7bb9a98176321e1182c9074650e47bb140741a44b6f5a96
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.1.5"
flutter_input_chips:
dependency: "direct main"
description:
name: flutter_input_chips
sha256: "4b45df0c8b80a23db86850d8f892144dc796f3f6eb13ebd8bcb2d242ec921e22"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.0"
flutter_lints:
dependency: "direct dev"
description:
@@ -315,22 +450,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.28"
flutter_slidable:
flutter_shaders:
dependency: transitive
description:
name: flutter_slidable
sha256: a857de7ea701f276fd6a6c4c67ae885b60729a3449e42766bb0e655171042801
name: flutter_shaders
sha256: "34794acadd8275d971e02df03afee3dee0f98dbfb8c4837082ad0034f612a3e2"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.2"
flutter_swiper_null_safety:
dependency: transitive
description:
name: flutter_swiper_null_safety
sha256: "5a855e0080d035c08e82f8b7fd2f106344943a30c9ab483b2584860a2f22eaaf"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.2"
version: "0.1.3"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -342,13 +469,13 @@ packages:
source: sdk
version: "0.0.0"
fluttertoast:
dependency: "direct main"
dependency: transitive
description:
name: fluttertoast
sha256: "25e51620424d92d3db3832464774a6143b5053f15e382d8ffbfd40b6e795dcf1"
sha256: "90778fe0497fe3a09166e8cf2e0867310ff434b794526589e77ec03cf08ba8e8"
url: "https://pub.flutter-io.cn"
source: hosted
version: "8.2.12"
version: "8.2.14"
form_builder_validators:
dependency: "direct main"
description:
@@ -473,10 +600,10 @@ packages:
dependency: "direct main"
description:
name: intl
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.19.0"
version: "0.20.2"
io:
dependency: transitive
description:
@@ -513,26 +640,26 @@ packages:
dependency: transitive
description:
name: leak_tracker
sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.flutter-io.cn"
source: hosted
version: "10.0.8"
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.9"
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.1"
version: "3.0.2"
lints:
dependency: transitive
description:
@@ -541,6 +668,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.1.1"
liquid_glass_widgets:
dependency: "direct main"
description:
name: liquid_glass_widgets
sha256: "776adcdb7d48af0b935642425936813074355830eb0a47ecca8b227549d5b057"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.16.3"
logger:
dependency: "direct main"
description:
@@ -577,10 +712,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.16.0"
version: "1.17.0"
mime:
dependency: transitive
description:
@@ -589,6 +724,30 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.0"
minio:
dependency: transitive
description:
name: minio
sha256: ee2ce47766e46c7d164f960f2f5ed6a9a82844d877f6b82574f6876ec50c56d1
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.5.8"
nested:
dependency: transitive
description:
name: nested
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.0"
octo_image:
dependency: transitive
description:
name: octo_image
sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.0"
package_config:
dependency: transitive
description:
@@ -621,6 +780,30 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.0"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.19"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.2"
path_provider_linux:
dependency: transitive
description:
@@ -645,6 +828,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.1.0"
photo_view:
dependency: "direct main"
description:
@@ -677,6 +868,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.5.1"
provider:
dependency: "direct main"
description:
name: provider
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.1.5+1"
pub_semver:
dependency: transitive
description:
@@ -693,30 +892,70 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.5.0"
shared_preferences:
dependency: "direct main"
rive:
dependency: transitive
description:
name: shared_preferences
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
name: rive
sha256: "84a640f48122679e48397935ed1b971db19ef2e44bfcbae60e42fb5ce29a0a4e"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.3"
version: "0.14.9"
rive_native:
dependency: transitive
description:
name: rive_native
sha256: "85dc8413398f7540eab9b73c6d704ecd5051ab9c57e539eed85ccd2a69f119aa"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.1.9"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.28.0"
share_plus:
dependency: "direct main"
description:
name: share_plus
sha256: d7dc0630a923883c6328ca31b89aa682bacbf2f8304162d29f7c6aaff03a27a1
url: "https://pub.flutter-io.cn"
source: hosted
version: "11.1.0"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.1.0"
shared_preferences:
dependency: transitive
description:
name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.5"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac"
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.10"
version: "2.4.23"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03"
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.4"
version: "2.5.6"
shared_preferences_linux:
dependency: transitive
description:
@@ -729,10 +968,10 @@ packages:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.1"
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
@@ -802,6 +1041,46 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.10.1"
sqflite:
dependency: transitive
description:
name: sqflite
sha256: "564cfed0746fe53140c23b70b308e045c3b31f17778f2f326ccb7d804ea0250a"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.2+1"
sqflite_android:
dependency: transitive
description:
name: sqflite_android
sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.2+3"
sqflite_common:
dependency: transitive
description:
name: sqflite_common
sha256: "1581ffbf7a0e333b380d6a30737d78516b826cb35beb7fb0bf8a3ea0c678b465"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.8"
sqflite_darwin:
dependency: transitive
description:
name: sqflite_darwin
sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.2"
sqflite_platform_interface:
dependency: transitive
description:
name: sqflite_platform_interface
sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.0"
stack_trace:
dependency: transitive
description:
@@ -835,37 +1114,37 @@ packages:
source: hosted
version: "1.4.1"
syncfusion_flutter_charts:
dependency: "direct main"
dependency: transitive
description:
name: syncfusion_flutter_charts
sha256: c58ca79e072680af6f0554f7c4b91886c1d8808f2522b49d557f16fb5cb3bb04
sha256: "68fdb029dad34a46e4c9cfad8ad66fe29db7b303bd96849261ab2b23a168d0e8"
url: "https://pub.flutter-io.cn"
source: hosted
version: "30.1.41"
version: "30.2.7"
syncfusion_flutter_core:
dependency: transitive
description:
name: syncfusion_flutter_core
sha256: "536753489e168f49659261d0abd02b101b34c0b1bde27898f2869aab05f1cbb6"
sha256: bfd026c0f9822b49ff26fed11cd3334519acb6a6ad4b0c81d9cd18df6af1c4c0
url: "https://pub.flutter-io.cn"
source: hosted
version: "30.1.41"
version: "30.2.7"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.4.0"
table_calendar:
dependency: "direct main"
description:
name: table_calendar
sha256: b2896b7c86adf3a4d9c911d860120fe3dbe03c85db43b22fd61f14ee78cdbb63
sha256: "0c0c6219878b363a2d5f40c7afb159d845f253d061dc3c822aa0d5fe0f721982"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.3"
tdesign_flutter:
dependency: "direct main"
description:
name: tdesign_flutter
sha256: b36b6f939f7a585184665202b6b3acf1922728962312b65182dbbff64c3b62f2
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.2.3"
version: "3.2.0"
tdesign_flutter_adaptation:
dependency: "direct overridden"
description:
@@ -886,10 +1165,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.7.4"
version: "0.7.7"
timelines_plus:
dependency: "direct main"
description:
@@ -906,6 +1185,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.2"
toggle_switch:
dependency: "direct main"
description:
name: toggle_switch
sha256: dca04512d7c23ed320d6c5ede1211a404f177d54d353bf785b07d15546a86ce5
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.3.0"
typed_data:
dependency: transitive
description:
@@ -914,14 +1201,54 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.0"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.2.1"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.1"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.4"
uuid:
dependency: transitive
description:
name: uuid
sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.5.2"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.4"
version: "2.2.0"
vm_service:
dependency: transitive
description:
@@ -962,6 +1289,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.3"
win32:
dependency: transitive
description:
name: win32
sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba"
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.13.0"
xdg_directories:
dependency: transitive
description:
@@ -970,6 +1305,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.5.0"
yaml:
dependency: transitive
description:
@@ -979,5 +1322,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.7.0 <4.0.0"
flutter: ">=3.29.0"
dart: ">=3.10.0 <4.0.0"
flutter: ">=3.38.0"

View File

@@ -1,116 +1 @@
name: food_hub_app
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
environment:
sdk: ^3.7.0
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
# The following adds the Cupertino Icons fonts to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
dio: ^5.7.0
timelines_plus: ^1.0.7
table_calendar: ^3.1.3
flutter_form_builder: ^10.0.0
form_builder_validators: ^11.1.2
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
image_picker: 1.0.8
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^5.0.0
# flutter pub run build_runner build
build_runner: ^2.4.5
json_serializable: ^6.7.1
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons fonts is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the fonts family name, and a "fonts" key with a
# list giving the asset and other descriptors for the fonts. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package
fonts:
- family: CustomFont
fonts:
- asset: fonts/custom.ttf
name: food_hub_app

View File

@@ -6,9 +6,15 @@
#include "generated_plugin_registrant.h"
#include <file_selector_windows/file_selector_windows.h>
#include <rive_native/rive_native_plugin.h>
#include <share_plus/share_plus_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
RiveNativePluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("RiveNativePlugin"));
SharePlusWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}

View File

@@ -3,7 +3,9 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
rive_native
share_plus
url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST