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:
hailin 2025-12-16 22:46:48 -08:00
parent 2399cc29d6
commit 92772f071a
4 changed files with 534 additions and 16 deletions

View File

@ -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;
}

View File

@ -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<ProfilePage> {
),
],
),
//
// - 使
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<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;

View File

@ -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);
}
}

View File

@ -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',