- 清理大量废弃的 barrel 导出文件,移除冗余的中间导出层 - 修复所有相对路径导入错误,统一调整为扁平化模块引用 - 更新多平台 pubspec 版本号与依赖库版本 - 补充后端功能问题管理后台与脚本工具 - 调整部分页面的快捷方式文案适配新功能 - 更新部分翻译覆盖率与API文档
449 lines
12 KiB
Dart
449 lines
12 KiB
Dart
/// ============================================================
|
||
/// 闲言APP — 倒计时核心模块(模型 + 状态管理)
|
||
/// 创建时间: 2026-06-12
|
||
/// 更新时间: 2026-06-12
|
||
/// 作用: 合并 countdown_models.dart 与 countdown_provider.dart
|
||
/// 包含:CountdownRepeat / CountdownCategory / CountdownEvent
|
||
/// CountdownState / CountdownNotifier / countdownProvider
|
||
/// 上次更新: 初始合并,扁平化 features/countdown 目录结构
|
||
/// ============================================================
|
||
|
||
import 'dart:async';
|
||
import 'dart:convert';
|
||
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
|
||
import '../../core/storage/kv_storage.dart';
|
||
import '../../core/utils/logger.dart';
|
||
import '../../core/services/device/live_activity_service.dart';
|
||
|
||
// ============================================================
|
||
// 数据模型
|
||
// ============================================================
|
||
|
||
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,
|
||
);
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 状态管理
|
||
// ============================================================
|
||
|
||
class CountdownState {
|
||
const CountdownState({
|
||
this.events = const [],
|
||
this.isLoading = true,
|
||
this.focusedEventId,
|
||
});
|
||
|
||
final List<CountdownEvent> events;
|
||
final bool isLoading;
|
||
final String? focusedEventId;
|
||
|
||
CountdownEvent? get focusedEvent {
|
||
if (focusedEventId == null) return null;
|
||
try {
|
||
return events.firstWhere((e) => e.id == focusedEventId);
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
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,
|
||
String? focusedEventId,
|
||
bool clearFocused = false,
|
||
}) {
|
||
return CountdownState(
|
||
events: events ?? this.events,
|
||
isLoading: isLoading ?? this.isLoading,
|
||
focusedEventId: clearFocused ? null : (focusedEventId ?? this.focusedEventId),
|
||
);
|
||
}
|
||
}
|
||
|
||
class CountdownNotifier extends Notifier<CountdownState> {
|
||
Timer? _updateTimer;
|
||
|
||
@override
|
||
CountdownState build() {
|
||
Future.microtask(() => _loadEvents()).catchError((_) {});
|
||
ref.onDispose(_onDispose);
|
||
return const CountdownState(isLoading: false);
|
||
}
|
||
|
||
static const _key = 'countdown_events';
|
||
|
||
// ============================================================
|
||
// 数据持久化
|
||
// ============================================================
|
||
|
||
Future<void> _loadEvents() async {
|
||
try {
|
||
final raw = KvStorage.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 KvStorage.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),
|
||
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,
|
||
),
|
||
];
|
||
}
|
||
|
||
// ============================================================
|
||
// 事件 CRUD
|
||
// ============================================================
|
||
|
||
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();
|
||
if (state.focusedEventId == event.id) {
|
||
_updateLiveActivity();
|
||
}
|
||
}
|
||
|
||
Future<void> deleteEvent(String id) async {
|
||
final wasFocused = state.focusedEventId == id;
|
||
state = state.copyWith(
|
||
events: state.events.where((e) => e.id != id).toList(),
|
||
clearFocused: wasFocused,
|
||
);
|
||
await _saveEvents();
|
||
if (wasFocused) {
|
||
_endLiveActivity();
|
||
_updateTimer?.cancel();
|
||
_updateTimer = null;
|
||
}
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
// ============================================================
|
||
// 灵动岛聚焦模式
|
||
// ============================================================
|
||
|
||
Future<void> focusEvent(String id) async {
|
||
final event = state.events.where((e) => e.id == id).firstOrNull;
|
||
if (event == null) return;
|
||
if (event.isPast) return;
|
||
|
||
if (state.focusedEventId == id) {
|
||
await unfocusEvent();
|
||
return;
|
||
}
|
||
|
||
if (state.focusedEventId != null) {
|
||
_endLiveActivity();
|
||
_updateTimer?.cancel();
|
||
}
|
||
|
||
state = state.copyWith(focusedEventId: id);
|
||
_startLiveActivity();
|
||
|
||
_updateTimer?.cancel();
|
||
_updateTimer = Timer.periodic(const Duration(minutes: 1), (_) {
|
||
_updateLiveActivity();
|
||
_checkEventArrival();
|
||
});
|
||
|
||
Log.i('CountdownNotifier: 聚焦倒计时 "${event.title}"');
|
||
}
|
||
|
||
Future<void> unfocusEvent() async {
|
||
_endLiveActivity();
|
||
_updateTimer?.cancel();
|
||
_updateTimer = null;
|
||
state = state.copyWith(clearFocused: true);
|
||
Log.i('CountdownNotifier: 取消聚焦倒计时');
|
||
}
|
||
|
||
void _startLiveActivity() {
|
||
final service = LiveActivityService.instance;
|
||
if (!service.isSupported) return;
|
||
|
||
final event = state.focusedEvent;
|
||
if (event == null) return;
|
||
|
||
final remainingMinutes = _calculateRemainingMinutes(event);
|
||
|
||
service.startCountdownActivity(
|
||
title: event.title,
|
||
remainingMinutes: remainingMinutes,
|
||
emoji: event.emoji,
|
||
);
|
||
}
|
||
|
||
void _updateLiveActivity() {
|
||
final service = LiveActivityService.instance;
|
||
if (!service.isSupported || !service.hasActiveActivity) return;
|
||
|
||
final event = state.focusedEvent;
|
||
if (event == null) {
|
||
_endLiveActivity();
|
||
return;
|
||
}
|
||
|
||
final remainingMinutes = _calculateRemainingMinutes(event);
|
||
|
||
service.updateCountdownActivity(
|
||
title: event.title,
|
||
remainingMinutes: remainingMinutes,
|
||
emoji: event.emoji,
|
||
);
|
||
}
|
||
|
||
void _endLiveActivity() {
|
||
final service = LiveActivityService.instance;
|
||
if (!service.isSupported) return;
|
||
service.endCountdownActivity();
|
||
}
|
||
|
||
int _calculateRemainingMinutes(CountdownEvent event) {
|
||
final now = DateTime.now();
|
||
final target = DateTime(
|
||
event.targetDate.year,
|
||
event.targetDate.month,
|
||
event.targetDate.day,
|
||
23,
|
||
59,
|
||
59,
|
||
);
|
||
final diff = target.difference(now);
|
||
return diff.inMinutes.clamp(0, diff.inMinutes);
|
||
}
|
||
|
||
void _checkEventArrival() {
|
||
final event = state.focusedEvent;
|
||
if (event == null) return;
|
||
|
||
if (event.isPast || event.isToday) {
|
||
_endLiveActivity();
|
||
_updateTimer?.cancel();
|
||
_updateTimer = null;
|
||
state = state.copyWith(clearFocused: true);
|
||
Log.i('CountdownNotifier: 倒计时 "${event.title}" 已到达,结束灵动岛');
|
||
}
|
||
}
|
||
|
||
void _onDispose() {
|
||
_updateTimer?.cancel();
|
||
_endLiveActivity();
|
||
}
|
||
}
|
||
|
||
final countdownProvider = NotifierProvider<CountdownNotifier, CountdownState>(
|
||
CountdownNotifier.new,
|
||
);
|