Files
xianyan/lib/features/countdown/countdown_core.dart
Developer f91be94e9c refactor: 完成项目架构重构,统一模块导入路径
- 清理大量废弃的 barrel 导出文件,移除冗余的中间导出层
- 修复所有相对路径导入错误,统一调整为扁平化模块引用
- 更新多平台 pubspec 版本号与依赖库版本
- 补充后端功能问题管理后台与脚本工具
- 调整部分页面的快捷方式文案适配新功能
- 更新部分翻译覆盖率与API文档
2026-06-12 08:53:57 +08:00

449 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// ============================================================
/// 闲言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,
);