feat: 新增壁纸图库组件和编辑器功能优化

- 新增壁纸图库相关组件(WallpaperGalleryView/WallpaperSearchBar等)
- 优化编辑器主题服务和系统UI管理
- 新增虚线边框和拖拽描边风格支持
- 完善今日诗词服务和阅读报告功能
- 修复多个UI问题和空指针异常
- 更新依赖库版本和SVG资源
- 优化交互动画和状态管理
- 补充文档和API测试脚本
This commit is contained in:
Developer
2026-05-05 05:03:33 +08:00
parent 839e118cdb
commit b5157c19f4
230 changed files with 44325 additions and 19116 deletions

View 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,
);
}
}

View 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(),
),
),
);
}
}

View 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(),
);