631 lines
22 KiB
Dart
631 lines
22 KiB
Dart
import 'dart:io' show Platform;
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import '../database/app_database.dart';
|
|
import '../models/plex_metadata.dart';
|
|
import '../utils/app_logger.dart';
|
|
import 'offline_mode_source.dart';
|
|
import '../utils/plex_cache_parser.dart';
|
|
import '../utils/watch_state_notifier.dart';
|
|
import 'multi_server_manager.dart';
|
|
import 'plex_api_cache.dart';
|
|
import 'plex_client.dart';
|
|
import 'settings_service.dart';
|
|
|
|
/// Service for managing offline watch progress and syncing to Plex servers.
|
|
///
|
|
/// Handles:
|
|
/// - Queuing progress updates when offline
|
|
/// - Queuing manual watch/unwatch actions
|
|
/// - Auto-marking items as watched at the server's threshold
|
|
/// - Syncing queued actions when connectivity is restored
|
|
class OfflineWatchSyncService extends ChangeNotifier {
|
|
final AppDatabase _database;
|
|
final MultiServerManager _serverManager;
|
|
|
|
OfflineModeSource? _offlineModeSource;
|
|
VoidCallback? _offlineModeListener;
|
|
bool _isSyncing = false;
|
|
bool _isBidirectionalSyncing = false;
|
|
bool _isShutDown = false;
|
|
DateTime? _lastSyncTime;
|
|
bool _hasPerformedStartupSync = false;
|
|
|
|
/// Callback to refresh download provider metadata after sync
|
|
VoidCallback? onWatchStatesRefreshed;
|
|
|
|
/// Get watched threshold for a server. Cascades:
|
|
/// 1. Online client's fetched server prefs
|
|
/// 2. Cached value in SettingsService
|
|
/// 3. Default 90%
|
|
double getWatchedThreshold(String serverId) {
|
|
final client = _serverManager.getClient(serverId);
|
|
if (client != null && client.serverPrefs.isNotEmpty) {
|
|
return client.watchedThresholdPercent / 100.0;
|
|
}
|
|
final cached = SettingsService.instanceOrNull?.read(SettingsService.watchedThresholdPref(serverId)) ?? 90;
|
|
return cached / 100.0;
|
|
}
|
|
|
|
/// Minimum interval between syncs.
|
|
/// Mobile: no throttle (always sync on resume for cross-device updates)
|
|
/// Desktop: 2 minutes (reduced from 10 min to handle tab-switching better)
|
|
static Duration get minSyncInterval {
|
|
if (Platform.isIOS || Platform.isAndroid) {
|
|
return Duration.zero;
|
|
}
|
|
return const Duration(minutes: 2);
|
|
}
|
|
|
|
/// Maximum sync attempts before giving up on an item
|
|
static const int maxSyncAttempts = 5;
|
|
|
|
OfflineWatchSyncService({required AppDatabase database, required MultiServerManager serverManager})
|
|
: _database = database,
|
|
_serverManager = serverManager;
|
|
|
|
/// Whether a sync is currently in progress
|
|
bool get isSyncing => _isSyncing;
|
|
|
|
/// Start monitoring for connectivity changes to auto-sync
|
|
void startConnectivityMonitoring(OfflineModeSource source) {
|
|
// Remove previous listener if any
|
|
if (_offlineModeSource != null && _offlineModeListener != null) {
|
|
_offlineModeSource!.removeListener(_offlineModeListener!);
|
|
}
|
|
|
|
_offlineModeSource = source;
|
|
_offlineModeListener = () {
|
|
if (!source.isOffline) {
|
|
// We just came online - trigger bidirectional sync
|
|
appLogger.i('Connectivity restored - starting bidirectional watch sync');
|
|
_performBidirectionalSync();
|
|
}
|
|
};
|
|
|
|
source.addListener(_offlineModeListener!);
|
|
|
|
// Don't sync on startup - servers aren't connected yet.
|
|
// Sync will happen when:
|
|
// - Connectivity is restored (listener triggers)
|
|
// - App resumes from background (onAppResumed)
|
|
}
|
|
|
|
/// Perform bidirectional sync: push local changes, then pull server states.
|
|
///
|
|
/// Push always happens immediately. Pull respects [minSyncInterval] unless [force] is true.
|
|
Future<void> _performBidirectionalSync({bool force = false}) async {
|
|
if (_isShutDown) return;
|
|
|
|
// Prevent overlapping bidirectional syncs
|
|
if (_isBidirectionalSyncing) {
|
|
appLogger.d('Bidirectional sync already in progress, skipping');
|
|
return;
|
|
}
|
|
|
|
if (_serverManager.onlineClients.isEmpty) {
|
|
appLogger.d('Skipping watch sync - no connected servers available yet');
|
|
return;
|
|
}
|
|
|
|
_isBidirectionalSyncing = true;
|
|
try {
|
|
// Always push local changes to server (never throttle outbound sync)
|
|
await syncPendingItems();
|
|
|
|
// Only throttle the pull from server
|
|
if (!force && _lastSyncTime != null) {
|
|
final elapsed = DateTime.now().difference(_lastSyncTime!);
|
|
if (elapsed < minSyncInterval) {
|
|
appLogger.d(
|
|
'Skipping server pull - last sync was ${elapsed.inMinutes}m ago (min: ${minSyncInterval.inMinutes}m)',
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Pull latest states from server
|
|
await syncWatchStatesFromServer();
|
|
_lastSyncTime = DateTime.now();
|
|
} finally {
|
|
_isBidirectionalSyncing = false;
|
|
}
|
|
}
|
|
|
|
/// Called when app becomes active - syncs for cross-device updates.
|
|
/// On mobile, always syncs immediately (device-switching scenario).
|
|
/// On desktop, respects the throttle interval.
|
|
void onAppResumed() {
|
|
if (_isShutDown) return;
|
|
if (_offlineModeSource?.isOffline != true) {
|
|
final isMobile = Platform.isIOS || Platform.isAndroid;
|
|
appLogger.d('App resumed - ${isMobile ? "forcing" : "checking"} sync');
|
|
_performBidirectionalSync(force: isMobile);
|
|
}
|
|
}
|
|
|
|
/// Called when servers connect on app startup.
|
|
///
|
|
/// Triggers the initial sync now that PlexClients are available.
|
|
/// Only runs once per app session.
|
|
void onServersConnected() {
|
|
if (_isShutDown) return;
|
|
if (_hasPerformedStartupSync) return;
|
|
_hasPerformedStartupSync = true;
|
|
|
|
if (_offlineModeSource?.isOffline != true) {
|
|
appLogger.i('Servers connected - performing startup sync');
|
|
_performBidirectionalSync();
|
|
}
|
|
}
|
|
|
|
/// Queue a progress update for later sync.
|
|
///
|
|
/// This is called during offline playback to track the watch position.
|
|
/// If progress exceeds the server's threshold, shouldMarkWatched is set to true.
|
|
Future<void> queueProgressUpdate({
|
|
required String serverId,
|
|
required String ratingKey,
|
|
required int viewOffset,
|
|
required int duration,
|
|
}) async {
|
|
final shouldMarkWatched = isWatchedByProgress(viewOffset, duration, serverId: serverId);
|
|
|
|
await _database.upsertProgressAction(
|
|
serverId: serverId,
|
|
ratingKey: ratingKey,
|
|
viewOffset: viewOffset,
|
|
duration: duration,
|
|
shouldMarkWatched: shouldMarkWatched,
|
|
);
|
|
|
|
appLogger.d(
|
|
'Queued offline progress: $serverId:$ratingKey at ${(viewOffset / 1000).toStringAsFixed(0)}s / ${(duration / 1000).toStringAsFixed(0)}s (${((viewOffset / duration) * 100).toStringAsFixed(1)}%)',
|
|
);
|
|
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Queue a manual "mark as watched" action.
|
|
///
|
|
/// Removes any conflicting actions for the same item.
|
|
Future<void> queueMarkWatched({required String serverId, required String ratingKey}) =>
|
|
_queueWatchStatusAction(serverId: serverId, ratingKey: ratingKey, actionType: OfflineActionType.watched.name);
|
|
|
|
/// Queue a manual "mark as unwatched" action.
|
|
///
|
|
/// Removes any conflicting actions for the same item.
|
|
Future<void> queueMarkUnwatched({required String serverId, required String ratingKey}) =>
|
|
_queueWatchStatusAction(serverId: serverId, ratingKey: ratingKey, actionType: OfflineActionType.unwatched.name);
|
|
|
|
/// Internal helper to queue watch/unwatch actions.
|
|
Future<void> _queueWatchStatusAction({
|
|
required String serverId,
|
|
required String ratingKey,
|
|
required String actionType,
|
|
}) async {
|
|
await _database.insertWatchAction(serverId: serverId, ratingKey: ratingKey, actionType: actionType);
|
|
|
|
appLogger.d('Queued offline mark $actionType: $serverId:$ratingKey');
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Check if an item should be considered watched based on progress percentage.
|
|
bool isWatchedByProgress(int viewOffset, int duration, {String? serverId}) {
|
|
if (duration == 0) return false;
|
|
final threshold = serverId != null ? getWatchedThreshold(serverId) : 0.9;
|
|
return (viewOffset / duration) >= threshold;
|
|
}
|
|
|
|
/// Get the local watch status for a media item.
|
|
///
|
|
/// Returns:
|
|
/// - `true` if item was marked as watched locally or progress >= server threshold
|
|
/// - `false` if item was marked as unwatched locally
|
|
/// - `null` if no local action exists (use cached server data)
|
|
Future<bool?> getLocalWatchStatus(String globalKey) async {
|
|
final action = await _database.getLatestWatchAction(globalKey);
|
|
if (action == null) return null;
|
|
|
|
switch (action.actionType) {
|
|
case 'watched':
|
|
return true;
|
|
case 'unwatched':
|
|
return false;
|
|
case 'progress':
|
|
// Check if progress exceeds threshold
|
|
return action.shouldMarkWatched;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Get local watch statuses for multiple items in a single database query.
|
|
///
|
|
/// Returns a map of globalKey -> watch status (true/false/null).
|
|
/// More efficient than calling getLocalWatchStatus multiple times.
|
|
Future<Map<String, bool?>> getLocalWatchStatusesBatched(Set<String> globalKeys) async {
|
|
if (globalKeys.isEmpty) return {};
|
|
|
|
final actions = await _database.getLatestWatchActionsForKeys(globalKeys);
|
|
final result = <String, bool?>{};
|
|
|
|
for (final key in globalKeys) {
|
|
final action = actions[key];
|
|
if (action == null) {
|
|
result[key] = null;
|
|
continue;
|
|
}
|
|
|
|
switch (action.actionType) {
|
|
case 'watched':
|
|
result[key] = true;
|
|
case 'unwatched':
|
|
result[key] = false;
|
|
case 'progress':
|
|
result[key] = action.shouldMarkWatched;
|
|
default:
|
|
result[key] = null;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// Get the local view offset (resume position) for a media item.
|
|
///
|
|
/// Returns the locally tracked position, or null if none exists.
|
|
Future<int?> getLocalViewOffset(String globalKey) async {
|
|
final action = await _database.getLatestWatchAction(globalKey);
|
|
if (action == null) return null;
|
|
|
|
// Only return offset for progress actions
|
|
if (action.actionType == OfflineActionType.progress.name) {
|
|
return action.viewOffset;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// Get count of pending sync items.
|
|
Future<int> getPendingSyncCount() {
|
|
return _database.getPendingSyncCount();
|
|
}
|
|
|
|
/// Sync all pending items to their respective servers.
|
|
///
|
|
/// Called automatically when connectivity is restored, or manually.
|
|
/// Actions are batched by server to reduce connectivity lookups.
|
|
Future<void> syncPendingItems() async {
|
|
if (_isSyncing) {
|
|
appLogger.d('Sync already in progress, skipping');
|
|
return;
|
|
}
|
|
|
|
_isSyncing = true;
|
|
notifyListeners();
|
|
|
|
try {
|
|
final pendingActions = await _database.getPendingWatchActions();
|
|
|
|
if (pendingActions.isEmpty) {
|
|
appLogger.d('No pending watch actions to sync');
|
|
return;
|
|
}
|
|
|
|
appLogger.i('Syncing ${pendingActions.length} pending watch actions');
|
|
|
|
// First pass: handle retry limit exceeded and group by server
|
|
final actionsByServer = <String, List<OfflineWatchProgressItem>>{};
|
|
|
|
for (final action in pendingActions) {
|
|
// Delete items that have exceeded retry limit
|
|
if (action.syncAttempts >= maxSyncAttempts) {
|
|
appLogger.w(
|
|
'Deleting action ${action.id} - exceeded retry limit '
|
|
'(${action.syncAttempts} attempts). Last error: ${action.lastError}',
|
|
);
|
|
await _database.deleteWatchAction(action.id);
|
|
continue;
|
|
}
|
|
|
|
// Check if server still exists
|
|
if (_serverManager.getServer(action.serverId) == null) {
|
|
appLogger.w('Deleting action ${action.id} - server ${action.serverId} no longer exists');
|
|
await _database.deleteWatchAction(action.id);
|
|
continue;
|
|
}
|
|
|
|
actionsByServer.putIfAbsent(action.serverId, () => []).add(action);
|
|
}
|
|
|
|
// Second pass: process each server's actions with single connectivity check
|
|
for (final entry in actionsByServer.entries) {
|
|
final serverId = entry.key;
|
|
final actions = entry.value;
|
|
|
|
await _withOnlineClient(serverId, (client) async {
|
|
for (final action in actions) {
|
|
try {
|
|
await _syncAction(client, action);
|
|
// Success - delete the action from queue
|
|
await _database.deleteWatchAction(action.id);
|
|
appLogger.d('Successfully synced action ${action.id}: ${action.actionType} for ${action.ratingKey}');
|
|
} catch (e) {
|
|
appLogger.w('Failed to sync action ${action.id}: $e');
|
|
await _database.updateSyncAttempt(action.id, e.toString());
|
|
}
|
|
}
|
|
});
|
|
|
|
// If _withOnlineClient returned null (server offline), mark actions for retry
|
|
if (_serverManager.getClient(serverId) == null || !_serverManager.isServerOnline(serverId)) {
|
|
for (final action in actions) {
|
|
// Only update if we haven't already processed it
|
|
final stillPending = await _database.getLatestWatchAction('${action.serverId}:${action.ratingKey}');
|
|
if (stillPending != null && stillPending.id == action.id) {
|
|
await _database.updateSyncAttempt(action.id, 'Server not available');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
_isSyncing = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// Execute a callback with an online client for the given server.
|
|
///
|
|
/// Returns null if no client available or server is offline.
|
|
/// The callback receives the PlexClient and should return the result.
|
|
Future<T?> _withOnlineClient<T>(String serverId, Future<T> Function(PlexClient client) callback) async {
|
|
final client = _serverManager.getClient(serverId);
|
|
if (client == null) {
|
|
appLogger.d('No client for server $serverId, skipping');
|
|
return null;
|
|
}
|
|
|
|
if (!_serverManager.isServerOnline(serverId)) {
|
|
appLogger.d('Server $serverId is offline, skipping');
|
|
return null;
|
|
}
|
|
|
|
return callback(client);
|
|
}
|
|
|
|
/// Sync a single action to the server.
|
|
Future<void> _syncAction(PlexClient client, OfflineWatchProgressItem action) async {
|
|
// Fetch metadata so the WatchStateNotifier emission inside
|
|
// markAsWatched/markAsUnwatched carries enough context for downstream
|
|
// listeners (UI invalidation, Trakt sync). Best-effort: a missed metadata
|
|
// fetch only suppresses the event, not the Plex API call.
|
|
final emitsEvent =
|
|
action.actionType == OfflineActionType.watched.name ||
|
|
action.actionType == OfflineActionType.unwatched.name ||
|
|
(action.actionType == OfflineActionType.progress.name && action.shouldMarkWatched);
|
|
PlexMetadata? metadata;
|
|
if (emitsEvent) {
|
|
try {
|
|
metadata = await client.getMetadataWithImages(action.ratingKey);
|
|
} catch (_) {
|
|
// Proceed without metadata — Plex still gets the call.
|
|
}
|
|
}
|
|
|
|
switch (action.actionType) {
|
|
case 'watched':
|
|
await client.markAsWatched(action.ratingKey, metadata: metadata);
|
|
break;
|
|
|
|
case 'unwatched':
|
|
await client.markAsUnwatched(action.ratingKey, metadata: metadata);
|
|
break;
|
|
|
|
case 'progress':
|
|
// First, update the timeline with current position
|
|
if (action.viewOffset != null && action.duration != null) {
|
|
await client.updateProgress(
|
|
action.ratingKey,
|
|
time: action.viewOffset!,
|
|
state: 'stopped', // Use 'stopped' since we're syncing after the fact
|
|
duration: action.duration,
|
|
);
|
|
}
|
|
|
|
// If progress exceeded threshold, also mark as watched
|
|
if (action.shouldMarkWatched) {
|
|
await client.markAsWatched(action.ratingKey, metadata: metadata);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// Sync watch states for all episodes in a single season.
|
|
///
|
|
/// Returns the number of episodes synced, or -1 on failure.
|
|
Future<int> _syncSeasonEpisodes(
|
|
PlexClient client,
|
|
String serverId,
|
|
String seasonRatingKey,
|
|
Set<String> downloadedEpisodeKeys,
|
|
) async {
|
|
try {
|
|
final seasonEpisodes = await client.getChildren(seasonRatingKey);
|
|
int synced = 0;
|
|
|
|
for (final episode in seasonEpisodes) {
|
|
if (!downloadedEpisodeKeys.contains(episode.ratingKey)) continue;
|
|
|
|
final cacheKey = '/library/metadata/${episode.ratingKey}';
|
|
final existing = await PlexApiCache.instance.get(serverId, cacheKey);
|
|
final existingMeta = PlexCacheParser.extractFirstMetadata(existing);
|
|
|
|
if (existingMeta != null) {
|
|
// Detect a watched-status flip from another device so listeners
|
|
// (UI, Trakt sync) get the change. `viewCount` going from 0 to >0
|
|
// (or back) is the canonical "marked watched" signal.
|
|
final wasWatched = ((existingMeta['viewCount'] as num?)?.toInt() ?? 0) > 0;
|
|
final isWatched = (episode.viewCount ?? 0) > 0;
|
|
|
|
// Only update watch-state fields — preserves Chapter/Marker/Media/etc.
|
|
existingMeta['viewCount'] = episode.viewCount;
|
|
existingMeta['viewOffset'] = episode.viewOffset;
|
|
existingMeta['lastViewedAt'] = episode.lastViewedAt;
|
|
existingMeta['viewedLeafCount'] = episode.viewedLeafCount;
|
|
await PlexApiCache.instance.put(serverId, cacheKey, {
|
|
'MediaContainer': {
|
|
'Metadata': [existingMeta],
|
|
},
|
|
});
|
|
|
|
if (wasWatched != isWatched) {
|
|
WatchStateNotifier().notifyWatched(metadata: episode, isNowWatched: isWatched);
|
|
}
|
|
|
|
// Repair corrupted entries (missing Media/Chapter from previous overwrites)
|
|
if (existingMeta['Media'] == null) {
|
|
try {
|
|
await client.getMetadataWithImages(episode.ratingKey);
|
|
} catch (e) {
|
|
appLogger.d('Cache repair fetch skipped for ${episode.ratingKey}', error: e);
|
|
}
|
|
}
|
|
} else {
|
|
// No existing entry — write what we have
|
|
await PlexApiCache.instance.put(serverId, cacheKey, {
|
|
'MediaContainer': {
|
|
'Metadata': [episode.toJson()],
|
|
},
|
|
});
|
|
}
|
|
synced++;
|
|
}
|
|
|
|
return synced;
|
|
} catch (e) {
|
|
appLogger.d('Failed to sync watch states for season $seasonRatingKey: $e');
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
/// Fetch latest watch states from server and update local cache.
|
|
///
|
|
/// Called when coming online or on app startup to pull any watch state
|
|
/// changes made on other devices.
|
|
///
|
|
/// Optimized to fetch episodes by season (one API call per season)
|
|
/// instead of one API call per episode.
|
|
Future<void> syncWatchStatesFromServer() async {
|
|
try {
|
|
// Get all downloaded items from database
|
|
final downloadedItems = await _database.getAllDownloadedMetadata();
|
|
|
|
if (downloadedItems.isEmpty) {
|
|
appLogger.d('No downloaded items to sync watch states for');
|
|
return;
|
|
}
|
|
|
|
appLogger.i('Syncing watch states from server for ${downloadedItems.length} items');
|
|
|
|
// Separate episodes (with season parent) from other items (movies, etc.)
|
|
// Structure: serverId -> seasonRatingKey -> Set<episodeRatingKey>
|
|
final episodesByServerAndSeason = <String, Map<String, Set<String>>>{};
|
|
// Structure: serverId -> List<ratingKey>
|
|
final nonEpisodeItems = <String, List<String>>{};
|
|
|
|
for (final item in downloadedItems) {
|
|
if (item.type == 'episode' && item.parentRatingKey != null) {
|
|
// Group episodes by server and season for batch fetching
|
|
episodesByServerAndSeason
|
|
.putIfAbsent(item.serverId, () => {})
|
|
.putIfAbsent(item.parentRatingKey!, () => {})
|
|
.add(item.ratingKey);
|
|
} else {
|
|
// Movies, or episodes without parent (fallback to individual fetch)
|
|
nonEpisodeItems.putIfAbsent(item.serverId, () => []).add(item.ratingKey);
|
|
}
|
|
}
|
|
|
|
int syncedCount = 0;
|
|
int seasonCount = 0;
|
|
|
|
// Fetch episodes by season (batch) - one API call per season
|
|
for (final serverEntry in episodesByServerAndSeason.entries) {
|
|
final serverId = serverEntry.key;
|
|
final seasonMap = serverEntry.value;
|
|
|
|
await _withOnlineClient(serverId, (client) async {
|
|
for (final seasonEntry in seasonMap.entries) {
|
|
final result = await _syncSeasonEpisodes(client, serverId, seasonEntry.key, seasonEntry.value);
|
|
if (result >= 0) {
|
|
syncedCount += result;
|
|
seasonCount++;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Fetch non-episode items individually (movies, etc.)
|
|
for (final entry in nonEpisodeItems.entries) {
|
|
final serverId = entry.key;
|
|
final ratingKeys = entry.value;
|
|
|
|
await _withOnlineClient(serverId, (client) async {
|
|
for (final ratingKey in ratingKeys) {
|
|
try {
|
|
// Snapshot prior viewCount before the cache-refreshing fetch so we
|
|
// can detect a watched-status change from another device.
|
|
final cacheKey = '/library/metadata/$ratingKey';
|
|
final priorCached = await PlexApiCache.instance.get(serverId, cacheKey);
|
|
final priorMeta = PlexCacheParser.extractFirstMetadata(priorCached);
|
|
final wasWatched = ((priorMeta?['viewCount'] as num?)?.toInt() ?? 0) > 0;
|
|
|
|
// getMetadataWithImages already caches the full API response
|
|
// (with chapters/markers) via _fetchWithCacheFallback internally
|
|
final metadata = await client.getMetadataWithImages(ratingKey);
|
|
if (metadata != null) {
|
|
syncedCount++;
|
|
final isWatched = (metadata.viewCount ?? 0) > 0;
|
|
if (wasWatched != isWatched) {
|
|
WatchStateNotifier().notifyWatched(metadata: metadata, isNowWatched: isWatched);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
appLogger.d('Failed to sync watch state for $ratingKey: $e');
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
final movieCount = nonEpisodeItems.values.fold(0, (a, b) => a + b.length);
|
|
appLogger.i('Synced watch states: $seasonCount seasons, $movieCount other items ($syncedCount total)');
|
|
|
|
// Notify download provider to refresh metadata from updated cache
|
|
if (syncedCount > 0) {
|
|
onWatchStatesRefreshed?.call();
|
|
}
|
|
|
|
notifyListeners();
|
|
} catch (e) {
|
|
appLogger.w('Error syncing watch states from server: $e');
|
|
}
|
|
}
|
|
|
|
/// Clear all pending watch actions (e.g., when logging out).
|
|
Future<void> clearAll() async {
|
|
await _database.clearAllWatchActions();
|
|
notifyListeners();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_isShutDown = true;
|
|
if (_offlineModeSource != null && _offlineModeListener != null) {
|
|
_offlineModeSource!.removeListener(_offlineModeListener!);
|
|
}
|
|
super.dispose();
|
|
}
|
|
}
|