feat(ui): 优化待领取明细显示和移除认种数量限制
- 移除单次认种100棵的数量限制 (planting-service) - 龙虎榜省市信息分两行显示 - 待领取明细改为堆叠卡片样式,节省屏幕空间 - 支持上下滑动选择卡片 - 滑动时有震动反馈 - 选中卡片展开显示完整信息 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
2399cc29d6
commit
92772f071a
|
|
@ -1,15 +1,13 @@
|
|||
import { IsInt, Min, Max } from 'class-validator';
|
||||
import { IsInt, Min } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class CreatePlantingOrderDto {
|
||||
@ApiProperty({
|
||||
description: '认种数量',
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
example: 1,
|
||||
})
|
||||
@IsInt({ message: '认种数量必须是整数' })
|
||||
@Min(1, { message: '认种数量最少为1棵' })
|
||||
@Max(100, { message: '单次认种数量最多为100棵' })
|
||||
treeCount: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import '../../../../routes/route_paths.dart';
|
|||
import '../../../../routes/app_router.dart';
|
||||
import '../../../auth/presentation/providers/auth_provider.dart';
|
||||
import '../widgets/team_tree_widget.dart';
|
||||
import '../widgets/stacked_cards_widget.dart';
|
||||
|
||||
/// 个人中心页面 - 显示用户信息、社区数据、收益和设置
|
||||
/// 包含用户资料、推荐信息、社区考核、收益领取等功能
|
||||
|
|
@ -1813,11 +1814,14 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||
),
|
||||
],
|
||||
),
|
||||
// 待领取奖励列表
|
||||
// 待领取奖励列表 - 使用堆叠卡片展示
|
||||
if (_pendingRewards.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
const Divider(color: Color(0x33D4AF37), height: 1),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'待领取明细',
|
||||
style: TextStyle(
|
||||
|
|
@ -1827,15 +1831,158 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'共 ${_pendingRewards.length} 笔',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontFamily: 'Inter',
|
||||
color: Color(0xFF8B5A2B),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// 奖励条目列表
|
||||
...(_pendingRewards.map((item) => _buildPendingRewardItem(item))),
|
||||
// 堆叠卡片展示
|
||||
StackedCardsView<PendingRewardItem>(
|
||||
items: _pendingRewards,
|
||||
peekHeight: 28,
|
||||
expandedCardHeight: 110,
|
||||
enableSound: true,
|
||||
itemBuilder: (item, isSelected, index) => _buildStackedPendingRewardCard(item, isSelected),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建单条待领取奖励项
|
||||
/// 构建堆叠卡片样式的待领取奖励项
|
||||
Widget _buildStackedPendingRewardCard(PendingRewardItem item, bool isSelected) {
|
||||
// 确保剩余时间不为负数
|
||||
final remainingSeconds = item.remainingSeconds > 0 ? item.remainingSeconds : 0;
|
||||
final hours = (remainingSeconds ~/ 3600).toString().padLeft(2, '0');
|
||||
final minutes = ((remainingSeconds % 3600) ~/ 60).toString().padLeft(2, '0');
|
||||
final seconds = (remainingSeconds % 60).toString().padLeft(2, '0');
|
||||
final countdown = '$hours:$minutes:$seconds';
|
||||
|
||||
// 构建金额显示文本
|
||||
final List<String> amountParts = [];
|
||||
if (item.usdtAmount > 0) {
|
||||
amountParts.add('${_formatNumber(item.usdtAmount)} 绿积分');
|
||||
}
|
||||
if (item.hashpowerAmount > 0) {
|
||||
amountParts.add('${_formatNumber(item.hashpowerAmount)} 算力');
|
||||
}
|
||||
final amountText = amountParts.isNotEmpty ? amountParts.join(' ') : '0 绿积分';
|
||||
|
||||
return Container(
|
||||
height: isSelected ? 110 : 48,
|
||||
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,
|
||||
),
|
||||
),
|
||||
child: isSelected
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 第一行:权益类型 + 倒计时
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
item.rightTypeName,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.timer_outlined,
|
||||
size: 14,
|
||||
color: Color(0xFFD4AF37),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
countdown,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontFamily: 'Consolas',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFFD4AF37),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// 第二行:金额信息
|
||||
Text(
|
||||
amountText,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
// 第三行:备注信息
|
||||
if (item.memo.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
item.memo,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontFamily: 'Inter',
|
||||
color: Color(0x995D4037),
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
)
|
||||
: Padding(
|
||||
// 未选中状态:只显示卡片头部预览
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
item.rightTypeName,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
amountText,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFFD4AF37),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建单条待领取奖励项(保留用于其他可能用途)
|
||||
Widget _buildPendingRewardItem(PendingRewardItem item) {
|
||||
// 确保剩余时间不为负数
|
||||
final remainingSeconds = item.remainingSeconds > 0 ? item.remainingSeconds : 0;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,364 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// 堆叠卡片组件
|
||||
/// 用于显示待领取奖励等明细列表,卡片堆叠显示,可上下滑动选择
|
||||
class StackedCardsWidget<T> extends StatefulWidget {
|
||||
/// 数据列表
|
||||
final List<T> items;
|
||||
|
||||
/// 构建单个卡片的方法
|
||||
final Widget Function(T item, bool isSelected) itemBuilder;
|
||||
|
||||
/// 卡片露出的高度(每张卡片之间的间距)
|
||||
final double peekHeight;
|
||||
|
||||
/// 展开的卡片高度
|
||||
final double expandedCardHeight;
|
||||
|
||||
/// 卡片宽度
|
||||
final double? cardWidth;
|
||||
|
||||
/// 选中卡片变化时的回调
|
||||
final ValueChanged<int>? onSelectedIndexChanged;
|
||||
|
||||
/// 是否启用声音反馈
|
||||
final bool enableSound;
|
||||
|
||||
const StackedCardsWidget({
|
||||
super.key,
|
||||
required this.items,
|
||||
required this.itemBuilder,
|
||||
this.peekHeight = 28,
|
||||
this.expandedCardHeight = 120,
|
||||
this.cardWidth,
|
||||
this.onSelectedIndexChanged,
|
||||
this.enableSound = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StackedCardsWidget<T>> createState() => _StackedCardsWidgetState<T>();
|
||||
}
|
||||
|
||||
class _StackedCardsWidgetState<T> extends State<StackedCardsWidget<T>>
|
||||
with SingleTickerProviderStateMixin {
|
||||
/// 当前选中的卡片索引
|
||||
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);
|
||||
}
|
||||
|
||||
@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;
|
||||
HapticFeedback.selectionClick();
|
||||
}
|
||||
|
||||
widget.onSelectedIndexChanged?.call(newIndex);
|
||||
}
|
||||
}
|
||||
|
||||
/// 点击卡片
|
||||
void _onCardTap(int index) {
|
||||
if (index == _selectedIndex) return;
|
||||
|
||||
setState(() {
|
||||
_selectedIndex = index;
|
||||
});
|
||||
|
||||
// 滚动到选中的卡片
|
||||
_scrollController.animateTo(
|
||||
index * widget.peekHeight,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
|
||||
if (widget.enableSound) {
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
|
||||
widget.onSelectedIndexChanged?.call(index);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.items.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// 计算总高度:展开卡片高度 + (卡片数-1) * 露出高度
|
||||
final totalHeight = widget.expandedCardHeight +
|
||||
(widget.items.length - 1) * widget.peekHeight;
|
||||
|
||||
return SizedBox(
|
||||
height: totalHeight.clamp(0, 300), // 最大高度限制
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
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];
|
||||
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 滚动结束时对齐到最近的卡片
|
||||
void _snapToNearestCard() {
|
||||
if (!_scrollController.hasClients) return;
|
||||
|
||||
final targetOffset = _selectedIndex * widget.peekHeight;
|
||||
if ((_scrollController.offset - targetOffset).abs() > 1) {
|
||||
_scrollController.animateTo(
|
||||
targetOffset,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 带堆叠效果的卡片堆叠组件(使用 Stack 实现)
|
||||
class StackedCardsView<T> extends StatefulWidget {
|
||||
/// 数据列表
|
||||
final List<T> items;
|
||||
|
||||
/// 构建单个卡片的方法
|
||||
final Widget Function(T item, bool isSelected, int index) itemBuilder;
|
||||
|
||||
/// 卡片露出的高度
|
||||
final double peekHeight;
|
||||
|
||||
/// 展开的卡片高度
|
||||
final double expandedCardHeight;
|
||||
|
||||
/// 是否启用声音反馈
|
||||
final bool enableSound;
|
||||
|
||||
/// 选中索引变化回调
|
||||
final ValueChanged<int>? onSelectedIndexChanged;
|
||||
|
||||
const StackedCardsView({
|
||||
super.key,
|
||||
required this.items,
|
||||
required this.itemBuilder,
|
||||
this.peekHeight = 24,
|
||||
this.expandedCardHeight = 120,
|
||||
this.enableSound = true,
|
||||
this.onSelectedIndexChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StackedCardsView<T>> createState() => _StackedCardsViewState<T>();
|
||||
}
|
||||
|
||||
class _StackedCardsViewState<T> extends State<StackedCardsView<T>> {
|
||||
/// 当前选中的卡片索引
|
||||
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;
|
||||
if (widget.enableSound) {
|
||||
HapticFeedback.selectionClick();
|
||||
}
|
||||
widget.onSelectedIndexChanged?.call(newIndex);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _selectCard(int index) {
|
||||
if (index == _selectedIndex) return;
|
||||
|
||||
setState(() {
|
||||
_selectedIndex = index;
|
||||
});
|
||||
|
||||
if (widget.enableSound) {
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
|
||||
widget.onSelectedIndexChanged?.call(index);
|
||||
}
|
||||
}
|
||||
|
|
@ -360,7 +360,16 @@ class _RankingPageState extends ConsumerState<RankingPage> {
|
|||
),
|
||||
),
|
||||
Text(
|
||||
'省市: ${item.province} · ${item.city}',
|
||||
'省份: ${item.province}',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
height: 1.5,
|
||||
color: Color(0xFF8B5A2B),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'城市: ${item.city}',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
|
|
|
|||
Loading…
Reference in New Issue