feat: 新增壁纸图库组件和编辑器功能优化
- 新增壁纸图库相关组件(WallpaperGalleryView/WallpaperSearchBar等) - 优化编辑器主题服务和系统UI管理 - 新增虚线边框和拖拽描边风格支持 - 完善今日诗词服务和阅读报告功能 - 修复多个UI问题和空指针异常 - 更新依赖库版本和SVG资源 - 优化交互动画和状态管理 - 补充文档和API测试脚本
This commit is contained in:
158
lib/features/countdown/models/countdown_models.dart
Normal file
158
lib/features/countdown/models/countdown_models.dart
Normal file
@@ -0,0 +1,158 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 倒计时数据模型
|
||||
/// 创建时间: 2026-05-02
|
||||
/// 更新时间: 2026-05-02
|
||||
/// 作用: 倒计时事件模型 + 重复规则 + 分类
|
||||
/// 上次更新: 初始创建
|
||||
/// ============================================================
|
||||
|
||||
enum CountdownRepeat {
|
||||
none('不重复', '🔄'),
|
||||
daily('每天', '📅'),
|
||||
weekly('每周', '📆'),
|
||||
monthly('每月', '🗓️'),
|
||||
yearly('每年', '🎂');
|
||||
|
||||
const CountdownRepeat(this.label, this.emoji);
|
||||
final String label;
|
||||
final String emoji;
|
||||
}
|
||||
|
||||
enum CountdownCategory {
|
||||
festival('节日', '🎉'),
|
||||
birthday('生日', '🎂'),
|
||||
anniversary('纪念日', '💝'),
|
||||
exam('考试', '📝'),
|
||||
travel('旅行', '✈️'),
|
||||
deadline('截止日', '⏰'),
|
||||
custom('自定义', '📌');
|
||||
|
||||
const CountdownCategory(this.label, this.emoji);
|
||||
final String label;
|
||||
final String emoji;
|
||||
}
|
||||
|
||||
class CountdownEvent {
|
||||
const CountdownEvent({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.targetDate,
|
||||
this.category = CountdownCategory.custom,
|
||||
this.repeat = CountdownRepeat.none,
|
||||
this.emoji = '📌',
|
||||
this.colorHex = '#FF6B6B',
|
||||
this.isPinned = false,
|
||||
this.createdAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final DateTime targetDate;
|
||||
final CountdownCategory category;
|
||||
final CountdownRepeat repeat;
|
||||
final String emoji;
|
||||
final String colorHex;
|
||||
final bool isPinned;
|
||||
final DateTime? createdAt;
|
||||
|
||||
int get daysRemaining {
|
||||
final now = DateTime.now();
|
||||
final target = DateTime(targetDate.year, targetDate.month, targetDate.day);
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
return target.difference(today).inDays;
|
||||
}
|
||||
|
||||
bool get isPast => daysRemaining < 0;
|
||||
bool get isToday => daysRemaining == 0;
|
||||
|
||||
String get remainingLabel {
|
||||
if (isToday) return '今天';
|
||||
if (isPast) return '已过 ${-daysRemaining} 天';
|
||||
return '$daysRemaining 天';
|
||||
}
|
||||
|
||||
DateTime? get nextOccurrence {
|
||||
if (repeat == CountdownRepeat.none) return null;
|
||||
final now = DateTime.now();
|
||||
var next = targetDate;
|
||||
|
||||
switch (repeat) {
|
||||
case CountdownRepeat.daily:
|
||||
if (next.isBefore(now)) {
|
||||
next = DateTime(now.year, now.month, now.day + 1);
|
||||
}
|
||||
case CountdownRepeat.weekly:
|
||||
while (next.isBefore(now)) {
|
||||
next = next.add(const Duration(days: 7));
|
||||
}
|
||||
case CountdownRepeat.monthly:
|
||||
while (next.isBefore(now)) {
|
||||
next = DateTime(next.year, next.month + 1, next.day);
|
||||
}
|
||||
case CountdownRepeat.yearly:
|
||||
while (next.isBefore(now)) {
|
||||
next = DateTime(next.year + 1, next.month, next.day);
|
||||
}
|
||||
case CountdownRepeat.none:
|
||||
return null;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'targetDate': targetDate.toIso8601String(),
|
||||
'category': category.name,
|
||||
'repeat': repeat.name,
|
||||
'emoji': emoji,
|
||||
'colorHex': colorHex,
|
||||
'isPinned': isPinned,
|
||||
'createdAt': createdAt?.toIso8601String(),
|
||||
};
|
||||
|
||||
factory CountdownEvent.fromJson(Map<String, dynamic> json) =>
|
||||
CountdownEvent(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
targetDate: DateTime.parse(json['targetDate'] as String),
|
||||
category: CountdownCategory.values.firstWhere(
|
||||
(e) => e.name == json['category'],
|
||||
orElse: () => CountdownCategory.custom,
|
||||
),
|
||||
repeat: CountdownRepeat.values.firstWhere(
|
||||
(e) => e.name == json['repeat'],
|
||||
orElse: () => CountdownRepeat.none,
|
||||
),
|
||||
emoji: json['emoji'] as String? ?? '📌',
|
||||
colorHex: json['colorHex'] as String? ?? '#FF6B6B',
|
||||
isPinned: json['isPinned'] as bool? ?? false,
|
||||
createdAt: json['createdAt'] != null
|
||||
? DateTime.parse(json['createdAt'] as String)
|
||||
: null,
|
||||
);
|
||||
|
||||
CountdownEvent copyWith({
|
||||
String? id,
|
||||
String? title,
|
||||
DateTime? targetDate,
|
||||
CountdownCategory? category,
|
||||
CountdownRepeat? repeat,
|
||||
String? emoji,
|
||||
String? colorHex,
|
||||
bool? isPinned,
|
||||
DateTime? createdAt,
|
||||
}) {
|
||||
return CountdownEvent(
|
||||
id: id ?? this.id,
|
||||
title: title ?? this.title,
|
||||
targetDate: targetDate ?? this.targetDate,
|
||||
category: category ?? this.category,
|
||||
repeat: repeat ?? this.repeat,
|
||||
emoji: emoji ?? this.emoji,
|
||||
colorHex: colorHex ?? this.colorHex,
|
||||
isPinned: isPinned ?? this.isPinned,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
605
lib/features/countdown/presentation/countdown_page.dart
Normal file
605
lib/features/countdown/presentation/countdown_page.dart
Normal file
@@ -0,0 +1,605 @@
|
||||
/// ============================================================
|
||||
/// 闲言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/keyboard_safe_sheet.dart';
|
||||
import '../models/countdown_models.dart';
|
||||
import '../providers/countdown_provider.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(
|
||||
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: 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 ?? '📌';
|
||||
var 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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
132
lib/features/countdown/providers/countdown_provider.dart
Normal file
132
lib/features/countdown/providers/countdown_provider.dart
Normal file
@@ -0,0 +1,132 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 倒计时状态管理
|
||||
/// 创建时间: 2026-05-02
|
||||
/// 更新时间: 2026-05-02
|
||||
/// 作用: 倒计时事件 CRUD + 持久化 + 排序
|
||||
/// 上次更新: 初始创建
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/storage/app_kv_store.dart';
|
||||
import '../../../core/utils/logger.dart';
|
||||
import '../models/countdown_models.dart';
|
||||
|
||||
class CountdownState {
|
||||
const CountdownState({this.events = const [], this.isLoading = true});
|
||||
|
||||
final List<CountdownEvent> events;
|
||||
final bool isLoading;
|
||||
|
||||
List<CountdownEvent> get pinned =>
|
||||
events.where((e) => e.isPinned).toList()
|
||||
..sort((a, b) => a.daysRemaining.compareTo(b.daysRemaining));
|
||||
|
||||
List<CountdownEvent> get upcoming =>
|
||||
events.where((e) => !e.isPinned && !e.isPast).toList()
|
||||
..sort((a, b) => a.daysRemaining.compareTo(b.daysRemaining));
|
||||
|
||||
List<CountdownEvent> get past =>
|
||||
events.where((e) => !e.isPinned && e.isPast).toList()
|
||||
..sort((a, b) => b.daysRemaining.compareTo(a.daysRemaining));
|
||||
|
||||
CountdownState copyWith({List<CountdownEvent>? events, bool? isLoading}) {
|
||||
return CountdownState(
|
||||
events: events ?? this.events,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CountdownNotifier extends StateNotifier<CountdownState> {
|
||||
CountdownNotifier() : super(const CountdownState()) {
|
||||
_loadEvents();
|
||||
}
|
||||
|
||||
static const _key = 'countdown_events';
|
||||
|
||||
Future<void> _loadEvents() async {
|
||||
try {
|
||||
final raw = AppKVStore.getString(_key);
|
||||
if (raw != null && raw.isNotEmpty) {
|
||||
final list = (jsonDecode(raw) as List<dynamic>)
|
||||
.map((e) => CountdownEvent.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
state = state.copyWith(events: list, isLoading: false);
|
||||
} else {
|
||||
state = state.copyWith(events: _defaultEvents(), isLoading: false);
|
||||
await _saveEvents();
|
||||
}
|
||||
} catch (e) {
|
||||
Log.e('倒计时加载失败', e);
|
||||
state = state.copyWith(isLoading: false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveEvents() async {
|
||||
try {
|
||||
final json = jsonEncode(state.events.map((e) => e.toJson()).toList());
|
||||
await AppKVStore.setString(_key, json);
|
||||
} catch (e) {
|
||||
Log.e('倒计时保存失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
List<CountdownEvent> _defaultEvents() {
|
||||
final now = DateTime.now();
|
||||
return [
|
||||
CountdownEvent(
|
||||
id: 'd1',
|
||||
title: '新年',
|
||||
targetDate: DateTime(now.year + 1, 1, 1),
|
||||
category: CountdownCategory.festival,
|
||||
emoji: '🎆',
|
||||
isPinned: true,
|
||||
),
|
||||
CountdownEvent(
|
||||
id: 'd2',
|
||||
title: '春节',
|
||||
targetDate: DateTime(now.year + 1, 1, 29),
|
||||
category: CountdownCategory.festival,
|
||||
emoji: '🧧',
|
||||
colorHex: '#FF4444',
|
||||
isPinned: true,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> addEvent(CountdownEvent event) async {
|
||||
state = state.copyWith(events: [...state.events, event]);
|
||||
await _saveEvents();
|
||||
}
|
||||
|
||||
Future<void> updateEvent(CountdownEvent event) async {
|
||||
state = state.copyWith(
|
||||
events: state.events.map((e) => e.id == event.id ? event : e).toList(),
|
||||
);
|
||||
await _saveEvents();
|
||||
}
|
||||
|
||||
Future<void> deleteEvent(String id) async {
|
||||
state = state.copyWith(
|
||||
events: state.events.where((e) => e.id != id).toList(),
|
||||
);
|
||||
await _saveEvents();
|
||||
}
|
||||
|
||||
Future<void> togglePin(String id) async {
|
||||
state = state.copyWith(
|
||||
events: state.events
|
||||
.map((e) => e.id == id ? e.copyWith(isPinned: !e.isPinned) : e)
|
||||
.toList(),
|
||||
);
|
||||
await _saveEvents();
|
||||
}
|
||||
}
|
||||
|
||||
final countdownProvider =
|
||||
StateNotifierProvider<CountdownNotifier, CountdownState>(
|
||||
(ref) => CountdownNotifier(),
|
||||
);
|
||||
Reference in New Issue
Block a user