import 'dart:async'; import 'dart:collection'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../api/model/extension.dart'; import '../../../api/model/install_extension.dart'; import '../../../api/model/store_extension.dart'; import '../../../api/model/switch_extension.dart'; import '../../../api/model/update_extension_settings.dart'; import '../../../core/capabilities/app_capabilities.dart'; import 'store_extension_details_provider.dart'; final extensionsControllerProvider = AsyncNotifierProvider( ExtensionsController.new, ); enum ExtensionListFilter { market, installed } class ExtensionListItem { const ExtensionListItem({this.installed, this.store}); final Extension? installed; final StoreExtension? store; bool get isInstalled => installed != null; String get id => installed?.identity ?? store!.id; String get title => installed?.title ?? store?.title ?? ''; String get author => installed?.author ?? store?.author ?? ''; String get description => installed?.description ?? store?.description ?? ''; String get version => installed?.version ?? store?.version ?? '0.0.0'; String? get homepage => installed?.homepage.isNotEmpty == true ? installed!.homepage : store?.homepage; String? get repoUrl => installed?.repository?.url.isNotEmpty == true ? installed!.repository!.url : store?.repoUrl; int get stars => store?.stars ?? 0; int get installCount => store?.installCount ?? 0; } class ExtensionsState { const ExtensionsState({ this.installedExtensions = const [], this.storeExtensions = const [], this.updateFlags = const {}, this.storePagination, this.storeQuery = '', this.storeSort = StoreExtensionSort.stars, this.listFilter = ExtensionListFilter.market, this.loadingInstalled = false, this.loadingStore = false, this.loadingMoreStore = false, this.busyExtensionIds = const {}, this.devMode = false, }); final List installedExtensions; final List storeExtensions; final Map updateFlags; final StorePagination? storePagination; final String storeQuery; final StoreExtensionSort storeSort; final ExtensionListFilter listFilter; final bool loadingInstalled; final bool loadingStore; final bool loadingMoreStore; final Set busyExtensionIds; final bool devMode; UnmodifiableMapView get installedMap => UnmodifiableMapView({for (final ext in installedExtensions) ext.identity: ext}); UnmodifiableMapView get storeMap => UnmodifiableMapView({for (final ext in storeExtensions) ext.id: ext}); List get displayItems { if (listFilter == ExtensionListFilter.installed) { return installedExtensions .map((ext) => ExtensionListItem(installed: ext, store: storeMap[ext.identity])) .toList(); } return storeExtensions.map((ext) => ExtensionListItem(installed: installedMap[ext.id], store: ext)).toList(); } ExtensionListItem? findItem(String id) { final installed = installedMap[id]; final store = storeMap[id]; if (installed == null && store == null) return null; return ExtensionListItem(installed: installed, store: store); } ExtensionsState copyWith({ List? installedExtensions, List? storeExtensions, Map? updateFlags, StorePagination? storePagination, bool clearStorePagination = false, String? storeQuery, StoreExtensionSort? storeSort, ExtensionListFilter? listFilter, bool? loadingInstalled, bool? loadingStore, bool? loadingMoreStore, Set? busyExtensionIds, bool? devMode, }) { return ExtensionsState( installedExtensions: installedExtensions ?? this.installedExtensions, storeExtensions: storeExtensions ?? this.storeExtensions, updateFlags: updateFlags ?? this.updateFlags, storePagination: clearStorePagination ? null : storePagination ?? this.storePagination, storeQuery: storeQuery ?? this.storeQuery, storeSort: storeSort ?? this.storeSort, listFilter: listFilter ?? this.listFilter, loadingInstalled: loadingInstalled ?? this.loadingInstalled, loadingStore: loadingStore ?? this.loadingStore, loadingMoreStore: loadingMoreStore ?? this.loadingMoreStore, busyExtensionIds: busyExtensionIds ?? this.busyExtensionIds, devMode: devMode ?? this.devMode, ); } } class ExtensionsController extends AsyncNotifier { static const manualInstallBusyKey = '__manual_install__'; int _devModeCount = 0; int _installedRevision = 0; int _storeRevision = 0; Future? _updateCheck; bool _checkUpdatesAgain = false; @override Future build() => _loadInitialData(); Future _loadInitialData({bool forceRefresh = false}) async { final next = _current.copyWith(loadingInstalled: true, loadingStore: true); state = AsyncValue.data(next); await Future.wait([ _loadInstalled(refreshUpdates: true, current: next), _refreshStore(current: _current, forceRefresh: forceRefresh), ]); return ref.mounted ? _current : next; } Future loadInitialData() async { await _loadInitialData(forceRefresh: true); } Future loadInstalled({bool refreshUpdates = false}) async { if (!ref.mounted) return; await _loadInstalled(refreshUpdates: refreshUpdates, current: _current.copyWith(loadingInstalled: true)); } Future refreshStore() async { await _refreshStore(current: _current.copyWith(loadingStore: true), forceRefresh: true); } Future loadMoreStore() async { final current = _current; final pagination = current.storePagination; if (pagination == null || !pagination.hasNext || current.loadingMoreStore) return; final revision = _storeRevision; state = AsyncValue.data(current.copyWith(loadingMoreStore: true)); try { final page = await ref .read(gopeedSiteApiProvider) .getExtensions( page: pagination.page + 1, limit: pagination.limit, sort: current.storeSort, query: current.storeQuery, ); if (!ref.mounted || revision != _storeRevision) return; state = AsyncValue.data( _current.copyWith( // Cached pages can briefly overlap when stars/install counts change. storeExtensions: { for (final extension in _current.storeExtensions) extension.id: extension, for (final extension in page.data) extension.id: extension, }.values.toList(), storePagination: page.pagination, loadingMoreStore: false, ), ); } catch (_) { if (!ref.mounted || revision != _storeRevision) return; state = AsyncValue.data(_current.copyWith(loadingMoreStore: false)); rethrow; } } Future searchStore(String query) async { if (query.trim() == _current.storeQuery) return; state = AsyncValue.data(_current.copyWith(storeQuery: query.trim())); await _refreshStore(current: _current); } Future changeSort(StoreExtensionSort sort) async { if (sort == _current.storeSort) return; state = AsyncValue.data(_current.copyWith(storeSort: sort)); await _refreshStore(current: _current); } void changeFilter(ExtensionListFilter filter) { state = AsyncValue.data(_current.copyWith(listFilter: filter)); } bool canUpdateItem(ExtensionListItem item) { final installed = item.installed; if (installed == null) return false; // The backend checks GitHub live for every installed extension; the store // index may lag behind and must not decide update visibility. return _current.updateFlags.containsKey(installed.identity); } Future installFromStore(StoreExtension extension) async { await _runBusy(extension.id, () async { final installUrl = (extension.directory ?? '').trim().isEmpty ? extension.repoUrl : '${extension.repoUrl}#${extension.directory!.trim()}'; final installedId = await ref.read(gopeedServiceProvider).installExtension(InstallExtension(url: installUrl)); await loadInstalled(refreshUpdates: false); unawaited(checkUpdate()); _bumpStoreInstallCount(installedId.isNotEmpty ? installedId : extension.id); _reportInstallSafe(installedId.isNotEmpty ? installedId : extension.id); }); } Future installFromUrl(String url, {bool devInstall = false}) async { await _runBusy(manualInstallBusyKey, () async { final installedId = await ref .read(gopeedServiceProvider) .installExtension(InstallExtension(devMode: devInstall, url: url)); await loadInstalled(refreshUpdates: false); unawaited(checkUpdate()); if (installedId.isNotEmpty) { _bumpStoreInstallCount(installedId); _reportInstallSafe(installedId); } }); } Future toggleExtension(Extension extension, bool enabled) async { await _runBusy(extension.identity, () async { await ref.read(gopeedServiceProvider).switchExtension(extension.identity, SwitchExtension(status: enabled)); await loadInstalled(refreshUpdates: false); }); } Future removeExtension(Extension extension) async { await _runBusy(extension.identity, () async { await ref.read(gopeedServiceProvider).deleteExtension(extension.identity); await loadInstalled(refreshUpdates: false); final flags = Map.of(_current.updateFlags)..remove(extension.identity); state = AsyncValue.data(_current.copyWith(updateFlags: flags)); }); } Future upgradeExtension(Extension extension) async { await _runBusy(extension.identity, () async { await ref.read(gopeedServiceProvider).updateExtension(extension.identity); await loadInstalled(refreshUpdates: false); // Drop the stale flag immediately; the background re-check confirms it. final flags = Map.of(_current.updateFlags)..remove(extension.identity); state = AsyncValue.data(_current.copyWith(updateFlags: flags)); unawaited(checkUpdate()); _bumpStoreInstallCount(extension.identity); _reportInstallSafe(extension.identity); }); } Future saveExtensionSettings(Extension extension, Map settings) async { await _runBusy(extension.identity, () async { await ref .read(gopeedServiceProvider) .updateExtensionSettings(extension.identity, UpdateExtensionSettings(settings: settings)); await loadInstalled(refreshUpdates: false); }); } Future checkUpdate() { if (!ref.mounted) return Future.value(); _checkUpdatesAgain = true; // Installation/refresh can request a fresh scan while one is running. // Coalesce those requests instead of launching overlapping Git operations. return _updateCheck ??= _runUpdateChecks().whenComplete(() => _updateCheck = null); } Future _runUpdateChecks() async { final service = ref.read(gopeedServiceProvider); while (ref.mounted && _checkUpdatesAgain) { _checkUpdatesAgain = false; final revision = _installedRevision; final installed = List.of(_current.installedExtensions); final flags = {}; for (final ext in installed) { if (!ref.mounted) return; if (revision != _installedRevision) break; try { final resp = await service.upgradeCheckExtension(ext.identity); if (!ref.mounted) return; if (revision != _installedRevision) break; if (resp.newVersion.isNotEmpty) flags[ext.identity] = resp.newVersion; } catch (_) { // A failed update check must never prevent loading the store. } } if (ref.mounted && revision == _installedRevision) { state = AsyncValue.data(_current.copyWith(updateFlags: flags)); } } } void tryOpenDevMode() { if (_devModeCount == 0) { Future.delayed(const Duration(seconds: 2), () { if (!ref.mounted || _current.devMode) return; _devModeCount = 0; }); } _devModeCount++; if (_devModeCount >= 5) { state = AsyncValue.data(_current.copyWith(devMode: true)); } } ExtensionsState get _current => state.value ?? const ExtensionsState(); Future _loadInstalled({required bool refreshUpdates, required ExtensionsState current}) async { final revision = ++_installedRevision; state = AsyncValue.data(current.copyWith(loadingInstalled: true)); final installed = await ref.read(gopeedServiceProvider).getExtensions(); if (!ref.mounted) return current; if (revision != _installedRevision) return _current; state = AsyncValue.data(_current.copyWith(installedExtensions: installed, loadingInstalled: false)); if (refreshUpdates) { unawaited(checkUpdate()); } return _current; } Future _refreshStore({required ExtensionsState current, bool forceRefresh = false}) async { final revision = ++_storeRevision; state = AsyncValue.data(current.copyWith(loadingStore: true, loadingMoreStore: false)); try { final page = await ref .read(gopeedSiteApiProvider) .getExtensions( page: 1, limit: 20, sort: current.storeSort, query: current.storeQuery, forceRefresh: forceRefresh, ); if (!ref.mounted || revision != _storeRevision) return; state = AsyncValue.data( _current.copyWith(storeExtensions: page.data, storePagination: page.pagination, loadingStore: false), ); } catch (_) { if (!ref.mounted || revision != _storeRevision) return; state = AsyncValue.data(_current.copyWith(loadingStore: false)); rethrow; } } Future _runBusy(String id, Future Function() action) async { if (_current.busyExtensionIds.contains(id)) return; state = AsyncValue.data(_current.copyWith(busyExtensionIds: {..._current.busyExtensionIds, id})); try { await action(); } finally { if (ref.mounted) { final busy = Set.of(_current.busyExtensionIds)..remove(id); state = AsyncValue.data(_current.copyWith(busyExtensionIds: busy)); } } } void _bumpStoreInstallCount(String id) { if (!ref.mounted) return; final current = _current; final index = current.storeExtensions.indexWhere((e) => e.id == id); if (index < 0) return; final ext = current.storeExtensions[index]; final updated = [...current.storeExtensions]; updated[index] = StoreExtension( id: ext.id, repoFullName: ext.repoFullName, repoUrl: ext.repoUrl, directory: ext.directory, commitSha: ext.commitSha, name: ext.name, author: ext.author, title: ext.title, description: ext.description, icon: ext.icon, version: ext.version, homepage: ext.homepage, readme: ext.readme, installCount: ext.installCount + 1, stars: ext.stars, topics: ext.topics, createdAt: ext.createdAt, updatedAt: ext.updatedAt, hasDetails: ext.hasDetails, ); state = AsyncValue.data(current.copyWith(storeExtensions: updated)); } void _reportInstallSafe(String id) { if (!ref.mounted) return; final siteApi = ref.read(gopeedSiteApiProvider); unawaited(() async { try { await siteApi.reportExtensionInstall(id); } catch (_) {} }()); } }