289 lines
7.8 KiB
Dart
289 lines
7.8 KiB
Dart
/// ============================================================
|
||
/// 闲言APP — 倒计时状态管理
|
||
/// 创建时间: 2026-05-02
|
||
/// 更新时间: 2026-06-02
|
||
/// 作用: 倒计时事件 CRUD + 持久化 + 排序 + 灵动岛聚焦模式
|
||
/// 上次更新: 集成灵动岛Live Activity,支持聚焦倒计时实时显示
|
||
/// ============================================================
|
||
|
||
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';
|
||
import '../models/countdown_models.dart';
|
||
|
||
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,
|
||
);
|