mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 20:48:24 +00:00
309 lines
10 KiB
Dart
309 lines
10 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:models/models.dart';
|
|
import 'package:zapstore/main.dart';
|
|
import 'package:zapstore/services/catalog_fetcher.dart';
|
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
|
|
|
/// How often to poll for updates from remote relays
|
|
const _pollInterval = Duration(minutes: 5);
|
|
|
|
/// Minimum time between manual refreshes
|
|
const _refreshCooldown = Duration(seconds: 30);
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// CATEGORIZED UPDATES
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
class CategorizedUpdates {
|
|
const CategorizedUpdates({
|
|
required this.automaticUpdates,
|
|
required this.manualUpdates,
|
|
required this.upToDateApps,
|
|
required this.uncatalogedApps,
|
|
this.showSkeleton = false,
|
|
});
|
|
|
|
final List<App> automaticUpdates;
|
|
final List<App> manualUpdates;
|
|
final List<App> upToDateApps;
|
|
final List<PackageInfo> uncatalogedApps;
|
|
final bool showSkeleton;
|
|
|
|
static const empty = CategorizedUpdates(
|
|
automaticUpdates: [],
|
|
manualUpdates: [],
|
|
upToDateApps: [],
|
|
uncatalogedApps: [],
|
|
showSkeleton: true,
|
|
);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// UPDATE POLLER STATE
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
class UpdatePollerState {
|
|
const UpdatePollerState({
|
|
this.isChecking = false,
|
|
this.lastCheckTime,
|
|
this.lastError,
|
|
this.apps = const [],
|
|
this.installableByApp = const {},
|
|
this.catalogedIds = const {},
|
|
});
|
|
|
|
final bool isChecking;
|
|
final DateTime? lastCheckTime;
|
|
final String? lastError;
|
|
|
|
/// App objects fetched from relay, used for display (name, icon, author)
|
|
final List<App> apps;
|
|
|
|
/// appIdentifier → Installable (3063 or 1063), used for update comparison
|
|
final Map<String, Installable> installableByApp;
|
|
|
|
/// All app identifiers found in relay catalog (superset of apps list,
|
|
/// since an installable may exist without a corresponding App object)
|
|
final Set<String> catalogedIds;
|
|
|
|
UpdatePollerState copyWith({
|
|
bool? isChecking,
|
|
DateTime? lastCheckTime,
|
|
String? lastError,
|
|
bool clearError = false,
|
|
List<App>? apps,
|
|
Map<String, Installable>? installableByApp,
|
|
Set<String>? catalogedIds,
|
|
}) {
|
|
return UpdatePollerState(
|
|
isChecking: isChecking ?? this.isChecking,
|
|
lastCheckTime: lastCheckTime ?? this.lastCheckTime,
|
|
lastError: clearError ? null : (lastError ?? this.lastError),
|
|
apps: apps ?? this.apps,
|
|
installableByApp: installableByApp ?? this.installableByApp,
|
|
catalogedIds: catalogedIds ?? this.catalogedIds,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// UPDATE POLLER - Fetches catalog data from relays
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
class UpdatePollerNotifier extends StateNotifier<UpdatePollerState> {
|
|
UpdatePollerNotifier(this.ref) : super(const UpdatePollerState()) {
|
|
_init();
|
|
}
|
|
|
|
final Ref ref;
|
|
Timer? _pollTimer;
|
|
|
|
void _init() {
|
|
ref.listen<AsyncValue<void>>(appInitializationProvider, (prev, next) {
|
|
if (prev is! AsyncData && next is AsyncData) {
|
|
_startPolling();
|
|
}
|
|
}, fireImmediately: true);
|
|
}
|
|
|
|
void _startPolling() {
|
|
_pollTimer?.cancel();
|
|
_pollTimer = Timer.periodic(_pollInterval, (_) => checkNow());
|
|
checkNow();
|
|
}
|
|
|
|
/// Trigger an update check. Called by timer and pull-to-refresh.
|
|
Future<void> checkNow() async {
|
|
if (state.isChecking) return;
|
|
|
|
if (state.lastCheckTime != null) {
|
|
final elapsed = DateTime.now().difference(state.lastCheckTime!);
|
|
if (elapsed < _refreshCooldown) return;
|
|
}
|
|
|
|
state = state.copyWith(isChecking: true);
|
|
|
|
try {
|
|
await ref.read(packageManagerProvider.notifier).syncInstalledPackages();
|
|
await _fetchCatalog();
|
|
state = state.copyWith(
|
|
isChecking: false,
|
|
lastCheckTime: DateTime.now(),
|
|
clearError: true,
|
|
);
|
|
} catch (e) {
|
|
debugPrint('[UpdatePoller] Check failed: $e');
|
|
state = state.copyWith(
|
|
isChecking: false,
|
|
lastCheckTime: DateTime.now(),
|
|
lastError: 'Update check failed — will retry',
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Fetch catalog data from relays and store in state.
|
|
/// Categorization is done by [categorizedUpdatesProvider].
|
|
Future<void> _fetchCatalog() async {
|
|
final pmState = ref.read(packageManagerProvider);
|
|
if (pmState.installed.isEmpty) {
|
|
state = state.copyWith(
|
|
apps: const [],
|
|
installableByApp: const {},
|
|
catalogedIds: const {},
|
|
);
|
|
return;
|
|
}
|
|
|
|
final storage = ref.read(storageNotifierProvider.notifier);
|
|
final result = await fetchCatalog(
|
|
storage: storage,
|
|
installedIds: pmState.installed.keys.toSet(),
|
|
platform: ref.read(packageManagerProvider.notifier).platform,
|
|
subscriptionPrefix: 'app-updates-poll',
|
|
);
|
|
|
|
final authorPubkeys = result.apps.map((a) => a.event.pubkey).toSet();
|
|
if (authorPubkeys.isNotEmpty) {
|
|
unawaited(
|
|
storage.query(
|
|
RequestFilter<Profile>(authors: authorPubkeys).toRequest(),
|
|
source: const LocalAndRemoteSource(
|
|
relays: {'social', 'vertex'},
|
|
cachedFor: Duration(hours: 2),
|
|
stream: false,
|
|
),
|
|
subscriptionPrefix: 'app-updates-profiles',
|
|
),
|
|
);
|
|
}
|
|
|
|
state = state.copyWith(
|
|
apps: result.apps,
|
|
installableByApp: result.installableByApp,
|
|
catalogedIds: result.catalogedIds,
|
|
);
|
|
}
|
|
|
|
/// Re-derive catalog from local DB without hitting relays.
|
|
/// Call when returning to the updates screen so that data written by
|
|
/// other code paths (detail screen, background service) is picked up
|
|
/// without waiting for the next poll cycle.
|
|
Future<void> refreshFromLocal() async {
|
|
final pmState = ref.read(packageManagerProvider);
|
|
if (pmState.installed.isEmpty) return;
|
|
|
|
final storage = ref.read(storageNotifierProvider.notifier);
|
|
final result = await fetchCatalog(
|
|
storage: storage,
|
|
installedIds: pmState.installed.keys.toSet(),
|
|
platform: ref.read(packageManagerProvider.notifier).platform,
|
|
subscriptionPrefix: 'app-updates-local',
|
|
source: const LocalSource(),
|
|
);
|
|
|
|
state = state.copyWith(
|
|
apps: result.apps,
|
|
installableByApp: result.installableByApp,
|
|
catalogedIds: result.catalogedIds,
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_pollTimer?.cancel();
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
final updatePollerProvider =
|
|
StateNotifierProvider<UpdatePollerNotifier, UpdatePollerState>(
|
|
(ref) => UpdatePollerNotifier(ref),
|
|
);
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// CATEGORIZED UPDATES PROVIDER
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
/// Pure synchronous derivation from poller catalog + installed packages.
|
|
/// Rebuilds when either changes.
|
|
final categorizedUpdatesProvider = Provider<CategorizedUpdates>((ref) {
|
|
final pollerState = ref.watch(updatePollerProvider);
|
|
final installed = ref.watch(
|
|
packageManagerProvider.select((s) => s.installed),
|
|
);
|
|
|
|
if (pollerState.lastCheckTime == null) {
|
|
return CategorizedUpdates.empty;
|
|
}
|
|
|
|
if (installed.isEmpty) {
|
|
return const CategorizedUpdates(
|
|
automaticUpdates: [],
|
|
manualUpdates: [],
|
|
upToDateApps: [],
|
|
uncatalogedApps: [],
|
|
);
|
|
}
|
|
|
|
final pm = ref.read(packageManagerProvider.notifier);
|
|
final installableByApp = pollerState.installableByApp;
|
|
final catalogedIds = pollerState.catalogedIds;
|
|
|
|
final automaticUpdates = <App>[];
|
|
final manualUpdates = <App>[];
|
|
final upToDateApps = <App>[];
|
|
|
|
for (final app in pollerState.apps) {
|
|
final pkg = installed[app.identifier];
|
|
if (pkg == null) continue;
|
|
|
|
final installable = installableByApp[app.identifier];
|
|
final hasUpdate =
|
|
installable != null && pm.hasUpdate(app.identifier, installable);
|
|
|
|
if (hasUpdate) {
|
|
if (pkg.canInstallSilently) {
|
|
automaticUpdates.add(app);
|
|
} else {
|
|
manualUpdates.add(app);
|
|
}
|
|
} else {
|
|
upToDateApps.add(app);
|
|
}
|
|
}
|
|
|
|
int byName(App a, App b) => (a.name ?? a.identifier).toLowerCase().compareTo(
|
|
(b.name ?? b.identifier).toLowerCase(),
|
|
);
|
|
automaticUpdates.sort(byName);
|
|
manualUpdates.sort(byName);
|
|
upToDateApps.sort(byName);
|
|
|
|
final uncatalogedApps =
|
|
installed.values
|
|
.where((pkg) => !catalogedIds.contains(pkg.appId))
|
|
.toList()
|
|
..sort(
|
|
(a, b) => (a.name ?? a.appId).toLowerCase().compareTo(
|
|
(b.name ?? b.appId).toLowerCase(),
|
|
),
|
|
);
|
|
|
|
return CategorizedUpdates(
|
|
automaticUpdates: automaticUpdates,
|
|
manualUpdates: manualUpdates,
|
|
upToDateApps: upToDateApps,
|
|
uncatalogedApps: uncatalogedApps,
|
|
);
|
|
});
|
|
|
|
/// Total number of apps with available updates
|
|
final updateCountProvider = Provider<int>((ref) {
|
|
final categorized = ref.watch(categorizedUpdatesProvider);
|
|
if (categorized.showSkeleton) return 0;
|
|
return categorized.automaticUpdates.length + categorized.manualUpdates.length;
|
|
});
|