import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; import 'models/reminder_action.dart'; class ReminderOutboxItem { final String opId; final String eventId; final ReminderAction action; final String? targetStatus; final DateTime occurredAt; final int retryCount; final DateTime? nextRetryAt; final ReminderOutboxState state; final String? lastError; const ReminderOutboxItem({ required this.opId, required this.eventId, required this.action, required this.occurredAt, this.targetStatus, this.retryCount = 0, this.nextRetryAt, this.state = ReminderOutboxState.pending, this.lastError, }); String get idempotencyBucket { final bucket = occurredAt.millisecondsSinceEpoch ~/ const Duration(minutes: 1).inMilliseconds; return '$eventId|${action.value}|$bucket'; } ReminderOutboxItem copyWith({ int? retryCount, DateTime? nextRetryAt, ReminderOutboxState? state, String? lastError, }) { return ReminderOutboxItem( opId: opId, eventId: eventId, action: action, targetStatus: targetStatus, occurredAt: occurredAt, retryCount: retryCount ?? this.retryCount, nextRetryAt: nextRetryAt ?? this.nextRetryAt, state: state ?? this.state, lastError: lastError ?? this.lastError, ); } Map toJson() { return { 'opId': opId, 'eventId': eventId, 'action': action.value, 'targetStatus': targetStatus, 'occurredAt': occurredAt.toIso8601String(), 'retryCount': retryCount, 'nextRetryAt': nextRetryAt?.toIso8601String(), 'state': state.value, 'lastError': lastError, }; } factory ReminderOutboxItem.fromJson(Map json) { return ReminderOutboxItem( opId: (json['opId'] as String?) ?? '', eventId: (json['eventId'] as String?) ?? '', action: ReminderAction.fromValue( (json['action'] as String?) ?? 'timeout_30s', ), targetStatus: json['targetStatus'] as String?, occurredAt: DateTime.parse(json['occurredAt'] as String), retryCount: (json['retryCount'] as int?) ?? 0, nextRetryAt: json['nextRetryAt'] != null ? DateTime.parse(json['nextRetryAt'] as String) : null, state: ReminderOutboxState.fromValue( (json['state'] as String?) ?? 'pending', ), lastError: json['lastError'] as String?, ); } } enum ReminderOutboxState { pending('pending'), done('done'), dead('dead'); const ReminderOutboxState(this.value); final String value; static ReminderOutboxState fromValue(String raw) { return ReminderOutboxState.values.firstWhere( (item) => item.value == raw, orElse: () => ReminderOutboxState.pending, ); } } class ReminderOutboxStore { static const String _key = 'calendar_reminder_outbox_v1'; final SharedPreferences _prefs; ReminderOutboxStore(this._prefs); Future enqueue(ReminderOutboxItem item) async { final current = await _readAll(); final duplicated = current.any( (existing) => existing.state == ReminderOutboxState.pending && existing.idempotencyBucket == item.idempotencyBucket, ); if (duplicated) { return; } current.add(item); await _writeAll(current); } Future> listPending() async { final all = await _readAll(); final now = DateTime.now(); return all .where((item) => item.state == ReminderOutboxState.pending) .where( (item) => item.nextRetryAt == null || !item.nextRetryAt!.isAfter(now), ) .toList(); } Future markDone(String opId) async { final all = await _readAll(); final updated = all .map( (item) => item.opId == opId ? item.copyWith( state: ReminderOutboxState.done, nextRetryAt: null, ) : item, ) .toList(); await _writeAll(updated); } Future markRetry(String opId, String error) async { final all = await _readAll(); final updated = all.map((item) { if (item.opId != opId) { return item; } final nextRetryCount = item.retryCount + 1; if (nextRetryCount >= 8) { return item.copyWith( retryCount: nextRetryCount, state: ReminderOutboxState.dead, lastError: error, nextRetryAt: null, ); } final delayMinutes = nextRetryCount == 1 ? 0 : 1 << (nextRetryCount - 1); return item.copyWith( retryCount: nextRetryCount, lastError: error, nextRetryAt: DateTime.now().add(Duration(minutes: delayMinutes)), ); }).toList(); await _writeAll(updated); } Future> _readAll() async { try { final raw = _prefs.getString(_key); if (raw == null || raw.isEmpty) { return []; } final list = jsonDecode(raw) as List; return list .whereType() .map( (item) => ReminderOutboxItem.fromJson(Map.from(item)), ) .toList(); } catch (_) { await _prefs.remove(_key); return []; } } Future _writeAll(List items) async { final raw = jsonEncode(items.map((item) => item.toJson()).toList()); await _prefs.setString(_key, raw); } }