feat:增加轮播图组件
This commit is contained in:
@@ -85,3 +85,31 @@ 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) {
|
||||
return Container(
|
||||
color: Colors.grey[200],
|
||||
child: const Icon(Icons.image, color: Colors.grey, size: 30),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
127
lib/widgets/common/year_selector.dart
Normal file
127
lib/widgets/common/year_selector.dart
Normal file
@@ -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<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: [
|
||||
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(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -54,13 +54,15 @@ class MomentCard extends StatelessWidget {
|
||||
type: TDAvatarType.customText,
|
||||
shape: TDAvatarShape.circle,
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
text: moment.username?[0]);
|
||||
text: moment.username?[0],
|
||||
);
|
||||
} else {
|
||||
return TDAvatar(
|
||||
size: TDAvatarSize.medium,
|
||||
type: TDAvatarType.normal,
|
||||
fit: BoxFit.contain,
|
||||
avatarUrl: '${AppConfig.baseApiUrl}/${moment.avatar}');
|
||||
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,10 +103,22 @@ class MomentCard extends StatelessWidget {
|
||||
}
|
||||
|
||||
// 生成图片URL列表(用于预览时切换)
|
||||
final List<String> imageUrls = moment.imageList
|
||||
final List<String> imageUrls =
|
||||
moment.imageList
|
||||
.map((path) => '${AppConfig.baseApiUrl}/$path')
|
||||
.toList();
|
||||
|
||||
void imageTapClick(int index) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder:
|
||||
(context) =>
|
||||
ImagePreviewPage(images: imageUrls, initialIndex: index),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return GridView.count(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
@@ -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]),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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<String> imageUrls =
|
||||
recipe.recordList
|
||||
.map((item) => '${AppConfig.baseApiUrl}/${item.imageUrl}')
|
||||
.toList();
|
||||
|
||||
Widget buildCarouselItem(String url) {
|
||||
return Builder(
|
||||
builder: (BuildContext context) {
|
||||
return AspectRatio(
|
||||
aspectRatio: 2,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: networkImage(url),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -7,7 +7,6 @@ import 'package:tdesign_flutter/tdesign_flutter.dart';
|
||||
class RecipeList extends StatefulWidget {
|
||||
const RecipeList({super.key});
|
||||
|
||||
|
||||
@override
|
||||
State<RecipeList> createState() => _RecipeListState();
|
||||
}
|
||||
@@ -34,11 +33,14 @@ class _RecipeListState extends State<RecipeList> {
|
||||
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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<RecipeTimeline> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
return Padding(
|
||||
padding: EdgeInsets.all(10),
|
||||
child: Column(
|
||||
children: [
|
||||
YearSelector(
|
||||
initialYear: DateTime.now().year,
|
||||
minYear: 2000,
|
||||
maxYear: 2100,
|
||||
onYearChanged: (year) => refreshRecord(year)
|
||||
onYearChanged: (year) => refreshRecord(year),
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
Expanded(child: timelineContainer(recordList)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -49,9 +55,7 @@ Widget timelineContainer(List<Record> recordList) {
|
||||
if (recordList.isEmpty) {
|
||||
return const TDEmpty(type: TDEmptyType.plain, emptyText: '暂无数据');
|
||||
} else {
|
||||
return Padding(
|
||||
padding: EdgeInsets.all(10),
|
||||
child: Timeline.tileBuilder(
|
||||
return Timeline.tileBuilder(
|
||||
theme: TimelineThemeData(nodePosition: 0, indicatorPosition: 0),
|
||||
builder: TimelineTileBuilder.connected(
|
||||
itemCount: recordList.length,
|
||||
@@ -64,7 +68,6 @@ Widget timelineContainer(List<Record> recordList) {
|
||||
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<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: [
|
||||
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(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
16
pubspec.lock
16
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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user