Files
xianyan/lib/features/countdown/presentation/countdown_page.dart
Developer c44457f94c style: 修复文件头部注释的多余BOM头字符
移除所有文件头部的不可见BOM前缀字符,统一文件头部注释格式,确保跨平台编译一致性
2026-05-27 08:05:47 +08:00

608 lines
22 KiB
Dart

/// ============================================================
/// 闲言APP — 倒计时页面
/// 创建时间: 2026-05-02
/// 更新时间: 2026-05-02
/// 作用: 倒计时事件列表 + 新增/编辑 + 分类筛选
/// 上次更新: UI增强 — 硬编码字体→AppTypography, 统一圆角AppRadius, 增加交错入场动画
/// ============================================================
import 'package:flutter/cupertino.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/app_radius.dart';
import '../../../core/theme/app_spacing.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/theme/app_typography.dart';
import '../../../shared/widgets/adaptive/keyboard_safe_sheet.dart';
import '../models/countdown_models.dart';
import '../providers/countdown_provider.dart';
import '../../../../shared/widgets/adaptive/adaptive_back_button.dart';
class CountdownPage extends ConsumerStatefulWidget {
const CountdownPage({super.key});
@override
ConsumerState<CountdownPage> createState() => _CountdownPageState();
}
class _CountdownPageState extends ConsumerState<CountdownPage> {
@override
Widget build(BuildContext context) {
final state = ref.watch(countdownProvider);
final ext = AppTheme.ext(context);
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
leading: const AdaptiveBackButton(),
middle: Text(
'⏰ 倒计时',
style: AppTypography.title3.copyWith(color: ext.textPrimary),
),
backgroundColor: ext.bgPrimary.withValues(alpha: 0.85),
border: null,
trailing: CupertinoButton(
padding: EdgeInsets.zero,
onPressed: () => _showAddSheet(context),
child: Icon(CupertinoIcons.add, color: ext.iconPrimary, size: 24),
),
),
child: SafeArea(
child: state.isLoading
? Center(
child: const CupertinoActivityIndicator(
radius: 16,
).animate().fadeIn(duration: 300.ms),
)
: _buildContent(state, ext),
),
);
}
// ============================================================
// 主内容
// ============================================================
Widget _buildContent(CountdownState state, AppThemeExtension ext) {
if (state.events.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'',
style: TextStyle(fontSize: 48),
).animate().scale(duration: 500.ms, begin: const Offset(0.5, 0.5)),
const SizedBox(height: AppSpacing.md),
Text(
'暂无倒计时',
style: AppTypography.body.copyWith(color: ext.textHint),
),
const SizedBox(height: AppSpacing.md),
CupertinoButton.filled(
onPressed: () => _showAddSheet(context),
child: const Text('添加倒计时'),
),
],
),
);
}
return ListView(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: 12,
),
children: [
if (state.pinned.isNotEmpty) ...[
_buildSectionTitle('📌 置顶', ext),
...state.pinned.asMap().entries.map(
(e) => _buildEventCard(e.value, ext, isPinned: true, index: e.key),
),
const SizedBox(height: AppSpacing.md),
],
if (state.upcoming.isNotEmpty) ...[
_buildSectionTitle('🔜 即将到来', ext),
...state.upcoming.asMap().entries.map(
(e) => _buildEventCard(e.value, ext, index: e.key),
),
const SizedBox(height: AppSpacing.md),
],
if (state.past.isNotEmpty) ...[
_buildSectionTitle('📅 已过去', ext),
...state.past.asMap().entries.map(
(e) => _buildEventCard(e.value, ext, isPast: true, index: e.key),
),
],
const SizedBox(height: 60),
],
);
}
// ============================================================
// 分区标题
// ============================================================
Widget _buildSectionTitle(String title, AppThemeExtension ext) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Text(
title,
style: AppTypography.callout.copyWith(
fontWeight: FontWeight.w600,
color: ext.textSecondary,
),
),
);
}
// ============================================================
// 事件卡片
// ============================================================
Widget _buildEventCard(
CountdownEvent event,
AppThemeExtension ext, {
bool isPinned = false,
bool isPast = false,
int index = 0,
}) {
final color = _parseColor(event.colorHex);
return GestureDetector(
onLongPress: () => _showEventActions(event),
child: Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: ext.bgCard,
borderRadius: AppRadius.lgBorder,
border: isPinned
? Border.all(color: color.withValues(alpha: 0.3))
: null,
),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: AppRadius.mdBorder,
),
child: Center(
child: Text(
event.emoji,
style: const TextStyle(fontSize: 24),
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
event.title,
style: AppTypography.body.copyWith(
fontWeight: FontWeight.w600,
color: isPast ? ext.textHint : ext.textPrimary,
),
),
const SizedBox(height: AppSpacing.xs),
Text(
_formatDate(event.targetDate),
style: AppTypography.footnote.copyWith(
color: ext.textHint,
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
event.isToday ? '🎉' : '${event.daysRemaining.abs()}',
style: AppTypography.title1.copyWith(
fontWeight: FontWeight.w300,
color: isPast ? ext.textDisabled : color,
fontSize: event.isToday ? 24 : 28,
),
),
Text(
event.remainingLabel,
style: AppTypography.caption1.copyWith(
color: isPast ? ext.textDisabled : ext.textSecondary,
),
),
],
),
],
),
),
)
.animate()
.fadeIn(duration: 350.ms, delay: (index * 50).ms)
.slideX(begin: 0.1, end: 0, duration: 350.ms, delay: (index * 50).ms);
}
// ============================================================
// 工具方法
// ============================================================
Color _parseColor(String hex) {
try {
final code = hex.replaceAll('#', '');
return Color(int.parse('FF$code', radix: 16));
} catch (_) {
return const Color(0xFFFF6B6B);
}
}
String _formatDate(DateTime date) =>
'${date.year}${date.month}${date.day}';
// ============================================================
// 事件操作
// ============================================================
void _showEventActions(CountdownEvent event) {
showCupertinoModalPopup<void>(
context: context,
builder: (ctx) => CupertinoActionSheet(
actions: [
CupertinoActionSheetAction(
onPressed: () {
Navigator.pop(ctx);
ref.read(countdownProvider.notifier).togglePin(event.id);
},
child: Text(event.isPinned ? '取消置顶' : '置顶'),
),
CupertinoActionSheetAction(
onPressed: () {
Navigator.pop(ctx);
_showEditSheet(event);
},
child: const Text('编辑'),
),
CupertinoActionSheetAction(
isDestructiveAction: true,
onPressed: () {
Navigator.pop(ctx);
_confirmDelete(event);
},
child: const Text('删除'),
),
],
cancelButton: CupertinoActionSheetAction(
onPressed: () => Navigator.pop(ctx),
child: const Text('取消'),
),
),
);
}
void _confirmDelete(CountdownEvent event) {
showCupertinoDialog<void>(
context: context,
builder: (ctx) => CupertinoAlertDialog(
title: Text('删除「${event.title}」?'),
content: const Text('删除后无法恢复'),
actions: [
CupertinoDialogAction(
onPressed: () => Navigator.pop(ctx),
child: const Text('取消'),
),
CupertinoDialogAction(
isDestructiveAction: true,
onPressed: () {
Navigator.pop(ctx);
ref.read(countdownProvider.notifier).deleteEvent(event.id);
},
child: const Text('删除'),
),
],
),
);
}
// ============================================================
// 新增/编辑表单
// ============================================================
void _showAddSheet(BuildContext context) {
_showEventForm(null);
}
void _showEditSheet(CountdownEvent event) {
_showEventForm(event);
}
void _showEventForm(CountdownEvent? existing) {
final titleCtl = TextEditingController(text: existing?.title ?? '');
var selectedDate =
existing?.targetDate ?? DateTime.now().add(const Duration(days: 30));
var selectedCategory = existing?.category ?? CountdownCategory.custom;
var selectedEmoji = existing?.emoji ?? '📌';
final selectedColor = existing?.colorHex ?? '#FF6B6B';
showCupertinoModalPopup<void>(
context: context,
builder: (ctx) {
return StatefulBuilder(
builder: (ctx, setModalState) {
final ext = AppTheme.ext(context);
return KeyboardSafeBuilder(
builder: (ctx, _) => Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(ctx).size.height * 0.7,
),
decoration: BoxDecoration(
color: ext.bgPrimary,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(20),
),
),
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Text(
existing != null ? '编辑倒计时' : '新建倒计时',
style: AppTypography.headline.copyWith(
fontWeight: FontWeight.w600,
color: ext.textPrimary,
),
),
),
const SizedBox(height: 20),
Row(
children: [
GestureDetector(
onTap: () => _showEmojiPicker(
ctx,
selectedEmoji,
(e) => setModalState(() => selectedEmoji = e),
),
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: _parseColor(
selectedColor,
).withValues(alpha: 0.12),
borderRadius: AppRadius.mdBorder,
),
child: Center(
child: Text(
selectedEmoji,
style: const TextStyle(fontSize: 22),
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: CupertinoTextField(
controller: titleCtl,
placeholder: '事件名称',
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: ext.bgCard,
borderRadius: AppRadius.mdBorder,
),
),
),
],
),
const SizedBox(height: AppSpacing.md),
Row(
children: [
Text(
'📅 日期',
style: AppTypography.subhead.copyWith(
color: ext.textSecondary,
),
),
const Spacer(),
GestureDetector(
onTap: () async {
final picked =
await showCupertinoModalPopup<DateTime>(
context: ctx,
builder: (c) => Container(
height: 260,
color: CupertinoColors.systemBackground
.resolveFrom(c),
child: CupertinoDatePicker(
mode: CupertinoDatePickerMode.date,
initialDateTime: selectedDate,
onDateTimeChanged: (d) => setModalState(
() => selectedDate = d,
),
),
),
);
if (picked != null)
setModalState(() => selectedDate = picked);
},
child: Text(
_formatDate(selectedDate),
style: AppTypography.subhead.copyWith(
color: ext.accent,
fontWeight: FontWeight.w500,
),
),
),
],
),
const SizedBox(height: AppSpacing.md),
Text(
'分类',
style: AppTypography.subhead.copyWith(
color: ext.textSecondary,
),
),
const SizedBox(height: AppSpacing.sm),
Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: CountdownCategory.values.map((cat) {
final isActive = cat == selectedCategory;
return GestureDetector(
onTap: () =>
setModalState(() => selectedCategory = cat),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: isActive
? ext.accent.withValues(alpha: 0.15)
: ext.bgCard,
borderRadius: AppRadius.lgBorder,
border: isActive
? Border.all(
color: ext.accent.withValues(
alpha: 0.3,
),
)
: null,
),
child: Text(
'${cat.emoji} ${cat.label}',
style: AppTypography.footnote.copyWith(
color: isActive
? ext.accent
: ext.textSecondary,
),
),
),
);
}).toList(),
),
const Spacer(),
Row(
children: [
Expanded(
child: CupertinoButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('取消'),
),
),
const SizedBox(width: 12),
Expanded(
child: CupertinoButton.filled(
onPressed: () {
if (titleCtl.text.trim().isEmpty) return;
final event = CountdownEvent(
id:
existing?.id ??
'cd_${DateTime.now().millisecondsSinceEpoch}',
title: titleCtl.text.trim(),
targetDate: selectedDate,
category: selectedCategory,
emoji: selectedEmoji,
colorHex: selectedColor,
isPinned: existing?.isPinned ?? false,
createdAt:
existing?.createdAt ?? DateTime.now(),
);
if (existing != null) {
ref
.read(countdownProvider.notifier)
.updateEvent(event);
} else {
ref
.read(countdownProvider.notifier)
.addEvent(event);
}
Navigator.pop(ctx);
},
child: Text(existing != null ? '保存' : '添加'),
),
),
],
),
],
),
),
),
);
},
);
},
);
}
void _showEmojiPicker(
BuildContext context,
String current,
ValueChanged<String> onSelected,
) {
const emojis = [
'📌',
'🎉',
'🎂',
'💝',
'📝',
'✈️',
'',
'🎆',
'🧧',
'🎄',
'🎃',
'🎓',
'💍',
'🏠',
'🚗',
'💻',
'📱',
'🎮',
];
final ext = AppTheme.ext(context);
showCupertinoModalPopup<void>(
context: context,
builder: (ctx) => Container(
height: 200,
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: ext.bgPrimary,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Wrap(
spacing: 12,
runSpacing: 12,
children: emojis
.map(
(e) => GestureDetector(
onTap: () {
onSelected(e);
Navigator.pop(ctx);
},
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: e == current
? ext.accent.withValues(alpha: 0.15)
: ext.bgCard,
borderRadius: AppRadius.smBorder,
),
child: Center(
child: Text(e, style: const TextStyle(fontSize: 22)),
),
),
),
)
.toList(),
),
),
);
}
}