608 lines
22 KiB
Dart
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(),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|