diff --git a/frontend/mobile-app/lib/features/profile/presentation/widgets/stacked_cards_widget.dart b/frontend/mobile-app/lib/features/profile/presentation/widgets/stacked_cards_widget.dart index 715b11a7..90f79f76 100644 --- a/frontend/mobile-app/lib/features/profile/presentation/widgets/stacked_cards_widget.dart +++ b/frontend/mobile-app/lib/features/profile/presentation/widgets/stacked_cards_widget.dart @@ -1,115 +1,94 @@ import 'package:flutter/material.dart'; -/// 堆叠卡片组件 -/// 用于显示待领取奖励等明细列表,卡片堆叠显示,可上下滑动选择 -class StackedCardsWidget extends StatefulWidget { +/// 可折叠卡片列表组件 +/// 用于显示待领取奖励等明细列表,只有选中的卡片展开,其他折叠显示摘要 +class CollapsibleCardList extends StatefulWidget { /// 数据列表 final List items; - /// 构建单个卡片的方法 - final Widget Function(T item, bool isSelected) itemBuilder; + /// 构建展开状态的卡片 + final Widget Function(T item) expandedBuilder; - /// 卡片露出的高度(每张卡片之间的间距) - final double peekHeight; + /// 构建折叠状态的卡片摘要 + final Widget Function(T item) collapsedBuilder; - /// 展开的卡片高度 - final double expandedCardHeight; + /// 展开卡片的高度 + final double expandedHeight; - /// 卡片宽度 - final double? cardWidth; + /// 折叠卡片的高度 + final double collapsedHeight; - /// 选中卡片变化时的回调 + /// 容器最大高度 + final double maxHeight; + + /// 选中索引变化回调 final ValueChanged? onSelectedIndexChanged; - /// 是否启用声音反馈 - final bool enableSound; - - const StackedCardsWidget({ + const CollapsibleCardList({ super.key, required this.items, - required this.itemBuilder, - this.peekHeight = 28, - this.expandedCardHeight = 120, - this.cardWidth, + required this.expandedBuilder, + required this.collapsedBuilder, + this.expandedHeight = 110, + this.collapsedHeight = 44, + this.maxHeight = 280, this.onSelectedIndexChanged, - this.enableSound = true, }); @override - State> createState() => _StackedCardsWidgetState(); + State> createState() => _CollapsibleCardListState(); } -class _StackedCardsWidgetState extends State> - with SingleTickerProviderStateMixin { - /// 当前选中的卡片索引 +class _CollapsibleCardListState extends State> { int _selectedIndex = 0; - - /// 动画控制器 - late AnimationController _animationController; - - /// 滚动控制器 - late ScrollController _scrollController; - - /// 上次触发反馈的索引 - int _lastFeedbackIndex = 0; - - @override - void initState() { - super.initState(); - _animationController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 200), - ); - _scrollController = ScrollController(); - _scrollController.addListener(_onScroll); - } + final ScrollController _scrollController = ScrollController(); @override void dispose() { - _animationController.dispose(); - _scrollController.removeListener(_onScroll); _scrollController.dispose(); super.dispose(); } - /// 滚动监听 - void _onScroll() { - if (widget.items.isEmpty) return; - - // 根据滚动位置计算当前选中的卡片 - final scrollOffset = _scrollController.offset; - final newIndex = (scrollOffset / widget.peekHeight).round().clamp(0, widget.items.length - 1); - - if (newIndex != _selectedIndex) { - setState(() { - _selectedIndex = newIndex; - }); - - // 记录上次反馈索引 - if (widget.enableSound && newIndex != _lastFeedbackIndex) { - _lastFeedbackIndex = newIndex; - } - - widget.onSelectedIndexChanged?.call(newIndex); - } - } - - /// 点击卡片 - void _onCardTap(int index) { + void _selectCard(int index) { if (index == _selectedIndex) return; setState(() { _selectedIndex = index; }); - // 滚动到选中的卡片 - _scrollController.animateTo( - index * widget.peekHeight, - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, - ); - widget.onSelectedIndexChanged?.call(index); + + // 滚动确保选中的卡片可见 + _scrollToSelected(index); + } + + void _scrollToSelected(int index) { + if (!_scrollController.hasClients) return; + + // 计算选中卡片的位置 + double offset = 0; + for (int i = 0; i < index; i++) { + offset += i == _selectedIndex ? widget.expandedHeight : widget.collapsedHeight; + offset += 6; // 间距 + } + + // 确保在可视范围内 + final viewportHeight = _scrollController.position.viewportDimension; + final maxScroll = _scrollController.position.maxScrollExtent; + + if (offset < _scrollController.offset) { + _scrollController.animateTo( + offset, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + ); + } else if (offset + widget.expandedHeight > _scrollController.offset + viewportHeight) { + _scrollController.animateTo( + (offset + widget.expandedHeight - viewportHeight).clamp(0, maxScroll), + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + ); + } } @override @@ -118,69 +97,145 @@ class _StackedCardsWidgetState extends State> return const SizedBox.shrink(); } - // 计算总高度:展开卡片高度 + (卡片数-1) * 露出高度 - final totalHeight = widget.expandedCardHeight + - (widget.items.length - 1) * widget.peekHeight; + // 计算内容总高度 + final contentHeight = widget.expandedHeight + + (widget.items.length - 1) * widget.collapsedHeight + + (widget.items.length - 1) * 6; // 间距 - return SizedBox( - height: totalHeight.clamp(0, 300), // 最大高度限制 - child: NotificationListener( - onNotification: (notification) { - // 处理滚动结束时的对齐 - if (notification is ScrollEndNotification) { - _snapToNearestCard(); - } - return false; - }, - child: ListView.builder( - controller: _scrollController, - physics: const BouncingScrollPhysics(), - itemCount: widget.items.length, - itemBuilder: (context, index) { - final isSelected = index == _selectedIndex; - final item = widget.items[index]; + // 使用 ConstrainedBox 限制最大高度 + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: contentHeight.clamp(0, widget.maxHeight), + ), + child: ListView.separated( + controller: _scrollController, + shrinkWrap: true, + physics: contentHeight > widget.maxHeight + ? const ClampingScrollPhysics() + : const NeverScrollableScrollPhysics(), + itemCount: widget.items.length, + separatorBuilder: (context, index) => const SizedBox(height: 6), + itemBuilder: (context, index) { + final isSelected = index == _selectedIndex; + final item = widget.items[index]; - return GestureDetector( - onTap: () => _onCardTap(index), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutCubic, - height: isSelected ? widget.expandedCardHeight : widget.peekHeight, - margin: EdgeInsets.only( - bottom: index == widget.items.length - 1 ? 0 : 0, - ), - child: AnimatedOpacity( - duration: const Duration(milliseconds: 200), - opacity: isSelected ? 1.0 : 0.85, - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: widget.itemBuilder(item, isSelected), - ), + return GestureDetector( + onTap: () => _selectCard(index), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + height: isSelected ? widget.expandedHeight : widget.collapsedHeight, + decoration: BoxDecoration( + color: isSelected ? Colors.white : const Color(0xFFFFFDF8), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isSelected ? const Color(0x44D4AF37) : const Color(0x22D4AF37), + width: isSelected ? 1.5 : 1, ), ), - ); - }, - ), + clipBehavior: Clip.antiAlias, + child: isSelected + ? widget.expandedBuilder(item) + : widget.collapsedBuilder(item), + ), + ); + }, ), ); } +} - /// 滚动结束时对齐到最近的卡片 - void _snapToNearestCard() { - if (!_scrollController.hasClients) return; +/// 带索引指示器的可折叠卡片列表 +class IndexedCollapsibleCardList extends StatefulWidget { + /// 数据列表 + final List items; - final targetOffset = _selectedIndex * widget.peekHeight; - if ((_scrollController.offset - targetOffset).abs() > 1) { - _scrollController.animateTo( - targetOffset, - duration: const Duration(milliseconds: 150), - curve: Curves.easeOutCubic, - ); + /// 构建展开状态的卡片 + final Widget Function(T item) expandedBuilder; + + /// 构建折叠状态的卡片摘要 + final Widget Function(T item) collapsedBuilder; + + /// 展开卡片的高度 + final double expandedHeight; + + /// 折叠卡片的高度 + final double collapsedHeight; + + /// 容器最大高度 + final double maxHeight; + + const IndexedCollapsibleCardList({ + super.key, + required this.items, + required this.expandedBuilder, + required this.collapsedBuilder, + this.expandedHeight = 110, + this.collapsedHeight = 44, + this.maxHeight = 280, + }); + + @override + State> createState() => + _IndexedCollapsibleCardListState(); +} + +class _IndexedCollapsibleCardListState + extends State> { + int _selectedIndex = 0; + + @override + Widget build(BuildContext context) { + if (widget.items.isEmpty) { + return const SizedBox.shrink(); } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 卡片列表 + CollapsibleCardList( + items: widget.items, + expandedBuilder: widget.expandedBuilder, + collapsedBuilder: widget.collapsedBuilder, + expandedHeight: widget.expandedHeight, + collapsedHeight: widget.collapsedHeight, + maxHeight: widget.maxHeight, + onSelectedIndexChanged: (index) { + setState(() { + _selectedIndex = index; + }); + }, + ), + // 索引指示器 + if (widget.items.length > 1) ...[ + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + widget.items.length, + (index) => Container( + width: index == _selectedIndex ? 16 : 6, + height: 6, + margin: const EdgeInsets.symmetric(horizontal: 2), + decoration: BoxDecoration( + color: index == _selectedIndex + ? const Color(0xFFD4AF37) + : const Color(0x33D4AF37), + borderRadius: BorderRadius.circular(3), + ), + ), + ), + ), + ], + ], + ); } } +// 保留旧的 StackedCardsView 以备兼容 /// 带堆叠效果的卡片堆叠组件(使用 Stack 实现) +/// 注意:此组件可能会超出容器范围,建议使用 CollapsibleCardList class StackedCardsView extends StatefulWidget { /// 数据列表 final List items; @@ -215,130 +270,8 @@ class StackedCardsView extends StatefulWidget { } class _StackedCardsViewState extends State> { - /// 当前选中的卡片索引 int _selectedIndex = 0; - /// 拖动起始位置 - double _dragStartY = 0; - - /// 拖动偏移量 - double _dragOffset = 0; - - @override - Widget build(BuildContext context) { - if (widget.items.isEmpty) { - return const SizedBox.shrink(); - } - - // 计算总高度 - final totalHeight = widget.expandedCardHeight + - (widget.items.length - 1) * widget.peekHeight; - - return SizedBox( - height: totalHeight.clamp(0.0, 320.0), - child: GestureDetector( - onVerticalDragStart: _onDragStart, - onVerticalDragUpdate: _onDragUpdate, - onVerticalDragEnd: _onDragEnd, - child: Stack( - clipBehavior: Clip.none, - children: List.generate(widget.items.length, (index) { - final isSelected = index == _selectedIndex; - final item = widget.items[index]; - - // 计算卡片位置 - double topPosition; - if (index <= _selectedIndex) { - // 选中卡片及其上方的卡片 - topPosition = index * widget.peekHeight; - } else { - // 选中卡片下方的卡片 - topPosition = widget.expandedCardHeight + - (index - 1) * widget.peekHeight; - } - - // 应用拖动偏移 - topPosition += _dragOffset * (index == _selectedIndex ? 0.5 : 0.2); - - return AnimatedPositioned( - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutCubic, - top: topPosition, - left: 0, - right: 0, - child: GestureDetector( - onTap: () => _selectCard(index), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutCubic, - height: isSelected - ? widget.expandedCardHeight - : widget.peekHeight + 20, // 额外高度用于显示卡片内容 - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - boxShadow: isSelected - ? [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ] - : [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 4, - offset: const Offset(0, 1), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: widget.itemBuilder(item, isSelected, index), - ), - ), - ), - ); - }), - ), - ), - ); - } - - void _onDragStart(DragStartDetails details) { - _dragStartY = details.globalPosition.dy; - } - - void _onDragUpdate(DragUpdateDetails details) { - setState(() { - _dragOffset = details.globalPosition.dy - _dragStartY; - }); - } - - void _onDragEnd(DragEndDetails details) { - // 根据拖动距离和速度决定是否切换卡片 - final velocity = details.primaryVelocity ?? 0; - final threshold = widget.peekHeight; - - int newIndex = _selectedIndex; - - if (_dragOffset < -threshold || velocity < -500) { - // 向上滑动,选择下一张卡片 - newIndex = (_selectedIndex + 1).clamp(0, widget.items.length - 1); - } else if (_dragOffset > threshold || velocity > 500) { - // 向下滑动,选择上一张卡片 - newIndex = (_selectedIndex - 1).clamp(0, widget.items.length - 1); - } - - setState(() { - _dragOffset = 0; - if (newIndex != _selectedIndex) { - _selectedIndex = newIndex; - widget.onSelectedIndexChanged?.call(newIndex); - } - }); - } - void _selectCard(int index) { if (index == _selectedIndex) return; @@ -348,4 +281,28 @@ class _StackedCardsViewState extends State> { widget.onSelectedIndexChanged?.call(index); } + + @override + Widget build(BuildContext context) { + if (widget.items.isEmpty) { + return const SizedBox.shrink(); + } + + // 使用 CollapsibleCardList 替代 Stack 实现 + return CollapsibleCardList( + items: widget.items, + expandedHeight: widget.expandedCardHeight, + collapsedHeight: widget.peekHeight + 20, + maxHeight: 280, + expandedBuilder: (item) { + final index = widget.items.indexOf(item); + return widget.itemBuilder(item, true, index); + }, + collapsedBuilder: (item) { + final index = widget.items.indexOf(item); + return widget.itemBuilder(item, false, index); + }, + onSelectedIndexChanged: widget.onSelectedIndexChanged, + ); + } }