diff --git a/lib/widgets/common/image_preview.dart b/lib/widgets/common/image.dart similarity index 76% rename from lib/widgets/common/image_preview.dart rename to lib/widgets/common/image.dart index f9a930b..91e70d8 100644 --- a/lib/widgets/common/image_preview.dart +++ b/lib/widgets/common/image.dart @@ -84,4 +84,32 @@ class _ImagePreviewPageState extends State { ), ); } +} + +Widget networkImage(String url) { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + url, + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + // 加载中显示占位符 + if (loadingProgress == null) return child; + return Center( + child: CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + ), + ); + }, + errorBuilder: (context, error, stackTrace) { + return Container( + color: Colors.grey[200], + child: const Icon(Icons.image, color: Colors.grey, size: 30), + ); + }, + ), + ); } \ No newline at end of file diff --git a/lib/widgets/common/year_selector.dart b/lib/widgets/common/year_selector.dart new file mode 100644 index 0000000..41bcd23 --- /dev/null +++ b/lib/widgets/common/year_selector.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:tdesign_flutter/tdesign_flutter.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 createState() => _YearSelectorState(); +} + +// SingleTickerProviderStateMixin 动画控制器 +class _YearSelectorState extends State + with SingleTickerProviderStateMixin { + late int _currentYear; + + // 用于动画效果 + late AnimationController _animationController; + late Animation _scaleAnimation; + + @override + void initState() { + super.initState(); + _currentYear = widget.initialYear; + + // 初始化动画控制器 + _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: 24, + fontWeight: FontWeight.bold, + color: Theme.of(context).primaryColor, + ), + ), + ), + SizedBox(width: 10), + TDButton( + icon: Icons.chevron_right, + size: TDButtonSize.small, + type: TDButtonType.fill, + shape: TDButtonShape.circle, + theme: TDButtonTheme.primary, + onTap: () => _nextYear(), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/widgets/moment/card.dart b/lib/widgets/moment/card.dart index 4c35ad9..0c27409 100644 --- a/lib/widgets/moment/card.dart +++ b/lib/widgets/moment/card.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:food_hub_app/config/app_config.dart'; import 'package:food_hub_app/models/moment.dart'; -import 'package:food_hub_app/widgets/common/image_preview.dart'; +import 'package:food_hub_app/widgets/common/image.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; class MomentCard extends StatelessWidget { @@ -50,17 +50,19 @@ class MomentCard extends StatelessWidget { 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]); + size: TDAvatarSize.medium, + type: TDAvatarType.customText, + shape: TDAvatarShape.circle, + backgroundColor: Theme.of(context).primaryColor, + text: moment.username?[0], + ); } else { return TDAvatar( - size: TDAvatarSize.medium, - type: TDAvatarType.normal, - fit: BoxFit.contain, - avatarUrl: '${AppConfig.baseApiUrl}/${moment.avatar}'); + size: TDAvatarSize.medium, + type: TDAvatarType.normal, + fit: BoxFit.contain, + avatarUrl: '${AppConfig.baseApiUrl}/${moment.avatar}', + ); } } @@ -68,10 +70,7 @@ class MomentCard extends StatelessWidget { Widget _buildNickname() { return Text( moment.username ?? "", - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), ); } @@ -104,9 +103,21 @@ class MomentCard extends StatelessWidget { } // 生成图片URL列表(用于预览时切换) - final List imageUrls = moment.imageList - .map((path) => '${AppConfig.baseApiUrl}/$path') - .toList(); + final List imageUrls = + moment.imageList + .map((path) => '${AppConfig.baseApiUrl}/$path') + .toList(); + + void imageTapClick(int index) { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => + ImagePreviewPage(images: imageUrls, initialIndex: index), + ), + ); + } return GridView.count( shrinkWrap: true, @@ -119,32 +130,9 @@ class MomentCard extends StatelessWidget { // 单个图片项:添加点击事件 return GestureDetector( // 点击图片时,跳转到预览页面 - onTap: () { - // 导航到全屏预览页面,传入所有图片URL和当前点击的索引 - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ImagePreviewPage( - images: imageUrls, - initialIndex: index, - ), - ), - ); - }, + onTap: () => imageTapClick(index), // 原图片组件 - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.network( - imageUrls[index], - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Container( - color: Colors.grey[200], - child: const Icon(Icons.image, color: Colors.grey, size: 30), - ); - }, - ), - ), + child: networkImage(imageUrls[index]), ); }), ); diff --git a/lib/widgets/recipe/card.dart b/lib/widgets/recipe/card.dart index 073099f..b8288c2 100644 --- a/lib/widgets/recipe/card.dart +++ b/lib/widgets/recipe/card.dart @@ -1,9 +1,9 @@ -import 'package:carousel_slider/carousel_slider.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_carousel_widget/flutter_carousel_widget.dart'; import 'package:food_hub_app/config/app_config.dart'; import 'package:food_hub_app/models/recipe.dart'; -import 'package:food_hub_app/widgets/common/index.dart'; +import 'package:food_hub_app/widgets/common/image.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; class RecipeCard extends StatelessWidget { @@ -13,19 +13,51 @@ class RecipeCard extends StatelessWidget { @override Widget build(BuildContext context) { + final List imageUrls = + recipe.recordList + .map((item) => '${AppConfig.baseApiUrl}/${item.imageUrl}') + .toList(); + + Widget buildCarouselItem(String url) { + return Builder( + builder: (BuildContext context) { + return AspectRatio( + aspectRatio: 2, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: networkImage(url), + ), + ); + }, + ); + } + return Card( + shape: RoundedRectangleBorder( + side: BorderSide(color: Theme.of(context).primaryColor, width: 1.0), + borderRadius: BorderRadius.circular(10.0), + ), elevation: 0, color: Colors.white, clipBehavior: Clip.antiAlias, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Image.network( - '${AppConfig.baseApiUrl}/${recipe.recordList[0].imageUrl}', - height: 200, - width: double.infinity, - fit: BoxFit.contain, - errorBuilder: (context, error, stackTrace) => errorImageContainer(200), + 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, + ), ), Divider( height: 1, diff --git a/lib/widgets/recipe/list.dart b/lib/widgets/recipe/list.dart index 79bc995..b06cb55 100644 --- a/lib/widgets/recipe/list.dart +++ b/lib/widgets/recipe/list.dart @@ -7,7 +7,6 @@ import 'package:tdesign_flutter/tdesign_flutter.dart'; class RecipeList extends StatefulWidget { const RecipeList({super.key}); - @override State createState() => _RecipeListState(); } @@ -34,11 +33,14 @@ class _RecipeListState extends State { if (recipeList.isEmpty) { return const TDEmpty(type: TDEmptyType.plain, emptyText: '暂无数据'); } else { - return ListView.builder( + return ListView.separated( itemCount: recipeList.length, itemBuilder: (context, index) { return RecipeCard(recipe: recipeList[index]); - } + }, + separatorBuilder: (BuildContext context, int index) { + return SizedBox(height: 10); + }, ); } } diff --git a/lib/widgets/recipe/timeline.dart b/lib/widgets/recipe/timeline.dart index 76abe53..57a8a31 100644 --- a/lib/widgets/recipe/timeline.dart +++ b/lib/widgets/recipe/timeline.dart @@ -2,7 +2,9 @@ import 'package:flutter/material.dart'; import 'package:food_hub_app/api/recipe.dart'; import 'package:food_hub_app/config/app_config.dart'; import 'package:food_hub_app/models/recipe.dart'; +import 'package:food_hub_app/widgets/common/image.dart'; import 'package:food_hub_app/widgets/common/index.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'; @@ -31,16 +33,20 @@ class _RecipeTimeline extends State { @override Widget build(BuildContext context) { - return Column( - children: [ - YearSelector( - initialYear: DateTime.now().year, - minYear: 2000, - maxYear: 2100, - onYearChanged: (year) => refreshRecord(year) - ), - Expanded(child: timelineContainer(recordList)), - ], + return Padding( + padding: EdgeInsets.all(10), + child: Column( + children: [ + YearSelector( + initialYear: DateTime.now().year, + minYear: 2000, + maxYear: 2100, + onYearChanged: (year) => refreshRecord(year), + ), + SizedBox(height: 10), + Expanded(child: timelineContainer(recordList)), + ], + ), ); } } @@ -49,21 +55,18 @@ 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]); - }, - ), + 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]); + }, ), ); } @@ -74,7 +77,19 @@ class TimelineCard extends StatelessWidget { const TimelineCard({super.key, required this.record}); - Widget cardContent() { + 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, @@ -84,13 +99,12 @@ class TimelineCard extends StatelessWidget { children: [ Text(record.name, style: TextStyle(fontSize: 16)), const SizedBox(height: 5), - Image.network( - '${AppConfig.baseApiUrl}/${record.imageUrl}', - width: double.infinity, - height: 250, - fit: BoxFit.contain, - errorBuilder: - (context, error, stackTrace) => errorImageContainer(250), + GestureDetector( + onTap: () => imageTapClick(), + child: AspectRatio( + aspectRatio: 1.5, + child: networkImage(imageUrls[0]), + ), ), ], ), @@ -111,135 +125,10 @@ class TimelineCard extends StatelessWidget { record.date, style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), - cardContent(), + cardContent(context), ], ), ), ); } } - -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 createState() => _YearSelectorState(); -} - -// SingleTickerProviderStateMixin 动画控制器 -class _YearSelectorState extends State - with SingleTickerProviderStateMixin { - late int _currentYear; - - // 用于动画效果 - late AnimationController _animationController; - late Animation _scaleAnimation; - - @override - void initState() { - super.initState(); - _currentYear = widget.initialYear; - - // 初始化动画控制器 - _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: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).primaryColor, - ), - ), - ), - SizedBox(width: 10), - TDButton( - icon: Icons.chevron_right, - size: TDButtonSize.small, - type: TDButtonType.fill, - shape: TDButtonShape.circle, - theme: TDButtonTheme.primary, - onTap: () => _nextYear(), - ), - ], - ); - } -} diff --git a/pubspec.lock b/pubspec.lock index 259d93f..32e222b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -105,14 +105,6 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "8.10.1" - carousel_slider: - dependency: "direct main" - description: - name: carousel_slider - sha256: bcc61735345c9ab5cb81073896579e735f81e35fd588907a393143ea986be8ff - url: "https://pub.flutter-io.cn" - source: hosted - version: "5.1.1" characters: dependency: transitive description: @@ -286,6 +278,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_carousel_widget: + dependency: "direct main" + description: + name: flutter_carousel_widget + sha256: "6473e6df04bfafea70efd58251fe5945d5aa8d19461575c1b9d83643f08e0c77" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.0" flutter_form_builder: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index c946b40..9483416 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -48,7 +48,7 @@ dependencies: shared_preferences: ^2.3.0 logger: ^2.6.0 photo_view: ^0.15.0 - carousel_slider: ^5.1.1 + flutter_carousel_widget: ^3.1.0 easy_refresh: ^3.4.0 dependency_overrides: