diff --git a/backend/services/planting-service/src/api/dto/request/create-planting-order.dto.ts b/backend/services/planting-service/src/api/dto/request/create-planting-order.dto.ts index 1f0e3f78..bfdd6b4b 100644 --- a/backend/services/planting-service/src/api/dto/request/create-planting-order.dto.ts +++ b/backend/services/planting-service/src/api/dto/request/create-planting-order.dto.ts @@ -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; } diff --git a/frontend/mobile-app/lib/features/profile/presentation/pages/profile_page.dart b/frontend/mobile-app/lib/features/profile/presentation/pages/profile_page.dart index 39181cd3..4a65e671 100644 --- a/frontend/mobile-app/lib/features/profile/presentation/pages/profile_page.dart +++ b/frontend/mobile-app/lib/features/profile/presentation/pages/profile_page.dart @@ -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,29 +1814,175 @@ class _ProfilePageState extends ConsumerState { ), ], ), - // 待领取奖励列表 + // 待领取奖励列表 - 使用堆叠卡片展示 if (_pendingRewards.isNotEmpty) ...[ const SizedBox(height: 16), const Divider(color: Color(0x33D4AF37), height: 1), const SizedBox(height: 12), - const Text( - '待领取明细', - style: TextStyle( - fontSize: 14, - fontFamily: 'Inter', - fontWeight: FontWeight.w600, - color: Color(0xFF5D4037), - ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + '待领取明细', + style: TextStyle( + fontSize: 14, + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + 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( + 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 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; 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 new file mode 100644 index 00000000..ddc6312b --- /dev/null +++ b/frontend/mobile-app/lib/features/profile/presentation/widgets/stacked_cards_widget.dart @@ -0,0 +1,364 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// 堆叠卡片组件 +/// 用于显示待领取奖励等明细列表,卡片堆叠显示,可上下滑动选择 +class StackedCardsWidget extends StatefulWidget { + /// 数据列表 + final List items; + + /// 构建单个卡片的方法 + final Widget Function(T item, bool isSelected) itemBuilder; + + /// 卡片露出的高度(每张卡片之间的间距) + final double peekHeight; + + /// 展开的卡片高度 + final double expandedCardHeight; + + /// 卡片宽度 + final double? cardWidth; + + /// 选中卡片变化时的回调 + final ValueChanged? 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> createState() => _StackedCardsWidgetState(); +} + +class _StackedCardsWidgetState extends State> + 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( + 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 extends StatefulWidget { + /// 数据列表 + final List items; + + /// 构建单个卡片的方法 + final Widget Function(T item, bool isSelected, int index) itemBuilder; + + /// 卡片露出的高度 + final double peekHeight; + + /// 展开的卡片高度 + final double expandedCardHeight; + + /// 是否启用声音反馈 + final bool enableSound; + + /// 选中索引变化回调 + final ValueChanged? onSelectedIndexChanged; + + const StackedCardsView({ + super.key, + required this.items, + required this.itemBuilder, + this.peekHeight = 24, + this.expandedCardHeight = 120, + this.enableSound = true, + this.onSelectedIndexChanged, + }); + + @override + State> createState() => _StackedCardsViewState(); +} + +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; + 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); + } +} diff --git a/frontend/mobile-app/lib/features/ranking/presentation/pages/ranking_page.dart b/frontend/mobile-app/lib/features/ranking/presentation/pages/ranking_page.dart index 10de4dda..ada0cb41 100644 --- a/frontend/mobile-app/lib/features/ranking/presentation/pages/ranking_page.dart +++ b/frontend/mobile-app/lib/features/ranking/presentation/pages/ranking_page.dart @@ -360,7 +360,16 @@ class _RankingPageState extends ConsumerState { ), ), 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',