From 65958d925e346114d21389e961b8fe8323d713b1 Mon Sep 17 00:00:00 2001 From: Cxx0822 <1556464090@qq.com> Date: Fri, 18 Jul 2025 17:06:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=A2=9E=E5=8A=A0=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/api/recipe.dart | 66 +++++++- lib/utils/date_util.dart | 21 +++ lib/utils/http_util.dart | 8 +- lib/views/record.dart | 39 +---- lib/widgets/recipe/recipe_calendar.dart | 117 ++++++++++++- lib/widgets/recipe/recipe_list.dart | 32 +++- lib/widgets/recipe/recipe_timeline.dart | 213 ++++++++++++++++++++++-- 7 files changed, 421 insertions(+), 75 deletions(-) create mode 100644 lib/utils/date_util.dart diff --git a/lib/api/recipe.dart b/lib/api/recipe.dart index a6c57ca..1ec5c9d 100644 --- a/lib/api/recipe.dart +++ b/lib/api/recipe.dart @@ -14,7 +14,10 @@ Future addRecipeApi(Recipe recipe) { } Future updateRecipeApi(int id, Recipe recipe) { - return HttpUtil().put("/food-service/food/food/recipe/$id", data: recipe); + return HttpUtil().put( + "/food-service/food/food/recipe/$id", + data: recipe, + ); } Future deleteRecipeApi(int id) { @@ -42,3 +45,64 @@ Future> queryRecipeApi(RecipeQuery recipeQuery) { converter: (data) => convertListResponse(data, Recipe.fromJson), ); } + +Future> queryFoodNameListApi() { + return HttpUtil().get>( + "/food-service/food/recipe/name", + converter: + (data) => convertListResponse(data, (json) => json.toString()), + ); +} + +Future addRecordApi(Record record) { + return HttpUtil().post("/food-service/food/record", data: record); +} + +Future updateRecordApi(int id, Record record) { + return HttpUtil().put("/food-service/food/record/$id", data: record); +} + +Future deleteRecordApi(int id) { + return HttpUtil().delete("/food-service/food/record/$id"); +} + +Future addRecipeCommentApi(int id, String content) { + return HttpUtil().post( + "/food-service/food/recipe/$id/comment", + data: content, + ); +} + +Future addRecipeLikeApi(int id) { + return HttpUtil().post("/food-service/food/recipe/$id/like"); +} + +Future deleteRecipeLikeApi(int id) { + return HttpUtil().delete("/food-service/food/recipe/$id/like"); +} + +Future addRecipeFavouriteApi(int id) { + return HttpUtil().post("/food-service/food/recipe/$id/favourite"); +} + +Future deleteRecipeFavouriteApi(int id) { + return HttpUtil().delete("/food-service/food/recipe/$id/like"); +} + +Future> queryRecordApi(String startDate, String endDate) { + return HttpUtil().get>( + "/food-service/food/record", + queryParameters: { + "startDate": startDate, + "endDate": endDate + }, + converter: (data) => convertListResponse(data, Record.fromJson), + ); +} + +Future> queryCategoryApi() { + return HttpUtil().get>( + "/food-service/food/category", + converter: (data) => convertListResponse(data, (json) => json.toString()), + ); +} diff --git a/lib/utils/date_util.dart b/lib/utils/date_util.dart new file mode 100644 index 0000000..980730c --- /dev/null +++ b/lib/utils/date_util.dart @@ -0,0 +1,21 @@ +/// 获取指定日期所在月份的第一天(返回"yyyy-MM-dd"格式) +/// [date] 可选参数,默认使用当前日期 +String getFirstDayOfMonth([DateTime? date]) { + final currentDate = date ?? DateTime.now(); + final firstDay = DateTime(currentDate.year, currentDate.month, 1); + return "${firstDay.year}-${_twoDigits(firstDay.month)}-${_twoDigits(firstDay.day)}"; +} + +/// 获取指定日期所在月份的最后一天(返回"yyyy-MM-dd"格式) +/// [date] 可选参数,默认使用当前日期 +String getLastDayOfMonth([DateTime? date]) { + final currentDate = date ?? DateTime.now(); + // 下个月第一天减一天即为当月最后一天 + final lastDay = DateTime(currentDate.year, currentDate.month + 1, 0); + return "${lastDay.year}-${_twoDigits(lastDay.month)}-${_twoDigits(lastDay.day)}"; +} + +/// 辅助函数:确保数字为两位数(如1→"01") +String _twoDigits(int n) { + return n.toString().padLeft(2, '0'); +} \ No newline at end of file diff --git a/lib/utils/http_util.dart b/lib/utils/http_util.dart index 81ba4cd..19d754d 100644 --- a/lib/utils/http_util.dart +++ b/lib/utils/http_util.dart @@ -1,4 +1,5 @@ import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; import 'package:food_hub_app/utils/sp_util.dart'; import 'package:food_hub_app/widgets/common/index.dart'; @@ -10,8 +11,7 @@ class HttpUtil { factory HttpUtil() => _instance; late Dio _dio; - bool isMock = true; - String baseUrl = "http://192.168.1.3:8100"; + String baseUrl = kDebugMode ? "http://172.29.101.108:8100" : "http://14.103.235.151:81"; // 请求头配置 Map headers = { @@ -55,7 +55,7 @@ class HttpUtil { InterceptorsWrapper( onResponse: (response, handler) { logger.d("响应状态码: ${response.statusCode}"); - logger.d("响应数据: ${response.data}"); + // logger.d("响应数据: ${response.data}"); return handler.next(response); }, ), @@ -81,7 +81,7 @@ class HttpUtil { T Function(dynamic data)? converter, }) async { try { - if (isMock) { + if (kDebugMode) { path = path.replaceFirst('/food-service', ''); } diff --git a/lib/views/record.dart b/lib/views/record.dart index ed10441..055d55a 100644 --- a/lib/views/record.dart +++ b/lib/views/record.dart @@ -1,6 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:food_hub_app/api/recipe.dart'; -import 'package:food_hub_app/models/recipe.dart'; import 'package:food_hub_app/widgets/recipe/recipe_calendar.dart'; import 'package:food_hub_app/widgets/recipe/recipe_list.dart'; import 'package:food_hub_app/widgets/recipe/recipe_timeline.dart'; @@ -16,9 +14,6 @@ class RecordPage extends StatefulWidget { class _RecordPageState extends State with SingleTickerProviderStateMixin { - List _recipeList = []; - - final List _recordList = []; late final TabController _tabController = TabController( length: 3, @@ -34,44 +29,14 @@ class _RecordPageState extends State @override void initState() { super.initState(); - _getRecipeList(); - _tabController.addListener(_handleTabChange); } @override void dispose() { - _tabController.removeListener(_handleTabChange); _tabController.dispose(); super.dispose(); } - void _handleTabChange() { - if (_tabController.indexIsChanging) { - return; - } - - // 根据当前选中的索引执行对应接口请求 - switch (_tabController.index) { - case 0: - _getRecipeList(); - break; - case 1: - print("2"); - break; - case 2: - print("3"); - break; - } - } - - Future _getRecipeList() async { - final result = await queryRecipeApi(RecipeQuery(category: "")); - - setState(() { - _recipeList = result; - }); - } - @override Widget build(BuildContext context) { return Padding( @@ -90,9 +55,9 @@ class _RecordPageState extends State child: TabBarView( controller: _tabController, children: [ - RecipeList(recipeList: _recipeList), + RecipeList(), RecipeCalendar(), - RecipeTimeline(recordList: _recordList), + RecipeTimeline(), ], ), ), diff --git a/lib/widgets/recipe/recipe_calendar.dart b/lib/widgets/recipe/recipe_calendar.dart index a9ede94..4c00c45 100644 --- a/lib/widgets/recipe/recipe_calendar.dart +++ b/lib/widgets/recipe/recipe_calendar.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:food_hub_app/api/recipe.dart'; +import 'package:food_hub_app/models/recipe.dart'; +import 'package:food_hub_app/utils/date_util.dart'; import 'package:food_hub_app/utils/index.dart'; import 'package:table_calendar/table_calendar.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; @@ -14,7 +17,41 @@ class _RecipeCalendarState extends State { DateTime _selectedDay = DateTime.now(); DateTime _focusedDay = DateTime.now(); + List recordList = []; + List selectRecordList = []; + @override + void initState() { + super.initState(); + refreshRecord(); + } + + Future refreshRecord() async { + final result = await queryRecordApi( + getFirstDayOfMonth(_focusedDay), + getLastDayOfMonth(_focusedDay), + ); + setState(() { + recordList = result; + + if (recordList.isNotEmpty) { + _selectedDay = DateTime.parse(recordList.last.date); + } else { + _selectedDay = _focusedDay; + } + + refreshSelectRecord(); + }); + } + + void refreshSelectRecord() { + setState(() { + selectRecordList = + recordList + .where((record) => record.date == formatDateTime(_selectedDay)) + .toList(); + }); + } TableCalendar recipeCalendar() { return TableCalendar( @@ -32,13 +69,23 @@ class _RecipeCalendarState extends State { setState(() { _selectedDay = selectedDay; _focusedDay = focusedDay; + refreshSelectRecord(); }); } }, onPageChanged: (focusedDay) { - print("cxx"); _focusedDay = focusedDay; + refreshRecord(); }, + // 自定义日期单元格构建器 + calendarBuilders: CalendarBuilders( + defaultBuilder: + (context, day, focusedDay) => _buildDateWidget(day, false, false), + selectedBuilder: + (context, day, focusedDay) => _buildDateWidget(day, true, false), + todayBuilder: + (context, day, focusedDay) => _buildDateWidget(day, false, true), + ), ); } @@ -52,17 +99,27 @@ class _RecipeCalendarState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(formatDateTime(_selectedDay, 'MM月dd日 EEEE')), - recipeRecordItem(), + if (selectRecordList.isEmpty) + TDEmpty(type: TDEmptyType.plain, emptyText: '暂无数据') + else + ListView.builder( + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + itemCount: selectRecordList.length, + itemBuilder: (context, index) { + return recipeRecordItem(selectRecordList[index]); + }, + ), ], ), ), ); } - Widget recipeRecordItem() { + Widget recipeRecordItem(Record record) { return Card( elevation: 0, - color: Colors.limeAccent.shade100, + color: Color(0xFFF5F5DC), child: Padding( padding: EdgeInsets.all(10), child: Row( @@ -70,7 +127,7 @@ class _RecipeCalendarState extends State { TDAvatar( size: TDAvatarSize.medium, type: TDAvatarType.customText, - text: 'A', + text: record.category[0], ), SizedBox(width: 10), Expanded( @@ -80,11 +137,11 @@ class _RecipeCalendarState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - "青椒土豆丝", + record.name, style: TextStyle(fontWeight: FontWeight.bold), ), SizedBox(height: 5), - Text("家常菜", style: TextStyle(color: Colors.grey)), + Text(record.category, style: TextStyle(color: Colors.grey)), ], ), ), @@ -101,13 +158,57 @@ class _RecipeCalendarState extends State { ); } + Widget _buildDateWidget(DateTime day, bool isSelected, bool isToday) { + // 检查当前日期是否在需要显示红点的列表中 + bool shouldShowRedDot = recordList.any( + (item) => item.date == formatDateTime(day), + ); + + return Container( + width: 40, + height: 40, + margin: EdgeInsets.all(2), + decoration: BoxDecoration( + color: + isSelected + ? Colors.blue + : isToday + ? Colors.grey[200] + : Colors.transparent, + shape: BoxShape.circle, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + // 日期数字 + Text( + day.day.toString(), + style: TextStyle(color: isSelected ? Colors.white : Colors.black87), + ), + // 底部红点 - 只在指定日期显示 + if (shouldShowRedDot) + Container( + margin: EdgeInsets.only(top: 2), + width: 6, + height: 6, + decoration: BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { return Column( children: [ Card(elevation: 0, color: Colors.white, child: recipeCalendar()), const SizedBox(height: 10), - dailyItem(), + Expanded(child: dailyItem()), ], ); } diff --git a/lib/widgets/recipe/recipe_list.dart b/lib/widgets/recipe/recipe_list.dart index d91e80c..1f3a025 100644 --- a/lib/widgets/recipe/recipe_list.dart +++ b/lib/widgets/recipe/recipe_list.dart @@ -1,20 +1,38 @@ import 'package:flutter/material.dart'; +import 'package:food_hub_app/api/recipe.dart'; import 'package:food_hub_app/models/recipe.dart'; import 'package:food_hub_app/widgets/recipe/recipe_card.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; -class RecipeList extends StatelessWidget { - final List recipeList; +class RecipeList extends StatefulWidget { + const RecipeList({super.key}); - const RecipeList({super.key, required this.recipeList}); + + @override + State createState() => _RecipeListState(); +} + +class _RecipeListState extends State { + List recipeList = []; + + @override + void initState() { + super.initState(); + refreshRecipeList(); + } + + Future refreshRecipeList() async { + final result = await queryRecipeApi(RecipeQuery(category: "")); + + setState(() { + recipeList = result; + }); + } @override Widget build(BuildContext context) { if (recipeList.isEmpty) { - return const TDEmpty( - type: TDEmptyType.plain, - emptyText: '暂无数据', - ); + return const TDEmpty(type: TDEmptyType.plain, emptyText: '暂无数据'); } else { return ListView.separated( itemCount: recipeList.length, diff --git a/lib/widgets/recipe/recipe_timeline.dart b/lib/widgets/recipe/recipe_timeline.dart index b1697f9..24eb0fe 100644 --- a/lib/widgets/recipe/recipe_timeline.dart +++ b/lib/widgets/recipe/recipe_timeline.dart @@ -1,26 +1,71 @@ import 'package:flutter/material.dart'; +import 'package:food_hub_app/api/recipe.dart'; import 'package:food_hub_app/models/recipe.dart'; +import 'package:food_hub_app/widgets/common/index.dart'; +import 'package:tdesign_flutter/tdesign_flutter.dart'; import 'package:timelines_plus/timelines_plus.dart'; -class RecipeTimeline extends StatelessWidget { - final List recordList; +class RecipeTimeline extends StatefulWidget { + const RecipeTimeline({super.key}); - const RecipeTimeline({super.key, required this.recordList}); + @override + State createState() => _RecipeTimeline(); +} + +class _RecipeTimeline extends State { + List recordList = []; + + @override + void initState() { + super.initState(); + refreshRecord(); + } + + Future refreshRecord() async { + final result = await queryRecordApi("2025-01-01", "2025-07-08"); + setState(() { + recordList = result; + }); + } @override Widget build(BuildContext context) { - return Timeline.tileBuilder( - theme: TimelineThemeData(nodePosition: 0, indicatorPosition: 0), - builder: TimelineTileBuilder.connected( - itemCount: recordList.length, - connectorBuilder: - (context, index, type) => Connector.solidLine(thickness: 2), - indicatorBuilder: (context, index) { - return Indicator.dot(size: 12.0); - }, - contentsBuilder: (context, index) { - return TimelineCard(record: recordList[index]); - }, + return Column( + children: [ + YearSelector( + initialYear: DateTime.now().year, + minYear: 2000, + maxYear: 2100, + onYearChanged: (year) { + print('选中的年份: $year'); + }, + accentColor: Colors.blue, + ), + Expanded(child: TimelineContainer(recordList)) + ], + ); + } +} + +Widget TimelineContainer(List recordList) { + if (recordList.isEmpty) { + return const TDEmpty(type: TDEmptyType.plain, emptyText: '暂无数据'); + } else { + return Padding( + padding: EdgeInsets.all(10), + child: Timeline.tileBuilder( + theme: TimelineThemeData(nodePosition: 0, indicatorPosition: 0), + builder: TimelineTileBuilder.connected( + itemCount: recordList.length, + connectorBuilder: + (context, index, type) => Connector.solidLine(thickness: 2), + indicatorBuilder: (context, index) { + return Indicator.dot(size: 12.0); + }, + contentsBuilder: (context, index) { + return TimelineCard(record: recordList[index]); + }, + ), ), ); } @@ -36,7 +81,7 @@ class TimelineCard extends StatelessWidget { return SizedBox( width: double.infinity, child: Padding( - padding: const EdgeInsets.only(left: 10, bottom: 10), + padding: const EdgeInsets.only(left: 10, bottom: 5), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -52,12 +97,15 @@ class TimelineCard extends StatelessWidget { child: Column( children: [ Text(record.name, style: TextStyle(fontSize: 16)), - const SizedBox(height: 10), + const SizedBox(height: 5), Image.network( - record.imageUrl, + 'http://172.29.101.108:8100/${record.imageUrl}', width: double.infinity, height: 250, fit: BoxFit.contain, + errorBuilder: + (context, error, stackTrace) => + errorImageContainer(250), ), ], ), @@ -69,3 +117,132 @@ class TimelineCard extends StatelessWidget { ); } } + +class YearSelector extends StatefulWidget { + final int initialYear; + final int? minYear; + final int? maxYear; + final Function(int) onYearChanged; + final Color? accentColor; + + const YearSelector({ + super.key, + required this.initialYear, + required this.onYearChanged, + this.minYear, + this.maxYear, + this.accentColor, + }); + + @override + State createState() => _YearSelectorState(); +} + +class _YearSelectorState extends State + with SingleTickerProviderStateMixin { + late int _currentYear; + late Color _accentColor; + + // 用于动画效果 + late AnimationController _animationController; + late Animation _scaleAnimation; + + @override + void initState() { + super.initState(); + _currentYear = widget.initialYear; + _accentColor = widget.accentColor ?? Theme + .of(context) + .primaryColor; + + // 初始化动画控制器 + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + ); + + // 缩放动画 + _scaleAnimation = Tween(begin: 1.0, end: 1.1).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeInOut), + ); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + /// 切换到上一年 + void _previousYear() { + if (widget.minYear == null || _currentYear > widget.minYear!) { + _animateYearChange(() { + setState(() { + _currentYear--; + }); + widget.onYearChanged(_currentYear); + }); + } + } + + /// 切换到下一年 + void _nextYear() { + if (widget.maxYear == null || _currentYear < widget.maxYear!) { + _animateYearChange(() { + setState(() { + _currentYear++; + }); + widget.onYearChanged(_currentYear); + }); + } + } + + /// 年份变化时的动画效果 + void _animateYearChange(VoidCallback onComplete) { + _animationController.forward().then((_) { + onComplete(); + _animationController.reverse(); + }); + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TDButton( + icon: Icons.chevron_left, + size: TDButtonSize.small, + type: TDButtonType.fill, + shape: TDButtonShape.circle, + theme: TDButtonTheme.primary, + onTap: () => _previousYear(), + ), + SizedBox(width: 10), + // 年份显示 + AnimatedBuilder( + animation: _scaleAnimation, + builder: (context, child) { + return Transform.scale(scale: _scaleAnimation.value, child: child); + }, + child: Text( + '$_currentYear', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: _accentColor, + ), + ), + ), + TDButton( + icon: Icons.chevron_right, + size: TDButtonSize.small, + type: TDButtonType.fill, + shape: TDButtonShape.circle, + theme: TDButtonTheme.primary, + onTap: () => _nextYear(), + ), + ], + ); + } +}