diff --git a/aves10.zip b/aves10.zip new file mode 100644 index 00000000..5f0995aa Binary files /dev/null and b/aves10.zip differ diff --git a/aves12.apk b/aves12.apk new file mode 100644 index 00000000..eb8a79fc Binary files /dev/null and b/aves12.apk differ diff --git a/aves12.zip b/aves12.zip new file mode 100644 index 00000000..a445fbb2 Binary files /dev/null and b/aves12.zip differ diff --git a/aves13.apk b/aves13.apk new file mode 100644 index 00000000..04037e01 Binary files /dev/null and b/aves13.apk differ diff --git a/aves13d.apk b/aves13d.apk new file mode 100644 index 00000000..2c511607 Binary files /dev/null and b/aves13d.apk differ diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index a2bf2c0a..2ce425ab 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -208,29 +208,71 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place updateTags(); } - void addEntries(Set entries, {bool notify = true}) { - if (entries.isEmpty) return; +void addEntries(Set entries, {bool notify = true}) { + if (entries.isEmpty) return; - final newIdMapEntries = Map.fromEntries(entries.map((entry) => MapEntry(entry.id, entry))); - if (_rawEntries.isNotEmpty) { - final newIds = newIdMapEntries.keys.toSet(); - _rawEntries.removeWhere((entry) => newIds.contains(entry.id)); - } + // ✅ DEDUPE per URI (evita raddoppi quando lo stesso media entra con ID diverso) + final newUris = entries + .map((e) => e.uri) + .whereType() + .where((u) => u.isNotEmpty) + .toSet(); - entries.where((entry) => entry.catalogDateMillis == null).forEach((entry) { - entry.catalogDateMillis = _savedDates[entry.id]; + if (newUris.isNotEmpty && _rawEntries.isNotEmpty) { + final removedByUri = {}; + + _rawEntries.removeWhere((entry) { + final u = entry.uri; + final match = u != null && newUris.contains(u); + if (match) removedByUri.add(entry); + return match; }); - _entryById.addAll(newIdMapEntries); - _rawEntries.addAll(entries); - _invalidate(entries: entries, notify: notify); - - addDirectories(albums: _applyHiddenFilters(entries).map((entry) => entry.directory).toSet(), notify: notify); - if (notify) { - eventBus.fire(EntryAddedEvent(entries)); + // rimuovi anche dalla mappa id->entry per non lasciare "zombie" + if (removedByUri.isNotEmpty) { + for (final old in removedByUri) { + _entryById.remove(old.id); + } } } + // Deduplica per ID (comportamento originale) + final newIdMapEntries = Map.fromEntries(entries.map((entry) => MapEntry(entry.id, entry))); + if (_rawEntries.isNotEmpty) { + final newIds = newIdMapEntries.keys.toSet(); + _rawEntries.removeWhere((entry) => newIds.contains(entry.id)); + } + + entries.where((entry) => entry.catalogDateMillis == null).forEach((entry) { + entry.catalogDateMillis = _savedDates[entry.id]; + }); + + _entryById.addAll(newIdMapEntries); + _rawEntries.addAll(entries); + _invalidate(entries: entries, notify: notify); + + addDirectories( + albums: _applyHiddenFilters(entries).map((entry) => entry.directory).toSet(), + notify: notify, + ); + if (notify) { + eventBus.fire(EntryAddedEvent(entries)); + } +} + +void removeEntriesFromMemory(Set entries, {bool notify = true}) { + if (entries.isEmpty) return; + + for (final e in entries) { + _entryById.remove(e.id); + } + _rawEntries.removeAll(entries); + updateDerivedFilters(entries); + if (notify) { + eventBus.fire(EntryRemovedEvent(entries)); + } +} + Future removeEntries(Set uris, {required bool includeTrash}) async { if (uris.isEmpty) return; diff --git a/lib/model/source/collection_source.dart.old b/lib/model/source/collection_source.dart.orig similarity index 86% rename from lib/model/source/collection_source.dart.old rename to lib/model/source/collection_source.dart.orig index cde71918..9e46b30b 100644 --- a/lib/model/source/collection_source.dart.old +++ b/lib/model/source/collection_source.dart.orig @@ -1,3 +1,4 @@ +// lib/model/source/collection_source.dart import 'dart:async'; import 'dart:ui'; @@ -40,6 +41,9 @@ import 'package:leak_tracker/leak_tracker.dart'; typedef SourceScope = Set?; +// Trace opzionale: mostra chi nasconderebbe i remoti (non li nascondiamo) +const bool kTraceHiddenRemotes = true; + mixin SourceBase { EventBus get eventBus; @@ -147,16 +151,31 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place } Set _getAppHiddenFilters() => { - ...settings.hiddenFilters, - ...vaults.vaultDirectories.where(vaults.isLocked).map((v) => StoredAlbumFilter(v, null)), - }; + ...settings.hiddenFilters, + ...vaults.vaultDirectories.where(vaults.isLocked).map((v) => StoredAlbumFilter(v, null)), + }; Iterable _applyHiddenFilters(Iterable entries) { final hiddenFilters = { TrashFilter.instance, ..._getAppHiddenFilters(), }; - return entries.where((entry) => !hiddenFilters.any((filter) => filter.test(entry))); + + // PATCH B: non nascondere i remoti con i filtri "nascosti" (tranne Trash) + return entries.where((entry) { + if (entry.origin == 1) { + if (kTraceHiddenRemotes) { + final hiddenBy = hiddenFilters.firstWhereOrNull((f) => f.test(entry)); + if (hiddenBy != null && !TrashFilter.instance.test(entry)) { + debugPrint('[hidden][trace] remote id=${entry.id} rid=${entry.remoteId} by=${hiddenBy.runtimeType}'); + } + } + // remoti: nascondi solo se nel cestino + return !TrashFilter.instance.test(entry); + } + // locali: logica originale + return !hiddenFilters.any((filter) => filter.test(entry)); + }); } Iterable _applyTrashFilter(Iterable entries) { @@ -189,29 +208,58 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place updateTags(); } - void addEntries(Set entries, {bool notify = true}) { - if (entries.isEmpty) return; +void addEntries(Set entries, {bool notify = true}) { + if (entries.isEmpty) return; - final newIdMapEntries = Map.fromEntries(entries.map((entry) => MapEntry(entry.id, entry))); - if (_rawEntries.isNotEmpty) { - final newIds = newIdMapEntries.keys.toSet(); - _rawEntries.removeWhere((entry) => newIds.contains(entry.id)); - } + // ✅ DEDUPE per URI (evita raddoppi quando lo stesso media entra con ID diverso) + final newUris = entries + .map((e) => e.uri) + .whereType() + .where((u) => u.isNotEmpty) + .toSet(); - entries.where((entry) => entry.catalogDateMillis == null).forEach((entry) { - entry.catalogDateMillis = _savedDates[entry.id]; + if (newUris.isNotEmpty && _rawEntries.isNotEmpty) { + final removedByUri = {}; + + _rawEntries.removeWhere((entry) { + final u = entry.uri; + final match = u != null && newUris.contains(u); + if (match) removedByUri.add(entry); + return match; }); - _entryById.addAll(newIdMapEntries); - _rawEntries.addAll(entries); - _invalidate(entries: entries, notify: notify); - - addDirectories(albums: _applyHiddenFilters(entries).map((entry) => entry.directory).toSet(), notify: notify); - if (notify) { - eventBus.fire(EntryAddedEvent(entries)); + // rimuovi anche dalla mappa id->entry per non lasciare "zombie" + if (removedByUri.isNotEmpty) { + for (final old in removedByUri) { + _entryById.remove(old.id); + } } } + // Deduplica per ID (comportamento originale) + final newIdMapEntries = Map.fromEntries(entries.map((entry) => MapEntry(entry.id, entry))); + if (_rawEntries.isNotEmpty) { + final newIds = newIdMapEntries.keys.toSet(); + _rawEntries.removeWhere((entry) => newIds.contains(entry.id)); + } + + entries.where((entry) => entry.catalogDateMillis == null).forEach((entry) { + entry.catalogDateMillis = _savedDates[entry.id]; + }); + + _entryById.addAll(newIdMapEntries); + _rawEntries.addAll(entries); + _invalidate(entries: entries, notify: notify); + + addDirectories( + albums: _applyHiddenFilters(entries).map((entry) => entry.directory).toSet(), + notify: notify, + ); + if (notify) { + eventBus.fire(EntryAddedEvent(entries)); + } +} + Future removeEntries(Set uris, {required bool includeTrash}) async { if (uris.isEmpty) return; @@ -242,6 +290,27 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place // caller should take care of updating these at the right time } + /// Carica dal DB tutte le entry **remote** (`origin=1`) non cestinate + /// e le aggiunge alla sorgente corrente (evitando duplicati per ID). + /// + /// 👉 Va chiamato **dopo** che la sorgente locale è stata inizializzata + /// (es. subito dopo `await source.init(...)` nel tuo `home_page.dart`). + Future appendRemoteEntries({bool notify = true}) async { + try { + final remotes = await localMediaDb.loadEntries(origin: 1); + if (remotes.isEmpty) return; + + // Manteniamo visibili solo quelli non cestinati + final visibleRemotes = remotes.where((e) => !e.trashed).toSet(); + if (visibleRemotes.isEmpty) return; + + // Merge usando la logica standard (aggiorna mappe, invalida, eventi, filtri, ecc.) + addEntries(visibleRemotes, notify: notify); + } catch (e, st) { + debugPrint('CollectionSource.appendRemoteEntries error: $e\n$st'); + } + } + Future _moveEntry(AvesEntry entry, Map newFields, {required bool persist}) async { newFields.keys.forEach((key) { final newValue = newFields[key]; diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart index bbcd0631..878fb688 100644 --- a/lib/model/source/media_store_source.dart +++ b/lib/model/source/media_store_source.dart @@ -1,5 +1,3 @@ -// lib/model/source/media_store_source.dart - import 'dart:async'; import 'package:aves/model/covers.dart'; @@ -20,9 +18,9 @@ import 'package:aves/utils/debouncer.dart'; import 'package:aves_model/aves_model.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; -import 'package:sqflite/sqflite.dart' show Sqflite; -// ⭐⭐⭐ AGGIUNTA: definizione origine remota ⭐⭐⭐ +// ⭐⭐⭐ (temporaneo) origine remota come nel tuo corrente. +// ATTENZIONE: poi la cambieremo perché colliderebbe con EntryOrigins.unknownContent (=1) const int ORIGIN_REMOTE = 1; class MediaStoreSource extends CollectionSource { @@ -98,31 +96,13 @@ class MediaStoreSource extends CollectionSource { final stopwatch = Stopwatch()..start(); state = SourceState.loading; + // ✅ STEP A: come Aves originale — pulizia SOLO in memoria, NON nel DB + clearEntries(); + final scopeAlbumFilters = _targetScope?.whereType(); final scopeDirectory = scopeAlbumFilters != null && scopeAlbumFilters.length == 1 ? scopeAlbumFilters.first.album : null; - // 🔒 Sentinella: conteggio remoti PRIMA - final preRem = Sqflite.firstIntValue( - await localMediaDb.rawDb.rawQuery('SELECT COUNT(*) FROM entry WHERE origin=$ORIGIN_REMOTE'), - ) ?? - 0; - debugPrint('[cleanup][pre] remoti in DB = $preRem'); - - // 🔧 PATCH: cancella SOLO i locali - final swClear = Stopwatch()..start(); - final deletedLocal = await localMediaDb.rawDb - .rawDelete('DELETE FROM entry WHERE origin = ?', [EntryOrigins.mediaStoreContent]); - swClear.stop(); - debugPrint('$runtimeType load ${swClear.elapsed} clear local entries deleted $deletedLocal rows'); - - // 🔒 Sentinella: conteggio remoti DOPO - final postRem = Sqflite.firstIntValue( - await localMediaDb.rawDb.rawQuery('SELECT COUNT(*) FROM entry WHERE origin=$ORIGIN_REMOTE'), - ) ?? - 0; - debugPrint('[cleanup][post] remoti in DB = $postRem (Δ=${postRem - preRem})'); - final Set topEntries = {}; if (loadTopEntriesFirst) { final topIds = settings.topEntryIds?.toSet(); @@ -424,6 +404,8 @@ class MediaStoreSource extends CollectionSource { _lastGeneration = await mediaStoreService.getGeneration(); } + // vault + Future _loadVaultEntries(String? directory) async { addEntries(await localMediaDb.loadEntries(origin: EntryOrigins.vault, directory: directory)); } diff --git a/lib/remote/collection_source_remote_ext.dart b/lib/remote/collection_source_remote_ext.dart index 53cfa3b5..45329f9d 100644 --- a/lib/remote/collection_source_remote_ext.dart +++ b/lib/remote/collection_source_remote_ext.dart @@ -1,25 +1,64 @@ // lib/remote/collection_source_remote_ext.dart +import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/services/common/services.dart'; import 'package:flutter/foundation.dart'; extension CollectionSourceRemoteExt on CollectionSource { /// Carica dal DB tutte le entry remote (origin=1) non cestinate - /// e le aggiunge alla CollectionSource, con log di diagnostica. + /// e le aggiunge alla CollectionSource evitando duplicati in memoria. Future appendRemoteEntriesFromDb() async { - // 1) carica dal DB - final remoti = await localMediaDb.loadEntries(origin: 1); + // 1) carica dal DB (qui è Set nella tua base di codice) + final Set remoti = await localMediaDb.loadEntries(origin: 1); debugPrint('[remote-append] candidati=${remoti.length}'); - // 2) filtra visibili (!!! booleano, NON e.trashed == 0) - final visibili = remoti.where((e) => !e.trashed).toSet(); - debugPrint('[remote-append] visibili=${visibili.length}'); + // 2) filtra visibili + final Iterable visibili = remoti.where((e) => !e.trashed); + final int visCount = visibili.length; + debugPrint('[remote-append] visibili=$visCount'); + + // 3) chiavi già presenti nella Source (per evitare doppioni in memoria) + final Set existingRemoteIds = allEntries + .where((e) => e.origin == 1 && !e.trashed) + .map((e) => e.remoteId) + .whereType() + .toSet(); + + final Set existingUris = allEntries + .where((e) => e.origin == 1 && !e.trashed) + .map((e) => e.uri) + .whereType() + .toSet(); + + // 4) dedupe deterministica “dentro il batch” per remoteId/uri + final Map byKey = {}; + for (final e in visibili) { + final rid = e.remoteId; + final key = (rid != null && rid.isNotEmpty) ? 'rid:$rid' : 'uri:${e.uri}'; + byKey[key] = e; + } + + // 5) prendi solo quelli non già presenti in memoria + final Set toAdd = {}; + for (final e in byKey.values) { + final rid = e.remoteId; + final u = e.uri; + + final bool alreadyInMemory = + (rid != null && rid.isNotEmpty && existingRemoteIds.contains(rid)) || + (u != null && u.isNotEmpty && existingUris.contains(u)); + + if (!alreadyInMemory) { + toAdd.add(e); + } + } - // 3) aggiungi alla source (usa allEntries, non "entries") final prima = allEntries.where((e) => e.origin == 1 && !e.trashed).length; - addEntries(visibili); + addEntries(toAdd); final dopo = allEntries.where((e) => e.origin == 1 && !e.trashed).length; debugPrint('[remote-append] appese=${dopo - prima} (prima=$prima -> dopo=$dopo)'); } } + + diff --git a/lib/remote/collection_source_remote_ext.dart.ok b/lib/remote/collection_source_remote_ext.dart.ok new file mode 100644 index 00000000..53cfa3b5 --- /dev/null +++ b/lib/remote/collection_source_remote_ext.dart.ok @@ -0,0 +1,25 @@ +// lib/remote/collection_source_remote_ext.dart +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/services/common/services.dart'; +import 'package:flutter/foundation.dart'; + +extension CollectionSourceRemoteExt on CollectionSource { + /// Carica dal DB tutte le entry remote (origin=1) non cestinate + /// e le aggiunge alla CollectionSource, con log di diagnostica. + Future appendRemoteEntriesFromDb() async { + // 1) carica dal DB + final remoti = await localMediaDb.loadEntries(origin: 1); + debugPrint('[remote-append] candidati=${remoti.length}'); + + // 2) filtra visibili (!!! booleano, NON e.trashed == 0) + final visibili = remoti.where((e) => !e.trashed).toSet(); + debugPrint('[remote-append] visibili=${visibili.length}'); + + // 3) aggiungi alla source (usa allEntries, non "entries") + final prima = allEntries.where((e) => e.origin == 1 && !e.trashed).length; + addEntries(visibili); + final dopo = allEntries.where((e) => e.origin == 1 && !e.trashed).length; + + debugPrint('[remote-append] appese=${dopo - prima} (prima=$prima -> dopo=$dopo)'); + } +} diff --git a/lib/remote/collection_source_remote_ext.dart.old b/lib/remote/collection_source_remote_ext.dart.old new file mode 100644 index 00000000..ff13be7c --- /dev/null +++ b/lib/remote/collection_source_remote_ext.dart.old @@ -0,0 +1,36 @@ +// lib/remote/collection_source_remote_ext.dart +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/services/common/services.dart'; +import 'package:flutter/foundation.dart'; + +extension CollectionSourceRemoteExt on CollectionSource { + /// Warm-start: carica dal DB le entry LOCALI (origin=0) e le aggiunge alla Source + Future appendLocalEntriesFromDb() async { + final locals = await localMediaDb.loadEntries(origin: 0); + debugPrint('[local-append] candidati=${locals.length}'); + + final visibili = locals.where((e) => !e.trashed && e.isDisplayable).toSet(); + debugPrint('[local-append] visibili=${visibili.length}'); + + final prima = allEntries.where((e) => e.origin == 0 && !e.trashed).length; + addEntries(visibili); + final dopo = allEntries.where((e) => e.origin == 0 && !e.trashed).length; + + debugPrint('[local-append] appese=${dopo - prima} (prima=$prima -> dopo=$dopo)'); + } + + /// Warm-start: carica dal DB tutte le entry REMOTE (origin=1) non cestinate + Future appendRemoteEntriesFromDb() async { + final remoti = await localMediaDb.loadEntries(origin: 1); + debugPrint('[remote-append] candidati=${remoti.length}'); + + final visibili = remoti.where((e) => !e.trashed).toSet(); + debugPrint('[remote-append] visibili=${visibili.length}'); + + final prima = allEntries.where((e) => e.origin == 1 && !e.trashed).length; + addEntries(visibili); + final dopo = allEntries.where((e) => e.origin == 1 && !e.trashed).length; + + debugPrint('[remote-append] appese=${dopo - prima} (prima=$prima -> dopo=$dopo)'); + } +} diff --git a/lib/remote/remote_controller.dart b/lib/remote/remote_controller.dart new file mode 100644 index 00000000..483e7773 --- /dev/null +++ b/lib/remote/remote_controller.dart @@ -0,0 +1,221 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/remote/remote_settings.dart'; +import 'package:aves/remote/remote_sync_bus.dart'; +import 'package:aves/remote/remote_repository.dart'; +import 'package:aves/services/common/services.dart'; +import 'package:aves/remote/remote_client.dart'; +import 'package:aves/remote/auth_client.dart'; +import 'package:aves/remote/collection_source_remote_ext.dart'; + +class RemoteController { + RemoteController._(); + static final RemoteController instance = RemoteController._(); + + static const _kBootstrapDone = 'remote_bootstrap_done'; + bool _syncInFlight = false; + + Future bootstrapDone() async { + final storage = FlutterSecureStorage(); + return (await storage.read(key: _kBootstrapDone)) == '1'; + } + + Future _setBootstrapDone() async { + final storage = FlutterSecureStorage(); + await storage.write(key: _kBootstrapDone, value: '1'); + } + + /// Chiamare all’avvio: imposta lo stato icona (grigio/verde) coerente con settings. + Future initBusFromSettings() async { + final s = await RemoteSettings.load(); + if (!s.enabled) { + RemoteSyncBus.instance.setDisabled(); + } else { + // enabled: stato iniziale "upToDate" (poi la sync può cambiare) + final opId = RemoteSyncBus.instance.start(total: 0, showOverlay: false); + RemoteSyncBus.instance.finishUpToDate(opId: opId); + } + } + + /// Logica d’avvio app: + /// - se remote OFF -> nascondi remoti dalla UI (memoria only) e stop + /// - se remote ON: + /// - se bootstrap done -> append DB immediato + sync silenzioso + /// - se bootstrap NOT done -> opzionale resume bootstrap + Future onAppStart({ + required CollectionSource source, + bool resumeBootstrapIfEnabled = true, + }) async { + final s = await RemoteSettings.load(); + + if (!s.enabled) { + RemoteSyncBus.instance.setDisabled(); + final remotesInMemory = source.allEntries.where((e) => e.origin == 1).toSet(); + if (remotesInMemory.isNotEmpty) { + source.removeEntriesFromMemory(remotesInMemory); + } + return; + } + + final done = await bootstrapDone(); + if (done) { + await source.appendRemoteEntriesFromDb(); + // sync in background (solo icona) + unawaited(fullSync(source: source, showOverlay: false)); + } else { + if (resumeBootstrapIfEnabled) { + unawaited(fullSync( + source: source, + showOverlay: true, + markBootstrapDoneOnSuccess: true, + )); + } + } + } + + /// Toggle da icona (tap) + Future toggleRemote({required CollectionSource source}) async { + final s = await RemoteSettings.load(); + + if (s.enabled) { + // TURN OFF + final upd = RemoteSettings( + enabled: false, + baseUrl: s.baseUrl, + indexPath: s.indexPath, + email: s.email, + password: s.password, + ); + await upd.save(); + + debugPrint('[remote] toggle -> enabled=false (OFF)'); + + // nascondi remoti (memoria only) + final remotesInMemory = source.allEntries.where((e) => e.origin == 1).toSet(); + if (remotesInMemory.isNotEmpty) { + source.removeEntriesFromMemory(remotesInMemory); + } + + // invalida sync in corso e icona grigia + RemoteSyncBus.instance.setDisabled(); + debugPrint('[remote] toggled OFF -> removed remotes from memory=${remotesInMemory.length}'); + return; + } + + // TURN ON + final upd = RemoteSettings( + enabled: true, + baseUrl: s.baseUrl, + indexPath: s.indexPath, + email: s.email, + password: s.password, + ); + await upd.save(); + + debugPrint('[remote] toggle -> enabled=true (ON)'); + + final first = !(await bootstrapDone()); + if (first) { + debugPrint('[remote] first enable -> FULL sync with overlay'); + await fullSync( + source: source, + showOverlay: true, + markBootstrapDoneOnSuccess: true, + ); + return; + } + + debugPrint('[remote] enable -> append DB then background sync'); + await source.appendRemoteEntriesFromDb(); + unawaited(fullSync(source: source, showOverlay: false)); + } + + /// Full sync remoto: + /// - showOverlay=true solo bootstrap + /// - showOverlay=false -> solo icona + Future fullSync({ + required CollectionSource source, + required bool showOverlay, + bool markBootstrapDoneOnSuccess = false, + }) async { + if (_syncInFlight) { + debugPrint('[remote] sync skipped (already in flight)'); + return; + } + _syncInFlight = true; + + final s = await RemoteSettings.load(); + if (!s.enabled) { + RemoteSyncBus.instance.setDisabled(); + _syncInFlight = false; + return; + } + + // Start: token opId (protezione anti-race) + final opId = RemoteSyncBus.instance.start(total: 0, showOverlay: showOverlay); + + try { + // base URL vuota -> server down + if (s.baseUrl.trim().isEmpty) { + debugPrint('[remote] serverDown (empty baseUrl)'); + RemoteSyncBus.instance.failServerDown(opId: opId); + return; + } + + RemoteAuth? auth; + if (s.email.isNotEmpty && s.password.isNotEmpty) { + auth = RemoteAuth(baseUrl: s.baseUrl, email: s.email, password: s.password); + } + + final client = RemoteJsonClient(s.baseUrl, s.indexPath, auth: auth); + + // fetch full list + final items = await client.fetchAll().timeout(const Duration(seconds: 30)); + final total = items.length; + + debugPrint('[remote] sync start overlay=$showOverlay total=$total'); + + // aggiorna total corretto + RemoteSyncBus.instance.update(opId: opId, done: 0, total: total); + + final repo = RemoteRepository(localMediaDb.rawDb); + await repo.deleteAllRemotes(); + + const chunkSize = 200; + int done = 0; + final serverIds = items.map((e) => e.id).where((v) => v.isNotEmpty).toSet(); + + for (var offset = 0; offset < total; offset += chunkSize) { + final end = (offset + chunkSize < total) ? offset + chunkSize : total; + await repo.upsertAll(items.sublist(offset, end), chunkSize: chunkSize); + done = end; + RemoteSyncBus.instance.update(opId: opId, done: done, total: total); + } + + await repo.pruneMissingRemotes(serverIds); + + // mostra remoti in UI (dopo sync) + await source.appendRemoteEntriesFromDb(); + + debugPrint('[remote] sync done'); + + if (markBootstrapDoneOnSuccess) { + await _setBootstrapDone(); + } + + RemoteSyncBus.instance.finishUpToDate(opId: opId); + } on TimeoutException { + debugPrint('[remote] serverDown (timeout)'); + RemoteSyncBus.instance.failServerDown(opId: opId); + } catch (e) { + debugPrint('[remote] serverDown (error=$e)'); + RemoteSyncBus.instance.failServerDown(opId: opId); + } finally { + _syncInFlight = false; + } + } +} diff --git a/lib/remote/remote_image_tile.dart b/lib/remote/remote_image_tile.dart index 56e32109..91556b76 100644 --- a/lib/remote/remote_image_tile.dart +++ b/lib/remote/remote_image_tile.dart @@ -1,35 +1,290 @@ // lib/remote/remote_image_tile.dart -import 'package:flutter/material.dart'; -import 'remote_http.dart'; -import 'package:aves/model/entry/entry.dart'; +import 'dart:ui' show FontFeature; -class RemoteImageTile extends StatelessWidget { +import 'package:flutter/material.dart'; +import 'package:aves/model/entry/entry.dart'; +import 'package:aves/model/entry/extensions/props.dart'; // entry.isVideo, durationText +import 'package:aves/remote/remote_http.dart'; + +/// Miniatura per contenuti **remoti** con overlay coerente a quello dei locali: +/// - Icona Play in basso-sinistra se è video +/// - Chip durata in basso-destra se `entry.durationMillis` è disponibile +/// +/// Fix principali: +/// 1) Mai "grigio stuck": retry automatico se la prima richiesta fallisce +/// 2) Preload: precache del thumb corrente + (opzionale) prefetch dei prossimi N thumb +class RemoteImageTile extends StatefulWidget { final AvesEntry entry; - const RemoteImageTile({super.key, required this.entry}); + final double borderRadius; + final BoxFit fit; + + // personalizzazioni overlay + final Color? overlayIconBg; + final Color? overlayIconFg; + final Color? durationBg; + final Color? durationFg; + + /// (Opzionale) lista di path relativi (thumb/path) da precaricare + /// Es: [nextRel1, nextRel2, ...] in ordine di priorità. + final List? prefetchRelPaths; + + /// Quanti prefetch eseguire al massimo (se prefetchRelPaths != null) + final int prefetchCount; + + /// Quanti tentativi di retry per la stessa tile (consigliato 1 o 2) + final int maxRetry; + + const RemoteImageTile({ + super.key, + required this.entry, + this.borderRadius = 12.0, + this.fit = BoxFit.cover, + this.overlayIconBg, + this.overlayIconFg, + this.durationBg, + this.durationFg, + this.prefetchRelPaths, + this.prefetchCount = 18, + this.maxRetry = 1, + }); + + @override + State createState() => _RemoteImageTileState(); +} + +class _RemoteImageTileState extends State { + late Future> _headersFuture; + int _attempt = 0; + bool _selfPrecached = false; + bool _neighborsPrecached = false; + + bool get _isRemote => widget.entry.origin == 1; + + @override + void initState() { + super.initState(); + // headers() dovrebbe essere già “cacheata” nel tuo RemoteHttp, ma la + // memorizziamo per non ricreare il Future ad ogni build. + _headersFuture = RemoteHttp.headers(); + } + + @override + void didUpdateWidget(covariant RemoteImageTile oldWidget) { + super.didUpdateWidget(oldWidget); + + // Se cambia entry o cambia url base, resettare stato retry/precache + if (oldWidget.entry.id != widget.entry.id || + oldWidget.entry.remoteThumb2 != widget.entry.remoteThumb2 || + oldWidget.entry.remoteThumb1 != widget.entry.remoteThumb1 || + oldWidget.entry.remotePath != widget.entry.remotePath || + oldWidget.entry.path != widget.entry.path) { + _attempt = 0; + _selfPrecached = false; + _neighborsPrecached = false; + _headersFuture = RemoteHttp.headers(); + } + + // Se cambia lista prefetch, consentiamo di rifarla + if (oldWidget.prefetchRelPaths != widget.prefetchRelPaths) { + _neighborsPrecached = false; + } + } @override Widget build(BuildContext context) { - // Usa SOLO campi remoti, mai entry.path - final rel = entry.remoteThumb2 ?? entry.remoteThumb1 ?? entry.remotePath; - if (rel == null || rel.isEmpty) { - return const ColoredBox(color: Colors.black12); - } - final url = RemoteHttp.absUrl(rel); + final entry = widget.entry; - return FutureBuilder>( - future: RemoteHttp.headers(), - builder: (context, snap) { - if (snap.connectionState != ConnectionState.done) { - return const ColoredBox(color: Colors.black12); - } - final hdrs = snap.data ?? const {}; - return Image.network( - url, - fit: BoxFit.cover, - headers: hdrs.isEmpty ? null : hdrs, - errorBuilder: (_, __, ___) => const Icon(Icons.broken_image), - ); - }, + final rel = entry.remoteThumb2 ?? entry.remoteThumb1 ?? entry.remotePath ?? entry.path; + if (!_isRemote || rel == null || rel.isEmpty) { + return _frame(context, const ColoredBox(color: Colors.black12)); + } + + final url = RemoteHttp.absUrl(rel); + final ar = (entry.displayAspectRatio > 0) ? entry.displayAspectRatio : 1.0; + + return AspectRatio( + aspectRatio: ar, + child: FutureBuilder>( + future: _headersFuture, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return _frame(context, const ColoredBox(color: Colors.black12), showProgress: true); + } + + final hdrs = snap.data ?? const {}; + + // ImageProvider “canonico” (serve anche per precache) + final provider = NetworkImage(url, headers: hdrs.isEmpty ? null : hdrs); + + // ✅ Precache del thumb corrente (una volta sola) + if (!_selfPrecached) { + _selfPrecached = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + precacheImage(provider, context); + }); + } + + // ✅ Prefetch dei prossimi N (se forniti) + if (!_neighborsPrecached && widget.prefetchRelPaths != null && widget.prefetchRelPaths!.isNotEmpty) { + _neighborsPrecached = true; + final next = widget.prefetchRelPaths! + .where((p) => p.isNotEmpty) + .take(widget.prefetchCount) + .map((p) => RemoteHttp.absUrl(p)) + .toList(growable: false); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + for (final u in next) { + precacheImage(NetworkImage(u, headers: hdrs.isEmpty ? null : hdrs), context); + } + }); + } + + final img = Image( + image: provider, + fit: widget.fit, + // ✅ mentre scarica: spinner (così non è "grigio stuck") + loadingBuilder: (context, child, progress) { + if (progress == null) return child; + return _frame(context, const ColoredBox(color: Colors.black12), showProgress: true); + }, + // ✅ se fallisce: retry automatico (1 volta di default) poi fallback soft + errorBuilder: (_, __, ___) { + if (_attempt < widget.maxRetry) { + // piccolo delay per evitare loop immediati + Future.delayed(const Duration(milliseconds: 150), () { + if (!mounted) return; + setState(() => _attempt++); + }); + // nel frattempo spinner (non grigio fisso) + return _frame(context, const ColoredBox(color: Colors.black12), showProgress: true); + } + // fallback definitivo: box neutro (ma NON rimane bloccato al primo errore) + return _frame(context, const ColoredBox(color: Colors.black26)); + }, + ); + + return _frame(context, img); + }, + ), + ); + } + + Widget _frame(BuildContext context, Widget child, {bool showProgress = false}) { + final theme = Theme.of(context); + final radius = BorderRadius.circular(widget.borderRadius); + + // Video detection robusta (isVideo + mime + estensione path) + final mime = (widget.entry.sourceMimeType ?? widget.entry.mimeType ?? '').toLowerCase(); + final p = (widget.entry.path ?? widget.entry.remotePath ?? '').toLowerCase(); + final looksVideo = p.endsWith('.mp4') || p.endsWith('.mov') || p.endsWith('.m4v') || p.endsWith('.mkv') || p.endsWith('.webm'); + final isVideo = widget.entry.isVideo || mime.startsWith('video/') || looksVideo; + + final showDuration = isVideo && (widget.entry.durationMillis ?? 0) > 0; + + return ClipRRect( + borderRadius: radius, + child: Stack( + fit: StackFit.expand, + children: [ + Positioned.fill(child: child), + + if (showProgress) + const Center( + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 1.5), + ), + ), + + if (isVideo) ...[ + Positioned( + left: 6, + bottom: 6, + child: _PlayBadge( + bg: widget.overlayIconBg ?? Colors.black.withOpacity(.55), + fg: widget.overlayIconFg ?? Colors.white, + ), + ), + if (showDuration) + Positioned( + right: 6, + bottom: 6, + child: _DurationChip( + text: widget.entry.durationText, + bg: widget.durationBg ?? Colors.black.withOpacity(.65), + fg: widget.durationFg ?? Colors.white, + borderRadius: 10, + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + textStyle: theme.textTheme.labelSmall?.copyWith( + color: widget.durationFg ?? Colors.white, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + ], + ], + ), + ); + } +} + +class _PlayBadge extends StatelessWidget { + final Color bg; + final Color fg; + + const _PlayBadge({ + required this.bg, + required this.fg, + }); + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration(color: bg, shape: BoxShape.circle), + child: Padding( + padding: const EdgeInsets.all(6), + child: Icon(Icons.play_arrow_rounded, color: fg, size: 16), + ), + ); + } +} + +class _DurationChip extends StatelessWidget { + final String text; + final Color bg; + final Color fg; + final double borderRadius; + final EdgeInsets padding; + final TextStyle? textStyle; + + const _DurationChip({ + super.key, + required this.text, + required this.bg, + required this.fg, + this.borderRadius = 10, + this.padding = const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + this.textStyle, + }); + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(borderRadius), + ), + child: Padding( + padding: padding, + child: Text( + text, + style: textStyle ?? Theme.of(context).textTheme.labelSmall?.copyWith(color: fg), + ), + ), ); } } diff --git a/lib/remote/remote_image_tile.dart.old b/lib/remote/remote_image_tile.dart.old new file mode 100644 index 00000000..6cdaa402 --- /dev/null +++ b/lib/remote/remote_image_tile.dart.old @@ -0,0 +1,189 @@ +// lib/remote/remote_image_tile.dart +import 'package:flutter/material.dart'; +import 'package:aves/model/entry/entry.dart'; +import 'package:aves/model/entry/extensions/props.dart'; // <-- necessario per entry.isVideo +import 'package:aves/remote/remote_http.dart'; + +/// Miniatura per contenuti **remoti** con overlay coerente a quello dei locali: +/// - Icona Play in basso-sinistra se è video +/// - Chip durata in basso-destra se `entry.durationMillis` è disponibile +/// +/// Nota: per mostrare la durata è necessario che `entry.durationMillis` sia valorizzato nel DB. +/// Se non c'è (es. il server non la fornisce), mostreremo comunque il badge Play. +class RemoteImageTile extends StatelessWidget { + final AvesEntry entry; + final double borderRadius; + final BoxFit fit; + final Color? overlayIconBg; // per personalizzare il cerchio dietro l'icona + final Color? overlayIconFg; + final Color? durationBg; + final Color? durationFg; + + const RemoteImageTile({ + super.key, + required this.entry, + this.borderRadius = 12.0, + this.fit = BoxFit.cover, // Se vuoi evitare crop in grid usa BoxFit.contain + this.overlayIconBg, + this.overlayIconFg, + this.durationBg, + this.durationFg, + }); + + bool get _isRemote => entry.origin == 1; + + @override + Widget build(BuildContext context) { + // URL assoluto per thumb o path remoto + final rel = entry.remoteThumb2 ?? entry.remoteThumb1 ?? entry.remotePath ?? entry.path; + if (!_isRemote || rel == null || rel.isEmpty) { + // Fallback: niente immagine -> box vuoto con bg tenue + return _frame( + context, + const ColoredBox(color: Colors.black12), + ); + } + + final url = RemoteHttp.absUrl(rel); + final ar = (entry.displayAspectRatio > 0) ? entry.displayAspectRatio : 1.0; + + return AspectRatio( + aspectRatio: ar, + child: FutureBuilder>( + future: RemoteHttp.headers(), + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return _frame( + context, + const ColoredBox(color: Colors.black12), + showProgress: true, + ); + } + final hdrs = snap.data ?? const {}; + final img = Image.network( + url, + fit: fit, + headers: hdrs.isEmpty ? null : hdrs, + errorBuilder: (_, __, ___) => const ColoredBox(color: Colors.black26), + ); + return _frame(context, img); + }, + ), + ); + } + + Widget _frame(BuildContext context, Widget child, {bool showProgress = false}) { + final theme = Theme.of(context); + final radius = BorderRadius.circular(borderRadius); + + final isVideo = entry.isVideo; // <-- disponibile grazie a props.dart + final showDuration = isVideo && (entry.durationMillis ?? 0) > 0; + + return ClipRRect( + borderRadius: radius, + child: Stack( + fit: StackFit.expand, + children: [ + Positioned.fill(child: child), + + // Progress (placeholder) opzionale + if (showProgress) + const Center( + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 1.5), + ), + ), + + // === OVERLAY VIDEO === + if (isVideo) ...[ + // Badge Play (in basso a sinistra) + Positioned( + left: 6, + bottom: 6, + child: _PlayBadge( + bg: overlayIconBg ?? Colors.black.withOpacity(.55), + fg: overlayIconFg ?? Colors.white, + ), + ), + // Chip Durata (in basso a destra), se disponibile + if (showDuration) + Positioned( + right: 6, + bottom: 6, + child: _DurationChip( + text: entry.durationText, + bg: durationBg ?? Colors.black.withOpacity(.65), + fg: durationFg ?? Colors.white, + borderRadius: 10, + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + textStyle: theme.textTheme.labelSmall?.copyWith( + color: durationFg ?? Colors.white, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + ], + ], + ), + ); + } +} + +class _PlayBadge extends StatelessWidget { + final Color bg; + final Color fg; + + const _PlayBadge({ + required this.bg, + required this.fg, + }); + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration(color: bg, shape: BoxShape.circle), + child: Padding( + padding: const EdgeInsets.all(6), + child: Icon(Icons.play_arrow_rounded, color: fg, size: 16), + ), + ); + } +} + +class _DurationChip extends StatelessWidget { + final String text; + final Color bg; + final Color fg; + final double borderRadius; + final EdgeInsets padding; + final TextStyle? textStyle; + + const _DurationChip({ + super.key, + required this.text, + required this.bg, + required this.fg, + this.borderRadius = 10, + this.padding = const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + this.textStyle, + }); + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(borderRadius), + ), + child: Padding( + padding: padding, + child: Text( + text, + style: textStyle ?? Theme.of(context).textTheme.labelSmall?.copyWith(color: fg), + ), + ), + ); + } +} diff --git a/lib/remote/remote_models.dart b/lib/remote/remote_models.dart index e2c4b39c..7fb5c229 100644 --- a/lib/remote/remote_models.dart +++ b/lib/remote/remote_models.dart @@ -43,10 +43,13 @@ class RemotePhotoItem { // Costruzione URL assoluto delegata a utility (in base alle impostazioni) String absoluteUrl(String baseUrl) => buildAbsoluteUri(baseUrl, path).toString(); - static DateTime? _tryParseIsoUtc(dynamic v) { if (v == null) return null; - try { return DateTime.parse(v.toString()).toUtc(); } catch (_) { return null; } + try { + return DateTime.parse(v.toString()).toUtc(); + } catch (_) { + return null; + } } static double? _toDouble(dynamic v) { @@ -55,6 +58,7 @@ class RemotePhotoItem { return double.tryParse(v.toString()); } + /// Converte secondi→ms se < 1000, altrimenti assume già millisecondi. static int? _toMillis(dynamic v) { if (v == null) return null; final num? n = (v is num) ? v : num.tryParse(v.toString()); @@ -85,7 +89,10 @@ class RemotePhotoItem { lng: gps != null ? _toDouble(gps['lng']) : null, alt: gps != null ? _toDouble(gps['alt']) : null, user: j['user']?.toString(), - durationMillis: _toMillis(j['duration']), + + // ⬇️ QUI LA MODIFICA: usiamo duration_ms (ms dal server) + durationMillis: _toMillis(j['duration_ms']), + location: loc, ); } @@ -115,14 +122,14 @@ class RemoteLocation { }); factory RemoteLocation.fromJson(Map j) => RemoteLocation( - continent: j['continent']?.toString(), - country: j['country']?.toString(), - region: j['region']?.toString(), - postcode: j['postcode']?.toString(), - city: j['city']?.toString(), - countyCode:j['county_code']?.toString(), - address: j['address']?.toString(), - timezone: j['timezone']?.toString(), - timeOffset:j['time']?.toString(), - ); + continent: j['continent']?.toString(), + country: j['country']?.toString(), + region: j['region']?.toString(), + postcode: j['postcode']?.toString(), + city: j['city']?.toString(), + countyCode: j['county_code']?.toString(), + address: j['address']?.toString(), + timezone: j['timezone']?.toString(), + timeOffset: j['time']?.toString(), + ); } diff --git a/lib/remote/remote_repository.dart b/lib/remote/remote_repository.dart index 4d8fbf1b..8d631e6b 100644 --- a/lib/remote/remote_repository.dart +++ b/lib/remote/remote_repository.dart @@ -3,7 +3,7 @@ import 'package:flutter/foundation.dart' show debugPrint; import 'package:sqflite/sqflite.dart'; import 'remote_models.dart'; -import 'remote_db_uris.dart'; // <-- helper per URI fittizi aves-remote://... +import 'remote_db_uris.dart'; // helper per URI fittizi aves-remote://... class RemoteRepository { final Database db; @@ -40,27 +40,28 @@ class RemoteRepository { } } -/// Assicura che tutte le entry remote abbiano un uri costruito da remoteId. -Future ensureRemoteUris(DatabaseExecutor dbExec) async { - try { - await dbExec.execute(''' - UPDATE entry - SET uri = 'remote://' || remoteId - WHERE origin = 1 - AND remoteId IS NOT NULL - AND remoteId != '' - AND (uri IS NULL OR uri = '' OR uri NOT LIKE 'remote://%'); - '''); - debugPrint('[RemoteRepository] ensureRemoteUris: migration applied'); - } catch (e, st) { - debugPrint('[RemoteRepository] ensureRemoteUris error: $e\n$st'); + /// Assicura che tutte le entry remote abbiano un uri costruito da remoteId. + /// (coerente con aves-remote://rid/...) + Future ensureRemoteUris(DatabaseExecutor dbExec) async { + try { + await dbExec.execute(''' + UPDATE entry + SET uri = 'aves-remote://rid/' || replace(remoteId, ' ', '') + WHERE origin = 1 + AND remoteId IS NOT NULL + AND trim(remoteId) != '' + AND (uri IS NULL OR trim(uri) = '' OR uri NOT LIKE 'aves-remote://%'); + '''); + debugPrint('[RemoteRepository] ensureRemoteUris: migration applied'); + } catch (e, st) { + debugPrint('[RemoteRepository] ensureRemoteUris error: $e\n$st'); + } } -} - /// Assicura che le colonne GPS e alcune colonne "remote*" esistano nella tabella `entry`. + /// Assicura che le colonne necessarie esistano nella tabella `entry`. Future _ensureEntryColumns(DatabaseExecutor dbExec) async { await _ensureColumns(dbExec, table: 'entry', columnsAndTypes: const { - // Core (alcune basi legacy potrebbero non averle ancora) + // Core 'uri': 'TEXT', // GPS @@ -77,13 +78,14 @@ Future ensureRemoteUris(DatabaseExecutor dbExec) async { 'provider': 'TEXT', 'trashed': 'INTEGER', 'remoteRotation': 'INTEGER', + + // ✅ Durata video (ms) + 'durationMillis': 'INTEGER', }); - // Indice "normale" per velocizzare il lookup su remoteId + // indice lookup try { - await dbExec.execute( - 'CREATE INDEX IF NOT EXISTS idx_entry_remoteId ON entry(remoteId);', - ); + await dbExec.execute('CREATE INDEX IF NOT EXISTS idx_entry_remoteId ON entry(remoteId);'); } catch (e, st) { debugPrint('[RemoteRepository] create index error: $e\n$st'); } @@ -107,10 +109,9 @@ Future ensureRemoteUris(DatabaseExecutor dbExec) async { } catch (e) { if (!_isBusy(e) || i == maxAttempts - 1) rethrow; await Future.delayed(delay); - delay *= 2; // 250 → 500 → 1000 ms + delay *= 2; } } - // non dovrebbe arrivare qui return await fn(); } @@ -125,8 +126,8 @@ Future ensureRemoteUris(DatabaseExecutor dbExec) async { return s; } - /// Candidato "canonico" (inserisce '/original/' dopo '/photos//' - /// se manca). Usato per lookup/fallback. + /// Candidato canonico: inserisce '/original/' dopo '/photos//' + /// se manca. Usato per lookup/fallback. String _canonCandidate(String? rawPath, String fileName) { var s = _normPath(rawPath); final seg = s.split('/'); // ['', 'photos', '', maybe 'original', ...] @@ -145,7 +146,7 @@ Future ensureRemoteUris(DatabaseExecutor dbExec) async { bool _isVideoItem(RemotePhotoItem it) { final mt = (it.mimeType ?? '').toLowerCase(); - final p = (it.path).toLowerCase(); + final p = it.path.toLowerCase(); return mt.startsWith('video/') || p.endsWith('.mp4') || p.endsWith('.mov') || @@ -154,72 +155,62 @@ Future ensureRemoteUris(DatabaseExecutor dbExec) async { p.endsWith('.webm'); } -Map _buildEntryRow(RemotePhotoItem it, {int? existingId}) { - // ============================================================ - // REMARK ORIGINALE (da ripristinare quando avrai ImageProvider) - final syntheticUri = RemoteDbUris.make(remoteId: it.id, remotePath: it.path); - // ============================================================ + Map _buildEntryRow(RemotePhotoItem it, {int? existingId}) { + final syntheticUri = RemoteDbUris.make(remoteId: it.id, remotePath: it.path); - // TEMPORARY FIX: usare URL HTTP basato su thub2 - //final syntheticUri = 'https://prova.patachina.it/${it.thub2}'; - //final syntheticUri = 'https://picsum.photos/400'; + int _makeContentId() { + final base = (it.id.isNotEmpty ? it.id : it.path); + final h = base.hashCode & 0x7fffffff; + return 1_000_000_000 + (h % 900_000_000); + } + final nowMs = DateTime.now().millisecondsSinceEpoch; + final dateModMs = it.takenAtUtc?.millisecondsSinceEpoch ?? nowMs; - int _makeContentId() { - final base = (it.id.isNotEmpty ? it.id : it.path); - final h = base.hashCode & 0x7fffffff; - return 1_000_000_000 + (h % 900_000_000); + return { + 'id': existingId, + 'contentId': _makeContentId(), + + 'uri': syntheticUri, + + 'path': it.path, + + // ✅ fallback MIME video se mancante + 'sourceMimeType': it.mimeType ?? (_isVideoItem(it) ? 'video/mp4' : 'image/jpeg'), + + 'width': it.width ?? 0, + 'height': it.height ?? 0, + 'sourceRotationDegrees': it.rotation ?? 0, + + 'sizeBytes': it.sizeBytes, + + 'title': it.name, + 'dateAddedSecs': nowMs ~/ 1000, + 'dateModifiedMillis': dateModMs, + 'sourceDateTakenMillis': it.takenAtUtc?.millisecondsSinceEpoch, + + // ✅ durata video (ms) (può essere null per foto) + 'durationMillis': it.durationMillis, + + 'trashed': 0, + 'origin': 1, + 'provider': 'json@patachina', + + 'latitude': it.lat, + 'longitude': it.lng, + 'altitude': it.alt, + + 'remoteId': it.id, + 'remotePath': it.path, + 'remoteThumb1': it.thub1, + 'remoteThumb2': it.thub2, + 'remoteRotation': it.rotation ?? 0, + + 'remoteWidth': it.width ?? 0, + 'remoteHeight': it.height ?? 0, + }; } - final nowMs = DateTime.now().millisecondsSinceEpoch; - final dateModMs = it.takenAtUtc?.millisecondsSinceEpoch ?? nowMs; - - return { - 'id': existingId, - 'contentId': _makeContentId(), - - // ✔️ URI HTTP temporaneo - 'uri': syntheticUri, - - // ✔️ MIME sempre valorizzato - 'path': it.path, - 'sourceMimeType': it.mimeType ?? 'image/jpeg', - - // ✔️ width/height sempre valorizzati - 'width': it.width ?? 0, - 'height': it.height ?? 0, - - // ✔️ rotation sempre valorizzata - 'sourceRotationDegrees': it.rotation ?? 0, - - 'sizeBytes': it.sizeBytes, - - 'title': it.name, - 'dateAddedSecs': nowMs ~/ 1000, - 'dateModifiedMillis': dateModMs, - 'sourceDateTakenMillis': it.takenAtUtc?.millisecondsSinceEpoch, - 'durationMillis': it.durationMillis, - - 'trashed': 0, - 'origin': 1, - 'provider': 'json@patachina', - - 'latitude': it.lat, - 'longitude': it.lng, - 'altitude': it.alt, - - 'remoteId': it.id, - 'remotePath': it.path, - 'remoteThumb1': it.thub1, - 'remoteThumb2': it.thub2, - 'remoteRotation': it.rotation ?? 0, - - // ✔️ remoteWidth/remoteHeight sempre valorizzati - 'remoteWidth': it.width ?? 0, - 'remoteHeight': it.height ?? 0, - }; -} - Map _buildAddressRow(int newId, RemoteLocation location) { return { 'id': newId, @@ -262,15 +253,14 @@ Map _buildEntryRow(RemotePhotoItem it, {int? existingId}) { final batch = txn.batch(); for (final it in chunk) { - // Log essenziale (puoi silenziare dopo i test) final raw = it.path; final norm = _normPath(raw); - final cand = _canonCandidate(raw, it.name); - debugPrint('[repo-upsert] in: rid=${it.id.substring(0,8)} name=${it.name} raw="$raw"'); + final cand = _canonCandidate(raw, it.name); // name non-null nel tuo modello + debugPrint('[repo-upsert] in: rid=${it.id.substring(0, 8)} name=${it.name} raw="$raw"'); - // Lookup record esistente: - // 1) per remoteId int? existingId; + + // 1) lookup per remoteId try { final existing = await txn.query( 'entry', @@ -284,7 +274,7 @@ Map _buildEntryRow(RemotePhotoItem it, {int? existingId}) { debugPrint('[RemoteRepository] lookup by remoteId failed for remoteId=${it.id}: $e\n$st'); } - // 2) fallback per remotePath = candidato canonico (/original/) + // 2) fallback per remotePath canonico if (existingId == null) { try { final byCanon = await txn.query( @@ -294,15 +284,13 @@ Map _buildEntryRow(RemotePhotoItem it, {int? existingId}) { whereArgs: [cand], limit: 1, ); - if (byCanon.isNotEmpty) { - existingId = byCanon.first['id'] as int?; - } + if (byCanon.isNotEmpty) existingId = byCanon.first['id'] as int?; } catch (e, st) { debugPrint('[RemoteRepository] lookup by canonical path failed "$cand": $e\n$st'); } } - // 3) ultimo fallback per remotePath "raw normalizzato" (solo slash) + // 3) fallback per remotePath normalizzato if (existingId == null) { try { final byNorm = await txn.query( @@ -312,15 +300,12 @@ Map _buildEntryRow(RemotePhotoItem it, {int? existingId}) { whereArgs: [norm], limit: 1, ); - if (byNorm.isNotEmpty) { - existingId = byNorm.first['id'] as int?; - } + if (byNorm.isNotEmpty) existingId = byNorm.first['id'] as int?; } catch (e, st) { debugPrint('[RemoteRepository] lookup by normalized path failed "$norm": $e\n$st'); } } - // Riga completa e REPLACE final row = _buildEntryRow(it, existingId: existingId); try { @@ -332,6 +317,7 @@ Map _buildEntryRow(RemotePhotoItem it, {int? existingId}) { } on DatabaseException catch (e, st) { debugPrint('[RemoteRepository] batch insert failed for remoteId=${it.id}: $e\n$st'); + // fallback: rimuovi gps se causa problemi final rowNoGps = Map.from(row) ..remove('latitude') ..remove('longitude') @@ -374,7 +360,7 @@ Map _buildEntryRow(RemotePhotoItem it, {int? existingId}) { } })); } catch (e, st) { - debugPrint('[RemoteRepository] upsert chunk ${offset}..${end - 1} ERROR: $e\n$st'); + debugPrint('[RemoteRepository] upsert chunk $offset..${end - 1} ERROR: $e\n$st'); rethrow; } } @@ -384,7 +370,6 @@ Map _buildEntryRow(RemotePhotoItem it, {int? existingId}) { // Unicità & deduplica // ========================= - /// Indice UNICO su `remoteId` limitato alle righe remote (origin=1). Future ensureUniqueRemoteId() async { try { await db.execute( @@ -397,7 +382,6 @@ Map _buildEntryRow(RemotePhotoItem it, {int? existingId}) { } } - /// Indice UNICO su `remotePath` (solo remoti) per prevenire doppi. Future ensureUniqueRemotePath() async { try { await db.execute( @@ -410,7 +394,6 @@ Map _buildEntryRow(RemotePhotoItem it, {int? existingId}) { } } - /// Dedup per `remoteId`, tenendo l’ultima riga. Future deduplicateRemotes() async { try { final deleted = await db.rawDelete( @@ -429,7 +412,6 @@ Map _buildEntryRow(RemotePhotoItem it, {int? existingId}) { } } - /// Dedup per `remotePath` (match esatto), tenendo l’ultima riga. Future deduplicateByRemotePath() async { try { final deleted = await db.rawDelete( @@ -448,15 +430,64 @@ Map _buildEntryRow(RemotePhotoItem it, {int? existingId}) { } } + // ========================= + // Bootstrap / prune remoti + // ========================= + + /// Bootstrap: cancella TUTTI i remoti + Future deleteAllRemotes() async { + try { + final deleted = await db.rawDelete('DELETE FROM entry WHERE origin=1'); + debugPrint('[RemoteRepository] deleteAllRemotes deleted=$deleted'); + return deleted; + } catch (e, st) { + debugPrint('[RemoteRepository] deleteAllRemotes error: $e\n$st'); + return 0; + } + } + + /// FULL sync: elimina i remoti NON più presenti nel serverRemoteIds + /// (hard-delete) + Future pruneMissingRemotes(Set serverRemoteIds) async { + if (serverRemoteIds.isEmpty) return 0; + + try { + final deleted = await db.transaction((txn) async { + await txn.execute('CREATE TEMP TABLE IF NOT EXISTS tmp_remote_ids(remoteId TEXT PRIMARY KEY);'); + await txn.execute('DELETE FROM tmp_remote_ids;'); + + final batch = txn.batch(); + for (final id in serverRemoteIds) { + batch.insert( + 'tmp_remote_ids', + {'remoteId': id}, + conflictAlgorithm: ConflictAlgorithm.ignore, + ); + } + await batch.commit(noResult: true); + + final deleted = await txn.rawDelete(''' + DELETE FROM entry + WHERE origin=1 + AND remoteId IS NOT NULL + AND remoteId NOT IN (SELECT remoteId FROM tmp_remote_ids) + '''); + return deleted; + }); + + debugPrint('[RemoteRepository] pruneMissingRemotes deleted=$deleted'); + return deleted; + } catch (e, st) { + debugPrint('[RemoteRepository] pruneMissingRemotes error: $e\n$st'); + return 0; + } + } + // ========================= // Backfill URI fittizi per remoti legacy // ========================= - /// Imposta un URI fittizio `aves-remote://...` per tutte le righe remote - /// con `uri` NULL/vuoto. Prima prova a usare `remoteId` (SQL puro), - /// poi completa i rimanenti (senza remoteId) in un loop Dart usando `remotePath`. Future backfillRemoteUris() async { - // 1) Backfill via SQL per chi ha remoteId (più veloce) try { final updated = await db.rawUpdate( "UPDATE entry " @@ -468,13 +499,12 @@ Map _buildEntryRow(RemotePhotoItem it, {int? existingId}) { debugPrint('[RemoteRepository] backfill URIs (SQL) error: $e\n$st'); } - // 2) Loop Dart per i (pochi) rimanenti senza remoteId ma con remotePath try { final rows = await db.rawQuery( "SELECT id, remotePath FROM entry " "WHERE origin=1 AND (uri IS NULL OR trim(uri)='') " "AND (remoteId IS NULL OR trim(remoteId)='') " - "AND remotePath IS NOT NULL" + "AND remotePath IS NOT NULL", ); if (rows.isNotEmpty) { @@ -498,15 +528,14 @@ Map _buildEntryRow(RemotePhotoItem it, {int? existingId}) { } // ========================= - // Backfill ESSENZIALI (contentId, dateModifiedMillis) per remoti legacy + // Backfill essentials per remoti legacy // ========================= Future backfillRemoteEssentials() async { - // 1) backfill contentId sintetico per remoti con contentId NULL/<=0 try { final rows = await db.rawQuery( "SELECT id, remoteId, remotePath FROM entry " - "WHERE origin=1 AND (contentId IS NULL OR contentId<=0)" + "WHERE origin=1 AND (contentId IS NULL OR contentId<=0)", ); if (rows.isNotEmpty) { for (final r in rows) { @@ -514,7 +543,7 @@ Map _buildEntryRow(RemotePhotoItem it, {int? existingId}) { final rid = (r['remoteId'] as String?) ?? ''; final rpath = (r['remotePath'] as String?) ?? ''; final base = rid.isNotEmpty ? rid : rpath; - final h = base.hashCode & 0x7fffffff; // positivo + final h = base.hashCode & 0x7fffffff; final cid = 1_000_000_000 + (h % 900_000_000); await db.update('entry', {'contentId': cid}, where: 'id=?', whereArgs: [id]); } @@ -524,7 +553,6 @@ Map _buildEntryRow(RemotePhotoItem it, {int? existingId}) { debugPrint('[RemoteRepository] backfill contentId error: $e\n$st'); } - // 2) backfill dateModifiedMillis (usa sourceDateTakenMillis se presente, altrimenti now) try { final nowMs = DateTime.now().millisecondsSinceEpoch; final updated = await db.rawUpdate( @@ -538,27 +566,15 @@ Map _buildEntryRow(RemotePhotoItem it, {int? existingId}) { } } - // ========================= - // Helper combinato: pulizia + indici + backfill URI/ESSENZIALI - // ========================= - Future sanitizeRemotes() async { await deduplicateRemotes(); - await deduplicateByRemotePath(); // opzionale ma utile + await deduplicateByRemotePath(); await ensureUniqueRemoteId(); await ensureUniqueRemotePath(); - - // Assicura che ogni remoto abbia un uri fittizio valorizzato await backfillRemoteUris(); - - // Assicura che i remoti abbiano contentId/dateModifiedMillis validi await backfillRemoteEssentials(); } - // ========================= - // Utils - // ========================= - Future countRemote() async { final rows = await db.rawQuery('SELECT COUNT(1) AS c FROM entry WHERE origin=1'); return (rows.first['c'] as int?) ?? 0; diff --git a/lib/remote/remote_repository.dart.old b/lib/remote/remote_repository.dart.old new file mode 100644 index 00000000..4a1034c2 --- /dev/null +++ b/lib/remote/remote_repository.dart.old @@ -0,0 +1,567 @@ +// lib/remote/remote_repository.dart +import 'package:flutter/foundation.dart' show debugPrint; +import 'package:sqflite/sqflite.dart'; + +import 'remote_models.dart'; +import 'remote_db_uris.dart'; // <-- helper per URI fittizi aves-remote://... + +class RemoteRepository { + final Database db; + RemoteRepository(this.db); + + // ========================= + // Helpers PRAGMA / schema + // ========================= + + Future _ensureColumns( + DatabaseExecutor dbExec, { + required String table, + required Map columnsAndTypes, + }) async { + try { + final rows = await dbExec.rawQuery('PRAGMA table_info($table);'); + final existing = rows.map((r) => (r['name'] as String)).toSet(); + + for (final entry in columnsAndTypes.entries) { + final col = entry.key; + final typ = entry.value; + if (!existing.contains(col)) { + final sql = 'ALTER TABLE $table ADD COLUMN $col $typ;'; + try { + await dbExec.execute(sql); + debugPrint('[RemoteRepository] executed: $sql'); + } catch (e, st) { + debugPrint('[RemoteRepository] failed to execute $sql: $e\n$st'); + } + } + } + } catch (e, st) { + debugPrint('[RemoteRepository] _ensureColumns($table) error: $e\n$st'); + } + } + +/// Assicura che tutte le entry remote abbiano un uri costruito da remoteId. +Future ensureRemoteUris(DatabaseExecutor dbExec) async { + try { + await dbExec.execute(''' + UPDATE entry + SET uri = 'remote://' || remoteId + WHERE origin = 1 + AND remoteId IS NOT NULL + AND remoteId != '' + AND (uri IS NULL OR uri = '' OR uri NOT LIKE 'remote://%'); + '''); + debugPrint('[RemoteRepository] ensureRemoteUris: migration applied'); + } catch (e, st) { + debugPrint('[RemoteRepository] ensureRemoteUris error: $e\n$st'); + } +} + + /// Assicura che le colonne GPS e alcune colonne "remote*" esistano nella tabella `entry`. + Future _ensureEntryColumns(DatabaseExecutor dbExec) async { + await _ensureColumns(dbExec, table: 'entry', columnsAndTypes: const { + // Core (alcune basi legacy potrebbero non averle ancora) + 'uri': 'TEXT', + + // GPS + 'latitude': 'REAL', + 'longitude': 'REAL', + 'altitude': 'REAL', + + // Campi remoti + 'remoteId': 'TEXT', + 'remotePath': 'TEXT', + 'remoteThumb1': 'TEXT', + 'remoteThumb2': 'TEXT', + 'origin': 'INTEGER', + 'provider': 'TEXT', + 'trashed': 'INTEGER', + 'remoteRotation': 'INTEGER', + 'durationMillis': 'INTEGER', + }); + + // Indice "normale" per velocizzare il lookup su remoteId + try { + await dbExec.execute( + 'CREATE INDEX IF NOT EXISTS idx_entry_remoteId ON entry(remoteId);', + ); + } catch (e, st) { + debugPrint('[RemoteRepository] create index error: $e\n$st'); + } + } + + // ========================= + // Retry su SQLITE_BUSY + // ========================= + + bool _isBusy(Object e) { + final s = e.toString(); + return s.contains('SQLITE_BUSY') || s.contains('database is locked'); + } + + Future _withRetryBusy(Future Function() fn) async { + const maxAttempts = 3; + var delay = const Duration(milliseconds: 250); + for (var i = 0; i < maxAttempts; i++) { + try { + return await fn(); + } catch (e) { + if (!_isBusy(e) || i == maxAttempts - 1) rethrow; + await Future.delayed(delay); + delay *= 2; // 250 → 500 → 1000 ms + } + } + // non dovrebbe arrivare qui + return await fn(); + } + + // ========================= + // Normalizzazione (solo supporto) + // ========================= + + String _normPath(String? p) { + if (p == null || p.isEmpty) return ''; + var s = p.trim().replaceAll(RegExp(r'/+'), '/'); + if (!s.startsWith('/')) s = '/$s'; + return s; + } + + /// Candidato "canonico" (inserisce '/original/' dopo '/photos//' + /// se manca). Usato per lookup/fallback. + String _canonCandidate(String? rawPath, String fileName) { + var s = _normPath(rawPath); + final seg = s.split('/'); // ['', 'photos', '', maybe 'original', ...] + if (seg.length >= 4 && seg[1] == 'photos' && seg[3] != 'original' && seg[3] != 'thumbs') { + seg.insert(3, 'original'); + } + if (fileName.isNotEmpty) { + seg[seg.length - 1] = fileName; + } + return seg.join('/'); + } + + // ========================= + // Utilities + // ========================= + + bool _isVideoItem(RemotePhotoItem it) { + final mt = (it.mimeType ?? '').toLowerCase(); + final p = (it.path).toLowerCase(); + return mt.startsWith('video/') || + p.endsWith('.mp4') || + p.endsWith('.mov') || + p.endsWith('.m4v') || + p.endsWith('.mkv') || + p.endsWith('.webm'); + } + +Map _buildEntryRow(RemotePhotoItem it, {int? existingId}) { + // ============================================================ + // REMARK ORIGINALE (da ripristinare quando avrai ImageProvider) + final syntheticUri = RemoteDbUris.make(remoteId: it.id, remotePath: it.path); + // ============================================================ + + // TEMPORARY FIX: usare URL HTTP basato su thub2 + //final syntheticUri = 'https://prova.patachina.it/${it.thub2}'; + //final syntheticUri = 'https://picsum.photos/400'; + + + int _makeContentId() { + final base = (it.id.isNotEmpty ? it.id : it.path); + final h = base.hashCode & 0x7fffffff; + return 1_000_000_000 + (h % 900_000_000); + } + + final nowMs = DateTime.now().millisecondsSinceEpoch; + final dateModMs = it.takenAtUtc?.millisecondsSinceEpoch ?? nowMs; + + return { + 'id': existingId, + 'contentId': _makeContentId(), + + // ✔️ URI HTTP temporaneo + 'uri': syntheticUri, + + // ✔️ MIME sempre valorizzato + 'path': it.path, + 'sourceMimeType': it.mimeType ?? 'image/jpeg', + + // ✔️ width/height sempre valorizzati + 'width': it.width ?? 0, + 'height': it.height ?? 0, + + // ✔️ rotation sempre valorizzata + 'sourceRotationDegrees': it.rotation ?? 0, + + 'sizeBytes': it.sizeBytes, + + 'title': it.name, + 'dateAddedSecs': nowMs ~/ 1000, + 'dateModifiedMillis': dateModMs, + 'sourceDateTakenMillis': it.takenAtUtc?.millisecondsSinceEpoch, + 'durationMillis': it.durationMillis, + + 'trashed': 0, + 'origin': 1, + 'provider': 'json@patachina', + + 'latitude': it.lat, + 'longitude': it.lng, + 'altitude': it.alt, + + 'remoteId': it.id, + 'remotePath': it.path, + 'remoteThumb1': it.thub1, + 'remoteThumb2': it.thub2, + 'remoteRotation': it.rotation ?? 0, + + // ✔️ remoteWidth/remoteHeight sempre valorizzati + 'remoteWidth': it.width ?? 0, + 'remoteHeight': it.height ?? 0, + }; +} + + Map _buildAddressRow(int newId, RemoteLocation location) { + return { + 'id': newId, + 'addressLine': location.address, + 'countryCode': null, + 'countryName': location.country, + 'adminArea': location.region, + 'locality': location.city, + }; + } + + // ========================= + // Upsert a chunk (con fallback robusti) + // ========================= + + Future upsertAll(List items, {int chunkSize = 200}) async { + debugPrint('RemoteRepository.upsertAll: items=${items.length}'); + if (items.isEmpty) return; + + await _withRetryBusy(() => _ensureEntryColumns(db)); + + // Protezione DB: crea indici unici dove mancano + await ensureUniqueRemoteId(); + await ensureUniqueRemotePath(); + + // Ordina: prima immagini, poi video + final images = []; + final videos = []; + for (final it in items) { + (_isVideoItem(it) ? videos : images).add(it); + } + final ordered = [...images, ...videos]; + + for (var offset = 0; offset < ordered.length; offset += chunkSize) { + final end = (offset + chunkSize < ordered.length) ? offset + chunkSize : ordered.length; + final chunk = ordered.sublist(offset, end); + + try { + await _withRetryBusy(() => db.transaction((txn) async { + final batch = txn.batch(); + + for (final it in chunk) { + // Log essenziale (puoi silenziare dopo i test) + final raw = it.path; + final norm = _normPath(raw); + final cand = _canonCandidate(raw, it.name); + debugPrint('[repo-upsert] in: rid=${it.id.substring(0,8)} name=${it.name} raw="$raw"'); + + // Lookup record esistente: + // 1) per remoteId + int? existingId; + try { + final existing = await txn.query( + 'entry', + columns: ['id'], + where: 'origin=1 AND remoteId = ?', + whereArgs: [it.id], + limit: 1, + ); + existingId = existing.isNotEmpty ? (existing.first['id'] as int?) : null; + } catch (e, st) { + debugPrint('[RemoteRepository] lookup by remoteId failed for remoteId=${it.id}: $e\n$st'); + } + + // 2) fallback per remotePath = candidato canonico (/original/) + if (existingId == null) { + try { + final byCanon = await txn.query( + 'entry', + columns: ['id'], + where: 'origin=1 AND remotePath = ?', + whereArgs: [cand], + limit: 1, + ); + if (byCanon.isNotEmpty) { + existingId = byCanon.first['id'] as int?; + } + } catch (e, st) { + debugPrint('[RemoteRepository] lookup by canonical path failed "$cand": $e\n$st'); + } + } + + // 3) ultimo fallback per remotePath "raw normalizzato" (solo slash) + if (existingId == null) { + try { + final byNorm = await txn.query( + 'entry', + columns: ['id'], + where: 'origin=1 AND remotePath = ?', + whereArgs: [norm], + limit: 1, + ); + if (byNorm.isNotEmpty) { + existingId = byNorm.first['id'] as int?; + } + } catch (e, st) { + debugPrint('[RemoteRepository] lookup by normalized path failed "$norm": $e\n$st'); + } + } + + // Riga completa e REPLACE + final row = _buildEntryRow(it, existingId: existingId); + + try { + batch.insert( + 'entry', + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } on DatabaseException catch (e, st) { + debugPrint('[RemoteRepository] batch insert failed for remoteId=${it.id}: $e\n$st'); + + final rowNoGps = Map.from(row) + ..remove('latitude') + ..remove('longitude') + ..remove('altitude'); + + batch.insert( + 'entry', + rowNoGps, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + } + + await batch.commit(noResult: true); + + // Secondo pass per address (se disponibile) + for (final it in chunk) { + if (it.location == null) continue; + + try { + final rows = await txn.query( + 'entry', + columns: ['id'], + where: 'origin=1 AND remoteId = ?', + whereArgs: [it.id], + limit: 1, + ); + if (rows.isEmpty) continue; + final newId = rows.first['id'] as int; + + final addr = _buildAddressRow(newId, it.location!); + await txn.insert( + 'address', + addr, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } catch (e, st) { + debugPrint('[RemoteRepository] insert address failed for remoteId=${it.id}: $e\n$st'); + } + } + })); + } catch (e, st) { + debugPrint('[RemoteRepository] upsert chunk ${offset}..${end - 1} ERROR: $e\n$st'); + rethrow; + } + } + } + + // ========================= + // Unicità & deduplica + // ========================= + + /// Indice UNICO su `remoteId` limitato alle righe remote (origin=1). + Future ensureUniqueRemoteId() async { + try { + await db.execute( + 'CREATE UNIQUE INDEX IF NOT EXISTS uq_entry_remote_remoteId ' + 'ON entry(remoteId) WHERE origin=1', + ); + debugPrint('[RemoteRepository] ensured UNIQUE index on entry(remoteId) for origin=1'); + } catch (e, st) { + debugPrint('[RemoteRepository] ensureUniqueRemoteId error: $e\n$st'); + } + } + + /// Indice UNICO su `remotePath` (solo remoti) per prevenire doppi. + Future ensureUniqueRemotePath() async { + try { + await db.execute( + 'CREATE UNIQUE INDEX IF NOT EXISTS uq_entry_remote_remotePath ' + 'ON entry(remotePath) WHERE origin=1 AND remotePath IS NOT NULL', + ); + debugPrint('[RemoteRepository] ensured UNIQUE index on entry(remotePath) for origin=1'); + } catch (e, st) { + debugPrint('[RemoteRepository] ensureUniqueRemotePath error: $e\n$st'); + } + } + + /// Dedup per `remoteId`, tenendo l’ultima riga. + Future deduplicateRemotes() async { + try { + final deleted = await db.rawDelete( + 'DELETE FROM entry ' + 'WHERE origin=1 AND remoteId IS NOT NULL AND id NOT IN (' + ' SELECT MAX(id) FROM entry ' + ' WHERE origin=1 AND remoteId IS NOT NULL ' + ' GROUP BY remoteId' + ')', + ); + debugPrint('[RemoteRepository] deduplicateRemotes deleted=$deleted'); + return deleted; + } catch (e, st) { + debugPrint('[RemoteRepository] deduplicateRemotes error: $e\n$st'); + return 0; + } + } + + /// Dedup per `remotePath` (match esatto), tenendo l’ultima riga. + Future deduplicateByRemotePath() async { + try { + final deleted = await db.rawDelete( + 'DELETE FROM entry ' + 'WHERE origin=1 AND remotePath IS NOT NULL AND id NOT IN (' + ' SELECT MAX(id) FROM entry ' + ' WHERE origin=1 AND remotePath IS NOT NULL ' + ' GROUP BY remotePath' + ')', + ); + debugPrint('[RemoteRepository] deduplicateByRemotePath deleted=$deleted'); + return deleted; + } catch (e, st) { + debugPrint('[RemoteRepository] deduplicateByRemotePath error: $e\n$st'); + return 0; + } + } + + // ========================= + // Backfill URI fittizi per remoti legacy + // ========================= + + /// Imposta un URI fittizio `aves-remote://...` per tutte le righe remote + /// con `uri` NULL/vuoto. Prima prova a usare `remoteId` (SQL puro), + /// poi completa i rimanenti (senza remoteId) in un loop Dart usando `remotePath`. + Future backfillRemoteUris() async { + // 1) Backfill via SQL per chi ha remoteId (più veloce) + try { + final updated = await db.rawUpdate( + "UPDATE entry " + "SET uri = 'aves-remote://rid/' || replace(remoteId, ' ', '') " + "WHERE origin=1 AND (uri IS NULL OR trim(uri)='') AND remoteId IS NOT NULL", + ); + debugPrint('[RemoteRepository] backfill URIs via SQL (remoteId) updated=$updated'); + } catch (e, st) { + debugPrint('[RemoteRepository] backfill URIs (SQL) error: $e\n$st'); + } + + // 2) Loop Dart per i (pochi) rimanenti senza remoteId ma con remotePath + try { + final rows = await db.rawQuery( + "SELECT id, remotePath FROM entry " + "WHERE origin=1 AND (uri IS NULL OR trim(uri)='') " + "AND (remoteId IS NULL OR trim(remoteId)='') " + "AND remotePath IS NOT NULL" + ); + + if (rows.isNotEmpty) { + for (final r in rows) { + final id = (r['id'] as num).toInt(); + final rp = (r['remotePath'] as String?) ?? ''; + final synthetic = RemoteDbUris.make(remotePath: rp); + await db.update( + 'entry', + {'uri': synthetic}, + where: 'id=?', + whereArgs: [id], + conflictAlgorithm: ConflictAlgorithm.ignore, + ); + } + debugPrint('[RemoteRepository] backfill URIs via Dart (remotePath) updated=${rows.length}'); + } + } catch (e, st) { + debugPrint('[RemoteRepository] backfill URIs (Dart) error: $e\n$st'); + } + } + + // ========================= + // Backfill ESSENZIALI (contentId, dateModifiedMillis) per remoti legacy + // ========================= + + Future backfillRemoteEssentials() async { + // 1) backfill contentId sintetico per remoti con contentId NULL/<=0 + try { + final rows = await db.rawQuery( + "SELECT id, remoteId, remotePath FROM entry " + "WHERE origin=1 AND (contentId IS NULL OR contentId<=0)" + ); + if (rows.isNotEmpty) { + for (final r in rows) { + final id = (r['id'] as num).toInt(); + final rid = (r['remoteId'] as String?) ?? ''; + final rpath = (r['remotePath'] as String?) ?? ''; + final base = rid.isNotEmpty ? rid : rpath; + final h = base.hashCode & 0x7fffffff; // positivo + final cid = 1_000_000_000 + (h % 900_000_000); + await db.update('entry', {'contentId': cid}, where: 'id=?', whereArgs: [id]); + } + debugPrint('[RemoteRepository] backfill contentId updated=${rows.length}'); + } + } catch (e, st) { + debugPrint('[RemoteRepository] backfill contentId error: $e\n$st'); + } + + // 2) backfill dateModifiedMillis (usa sourceDateTakenMillis se presente, altrimenti now) + try { + final nowMs = DateTime.now().millisecondsSinceEpoch; + final updated = await db.rawUpdate( + "UPDATE entry SET dateModifiedMillis = COALESCE(sourceDateTakenMillis, ?) " + "WHERE origin=1 AND (dateModifiedMillis IS NULL OR dateModifiedMillis=0)", + [nowMs], + ); + debugPrint('[RemoteRepository] backfill dateModifiedMillis updated=$updated'); + } catch (e, st) { + debugPrint('[RemoteRepository] backfill dateModifiedMillis error: $e\n$st'); + } + } + + // ========================= + // Helper combinato: pulizia + indici + backfill URI/ESSENZIALI + // ========================= + + Future sanitizeRemotes() async { + await deduplicateRemotes(); + await deduplicateByRemotePath(); // opzionale ma utile + await ensureUniqueRemoteId(); + await ensureUniqueRemotePath(); + + // Assicura che ogni remoto abbia un uri fittizio valorizzato + await backfillRemoteUris(); + + // Assicura che i remoti abbiano contentId/dateModifiedMillis validi + await backfillRemoteEssentials(); + } + + // ========================= + // Utils + // ========================= + + Future countRemote() async { + final rows = await db.rawQuery('SELECT COUNT(1) AS c FROM entry WHERE origin=1'); + return (rows.first['c'] as int?) ?? 0; + } +} diff --git a/lib/remote/remote_settings.dart b/lib/remote/remote_settings.dart index cb22f892..5cbbf7a3 100644 --- a/lib/remote/remote_settings.dart +++ b/lib/remote/remote_settings.dart @@ -7,7 +7,7 @@ class RemoteSettings { static const _storage = FlutterSecureStorage( aOptions: AndroidOptions( encryptedSharedPreferences: true, - resetOnError: true, // auto-reset della singola voce cifrata se fallisce la decrittazione + resetOnError: true, ), ); @@ -17,13 +17,16 @@ class RemoteSettings { static const _kEmail = 'remote_email'; static const _kPassword = 'remote_password'; - static final bool defaultEnabled = kDebugMode ? true : false; + // ✅ remote OFF by default ALWAYS + static const bool defaultEnabled = false; + + // ✅ in debug puoi precompilare credenziali/URL, ma NON attivare automaticamente static final String defaultBaseUrl = kDebugMode ? 'https://prova.patachina.it' : ''; static final String defaultIndexPath = kDebugMode ? 'photos/' : ''; static final String defaultEmail = kDebugMode ? 'fabio@gmail.com' : ''; static final String defaultPassword = kDebugMode ? 'master66' : ''; - bool enabled; + bool enabled; String baseUrl; String indexPath; String email; @@ -37,36 +40,30 @@ class RemoteSettings { required this.password, }); - // 🔎 helper: leggi una chiave in modo “safe” e, se fallisce, cancella solo quella static Future _readKeySafe(String key) async { try { return await _storage.read(key: key); } on PlatformException { - // solo questa chiave è corrotta → la pulisco await _storage.delete(key: key); return null; } } - // 🧼 helper: rimuove caratteri invisibili/di controllo tipici che “sporcano” gli URL static String _sanitizeUrl(String s) { - // rimuove BOM, LRM/RLM e altri ‘format characters’ comuni negli incolla const _invisibles = r'[\u200B-\u200F\u202A-\u202E\u2060-\u2064\uFEFF]'; final cleaned = s.replaceAll(RegExp(_invisibles), ''); return cleaned.trim(); } static Future load() async { - // legge *per singola chiave* con fallback ai default final enabledStr = await _readKeySafe(_kEnabled); final rawBase = await _readKeySafe(_kBaseUrl); final indexPath = await _readKeySafe(_kIndexPath) ?? defaultIndexPath; final email = await _readKeySafe(_kEmail) ?? defaultEmail; final password = await _readKeySafe(_kPassword) ?? defaultPassword; + // ✅ defaultEnabled è false sempre final enabled = (enabledStr ?? (defaultEnabled ? 'true' : 'false')) == 'true'; - - // sanitize della base URL (toglie caratteri non alfabetici “invisibili”) final baseUrl = _sanitizeUrl(rawBase ?? defaultBaseUrl); return RemoteSettings( @@ -79,7 +76,6 @@ class RemoteSettings { } Future save() async { - // Sanitize prima di salvare, così evitiamo che restino in storage caratteri strani await _storage.write(key: _kEnabled, value: enabled ? 'true' : 'false'); await _storage.write(key: _kBaseUrl, value: _sanitizeUrl(baseUrl)); await _storage.write(key: _kIndexPath, value: indexPath.trim()); @@ -97,16 +93,18 @@ class RemoteSettings { await _storage.write(key: key, value: value); } } on PlatformException { - // chiave “sporca” → reset di quella sola chiave e poi scrittura await _storage.delete(key: key); await _storage.write(key: key, value: value); } } - await _seed(_kEnabled, defaultEnabled ? 'true' : 'false'); + // ✅ anche in debug: remote parte SPENTO + await _seed(_kEnabled, 'false'); + + // ✅ ma precompila gli altri campi per comodità await _seed(_kBaseUrl, defaultBaseUrl); await _seed(_kIndexPath, defaultIndexPath); await _seed(_kEmail, defaultEmail); await _seed(_kPassword, defaultPassword); } -} +} \ No newline at end of file diff --git a/lib/remote/remote_settings_dialog.dart b/lib/remote/remote_settings_dialog.dart new file mode 100644 index 00000000..1a125571 --- /dev/null +++ b/lib/remote/remote_settings_dialog.dart @@ -0,0 +1,185 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/remote/collection_source_remote_ext.dart'; // appendRemoteEntriesFromDb() +import 'package:aves/remote/remote_controller.dart'; +import 'package:aves/remote/remote_settings.dart'; +import 'package:aves/remote/remote_http.dart'; +import 'package:aves/remote/remote_sync_bus.dart'; + +class RemoteSettingsDialog { + static Future show(BuildContext context) async { + final s = await RemoteSettings.load(); + + final formKey = GlobalKey(); + + // stato locale della dialog (serve StateSetter) + bool enabled = s.enabled; + + final baseUrlC = TextEditingController(text: s.baseUrl); + final indexC = TextEditingController(text: s.indexPath); + final emailC = TextEditingController(text: s.email); + final pwC = TextEditingController(text: s.password); + + String? validateBaseUrl(String? v) { + final txt = (v ?? '').trim(); + if (txt.isEmpty) return 'Obbligatorio'; + final uri = Uri.tryParse(txt); + if (uri == null || !(uri.hasScheme && (uri.scheme == 'http' || uri.scheme == 'https'))) { + return 'URL non valida (deve iniziare con http/https)'; + } + return null; + } + + String? validateIndex(String? v) { + final txt = (v ?? '').trim(); + if (txt.isEmpty) return 'Obbligatorio'; + return null; + } + + Future applyRuntimeEffects({ + required bool newEnabled, + required bool oldEnabled, + }) async { + // Se non ho CollectionSource nel contesto, niente crash: applico solo storage + http. + CollectionSource? source; + try { + source = context.read(); + } catch (_) { + source = null; + } + + // OFF + if (!newEnabled) { + RemoteSyncBus.instance.setDisabled(); + if (source != null) { + final remotesInMemory = source.allEntries.where((e) => e.origin == 1).toSet(); + if (remotesInMemory.isNotEmpty) { + source.removeEntriesFromMemory(remotesInMemory); + } + } + return; + } + + // ON + if (source == null) { + // Non posso mostrare/nascondere, ma almeno imposto lo stato icona coerente + await RemoteController.instance.initBusFromSettings(); + return; + } + + // Se sto passando OFF->ON, voglio fare: + // - prima attivazione: FULL sync con overlay (contatore) + // - successive: append DB + sync silenzioso + final isFirstEnable = !(await RemoteController.instance.bootstrapDone()); + + if (isFirstEnable) { + // FULL sync con overlay + set bootstrap_done on success + await RemoteController.instance.fullSync( + source: source, + showOverlay: true, + markBootstrapDoneOnSuccess: true, + ); + } else { + // Mostra subito cache DB e sync in background silenzioso + await source.appendRemoteEntriesFromDb(); + // non blocchiamo la UI + // ignore: unawaited_futures + RemoteController.instance.fullSync( + source: source, + showOverlay: false, + markBootstrapDoneOnSuccess: false, + ); + } + } + + await showDialog( + context: context, + builder: (_) => StatefulBuilder( + builder: (context, setStateDialog) => AlertDialog( + title: const Text('Remote Settings'), + content: Form( + key: formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SwitchListTile( + title: const Text('Abilita remote'), + value: enabled, + onChanged: (v) => setStateDialog(() => enabled = v), + contentPadding: EdgeInsets.zero, + ), + const SizedBox(height: 8), + TextFormField( + controller: baseUrlC, + decoration: const InputDecoration(labelText: 'Base URL'), + validator: validateBaseUrl, + ), + const SizedBox(height: 8), + TextFormField( + controller: indexC, + decoration: const InputDecoration(labelText: 'Index path'), + validator: validateIndex, + ), + const SizedBox(height: 8), + TextFormField( + controller: emailC, + decoration: const InputDecoration(labelText: 'User/Email'), + ), + const SizedBox(height: 8), + TextFormField( + controller: pwC, + obscureText: true, + decoration: const InputDecoration(labelText: 'Password'), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).maybePop(), + child: const Text('Annulla'), + ), + ElevatedButton.icon( + onPressed: () async { + if (!formKey.currentState!.validate()) return; + + final oldEnabled = s.enabled; + final newEnabled = enabled; + + final upd = RemoteSettings( + enabled: newEnabled, + baseUrl: baseUrlC.text.trim(), + indexPath: indexC.text.trim(), + email: emailC.text.trim(), + password: pwC.text, + ); + + await upd.save(); + + // aggiorna subito base url/token + await RemoteHttp.refreshFromSettings(); + await RemoteHttp.warmUp(); + + // ✅ applica subito ON/OFF live (mostra/nascondi + sync) + await applyRuntimeEffects(newEnabled: newEnabled, oldEnabled: oldEnabled); + + if (context.mounted) Navigator.of(context).pop(); + }, + icon: const Icon(Icons.save), + label: const Text('Salva'), + ), + ], + ), + ), + ); + + baseUrlC.dispose(); + indexC.dispose(); + emailC.dispose(); + pwC.dispose(); + } +} diff --git a/lib/remote/remote_settings_page.dart b/lib/remote/remote_settings_page.dart index 96e1c69d..8e466cd9 100644 --- a/lib/remote/remote_settings_page.dart +++ b/lib/remote/remote_settings_page.dart @@ -1,4 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/remote/collection_source_remote_ext.dart'; // per appendRemoteEntriesFromDb() +import 'package:aves/remote/remote_controller.dart'; +import 'package:aves/remote/remote_sync_bus.dart'; + import 'remote_settings.dart'; import 'remote_http.dart'; @@ -49,7 +56,6 @@ class _RemoteSettingsPageState extends State { _loaded = true; }); } catch (e) { - // Fail-open: apri comunque con default/blank e notifica _showSnack('Impossibile leggere le impostazioni sicure: $e'); if (!mounted) return; setState(() { @@ -70,7 +76,6 @@ class _RemoteSettingsPageState extends State { if (uri == null || !(uri.hasScheme && (uri.scheme == 'http' || uri.scheme == 'https'))) { return 'URL non valida (deve iniziare con http/https)'; } - // opzionale: blocca spazi/controlli interni if (RegExp(r'[\u200B-\u200F\u202A-\u202E\u2060-\u2064\uFEFF]').hasMatch(s)) { return 'URL contiene caratteri non validi (invisibili)'; } @@ -83,6 +88,28 @@ class _RemoteSettingsPageState extends State { return null; } + Future _applyRuntimeEffects() async { + // Applica subito l'effetto ON/OFF in UI + try { + final source = context.read(); + + if (!_enabled) { + // OFF: nascondi remoti dalla UI e imposta icona grigia + final remotesInMemory = source.allEntries.where((e) => e.origin == 1).toSet(); + if (remotesInMemory.isNotEmpty) { + source.removeEntriesFromMemory(remotesInMemory); + } + RemoteSyncBus.instance.setDisabled(); + } else { + // ON: mostra subito remoti da DB (senza full sync qui) + await source.appendRemoteEntriesFromDb(); + await RemoteController.instance.initBusFromSettings(); + } + } catch (_) { + // se la pagina non è nel contesto con Provider(CollectionSource), non facciamo crash + } + } + Future _save() async { if (!(_form.currentState?.validate() ?? false)) return; @@ -98,9 +125,12 @@ class _RemoteSettingsPageState extends State { await s.save(); - // ✅ forza Aves a usare SUBITO base URL & token aggiornati + // aggiorna headers/token await RemoteHttp.refreshFromSettings(); - await RemoteHttp.warmUp(); // non bloccante: utile per loggare stato token/base + await RemoteHttp.warmUp(); + + // ✅ applica subito ON/OFF live + await _applyRuntimeEffects(); if (!mounted) return; _showSnack('Impostazioni remote salvate'); @@ -116,7 +146,7 @@ class _RemoteSettingsPageState extends State { void _showSnack(String msg) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - behavior: SnackBarBehavior.fixed, // evita "floating off screen" + behavior: SnackBarBehavior.fixed, content: Text(msg), duration: const Duration(seconds: 3), ), @@ -207,5 +237,4 @@ class _RemoteSettingsPageState extends State { ), ); } -} - +} \ No newline at end of file diff --git a/lib/remote/remote_sync_bus.dart b/lib/remote/remote_sync_bus.dart new file mode 100644 index 00000000..6df2374a --- /dev/null +++ b/lib/remote/remote_sync_bus.dart @@ -0,0 +1,67 @@ +import 'package:flutter/foundation.dart'; + +enum RemoteSyncState { disabled, syncing, upToDate, serverDown } + +class RemoteSyncProgress { + final int done; + final int total; + + /// true solo nel bootstrap (prima attivazione), per contatore stile Aves + final bool showOverlay; + + const RemoteSyncProgress({ + required this.done, + required this.total, + this.showOverlay = false, + }); +} + +class RemoteSyncBus { + RemoteSyncBus._(); + static final RemoteSyncBus instance = RemoteSyncBus._(); + + final ValueNotifier stateNotifier = ValueNotifier(RemoteSyncState.disabled); + final ValueNotifier progressNotifier = ValueNotifier(null); + + int _opId = 0; + + int nextOp() => ++_opId; + bool _isStale(int opId) => opId != _opId; + + /// Spegne remote e invalida qualunque sync in corso. + void setDisabled() { + _opId++; // invalida operazioni in corso + stateNotifier.value = RemoteSyncState.disabled; + progressNotifier.value = null; + } + + /// Avvia sync e ritorna un token opId. + int start({required int total, required bool showOverlay}) { + final opId = nextOp(); + stateNotifier.value = RemoteSyncState.syncing; + progressNotifier.value = RemoteSyncProgress(done: 0, total: total, showOverlay: showOverlay); + return opId; + } + + void update({required int opId, required int done, required int total}) { + if (_isStale(opId)) return; + final cur = progressNotifier.value; + progressNotifier.value = RemoteSyncProgress( + done: done, + total: total, + showOverlay: cur?.showOverlay ?? false, + ); + } + + void finishUpToDate({required int opId}) { + if (_isStale(opId)) return; + stateNotifier.value = RemoteSyncState.upToDate; + progressNotifier.value = null; + } + + void failServerDown({required int opId}) { + if (_isStale(opId)) return; + stateNotifier.value = RemoteSyncState.serverDown; + progressNotifier.value = null; + } +} \ No newline at end of file diff --git a/lib/remote/remote_sync_bus.dart.ok b/lib/remote/remote_sync_bus.dart.ok new file mode 100644 index 00000000..87ca0c1b --- /dev/null +++ b/lib/remote/remote_sync_bus.dart.ok @@ -0,0 +1,51 @@ +// lib/remote/remote_sync_bus.dart +import 'package:flutter/foundation.dart'; + +class RemoteSyncProgress { + final String phase; + final int done; + final int total; + final bool finished; + + const RemoteSyncProgress({ + required this.phase, + required this.done, + required this.total, + this.finished = false, + }); + + double? get value => total > 0 ? done / total : null; +} + +class RemoteSyncBus { + RemoteSyncBus._(); + static final RemoteSyncBus instance = RemoteSyncBus._(); + + final ValueNotifier notifier = ValueNotifier(null); + + void start({required String phase, required int total}) { + notifier.value = RemoteSyncProgress(phase: phase, done: 0, total: total); + } + + void update({required String phase, required int done, required int total}) { + notifier.value = RemoteSyncProgress(phase: phase, done: done, total: total); + } + + void finish({String phase = 'Completato'}) { + final cur = notifier.value; + if (cur == null) return; + notifier.value = RemoteSyncProgress( + phase: phase, + done: cur.total, + total: cur.total, + finished: true, + ); + // auto-hide dopo 1s + Future.delayed(const Duration(seconds: 1), () { + if (notifier.value?.finished == true) notifier.value = null; + }); + } + + void clear() => notifier.value = null; +} + diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 60e2c9c6..5e4e081f 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -1,801 +1,774 @@ -import 'dart:async'; -import 'dart:math'; - -import 'package:aves/app_mode.dart'; -import 'package:aves/model/entry/entry.dart'; -import 'package:aves/model/filters/container/dynamic_album.dart'; -import 'package:aves/model/filters/container/set_and.dart'; -import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/filters/query.dart'; -import 'package:aves/model/filters/trash.dart'; -import 'package:aves/model/query.dart'; -import 'package:aves/model/selection.dart'; -import 'package:aves/model/settings/enums/accessibility_animations.dart'; -import 'package:aves/model/settings/settings.dart'; -import 'package:aves/model/source/collection_lens.dart'; -import 'package:aves/model/source/collection_source.dart'; -import 'package:aves/theme/durations.dart'; -import 'package:aves/theme/icons.dart'; -import 'package:aves/theme/themes.dart'; -import 'package:aves/view/view.dart'; -import 'package:aves/widgets/aves_app.dart'; -import 'package:aves/widgets/collection/collection_page.dart'; -import 'package:aves/widgets/collection/entry_set_action_delegate.dart'; -import 'package:aves/widgets/collection/filter_bar.dart'; -import 'package:aves/widgets/collection/query_bar.dart'; -import 'package:aves/widgets/common/action_controls/quick_choosers/move_button.dart'; -import 'package:aves/widgets/common/action_controls/quick_choosers/rate_button.dart'; -import 'package:aves/widgets/common/action_controls/quick_choosers/tag_button.dart'; -import 'package:aves/widgets/common/action_controls/togglers/favourite.dart'; -import 'package:aves/widgets/common/action_controls/togglers/title_search.dart'; -import 'package:aves/widgets/common/app_bar/app_bar_subtitle.dart'; -import 'package:aves/widgets/common/app_bar/app_bar_title.dart'; -import 'package:aves/widgets/common/basic/popup/container.dart'; -import 'package:aves/widgets/common/basic/popup/expansion_panel.dart'; -import 'package:aves/widgets/common/basic/popup/menu_row.dart'; -import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/common/identity/aves_app_bar.dart'; -import 'package:aves/widgets/common/identity/buttons/captioned_button.dart'; -import 'package:aves/widgets/common/search/route.dart'; -import 'package:aves/widgets/common/tile_extent_controller.dart'; -import 'package:aves/widgets/dialogs/tile_view_dialog.dart'; -import 'package:aves/widgets/search/collection_search_delegate.dart'; -import 'package:aves/widgets/viewer/controls/notifications.dart'; -import 'package:aves_model/aves_model.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; -import 'package:provider/provider.dart'; - -class CollectionAppBar extends StatefulWidget { - final ValueNotifier appBarHeightNotifier; - final ScrollController scrollController; - final CollectionLens collection; - - const CollectionAppBar({ - super.key, - required this.appBarHeightNotifier, - required this.scrollController, - required this.collection, - }); - - @override - State createState() => _CollectionAppBarState(); -} - -class _CollectionAppBarState extends State with RouteAware, SingleTickerProviderStateMixin, WidgetsBindingObserver { - final Set _subscriptions = {}; - final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate(); - late AnimationController _browseToSelectAnimation; - final ValueNotifier _isSelectingNotifier = ValueNotifier(false); - final FocusNode _queryBarFocusNode = FocusNode(); - late final Listenable _queryFocusRequestNotifier; - double _statusBarHeight = 0; - - CollectionLens get collection => widget.collection; - - bool get isTrash => collection.filters.contains(TrashFilter.instance); - - CollectionSource get source => collection.source; - - Set get visibleFilters => collection.filters.where((v) => !(v is QueryFilter && v.live) && v is! TrashFilter).toSet(); - - bool get showFilterBar => visibleFilters.isNotEmpty; - - static const _sortOptions = [ - EntrySortFactor.date, - EntrySortFactor.size, - EntrySortFactor.name, - EntrySortFactor.rating, - EntrySortFactor.duration, - EntrySortFactor.path, - ]; - - static const _sectionOptions = [ - EntrySectionFactor.album, - EntrySectionFactor.month, - EntrySectionFactor.day, - EntrySectionFactor.none, - ]; - - static const _layoutOptions = [ - TileLayout.mosaic, - TileLayout.grid, - TileLayout.list, - ]; - - static const _trashSelectionQuickActions = [ - EntrySetAction.delete, - EntrySetAction.restore, - ]; - - @override - void initState() { - super.initState(); - final query = context.read(); - _subscriptions.add(query.enabledStream.listen((e) => _updateAppBarHeight())); - _queryFocusRequestNotifier = query.focusRequestNotifier; - _queryFocusRequestNotifier.addListener(_onQueryFocusRequest); - _queryBarFocusNode.addListener(_onQueryBarFocusChanged); - _browseToSelectAnimation = AnimationController( - duration: context.read().iconAnimation, - vsync: this, - ); - _isSelectingNotifier.addListener(_onActivityChanged); - _registerWidget(widget); - WidgetsBinding.instance.addObserver(this); - WidgetsBinding.instance.addPostFrameCallback((_) { - _updateStatusBarHeight(); - _onFilterChanged(); - }); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - final route = ModalRoute.of(context); - if (route is PageRoute) { - AvesApp.pageRouteObserver.subscribe(this, route); - } - } - - @override - void didUpdateWidget(covariant CollectionAppBar oldWidget) { - super.didUpdateWidget(oldWidget); - _unregisterWidget(oldWidget); - _registerWidget(widget); - } - - @override - void dispose() { - _unregisterWidget(widget); - _queryBarFocusNode.dispose(); - _queryFocusRequestNotifier.removeListener(_onQueryFocusRequest); - _queryBarFocusNode.removeListener(_onQueryBarFocusChanged); - _isSelectingNotifier.dispose(); - _browseToSelectAnimation.dispose(); - _subscriptions - ..forEach((sub) => sub.cancel()) - ..clear(); - WidgetsBinding.instance.removeObserver(this); - AvesApp.pageRouteObserver.unsubscribe(this); - super.dispose(); - } - - void _registerWidget(CollectionAppBar widget) { - widget.collection.filterChangeNotifier.addListener(_onFilterChanged); - } - - void _unregisterWidget(CollectionAppBar widget) { - widget.collection.filterChangeNotifier.removeListener(_onFilterChanged); - } - - @override - void didPushNext() { - // unfocus when navigating away, so that when navigating back, - // the query bar does not get back focus and bring the keyboard - _queryBarFocusNode.unfocus(); - } - - @override - void didChangeMetrics() { - // when top padding or text scale factor change - WidgetsBinding.instance.addPostFrameCallback((_) => _updateStatusBarHeight()); - } - - @override - Widget build(BuildContext context) { - final appMode = context.watch>().value; - final selection = context.watch>(); - final isSelecting = selection.isSelecting; - _isSelectingNotifier.value = isSelecting; - return NotificationListener( - // cancel notification bubbling so that the draggable scroll bar - // does not misinterpret filter bar scrolling for collection scrolling - onNotification: (notification) => true, - child: AnimatedBuilder( - animation: collection.filterChangeNotifier, - builder: (context, child) { - final canRemoveFilters = appMode != AppMode.pickFilteredMediaInternal; - return Selector( - selector: (context, query) => query.enabled, - builder: (context, queryEnabled, child) { - return Selector>( - selector: (context, s) => s.collectionBrowsingQuickActions, - builder: (context, _, child) { - final useTvLayout = settings.useTvLayout; - final onFilterTap = canRemoveFilters ? collection.removeFilter : null; - return AvesAppBar( - contentHeight: appBarContentHeight, - pinned: context.select, bool>((selection) => selection.isSelecting), - leading: _buildAppBarLeading( - hasDrawer: appMode.canNavigate, - isSelecting: isSelecting, - ), - title: _buildAppBarTitle(isSelecting), - actions: (context, maxWidth) => useTvLayout ? [] : _buildActions(context, selection, maxWidth), - bottom: Column( - children: [ - if (useTvLayout) - SizedBox( - height: CaptionedButton.getTelevisionButtonHeight(context), - child: ListView( - padding: const EdgeInsets.symmetric(horizontal: 8), - scrollDirection: Axis.horizontal, - children: _buildActions(context, selection, double.infinity), - ), - ), - if (showFilterBar) - NotificationListener( - onNotification: (notification) { - if (notification is SelectFilterNotification) { - collection.addFilters({notification.filter}); - return true; - } else if (notification is DecomposeFilterNotification) { - final filter = notification.filter; - if (filter is DynamicAlbumFilter) { - final innerFilter = filter.filter; - final newFilters = innerFilter is SetAndFilter ? innerFilter.innerFilters : {innerFilter}; - collection.addFilters(newFilters); - collection.removeFilter(filter); - return true; - } - } - return false; - }, - child: FilterBar( - filters: visibleFilters, - onTap: onFilterTap, - onRemove: onFilterTap, - ), - ), - if (queryEnabled) - EntryQueryBar( - queryNotifier: context.select>((query) => query.queryNotifier), - focusNode: _queryBarFocusNode, - ), - ], - ), - transitionKey: isSelecting, - ); - }, - ); - }, - ); - }, - ), - ); - } - - double get appBarContentHeight { - final textScaler = MediaQuery.textScalerOf(context); - double height = textScaler.scale(kToolbarHeight); - if (settings.useTvLayout) { - height += CaptionedButton.getTelevisionButtonHeight(context); - } - if (showFilterBar) { - height += FilterBar.preferredHeight; - } - if (context.read().enabled) { - height += EntryQueryBar.getPreferredHeight(textScaler); - } - return height; - } - - Widget? _buildAppBarLeading({required bool hasDrawer, required bool isSelecting}) { - if (settings.useTvLayout) return null; - - if (!hasDrawer) { - return const CloseButton(); - } - - VoidCallback? onPressed; - String? tooltip; - if (isSelecting) { - onPressed = () => context.read>().browse(); - tooltip = MaterialLocalizations.of(context).backButtonTooltip; - } else { - onPressed = Scaffold.of(context).openDrawer; - tooltip = MaterialLocalizations.of(context).openAppDrawerTooltip; - } - return IconButton( - // key is expected by test driver - key: const Key('appbar-leading-button'), - icon: AnimatedIcon( - icon: AnimatedIcons.menu_arrow, - progress: _browseToSelectAnimation, - ), - onPressed: onPressed, - tooltip: tooltip, - ); - } - - Widget _buildAppBarTitle(bool isSelecting) { - final l10n = context.l10n; - - if (isSelecting) { - // `Selection` may not be available during hero - return Selector?, int>( - selector: (context, selection) => selection?.selectedItems.length ?? 0, - builder: (context, count, child) => Text( - count == 0 ? l10n.collectionSelectPageTitle : l10n.itemCount(count), - softWrap: false, - overflow: TextOverflow.fade, - maxLines: 1, - ), - ); - } else { - final appMode = context.watch>().value; - Widget title = Text( - appMode.isPickingMedia ? l10n.collectionPickPageTitle : (isTrash ? l10n.binPageTitle : l10n.collectionPageTitle), - softWrap: false, - overflow: TextOverflow.fade, - maxLines: 1, - ); - if (appMode == AppMode.main) { - title = SourceStateAwareAppBarTitle( - title: title, - source: source, - ); - } - return InteractiveAppBarTitle( - onTap: appMode.canNavigate ? _goToSearch : null, - child: title, - ); - } - } - - List _buildActions(BuildContext context, Selection selection, double maxWidth) { - final appMode = context.watch>().value; - final isSelecting = selection.isSelecting; - final selectedItemCount = selection.selectedItems.length; - - bool isVisible(EntrySetAction action) => _actionDelegate.isVisible( - action, - appMode: appMode, - isSelecting: isSelecting, - itemCount: collection.entryCount, - selectedItemCount: selectedItemCount, - isTrash: isTrash, - ); - bool canApply(EntrySetAction action) => _actionDelegate.canApply( - action, - isSelecting: isSelecting, - collection: collection, - selectedItemCount: selectedItemCount, - ); - - return settings.useTvLayout - ? _buildTelevisionActions( - context: context, - appMode: appMode, - selection: selection, - isVisible: isVisible, - canApply: canApply, - ) - : _buildMobileActions( - context: context, - appMode: appMode, - selection: selection, - maxWidth: maxWidth, - isVisible: isVisible, - canApply: canApply, - ); - } - - List _buildTelevisionActions({ - required BuildContext context, - required AppMode appMode, - required Selection selection, - required bool Function(EntrySetAction action) isVisible, - required bool Function(EntrySetAction action) canApply, - }) { - final isSelecting = selection.isSelecting; - - return [ - ...EntrySetActions.general, - ...isSelecting ? EntrySetActions.pageSelection : EntrySetActions.pageBrowsing, - ].nonNulls.where(isVisible).map((action) { - final enabled = canApply(action); - return CaptionedButton( - iconButtonBuilder: (context, focusNode) => _buildButtonIcon( - context, - action, - enabled: enabled, - selection: selection, - focusNode: focusNode, - ), - captionText: _buildButtonCaption(context, action, enabled: enabled), - onPressed: enabled ? () => _onActionSelected(action) : null, - ); - }).toList(); - } - - static double _iconButtonWidth(BuildContext context) { - const defaultPadding = EdgeInsets.all(8); - const defaultIconSize = 24.0; - return defaultPadding.horizontal + MediaQuery.textScalerOf(context).scale(defaultIconSize); - } - - List _buildMobileActions({ - required BuildContext context, - required AppMode appMode, - required Selection selection, - required double maxWidth, - required bool Function(EntrySetAction action) isVisible, - required bool Function(EntrySetAction action) canApply, - }) { - final availableCount = (maxWidth / _iconButtonWidth(context)).floor(); - - final isSelecting = selection.isSelecting; - final selectedItemCount = selection.selectedItems.length; - final hasSelection = selectedItemCount > 0; - - final browsingQuickActions = settings.collectionBrowsingQuickActions; - final selectionQuickActions = isTrash ? _trashSelectionQuickActions : settings.collectionSelectionQuickActions; - final quickActions = (isSelecting ? selectionQuickActions : browsingQuickActions).take(max(0, availableCount - 1)).toList(); - final quickActionButtons = quickActions - .where(isVisible) - .map( - (action) => _buildButtonIcon(context, action, enabled: canApply(action), selection: selection), - ); - - final animations = context.select((v) => v.accessibilityAnimations); - return [ - ...quickActionButtons, - PopupMenuButton( - // key is expected by test driver - key: const Key('appbar-menu-button'), - itemBuilder: (context) { - bool _isValidForMenu(EntrySetAction? v) => v == null || (!quickActions.contains(v) && isVisible(v)); - final generalMenuItems = EntrySetActions.general - .where(_isValidForMenu) - .map( - (action) => _toMenuItem(action, enabled: canApply(action), selection: selection), - ); - - final allContextualActions = isSelecting ? EntrySetActions.pageSelection : EntrySetActions.pageBrowsing; - final contextualMenuActions = allContextualActions.where(_isValidForMenu).fold([], (prev, v) { - if (v == null && (prev.isEmpty || prev.last == null)) return prev; - return [...prev, v]; - }); - if (contextualMenuActions.isNotEmpty && contextualMenuActions.last == null) { - contextualMenuActions.removeLast(); - } - - final contextualMenuItems = >[ - ...contextualMenuActions.map( - (action) { - if (action == null) return const PopupMenuDivider(); - return _toMenuItem(action, enabled: canApply(action), selection: selection); - }, - ), - if (isSelecting && !settings.isReadOnly && appMode == AppMode.main && !isTrash) - PopupMenuExpansionPanel( - enabled: hasSelection, - value: 'edit', - icon: AIcons.edit, - title: context.l10n.collectionActionEdit, - items: [ - _buildRotateAndFlipMenuItems(context, canApply: canApply), - ...EntrySetActions.edit.where((v) => isVisible(v) && !quickActions.contains(v)).map((action) => _toMenuItem(action, enabled: canApply(action), selection: selection)), - ], - ), - ]; - - return [ - ...generalMenuItems, - if (contextualMenuItems.isNotEmpty) ...[ - const PopupMenuDivider(), - ...contextualMenuItems, - ], - ]; - }, - onSelected: (action) async { - // wait for the popup menu to hide before proceeding with the action - await Future.delayed(animations.popUpAnimationDelay * timeDilation); - await _onActionSelected(action); - }, - popUpAnimationStyle: animations.popUpAnimationStyle, - ), - ]; - } - - Set _getExpandedSelectedItems(Selection selection) { - return selection.selectedItems.expand((entry) => entry.stackedEntries ?? {entry}).toSet(); - } - - // key is expected by test driver - Key _getActionKey(EntrySetAction action) => Key('menu-${action.name}'); - - Widget _buildButtonIcon( - BuildContext context, - EntrySetAction action, { - required bool enabled, - FocusNode? focusNode, - required Selection selection, - }) { - final blurred = settings.enableBlurEffect; - final onPressed = enabled ? () => _onActionSelected(action) : null; - switch (action) { - case EntrySetAction.toggleTitleSearch: - // `Query` may not be available during hero - return Selector( - selector: (context, query) => query?.enabled ?? false, - builder: (context, queryEnabled, child) { - return TitleSearchToggler( - queryEnabled: queryEnabled, - onPressed: onPressed, - focusNode: focusNode, - ); - }, - ); - case EntrySetAction.copy: - return MoveButton( - copy: true, - blurred: blurred, - onChooserValue: (album) => _actionDelegate.quickMove(context, album, copy: true), - onPressed: onPressed, - ); - case EntrySetAction.move: - return MoveButton( - copy: false, - blurred: blurred, - onChooserValue: (album) => _actionDelegate.quickMove(context, album, copy: false), - onPressed: onPressed, - ); - case EntrySetAction.editRating: - return RateButton( - blurred: blurred, - onChooserValue: (rating) => _actionDelegate.quickRate(context, rating), - focusNode: focusNode, - onPressed: onPressed, - ); - case EntrySetAction.editTags: - return TagButton( - blurred: blurred, - onChooserValue: (filter) => _actionDelegate.quickTag(context, filter), - focusNode: focusNode, - onPressed: onPressed, - ); - case EntrySetAction.toggleFavourite: - return FavouriteToggler( - entries: _getExpandedSelectedItems(selection), - focusNode: focusNode, - onPressed: onPressed, - ); - default: - return IconButton( - key: _getActionKey(action), - icon: action.getIcon(), - onPressed: onPressed, - focusNode: focusNode, - tooltip: action.getText(context), - ); - } - } - - Widget _buildButtonCaption( - BuildContext context, - EntrySetAction action, { - required bool enabled, - }) { - switch (action) { - case EntrySetAction.toggleTitleSearch: - return TitleSearchTogglerCaption( - enabled: enabled, - ); - default: - return CaptionedButtonText( - text: action.getText(context), - enabled: enabled, - ); - } - } - - PopupMenuItem _toMenuItem(EntrySetAction action, {required bool enabled, required Selection selection}) { - late Widget child; - switch (action) { - case EntrySetAction.toggleTitleSearch: - child = TitleSearchToggler( - queryEnabled: context.read().enabled, - isMenuItem: true, - ); - case EntrySetAction.toggleFavourite: - child = FavouriteToggler( - entries: _getExpandedSelectedItems(selection), - isMenuItem: true, - ); - default: - child = MenuRow(text: action.getText(context), icon: action.getIcon()); - } - return PopupMenuItem( - key: _getActionKey(action), - value: action, - enabled: enabled, - child: child, - ); - } - - PopupMenuEntry _buildRotateAndFlipMenuItems( - BuildContext context, { - required bool Function(EntrySetAction action) canApply, - }) { - Widget buildDivider() => const SizedBox( - height: 16, - child: VerticalDivider( - width: 1, - thickness: 1, - ), - ); - - Widget buildItem(EntrySetAction action) => Expanded( - child: Material( - color: Colors.transparent, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(8)), - ), - clipBehavior: Clip.antiAlias, - child: PopupMenuItem( - value: action, - enabled: canApply(action), - child: Tooltip( - message: action.getText(context), - child: Center(child: action.getIcon()), - ), - ), - ), - ); - - return PopupMenuItemContainer( - child: Row( - children: [ - buildDivider(), - buildItem(EntrySetAction.rotateCCW), - buildDivider(), - buildItem(EntrySetAction.rotateCW), - buildDivider(), - buildItem(EntrySetAction.flip), - buildDivider(), - ], - ), - ); - } - - void _onActivityChanged() { - if (context.read>().isSelecting) { - _browseToSelectAnimation.forward(); - } else { - _browseToSelectAnimation.reverse(); - } - } - - void _onFilterChanged() { - _updateAppBarHeight(); - - final filters = collection.filters; - if (filters.isNotEmpty) { - final selection = context.read>(); - if (selection.isSelecting) { - final toRemove = selection.selectedItems.where((entry) => !filters.every((f) => f.test(entry))).toSet(); - selection.removeFromSelection(toRemove); - } - } - } - - void _onQueryFocusRequest() => _queryBarFocusNode.requestFocus(); - - void _onQueryBarFocusChanged() { - if (_queryBarFocusNode.hasFocus) { - // the query bar is in the top sliver of the page scrollable, - // so when the bar text field gets focus and requests to be on screen, - // it will scroll to show it by default, but it may not end at the very top, - // so we do it manually for a more predicable end position - _scrollToTop(); - } - } - - void _scrollToTop() => widget.scrollController.jumpTo(0); - - void _updateStatusBarHeight() { - if (!mounted) { - return; - } - _statusBarHeight = MediaQuery.paddingOf(context).top; - _updateAppBarHeight(); - } - - void _updateAppBarHeight() { - widget.appBarHeightNotifier.value = _statusBarHeight + AvesAppBar.appBarHeightForContentHeight(appBarContentHeight); - } - - Future _onActionSelected(EntrySetAction action) async { - switch (action) { - // general - case EntrySetAction.configureView: - await _configureView(); - case EntrySetAction.select: - context.read>().select(); - case EntrySetAction.selectAll: - context.read>().addToSelection(collection.sortedEntries); - case EntrySetAction.selectNone: - context.read>().clearSelection(); - // browsing - case EntrySetAction.searchCollection: - case EntrySetAction.toggleTitleSearch: - case EntrySetAction.addDynamicAlbum: - case EntrySetAction.addShortcut: - case EntrySetAction.setHome: - // browsing or selecting - case EntrySetAction.map: - case EntrySetAction.slideshow: - case EntrySetAction.stats: - case EntrySetAction.rescan: - case EntrySetAction.emptyBin: - // selecting - case EntrySetAction.share: - case EntrySetAction.delete: - case EntrySetAction.restore: - case EntrySetAction.copy: - case EntrySetAction.move: - case EntrySetAction.rename: - case EntrySetAction.convert: - case EntrySetAction.toggleFavourite: - case EntrySetAction.rotateCCW: - case EntrySetAction.rotateCW: - case EntrySetAction.flip: - case EntrySetAction.editDate: - case EntrySetAction.editLocation: - case EntrySetAction.editTitleDescription: - case EntrySetAction.editRating: - case EntrySetAction.editTags: - case EntrySetAction.removeMetadata: - _actionDelegate.onActionSelected(context, action); - } - } - - Future _configureView() async { - final initialValue = ( - settings.collectionSortFactor, - settings.collectionSectionFactor, - settings.getTileLayout(CollectionPage.routeName), - settings.collectionSortReverse, - ); - final extentController = context.read(); - final value = await showDialog<(EntrySortFactor?, EntrySectionFactor?, TileLayout?, bool)>( - context: context, - builder: (context) { - return TileViewDialog( - initialValue: initialValue, - sortOptions: _sortOptions.map((v) => TileViewDialogOption(value: v, title: v.getName(context), icon: v.icon)).toList(), - sectionOptions: _sectionOptions.map((v) => TileViewDialogOption(value: v, title: v.getName(context), icon: v.icon)).toList(), - layoutOptions: _layoutOptions.map((v) => TileViewDialogOption(value: v, title: v.getName(context), icon: v.icon)).toList(), - sortOrder: (factor, reverse) => factor.getOrderName(context, reverse), - canSection: (s, g, l) => s == EntrySortFactor.date, - tileExtentController: extentController, - ); - }, - routeSettings: const RouteSettings(name: TileViewDialog.routeName), - ); - // wait for the dialog to hide - await Future.delayed(ADurations.dialogTransitionLoose * timeDilation); - if (value != null && initialValue != value) { - settings.collectionSortFactor = value.$1!; - settings.collectionSectionFactor = value.$2!; - settings.setTileLayout(CollectionPage.routeName, value.$3!); - settings.collectionSortReverse = value.$4; - } - } - - void _goToSearch() { - Navigator.maybeOf(context)?.push( - SearchPageRoute( - delegate: CollectionSearchDelegate( - searchFieldLabel: context.l10n.searchCollectionFieldHint, - searchFieldStyle: Themes.searchFieldStyle(context), - source: collection.source, - parentCollection: collection, - ), - ), - ); - } -} +import 'dart:async'; +import 'dart:math'; + +import 'package:aves/app_mode.dart'; +import 'package:aves/model/entry/entry.dart'; +import 'package:aves/model/filters/container/dynamic_album.dart'; +import 'package:aves/model/filters/container/set_and.dart'; +import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/filters/query.dart'; +import 'package:aves/model/filters/trash.dart'; +import 'package:aves/model/query.dart'; +import 'package:aves/model/selection.dart'; +import 'package:aves/model/settings/enums/accessibility_animations.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/theme/themes.dart'; +import 'package:aves/view/view.dart'; +import 'package:aves/widgets/aves_app.dart'; +import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/collection/entry_set_action_delegate.dart'; +import 'package:aves/widgets/collection/filter_bar.dart'; +import 'package:aves/widgets/collection/query_bar.dart'; +import 'package:aves/widgets/common/action_controls/quick_choosers/move_button.dart'; +import 'package:aves/widgets/common/action_controls/quick_choosers/rate_button.dart'; +import 'package:aves/widgets/common/action_controls/quick_choosers/tag_button.dart'; +import 'package:aves/widgets/common/action_controls/togglers/favourite.dart'; +import 'package:aves/widgets/common/action_controls/togglers/title_search.dart'; +import 'package:aves/widgets/common/app_bar/app_bar_subtitle.dart'; +import 'package:aves/widgets/common/app_bar/app_bar_title.dart'; +import 'package:aves/widgets/common/basic/popup/container.dart'; +import 'package:aves/widgets/common/basic/popup/expansion_panel.dart'; +import 'package:aves/widgets/common/basic/popup/menu_row.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/aves_app_bar.dart'; +import 'package:aves/widgets/common/identity/buttons/captioned_button.dart'; +import 'package:aves/widgets/common/search/route.dart'; +import 'package:aves/widgets/common/tile_extent_controller.dart'; +import 'package:aves/widgets/dialogs/tile_view_dialog.dart'; +import 'package:aves/widgets/search/collection_search_delegate.dart'; +import 'package:aves/widgets/viewer/controls/notifications.dart'; +import 'package:aves_model/aves_model.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:provider/provider.dart'; + +// ✅ Remote icon/button (tap toggle + long-press settings) +import 'package:aves/widgets/collection/remote_status_button.dart'; + +class CollectionAppBar extends StatefulWidget { + final ValueNotifier appBarHeightNotifier; + final ScrollController scrollController; + final CollectionLens collection; + + const CollectionAppBar({ + super.key, + required this.appBarHeightNotifier, + required this.scrollController, + required this.collection, + }); + + @override + State createState() => _CollectionAppBarState(); +} + +class _CollectionAppBarState extends State + with RouteAware, SingleTickerProviderStateMixin, WidgetsBindingObserver { + final Set _subscriptions = {}; + final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate(); + late AnimationController _browseToSelectAnimation; + final ValueNotifier _isSelectingNotifier = ValueNotifier(false); + final FocusNode _queryBarFocusNode = FocusNode(); + late final Listenable _queryFocusRequestNotifier; + double _statusBarHeight = 0; + + CollectionLens get collection => widget.collection; + + bool get isTrash => collection.filters.contains(TrashFilter.instance); + + CollectionSource get source => collection.source; + + Set get visibleFilters => + collection.filters.where((v) => !(v is QueryFilter && v.live) && v is! TrashFilter).toSet(); + + bool get showFilterBar => visibleFilters.isNotEmpty; + + static const _sortOptions = [ + EntrySortFactor.date, + EntrySortFactor.size, + EntrySortFactor.name, + EntrySortFactor.rating, + EntrySortFactor.duration, + EntrySortFactor.path, + ]; + + static const _sectionOptions = [ + EntrySectionFactor.album, + EntrySectionFactor.month, + EntrySectionFactor.day, + EntrySectionFactor.none, + ]; + + static const _layoutOptions = [ + TileLayout.mosaic, + TileLayout.grid, + TileLayout.list, + ]; + + static const _trashSelectionQuickActions = [ + EntrySetAction.delete, + EntrySetAction.restore, + ]; + + @override + void initState() { + super.initState(); + final query = context.read(); + _subscriptions.add(query.enabledStream.listen((e) => _updateAppBarHeight())); + _queryFocusRequestNotifier = query.focusRequestNotifier; + _queryFocusRequestNotifier.addListener(_onQueryFocusRequest); + _queryBarFocusNode.addListener(_onQueryBarFocusChanged); + _browseToSelectAnimation = AnimationController( + duration: context.read().iconAnimation, + vsync: this, + ); + _isSelectingNotifier.addListener(_onActivityChanged); + _registerWidget(widget); + WidgetsBinding.instance.addObserver(this); + WidgetsBinding.instance.addPostFrameCallback((_) { + _updateStatusBarHeight(); + _onFilterChanged(); + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final route = ModalRoute.of(context); + if (route is PageRoute) { + AvesApp.pageRouteObserver.subscribe(this, route); + } + } + + @override + void didUpdateWidget(covariant CollectionAppBar oldWidget) { + super.didUpdateWidget(oldWidget); + _unregisterWidget(oldWidget); + _registerWidget(widget); + } + + @override + void dispose() { + _unregisterWidget(widget); + _queryBarFocusNode.dispose(); + _queryFocusRequestNotifier.removeListener(_onQueryFocusRequest); + _queryBarFocusNode.removeListener(_onQueryBarFocusChanged); + _isSelectingNotifier.dispose(); + _browseToSelectAnimation.dispose(); + _subscriptions + ..forEach((sub) => sub.cancel()) + ..clear(); + WidgetsBinding.instance.removeObserver(this); + AvesApp.pageRouteObserver.unsubscribe(this); + super.dispose(); + } + + void _registerWidget(CollectionAppBar widget) { + widget.collection.filterChangeNotifier.addListener(_onFilterChanged); + } + + void _unregisterWidget(CollectionAppBar widget) { + widget.collection.filterChangeNotifier.removeListener(_onFilterChanged); + } + + @override + void didPushNext() { + _queryBarFocusNode.unfocus(); + } + + @override + void didChangeMetrics() { + WidgetsBinding.instance.addPostFrameCallback((_) => _updateStatusBarHeight()); + } + + @override + Widget build(BuildContext context) { + final appMode = context.watch>().value; + final selection = context.watch>(); + final isSelecting = selection.isSelecting; + _isSelectingNotifier.value = isSelecting; + + return NotificationListener( + onNotification: (notification) => true, + child: AnimatedBuilder( + animation: collection.filterChangeNotifier, + builder: (context, child) { + final canRemoveFilters = appMode != AppMode.pickFilteredMediaInternal; + return Selector( + selector: (context, query) => query.enabled, + builder: (context, queryEnabled, child) { + return Selector>( + selector: (context, s) => s.collectionBrowsingQuickActions, + builder: (context, _, child) { + final useTvLayout = settings.useTvLayout; + final onFilterTap = canRemoveFilters ? collection.removeFilter : null; + + return AvesAppBar( + contentHeight: appBarContentHeight, + pinned: context.select, bool>((selection) => selection.isSelecting), + leading: _buildAppBarLeading( + hasDrawer: appMode.canNavigate, + isSelecting: isSelecting, + ), + title: _buildAppBarTitle(isSelecting), + + // ✅ actions: parabola + azioni originali (mobile); solo parabola (tv) + actions: (context, maxWidth) => _buildAppBarActions( + context: context, + selection: selection, + maxWidth: maxWidth, + useTvLayout: useTvLayout, + ), + + bottom: Column( + children: [ + if (useTvLayout) + SizedBox( + height: CaptionedButton.getTelevisionButtonHeight(context), + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 8), + scrollDirection: Axis.horizontal, + children: _buildActions(context, selection, double.infinity), + ), + ), + if (showFilterBar) + NotificationListener( + onNotification: (notification) { + if (notification is SelectFilterNotification) { + collection.addFilters({notification.filter}); + return true; + } else if (notification is DecomposeFilterNotification) { + final filter = notification.filter; + if (filter is DynamicAlbumFilter) { + final innerFilter = filter.filter; + final newFilters = innerFilter is SetAndFilter ? innerFilter.innerFilters : {innerFilter}; + collection.addFilters(newFilters); + collection.removeFilter(filter); + return true; + } + } + return false; + }, + child: FilterBar( + filters: visibleFilters, + onTap: onFilterTap, + onRemove: onFilterTap, + ), + ), + if (queryEnabled) + EntryQueryBar( + queryNotifier: context.select>((query) => query.queryNotifier), + focusNode: _queryBarFocusNode, + ), + ], + ), + transitionKey: isSelecting, + ); + }, + ); + }, + ); + }, + ), + ); + } + + // ✅ NEW: actions con RemoteStatusButton sempre visibile + List _buildAppBarActions({ + required BuildContext context, + required Selection selection, + required double maxWidth, + required bool useTvLayout, + }) { + final statusButton = Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: RemoteStatusButton(source: source), + ); + + if (useTvLayout) { + // in TV layout le azioni sono nella barra sotto, qui solo lo stato remoto + return [statusButton]; + } + + return [ + statusButton, + ..._buildActions(context, selection, maxWidth), + ]; + } + + double get appBarContentHeight { + final textScaler = MediaQuery.textScalerOf(context); + double height = textScaler.scale(kToolbarHeight); + if (settings.useTvLayout) { + height += CaptionedButton.getTelevisionButtonHeight(context); + } + if (showFilterBar) { + height += FilterBar.preferredHeight; + } + if (context.read().enabled) { + height += EntryQueryBar.getPreferredHeight(textScaler); + } + return height; + } + + Widget? _buildAppBarLeading({required bool hasDrawer, required bool isSelecting}) { + if (settings.useTvLayout) return null; + + if (!hasDrawer) { + return const CloseButton(); + } + + VoidCallback? onPressed; + String? tooltip; + if (isSelecting) { + onPressed = () => context.read>().browse(); + tooltip = MaterialLocalizations.of(context).backButtonTooltip; + } else { + onPressed = Scaffold.of(context).openDrawer; + tooltip = MaterialLocalizations.of(context).openAppDrawerTooltip; + } + return IconButton( + key: const Key('appbar-leading-button'), + icon: AnimatedIcon( + icon: AnimatedIcons.menu_arrow, + progress: _browseToSelectAnimation, + ), + onPressed: onPressed, + tooltip: tooltip, + ); + } + + Widget _buildAppBarTitle(bool isSelecting) { + final l10n = context.l10n; + + if (isSelecting) { + return Selector?, int>( + selector: (context, selection) => selection?.selectedItems.length ?? 0, + builder: (context, count, child) => Text( + count == 0 ? l10n.collectionSelectPageTitle : l10n.itemCount(count), + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + ), + ); + } else { + final appMode = context.watch>().value; + Widget title = Text( + appMode.isPickingMedia ? l10n.collectionPickPageTitle : (isTrash ? l10n.binPageTitle : l10n.collectionPageTitle), + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + ); + if (appMode == AppMode.main) { + title = SourceStateAwareAppBarTitle( + title: title, + source: source, + ); + } + return InteractiveAppBarTitle( + onTap: appMode.canNavigate ? _goToSearch : null, + child: title, + ); + } + } + + List _buildActions(BuildContext context, Selection selection, double maxWidth) { + final appMode = context.watch>().value; + final isSelecting = selection.isSelecting; + final selectedItemCount = selection.selectedItems.length; + + bool isVisible(EntrySetAction action) => _actionDelegate.isVisible( + action, + appMode: appMode, + isSelecting: isSelecting, + itemCount: collection.entryCount, + selectedItemCount: selectedItemCount, + isTrash: isTrash, + ); + bool canApply(EntrySetAction action) => _actionDelegate.canApply( + action, + isSelecting: isSelecting, + collection: collection, + selectedItemCount: selectedItemCount, + ); + + return settings.useTvLayout + ? _buildTelevisionActions( + context: context, + appMode: appMode, + selection: selection, + isVisible: isVisible, + canApply: canApply, + ) + : _buildMobileActions( + context: context, + appMode: appMode, + selection: selection, + maxWidth: maxWidth, + isVisible: isVisible, + canApply: canApply, + ); + } + + List _buildTelevisionActions({ + required BuildContext context, + required AppMode appMode, + required Selection selection, + required bool Function(EntrySetAction action) isVisible, + required bool Function(EntrySetAction action) canApply, + }) { + final isSelecting = selection.isSelecting; + + return [ + ...EntrySetActions.general, + ...isSelecting ? EntrySetActions.pageSelection : EntrySetActions.pageBrowsing, + ].nonNulls.where(isVisible).map((action) { + final enabled = canApply(action); + return CaptionedButton( + iconButtonBuilder: (context, focusNode) => _buildButtonIcon( + context, + action, + enabled: enabled, + selection: selection, + focusNode: focusNode, + ), + captionText: _buildButtonCaption(context, action, enabled: enabled), + onPressed: enabled ? () => _onActionSelected(action) : null, + ); + }).toList(); + } + + static double _iconButtonWidth(BuildContext context) { + const defaultPadding = EdgeInsets.all(8); + const defaultIconSize = 24.0; + return defaultPadding.horizontal + MediaQuery.textScalerOf(context).scale(defaultIconSize); + } + + List _buildMobileActions({ + required BuildContext context, + required AppMode appMode, + required Selection selection, + required double maxWidth, + required bool Function(EntrySetAction action) isVisible, + required bool Function(EntrySetAction action) canApply, + }) { + final availableCount = (maxWidth / _iconButtonWidth(context)).floor(); + + final isSelecting = selection.isSelecting; + final selectedItemCount = selection.selectedItems.length; + final hasSelection = selectedItemCount > 0; + + final browsingQuickActions = settings.collectionBrowsingQuickActions; + final selectionQuickActions = isTrash ? _trashSelectionQuickActions : settings.collectionSelectionQuickActions; + final quickActions = (isSelecting ? selectionQuickActions : browsingQuickActions).take(max(0, availableCount - 1)).toList(); + final quickActionButtons = quickActions + .where(isVisible) + .map((action) => _buildButtonIcon(context, action, enabled: canApply(action), selection: selection)); + + final animations = context.select((v) => v.accessibilityAnimations); + return [ + ...quickActionButtons, + PopupMenuButton( + key: const Key('appbar-menu-button'), + itemBuilder: (context) { + bool _isValidForMenu(EntrySetAction? v) => v == null || (!quickActions.contains(v) && isVisible(v)); + final generalMenuItems = EntrySetActions.general + .where(_isValidForMenu) + .map((action) => _toMenuItem(action, enabled: canApply(action), selection: selection)); + + final allContextualActions = isSelecting ? EntrySetActions.pageSelection : EntrySetActions.pageBrowsing; + final contextualMenuActions = allContextualActions.where(_isValidForMenu).fold>([], (prev, v) { + if (v == null && (prev.isEmpty || prev.last == null)) return prev; + return [...prev, v]; + }); + if (contextualMenuActions.isNotEmpty && contextualMenuActions.last == null) { + contextualMenuActions.removeLast(); + } + + final contextualMenuItems = >[ + ...contextualMenuActions.map((action) { + if (action == null) return const PopupMenuDivider(); + return _toMenuItem(action, enabled: canApply(action), selection: selection); + }), + if (isSelecting && !settings.isReadOnly && appMode == AppMode.main && !isTrash) + PopupMenuExpansionPanel( + enabled: hasSelection, + value: 'edit', + icon: AIcons.edit, + title: context.l10n.collectionActionEdit, + items: [ + _buildRotateAndFlipMenuItems(context, canApply: canApply), + ...EntrySetActions.edit + .where((v) => isVisible(v) && !quickActions.contains(v)) + .map((action) => _toMenuItem(action, enabled: canApply(action), selection: selection)), + ], + ), + ]; + + return [ + ...generalMenuItems, + if (contextualMenuItems.isNotEmpty) ...[ + const PopupMenuDivider(), + ...contextualMenuItems, + ], + ]; + }, + onSelected: (action) async { + await Future.delayed(animations.popUpAnimationDelay * timeDilation); + await _onActionSelected(action); + }, + popUpAnimationStyle: animations.popUpAnimationStyle, + ), + ]; + } + + Set _getExpandedSelectedItems(Selection selection) { + return selection.selectedItems.expand((entry) => entry.stackedEntries ?? {entry}).toSet(); + } + + Key _getActionKey(EntrySetAction action) => Key('menu-${action.name}'); + + Widget _buildButtonIcon( + BuildContext context, + EntrySetAction action, { + required bool enabled, + FocusNode? focusNode, + required Selection selection, + }) { + final blurred = settings.enableBlurEffect; + final onPressed = enabled ? () => _onActionSelected(action) : null; + switch (action) { + case EntrySetAction.toggleTitleSearch: + return Selector( + selector: (context, query) => query?.enabled ?? false, + builder: (context, queryEnabled, child) { + return TitleSearchToggler( + queryEnabled: queryEnabled, + onPressed: onPressed, + focusNode: focusNode, + ); + }, + ); + case EntrySetAction.copy: + return MoveButton( + copy: true, + blurred: blurred, + onChooserValue: (album) => _actionDelegate.quickMove(context, album, copy: true), + onPressed: onPressed, + ); + case EntrySetAction.move: + return MoveButton( + copy: false, + blurred: blurred, + onChooserValue: (album) => _actionDelegate.quickMove(context, album, copy: false), + onPressed: onPressed, + ); + case EntrySetAction.editRating: + return RateButton( + blurred: blurred, + onChooserValue: (rating) => _actionDelegate.quickRate(context, rating), + focusNode: focusNode, + onPressed: onPressed, + ); + case EntrySetAction.editTags: + return TagButton( + blurred: blurred, + onChooserValue: (filter) => _actionDelegate.quickTag(context, filter), + focusNode: focusNode, + onPressed: onPressed, + ); + case EntrySetAction.toggleFavourite: + return FavouriteToggler( + entries: _getExpandedSelectedItems(selection), + focusNode: focusNode, + onPressed: onPressed, + ); + default: + return IconButton( + key: _getActionKey(action), + icon: action.getIcon(), + onPressed: onPressed, + focusNode: focusNode, + tooltip: action.getText(context), + ); + } + } + + Widget _buildButtonCaption( + BuildContext context, + EntrySetAction action, { + required bool enabled, + }) { + switch (action) { + case EntrySetAction.toggleTitleSearch: + return TitleSearchTogglerCaption(enabled: enabled); + default: + return CaptionedButtonText(text: action.getText(context), enabled: enabled); + } + } + + PopupMenuItem _toMenuItem(EntrySetAction action, {required bool enabled, required Selection selection}) { + late Widget child; + switch (action) { + case EntrySetAction.toggleTitleSearch: + child = TitleSearchToggler( + queryEnabled: context.read().enabled, + isMenuItem: true, + ); + case EntrySetAction.toggleFavourite: + child = FavouriteToggler( + entries: _getExpandedSelectedItems(selection), + isMenuItem: true, + ); + default: + child = MenuRow(text: action.getText(context), icon: action.getIcon()); + } + return PopupMenuItem( + key: _getActionKey(action), + value: action, + enabled: enabled, + child: child, + ); + } + + PopupMenuEntry _buildRotateAndFlipMenuItems( + BuildContext context, { + required bool Function(EntrySetAction action) canApply, + }) { + Widget buildDivider() => const SizedBox( + height: 16, + child: VerticalDivider(width: 1, thickness: 1), + ); + + Widget buildItem(EntrySetAction action) => Expanded( + child: Material( + color: Colors.transparent, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(8))), + clipBehavior: Clip.antiAlias, + child: PopupMenuItem( + value: action, + enabled: canApply(action), + child: Tooltip(message: action.getText(context), child: Center(child: action.getIcon())), + ), + ), + ); + + return PopupMenuItemContainer( + child: Row( + children: [ + buildDivider(), + buildItem(EntrySetAction.rotateCCW), + buildDivider(), + buildItem(EntrySetAction.rotateCW), + buildDivider(), + buildItem(EntrySetAction.flip), + buildDivider(), + ], + ), + ); + } + + void _onActivityChanged() { + if (context.read>().isSelecting) { + _browseToSelectAnimation.forward(); + } else { + _browseToSelectAnimation.reverse(); + } + } + + void _onFilterChanged() { + _updateAppBarHeight(); + + final filters = collection.filters; + if (filters.isNotEmpty) { + final selection = context.read>(); + if (selection.isSelecting) { + final toRemove = selection.selectedItems.where((entry) => !filters.every((f) => f.test(entry))).toSet(); + selection.removeFromSelection(toRemove); + } + } + } + + void _onQueryFocusRequest() => _queryBarFocusNode.requestFocus(); + + void _onQueryBarFocusChanged() { + if (_queryBarFocusNode.hasFocus) { + _scrollToTop(); + } + } + + void _scrollToTop() => widget.scrollController.jumpTo(0); + + void _updateStatusBarHeight() { + if (!mounted) return; + _statusBarHeight = MediaQuery.paddingOf(context).top; + _updateAppBarHeight(); + } + + void _updateAppBarHeight() { + widget.appBarHeightNotifier.value = _statusBarHeight + AvesAppBar.appBarHeightForContentHeight(appBarContentHeight); + } + + Future _onActionSelected(EntrySetAction action) async { + switch (action) { + case EntrySetAction.configureView: + await _configureView(); + case EntrySetAction.select: + context.read>().select(); + case EntrySetAction.selectAll: + context.read>().addToSelection(collection.sortedEntries); + case EntrySetAction.selectNone: + context.read>().clearSelection(); + default: + _actionDelegate.onActionSelected(context, action); + } + } + + Future _configureView() async { + final initialValue = ( + settings.collectionSortFactor, + settings.collectionSectionFactor, + settings.getTileLayout(CollectionPage.routeName), + settings.collectionSortReverse, + ); + final extentController = context.read(); + final value = await showDialog<(EntrySortFactor?, EntrySectionFactor?, TileLayout?, bool)>( + context: context, + builder: (context) { + return TileViewDialog( + initialValue: initialValue, + sortOptions: _sortOptions.map((v) => TileViewDialogOption(value: v, title: v.getName(context), icon: v.icon)).toList(), + sectionOptions: _sectionOptions.map((v) => TileViewDialogOption(value: v, title: v.getName(context), icon: v.icon)).toList(), + layoutOptions: _layoutOptions.map((v) => TileViewDialogOption(value: v, title: v.getName(context), icon: v.icon)).toList(), + sortOrder: (factor, reverse) => factor.getOrderName(context, reverse), + canSection: (s, g, l) => s == EntrySortFactor.date, + tileExtentController: extentController, + ); + }, + routeSettings: const RouteSettings(name: TileViewDialog.routeName), + ); + await Future.delayed(ADurations.dialogTransitionLoose * timeDilation); + if (value != null && initialValue != value) { + settings.collectionSortFactor = value.$1!; + settings.collectionSectionFactor = value.$2!; + settings.setTileLayout(CollectionPage.routeName, value.$3!); + settings.collectionSortReverse = value.$4; + } + } + + void _goToSearch() { + Navigator.maybeOf(context)?.push( + SearchPageRoute( + delegate: CollectionSearchDelegate( + searchFieldLabel: context.l10n.searchCollectionFieldHint, + searchFieldStyle: Themes.searchFieldStyle(context), + source: collection.source, + parentCollection: collection, + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/collection/app_bar.dart.ok b/lib/widgets/collection/app_bar.dart.ok new file mode 100644 index 00000000..60e2c9c6 --- /dev/null +++ b/lib/widgets/collection/app_bar.dart.ok @@ -0,0 +1,801 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:aves/app_mode.dart'; +import 'package:aves/model/entry/entry.dart'; +import 'package:aves/model/filters/container/dynamic_album.dart'; +import 'package:aves/model/filters/container/set_and.dart'; +import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/filters/query.dart'; +import 'package:aves/model/filters/trash.dart'; +import 'package:aves/model/query.dart'; +import 'package:aves/model/selection.dart'; +import 'package:aves/model/settings/enums/accessibility_animations.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/theme/themes.dart'; +import 'package:aves/view/view.dart'; +import 'package:aves/widgets/aves_app.dart'; +import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/collection/entry_set_action_delegate.dart'; +import 'package:aves/widgets/collection/filter_bar.dart'; +import 'package:aves/widgets/collection/query_bar.dart'; +import 'package:aves/widgets/common/action_controls/quick_choosers/move_button.dart'; +import 'package:aves/widgets/common/action_controls/quick_choosers/rate_button.dart'; +import 'package:aves/widgets/common/action_controls/quick_choosers/tag_button.dart'; +import 'package:aves/widgets/common/action_controls/togglers/favourite.dart'; +import 'package:aves/widgets/common/action_controls/togglers/title_search.dart'; +import 'package:aves/widgets/common/app_bar/app_bar_subtitle.dart'; +import 'package:aves/widgets/common/app_bar/app_bar_title.dart'; +import 'package:aves/widgets/common/basic/popup/container.dart'; +import 'package:aves/widgets/common/basic/popup/expansion_panel.dart'; +import 'package:aves/widgets/common/basic/popup/menu_row.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/aves_app_bar.dart'; +import 'package:aves/widgets/common/identity/buttons/captioned_button.dart'; +import 'package:aves/widgets/common/search/route.dart'; +import 'package:aves/widgets/common/tile_extent_controller.dart'; +import 'package:aves/widgets/dialogs/tile_view_dialog.dart'; +import 'package:aves/widgets/search/collection_search_delegate.dart'; +import 'package:aves/widgets/viewer/controls/notifications.dart'; +import 'package:aves_model/aves_model.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:provider/provider.dart'; + +class CollectionAppBar extends StatefulWidget { + final ValueNotifier appBarHeightNotifier; + final ScrollController scrollController; + final CollectionLens collection; + + const CollectionAppBar({ + super.key, + required this.appBarHeightNotifier, + required this.scrollController, + required this.collection, + }); + + @override + State createState() => _CollectionAppBarState(); +} + +class _CollectionAppBarState extends State with RouteAware, SingleTickerProviderStateMixin, WidgetsBindingObserver { + final Set _subscriptions = {}; + final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate(); + late AnimationController _browseToSelectAnimation; + final ValueNotifier _isSelectingNotifier = ValueNotifier(false); + final FocusNode _queryBarFocusNode = FocusNode(); + late final Listenable _queryFocusRequestNotifier; + double _statusBarHeight = 0; + + CollectionLens get collection => widget.collection; + + bool get isTrash => collection.filters.contains(TrashFilter.instance); + + CollectionSource get source => collection.source; + + Set get visibleFilters => collection.filters.where((v) => !(v is QueryFilter && v.live) && v is! TrashFilter).toSet(); + + bool get showFilterBar => visibleFilters.isNotEmpty; + + static const _sortOptions = [ + EntrySortFactor.date, + EntrySortFactor.size, + EntrySortFactor.name, + EntrySortFactor.rating, + EntrySortFactor.duration, + EntrySortFactor.path, + ]; + + static const _sectionOptions = [ + EntrySectionFactor.album, + EntrySectionFactor.month, + EntrySectionFactor.day, + EntrySectionFactor.none, + ]; + + static const _layoutOptions = [ + TileLayout.mosaic, + TileLayout.grid, + TileLayout.list, + ]; + + static const _trashSelectionQuickActions = [ + EntrySetAction.delete, + EntrySetAction.restore, + ]; + + @override + void initState() { + super.initState(); + final query = context.read(); + _subscriptions.add(query.enabledStream.listen((e) => _updateAppBarHeight())); + _queryFocusRequestNotifier = query.focusRequestNotifier; + _queryFocusRequestNotifier.addListener(_onQueryFocusRequest); + _queryBarFocusNode.addListener(_onQueryBarFocusChanged); + _browseToSelectAnimation = AnimationController( + duration: context.read().iconAnimation, + vsync: this, + ); + _isSelectingNotifier.addListener(_onActivityChanged); + _registerWidget(widget); + WidgetsBinding.instance.addObserver(this); + WidgetsBinding.instance.addPostFrameCallback((_) { + _updateStatusBarHeight(); + _onFilterChanged(); + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final route = ModalRoute.of(context); + if (route is PageRoute) { + AvesApp.pageRouteObserver.subscribe(this, route); + } + } + + @override + void didUpdateWidget(covariant CollectionAppBar oldWidget) { + super.didUpdateWidget(oldWidget); + _unregisterWidget(oldWidget); + _registerWidget(widget); + } + + @override + void dispose() { + _unregisterWidget(widget); + _queryBarFocusNode.dispose(); + _queryFocusRequestNotifier.removeListener(_onQueryFocusRequest); + _queryBarFocusNode.removeListener(_onQueryBarFocusChanged); + _isSelectingNotifier.dispose(); + _browseToSelectAnimation.dispose(); + _subscriptions + ..forEach((sub) => sub.cancel()) + ..clear(); + WidgetsBinding.instance.removeObserver(this); + AvesApp.pageRouteObserver.unsubscribe(this); + super.dispose(); + } + + void _registerWidget(CollectionAppBar widget) { + widget.collection.filterChangeNotifier.addListener(_onFilterChanged); + } + + void _unregisterWidget(CollectionAppBar widget) { + widget.collection.filterChangeNotifier.removeListener(_onFilterChanged); + } + + @override + void didPushNext() { + // unfocus when navigating away, so that when navigating back, + // the query bar does not get back focus and bring the keyboard + _queryBarFocusNode.unfocus(); + } + + @override + void didChangeMetrics() { + // when top padding or text scale factor change + WidgetsBinding.instance.addPostFrameCallback((_) => _updateStatusBarHeight()); + } + + @override + Widget build(BuildContext context) { + final appMode = context.watch>().value; + final selection = context.watch>(); + final isSelecting = selection.isSelecting; + _isSelectingNotifier.value = isSelecting; + return NotificationListener( + // cancel notification bubbling so that the draggable scroll bar + // does not misinterpret filter bar scrolling for collection scrolling + onNotification: (notification) => true, + child: AnimatedBuilder( + animation: collection.filterChangeNotifier, + builder: (context, child) { + final canRemoveFilters = appMode != AppMode.pickFilteredMediaInternal; + return Selector( + selector: (context, query) => query.enabled, + builder: (context, queryEnabled, child) { + return Selector>( + selector: (context, s) => s.collectionBrowsingQuickActions, + builder: (context, _, child) { + final useTvLayout = settings.useTvLayout; + final onFilterTap = canRemoveFilters ? collection.removeFilter : null; + return AvesAppBar( + contentHeight: appBarContentHeight, + pinned: context.select, bool>((selection) => selection.isSelecting), + leading: _buildAppBarLeading( + hasDrawer: appMode.canNavigate, + isSelecting: isSelecting, + ), + title: _buildAppBarTitle(isSelecting), + actions: (context, maxWidth) => useTvLayout ? [] : _buildActions(context, selection, maxWidth), + bottom: Column( + children: [ + if (useTvLayout) + SizedBox( + height: CaptionedButton.getTelevisionButtonHeight(context), + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 8), + scrollDirection: Axis.horizontal, + children: _buildActions(context, selection, double.infinity), + ), + ), + if (showFilterBar) + NotificationListener( + onNotification: (notification) { + if (notification is SelectFilterNotification) { + collection.addFilters({notification.filter}); + return true; + } else if (notification is DecomposeFilterNotification) { + final filter = notification.filter; + if (filter is DynamicAlbumFilter) { + final innerFilter = filter.filter; + final newFilters = innerFilter is SetAndFilter ? innerFilter.innerFilters : {innerFilter}; + collection.addFilters(newFilters); + collection.removeFilter(filter); + return true; + } + } + return false; + }, + child: FilterBar( + filters: visibleFilters, + onTap: onFilterTap, + onRemove: onFilterTap, + ), + ), + if (queryEnabled) + EntryQueryBar( + queryNotifier: context.select>((query) => query.queryNotifier), + focusNode: _queryBarFocusNode, + ), + ], + ), + transitionKey: isSelecting, + ); + }, + ); + }, + ); + }, + ), + ); + } + + double get appBarContentHeight { + final textScaler = MediaQuery.textScalerOf(context); + double height = textScaler.scale(kToolbarHeight); + if (settings.useTvLayout) { + height += CaptionedButton.getTelevisionButtonHeight(context); + } + if (showFilterBar) { + height += FilterBar.preferredHeight; + } + if (context.read().enabled) { + height += EntryQueryBar.getPreferredHeight(textScaler); + } + return height; + } + + Widget? _buildAppBarLeading({required bool hasDrawer, required bool isSelecting}) { + if (settings.useTvLayout) return null; + + if (!hasDrawer) { + return const CloseButton(); + } + + VoidCallback? onPressed; + String? tooltip; + if (isSelecting) { + onPressed = () => context.read>().browse(); + tooltip = MaterialLocalizations.of(context).backButtonTooltip; + } else { + onPressed = Scaffold.of(context).openDrawer; + tooltip = MaterialLocalizations.of(context).openAppDrawerTooltip; + } + return IconButton( + // key is expected by test driver + key: const Key('appbar-leading-button'), + icon: AnimatedIcon( + icon: AnimatedIcons.menu_arrow, + progress: _browseToSelectAnimation, + ), + onPressed: onPressed, + tooltip: tooltip, + ); + } + + Widget _buildAppBarTitle(bool isSelecting) { + final l10n = context.l10n; + + if (isSelecting) { + // `Selection` may not be available during hero + return Selector?, int>( + selector: (context, selection) => selection?.selectedItems.length ?? 0, + builder: (context, count, child) => Text( + count == 0 ? l10n.collectionSelectPageTitle : l10n.itemCount(count), + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + ), + ); + } else { + final appMode = context.watch>().value; + Widget title = Text( + appMode.isPickingMedia ? l10n.collectionPickPageTitle : (isTrash ? l10n.binPageTitle : l10n.collectionPageTitle), + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + ); + if (appMode == AppMode.main) { + title = SourceStateAwareAppBarTitle( + title: title, + source: source, + ); + } + return InteractiveAppBarTitle( + onTap: appMode.canNavigate ? _goToSearch : null, + child: title, + ); + } + } + + List _buildActions(BuildContext context, Selection selection, double maxWidth) { + final appMode = context.watch>().value; + final isSelecting = selection.isSelecting; + final selectedItemCount = selection.selectedItems.length; + + bool isVisible(EntrySetAction action) => _actionDelegate.isVisible( + action, + appMode: appMode, + isSelecting: isSelecting, + itemCount: collection.entryCount, + selectedItemCount: selectedItemCount, + isTrash: isTrash, + ); + bool canApply(EntrySetAction action) => _actionDelegate.canApply( + action, + isSelecting: isSelecting, + collection: collection, + selectedItemCount: selectedItemCount, + ); + + return settings.useTvLayout + ? _buildTelevisionActions( + context: context, + appMode: appMode, + selection: selection, + isVisible: isVisible, + canApply: canApply, + ) + : _buildMobileActions( + context: context, + appMode: appMode, + selection: selection, + maxWidth: maxWidth, + isVisible: isVisible, + canApply: canApply, + ); + } + + List _buildTelevisionActions({ + required BuildContext context, + required AppMode appMode, + required Selection selection, + required bool Function(EntrySetAction action) isVisible, + required bool Function(EntrySetAction action) canApply, + }) { + final isSelecting = selection.isSelecting; + + return [ + ...EntrySetActions.general, + ...isSelecting ? EntrySetActions.pageSelection : EntrySetActions.pageBrowsing, + ].nonNulls.where(isVisible).map((action) { + final enabled = canApply(action); + return CaptionedButton( + iconButtonBuilder: (context, focusNode) => _buildButtonIcon( + context, + action, + enabled: enabled, + selection: selection, + focusNode: focusNode, + ), + captionText: _buildButtonCaption(context, action, enabled: enabled), + onPressed: enabled ? () => _onActionSelected(action) : null, + ); + }).toList(); + } + + static double _iconButtonWidth(BuildContext context) { + const defaultPadding = EdgeInsets.all(8); + const defaultIconSize = 24.0; + return defaultPadding.horizontal + MediaQuery.textScalerOf(context).scale(defaultIconSize); + } + + List _buildMobileActions({ + required BuildContext context, + required AppMode appMode, + required Selection selection, + required double maxWidth, + required bool Function(EntrySetAction action) isVisible, + required bool Function(EntrySetAction action) canApply, + }) { + final availableCount = (maxWidth / _iconButtonWidth(context)).floor(); + + final isSelecting = selection.isSelecting; + final selectedItemCount = selection.selectedItems.length; + final hasSelection = selectedItemCount > 0; + + final browsingQuickActions = settings.collectionBrowsingQuickActions; + final selectionQuickActions = isTrash ? _trashSelectionQuickActions : settings.collectionSelectionQuickActions; + final quickActions = (isSelecting ? selectionQuickActions : browsingQuickActions).take(max(0, availableCount - 1)).toList(); + final quickActionButtons = quickActions + .where(isVisible) + .map( + (action) => _buildButtonIcon(context, action, enabled: canApply(action), selection: selection), + ); + + final animations = context.select((v) => v.accessibilityAnimations); + return [ + ...quickActionButtons, + PopupMenuButton( + // key is expected by test driver + key: const Key('appbar-menu-button'), + itemBuilder: (context) { + bool _isValidForMenu(EntrySetAction? v) => v == null || (!quickActions.contains(v) && isVisible(v)); + final generalMenuItems = EntrySetActions.general + .where(_isValidForMenu) + .map( + (action) => _toMenuItem(action, enabled: canApply(action), selection: selection), + ); + + final allContextualActions = isSelecting ? EntrySetActions.pageSelection : EntrySetActions.pageBrowsing; + final contextualMenuActions = allContextualActions.where(_isValidForMenu).fold([], (prev, v) { + if (v == null && (prev.isEmpty || prev.last == null)) return prev; + return [...prev, v]; + }); + if (contextualMenuActions.isNotEmpty && contextualMenuActions.last == null) { + contextualMenuActions.removeLast(); + } + + final contextualMenuItems = >[ + ...contextualMenuActions.map( + (action) { + if (action == null) return const PopupMenuDivider(); + return _toMenuItem(action, enabled: canApply(action), selection: selection); + }, + ), + if (isSelecting && !settings.isReadOnly && appMode == AppMode.main && !isTrash) + PopupMenuExpansionPanel( + enabled: hasSelection, + value: 'edit', + icon: AIcons.edit, + title: context.l10n.collectionActionEdit, + items: [ + _buildRotateAndFlipMenuItems(context, canApply: canApply), + ...EntrySetActions.edit.where((v) => isVisible(v) && !quickActions.contains(v)).map((action) => _toMenuItem(action, enabled: canApply(action), selection: selection)), + ], + ), + ]; + + return [ + ...generalMenuItems, + if (contextualMenuItems.isNotEmpty) ...[ + const PopupMenuDivider(), + ...contextualMenuItems, + ], + ]; + }, + onSelected: (action) async { + // wait for the popup menu to hide before proceeding with the action + await Future.delayed(animations.popUpAnimationDelay * timeDilation); + await _onActionSelected(action); + }, + popUpAnimationStyle: animations.popUpAnimationStyle, + ), + ]; + } + + Set _getExpandedSelectedItems(Selection selection) { + return selection.selectedItems.expand((entry) => entry.stackedEntries ?? {entry}).toSet(); + } + + // key is expected by test driver + Key _getActionKey(EntrySetAction action) => Key('menu-${action.name}'); + + Widget _buildButtonIcon( + BuildContext context, + EntrySetAction action, { + required bool enabled, + FocusNode? focusNode, + required Selection selection, + }) { + final blurred = settings.enableBlurEffect; + final onPressed = enabled ? () => _onActionSelected(action) : null; + switch (action) { + case EntrySetAction.toggleTitleSearch: + // `Query` may not be available during hero + return Selector( + selector: (context, query) => query?.enabled ?? false, + builder: (context, queryEnabled, child) { + return TitleSearchToggler( + queryEnabled: queryEnabled, + onPressed: onPressed, + focusNode: focusNode, + ); + }, + ); + case EntrySetAction.copy: + return MoveButton( + copy: true, + blurred: blurred, + onChooserValue: (album) => _actionDelegate.quickMove(context, album, copy: true), + onPressed: onPressed, + ); + case EntrySetAction.move: + return MoveButton( + copy: false, + blurred: blurred, + onChooserValue: (album) => _actionDelegate.quickMove(context, album, copy: false), + onPressed: onPressed, + ); + case EntrySetAction.editRating: + return RateButton( + blurred: blurred, + onChooserValue: (rating) => _actionDelegate.quickRate(context, rating), + focusNode: focusNode, + onPressed: onPressed, + ); + case EntrySetAction.editTags: + return TagButton( + blurred: blurred, + onChooserValue: (filter) => _actionDelegate.quickTag(context, filter), + focusNode: focusNode, + onPressed: onPressed, + ); + case EntrySetAction.toggleFavourite: + return FavouriteToggler( + entries: _getExpandedSelectedItems(selection), + focusNode: focusNode, + onPressed: onPressed, + ); + default: + return IconButton( + key: _getActionKey(action), + icon: action.getIcon(), + onPressed: onPressed, + focusNode: focusNode, + tooltip: action.getText(context), + ); + } + } + + Widget _buildButtonCaption( + BuildContext context, + EntrySetAction action, { + required bool enabled, + }) { + switch (action) { + case EntrySetAction.toggleTitleSearch: + return TitleSearchTogglerCaption( + enabled: enabled, + ); + default: + return CaptionedButtonText( + text: action.getText(context), + enabled: enabled, + ); + } + } + + PopupMenuItem _toMenuItem(EntrySetAction action, {required bool enabled, required Selection selection}) { + late Widget child; + switch (action) { + case EntrySetAction.toggleTitleSearch: + child = TitleSearchToggler( + queryEnabled: context.read().enabled, + isMenuItem: true, + ); + case EntrySetAction.toggleFavourite: + child = FavouriteToggler( + entries: _getExpandedSelectedItems(selection), + isMenuItem: true, + ); + default: + child = MenuRow(text: action.getText(context), icon: action.getIcon()); + } + return PopupMenuItem( + key: _getActionKey(action), + value: action, + enabled: enabled, + child: child, + ); + } + + PopupMenuEntry _buildRotateAndFlipMenuItems( + BuildContext context, { + required bool Function(EntrySetAction action) canApply, + }) { + Widget buildDivider() => const SizedBox( + height: 16, + child: VerticalDivider( + width: 1, + thickness: 1, + ), + ); + + Widget buildItem(EntrySetAction action) => Expanded( + child: Material( + color: Colors.transparent, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + clipBehavior: Clip.antiAlias, + child: PopupMenuItem( + value: action, + enabled: canApply(action), + child: Tooltip( + message: action.getText(context), + child: Center(child: action.getIcon()), + ), + ), + ), + ); + + return PopupMenuItemContainer( + child: Row( + children: [ + buildDivider(), + buildItem(EntrySetAction.rotateCCW), + buildDivider(), + buildItem(EntrySetAction.rotateCW), + buildDivider(), + buildItem(EntrySetAction.flip), + buildDivider(), + ], + ), + ); + } + + void _onActivityChanged() { + if (context.read>().isSelecting) { + _browseToSelectAnimation.forward(); + } else { + _browseToSelectAnimation.reverse(); + } + } + + void _onFilterChanged() { + _updateAppBarHeight(); + + final filters = collection.filters; + if (filters.isNotEmpty) { + final selection = context.read>(); + if (selection.isSelecting) { + final toRemove = selection.selectedItems.where((entry) => !filters.every((f) => f.test(entry))).toSet(); + selection.removeFromSelection(toRemove); + } + } + } + + void _onQueryFocusRequest() => _queryBarFocusNode.requestFocus(); + + void _onQueryBarFocusChanged() { + if (_queryBarFocusNode.hasFocus) { + // the query bar is in the top sliver of the page scrollable, + // so when the bar text field gets focus and requests to be on screen, + // it will scroll to show it by default, but it may not end at the very top, + // so we do it manually for a more predicable end position + _scrollToTop(); + } + } + + void _scrollToTop() => widget.scrollController.jumpTo(0); + + void _updateStatusBarHeight() { + if (!mounted) { + return; + } + _statusBarHeight = MediaQuery.paddingOf(context).top; + _updateAppBarHeight(); + } + + void _updateAppBarHeight() { + widget.appBarHeightNotifier.value = _statusBarHeight + AvesAppBar.appBarHeightForContentHeight(appBarContentHeight); + } + + Future _onActionSelected(EntrySetAction action) async { + switch (action) { + // general + case EntrySetAction.configureView: + await _configureView(); + case EntrySetAction.select: + context.read>().select(); + case EntrySetAction.selectAll: + context.read>().addToSelection(collection.sortedEntries); + case EntrySetAction.selectNone: + context.read>().clearSelection(); + // browsing + case EntrySetAction.searchCollection: + case EntrySetAction.toggleTitleSearch: + case EntrySetAction.addDynamicAlbum: + case EntrySetAction.addShortcut: + case EntrySetAction.setHome: + // browsing or selecting + case EntrySetAction.map: + case EntrySetAction.slideshow: + case EntrySetAction.stats: + case EntrySetAction.rescan: + case EntrySetAction.emptyBin: + // selecting + case EntrySetAction.share: + case EntrySetAction.delete: + case EntrySetAction.restore: + case EntrySetAction.copy: + case EntrySetAction.move: + case EntrySetAction.rename: + case EntrySetAction.convert: + case EntrySetAction.toggleFavourite: + case EntrySetAction.rotateCCW: + case EntrySetAction.rotateCW: + case EntrySetAction.flip: + case EntrySetAction.editDate: + case EntrySetAction.editLocation: + case EntrySetAction.editTitleDescription: + case EntrySetAction.editRating: + case EntrySetAction.editTags: + case EntrySetAction.removeMetadata: + _actionDelegate.onActionSelected(context, action); + } + } + + Future _configureView() async { + final initialValue = ( + settings.collectionSortFactor, + settings.collectionSectionFactor, + settings.getTileLayout(CollectionPage.routeName), + settings.collectionSortReverse, + ); + final extentController = context.read(); + final value = await showDialog<(EntrySortFactor?, EntrySectionFactor?, TileLayout?, bool)>( + context: context, + builder: (context) { + return TileViewDialog( + initialValue: initialValue, + sortOptions: _sortOptions.map((v) => TileViewDialogOption(value: v, title: v.getName(context), icon: v.icon)).toList(), + sectionOptions: _sectionOptions.map((v) => TileViewDialogOption(value: v, title: v.getName(context), icon: v.icon)).toList(), + layoutOptions: _layoutOptions.map((v) => TileViewDialogOption(value: v, title: v.getName(context), icon: v.icon)).toList(), + sortOrder: (factor, reverse) => factor.getOrderName(context, reverse), + canSection: (s, g, l) => s == EntrySortFactor.date, + tileExtentController: extentController, + ); + }, + routeSettings: const RouteSettings(name: TileViewDialog.routeName), + ); + // wait for the dialog to hide + await Future.delayed(ADurations.dialogTransitionLoose * timeDilation); + if (value != null && initialValue != value) { + settings.collectionSortFactor = value.$1!; + settings.collectionSectionFactor = value.$2!; + settings.setTileLayout(CollectionPage.routeName, value.$3!); + settings.collectionSortReverse = value.$4; + } + } + + void _goToSearch() { + Navigator.maybeOf(context)?.push( + SearchPageRoute( + delegate: CollectionSearchDelegate( + searchFieldLabel: context.l10n.searchCollectionFieldHint, + searchFieldStyle: Themes.searchFieldStyle(context), + source: collection.source, + parentCollection: collection, + ), + ), + ); + } +} diff --git a/lib/widgets/collection/app_bar.dart.orig b/lib/widgets/collection/app_bar.dart.orig new file mode 100644 index 00000000..da6bebce --- /dev/null +++ b/lib/widgets/collection/app_bar.dart.orig @@ -0,0 +1,817 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:aves/app_mode.dart'; +import 'package:aves/model/entry/entry.dart'; +import 'package:aves/model/filters/container/dynamic_album.dart'; +import 'package:aves/model/filters/container/set_and.dart'; +import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/filters/query.dart'; +import 'package:aves/model/filters/trash.dart'; +import 'package:aves/model/query.dart'; +import 'package:aves/model/selection.dart'; +import 'package:aves/model/settings/enums/accessibility_animations.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/theme/themes.dart'; +import 'package:aves/view/view.dart'; +import 'package:aves/widgets/aves_app.dart'; +import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/collection/entry_set_action_delegate.dart'; +import 'package:aves/widgets/collection/filter_bar.dart'; +import 'package:aves/widgets/collection/query_bar.dart'; +import 'package:aves/widgets/common/action_controls/quick_choosers/move_button.dart'; +import 'package:aves/widgets/common/action_controls/quick_choosers/rate_button.dart'; +import 'package:aves/widgets/common/action_controls/quick_choosers/tag_button.dart'; +import 'package:aves/widgets/common/action_controls/togglers/favourite.dart'; +import 'package:aves/widgets/common/action_controls/togglers/title_search.dart'; +import 'package:aves/widgets/common/app_bar/app_bar_subtitle.dart'; +import 'package:aves/widgets/common/app_bar/app_bar_title.dart'; +import 'package:aves/widgets/common/basic/popup/container.dart'; +import 'package:aves/widgets/common/basic/popup/expansion_panel.dart'; +import 'package:aves/widgets/common/basic/popup/menu_row.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/aves_app_bar.dart'; +import 'package:aves/widgets/common/identity/buttons/captioned_button.dart'; +import 'package:aves/widgets/common/search/route.dart'; +import 'package:aves/widgets/common/tile_extent_controller.dart'; +import 'package:aves/widgets/dialogs/tile_view_dialog.dart'; +import 'package:aves/widgets/search/collection_search_delegate.dart'; +import 'package:aves/widgets/viewer/controls/notifications.dart'; +import 'package:aves_model/aves_model.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:provider/provider.dart'; + +// ✅ REMOTE STATUS ICON (parabola in header) +import 'package:aves/widgets/collection/remote_status_icon.dart'; + +class CollectionAppBar extends StatefulWidget { + final ValueNotifier appBarHeightNotifier; + final ScrollController scrollController; + final CollectionLens collection; + + const CollectionAppBar({ + super.key, + required this.appBarHeightNotifier, + required this.scrollController, + required this.collection, + }); + + @override + State createState() => _CollectionAppBarState(); +} + +class _CollectionAppBarState extends State with RouteAware, SingleTickerProviderStateMixin, WidgetsBindingObserver { + final Set _subscriptions = {}; + final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate(); + late AnimationController _browseToSelectAnimation; + final ValueNotifier _isSelectingNotifier = ValueNotifier(false); + final FocusNode _queryBarFocusNode = FocusNode(); + late final Listenable _queryFocusRequestNotifier; + double _statusBarHeight = 0; + + CollectionLens get collection => widget.collection; + + bool get isTrash => collection.filters.contains(TrashFilter.instance); + + CollectionSource get source => collection.source; + + Set get visibleFilters => collection.filters.where((v) => !(v is QueryFilter && v.live) && v is! TrashFilter).toSet(); + + bool get showFilterBar => visibleFilters.isNotEmpty; + + static const _sortOptions = [ + EntrySortFactor.date, + EntrySortFactor.size, + EntrySortFactor.name, + EntrySortFactor.rating, + EntrySortFactor.duration, + EntrySortFactor.path, + ]; + + static const _sectionOptions = [ + EntrySectionFactor.album, + EntrySectionFactor.month, + EntrySectionFactor.day, + EntrySectionFactor.none, + ]; + + static const _layoutOptions = [ + TileLayout.mosaic, + TileLayout.grid, + TileLayout.list, + ]; + + static const _trashSelectionQuickActions = [ + EntrySetAction.delete, + EntrySetAction.restore, + ]; + + @override + void initState() { + super.initState(); + final query = context.read(); + _subscriptions.add(query.enabledStream.listen((e) => _updateAppBarHeight())); + _queryFocusRequestNotifier = query.focusRequestNotifier; + _queryFocusRequestNotifier.addListener(_onQueryFocusRequest); + _queryBarFocusNode.addListener(_onQueryBarFocusChanged); + _browseToSelectAnimation = AnimationController( + duration: context.read().iconAnimation, + vsync: this, + ); + _isSelectingNotifier.addListener(_onActivityChanged); + _registerWidget(widget); + WidgetsBinding.instance.addObserver(this); + WidgetsBinding.instance.addPostFrameCallback((_) { + _updateStatusBarHeight(); + _onFilterChanged(); + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final route = ModalRoute.of(context); + if (route is PageRoute) { + AvesApp.pageRouteObserver.subscribe(this, route); + } + } + + @override + void didUpdateWidget(covariant CollectionAppBar oldWidget) { + super.didUpdateWidget(oldWidget); + _unregisterWidget(oldWidget); + _registerWidget(widget); + } + + @override + void dispose() { + _unregisterWidget(widget); + _queryBarFocusNode.dispose(); + _queryFocusRequestNotifier.removeListener(_onQueryFocusRequest); + _queryBarFocusNode.removeListener(_onQueryBarFocusChanged); + _isSelectingNotifier.dispose(); + _browseToSelectAnimation.dispose(); + _subscriptions + ..forEach((sub) => sub.cancel()) + ..clear(); + WidgetsBinding.instance.removeObserver(this); + AvesApp.pageRouteObserver.unsubscribe(this); + super.dispose(); + } + + void _registerWidget(CollectionAppBar widget) { + widget.collection.filterChangeNotifier.addListener(_onFilterChanged); + } + + void _unregisterWidget(CollectionAppBar widget) { + widget.collection.filterChangeNotifier.removeListener(_onFilterChanged); + } + + @override + void didPushNext() { + _queryBarFocusNode.unfocus(); + } + + @override + void didChangeMetrics() { + WidgetsBinding.instance.addPostFrameCallback((_) => _updateStatusBarHeight()); + } + + @override + Widget build(BuildContext context) { + final appMode = context.watch>().value; + final selection = context.watch>(); + final isSelecting = selection.isSelecting; + _isSelectingNotifier.value = isSelecting; + + return NotificationListener( + onNotification: (notification) => true, + child: AnimatedBuilder( + animation: collection.filterChangeNotifier, + builder: (context, child) { + final canRemoveFilters = appMode != AppMode.pickFilteredMediaInternal; + return Selector( + selector: (context, query) => query.enabled, + builder: (context, queryEnabled, child) { + return Selector>( + selector: (context, s) => s.collectionBrowsingQuickActions, + builder: (context, _, child) { + final useTvLayout = settings.useTvLayout; + final onFilterTap = canRemoveFilters ? collection.removeFilter : null; + + return AvesAppBar( + contentHeight: appBarContentHeight, + pinned: context.select, bool>((selection) => selection.isSelecting), + leading: _buildAppBarLeading( + hasDrawer: appMode.canNavigate, + isSelecting: isSelecting, + ), + title: _buildAppBarTitle(isSelecting), + + // ✅ MOD: actions sempre con parabola + (se mobile) azioni originali + actions: (context, maxWidth) => _buildAppBarActions( + context: context, + selection: selection, + maxWidth: maxWidth, + useTvLayout: useTvLayout, + ), + + bottom: Column( + children: [ + if (useTvLayout) + SizedBox( + height: CaptionedButton.getTelevisionButtonHeight(context), + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 8), + scrollDirection: Axis.horizontal, + children: _buildActions(context, selection, double.infinity), + ), + ), + if (showFilterBar) + NotificationListener( + onNotification: (notification) { + if (notification is SelectFilterNotification) { + collection.addFilters({notification.filter}); + return true; + } else if (notification is DecomposeFilterNotification) { + final filter = notification.filter; + if (filter is DynamicAlbumFilter) { + final innerFilter = filter.filter; + final newFilters = innerFilter is SetAndFilter ? innerFilter.innerFilters : {innerFilter}; + collection.addFilters(newFilters); + collection.removeFilter(filter); + return true; + } + } + return false; + }, + child: FilterBar( + filters: visibleFilters, + onTap: onFilterTap, + onRemove: onFilterTap, + ), + ), + if (queryEnabled) + EntryQueryBar( + queryNotifier: context.select>((query) => query.queryNotifier), + focusNode: _queryBarFocusNode, + ), + ], + ), + transitionKey: isSelecting, + ); + }, + ); + }, + ); + }, + ), + ); + } + + // ✅ NEW: costruisce la lista actions con la parabola sempre presente + List _buildAppBarActions({ + required BuildContext context, + required Selection selection, + required double maxWidth, + required bool useTvLayout, + }) { + final statusIcon = const Padding( + padding: EdgeInsets.symmetric(horizontal: 6), + child: Tooltip( + message: 'Remote sync status', + child: RemoteStatusIcon(), + ), + ); + + // In TV layout Aves mostra le azioni in basso; qui mettiamo solo la parabola in header + if (useTvLayout) { + return [statusIcon]; + } + + // Mobile: parabola + azioni esistenti + return [ + statusIcon, + ..._buildActions(context, selection, maxWidth), + ]; + } + + double get appBarContentHeight { + final textScaler = MediaQuery.textScalerOf(context); + double height = textScaler.scale(kToolbarHeight); + if (settings.useTvLayout) { + height += CaptionedButton.getTelevisionButtonHeight(context); + } + if (showFilterBar) { + height += FilterBar.preferredHeight; + } + if (context.read().enabled) { + height += EntryQueryBar.getPreferredHeight(textScaler); + } + return height; + } + + Widget? _buildAppBarLeading({required bool hasDrawer, required bool isSelecting}) { + if (settings.useTvLayout) return null; + + if (!hasDrawer) { + return const CloseButton(); + } + + VoidCallback? onPressed; + String? tooltip; + if (isSelecting) { + onPressed = () => context.read>().browse(); + tooltip = MaterialLocalizations.of(context).backButtonTooltip; + } else { + onPressed = Scaffold.of(context).openDrawer; + tooltip = MaterialLocalizations.of(context).openAppDrawerTooltip; + } + return IconButton( + key: const Key('appbar-leading-button'), + icon: AnimatedIcon( + icon: AnimatedIcons.menu_arrow, + progress: _browseToSelectAnimation, + ), + onPressed: onPressed, + tooltip: tooltip, + ); + } + + Widget _buildAppBarTitle(bool isSelecting) { + final l10n = context.l10n; + + if (isSelecting) { + return Selector?, int>( + selector: (context, selection) => selection?.selectedItems.length ?? 0, + builder: (context, count, child) => Text( + count == 0 ? l10n.collectionSelectPageTitle : l10n.itemCount(count), + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + ), + ); + } else { + final appMode = context.watch>().value; + Widget title = Text( + appMode.isPickingMedia ? l10n.collectionPickPageTitle : (isTrash ? l10n.binPageTitle : l10n.collectionPageTitle), + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + ); + if (appMode == AppMode.main) { + title = SourceStateAwareAppBarTitle( + title: title, + source: source, + ); + } + return InteractiveAppBarTitle( + onTap: appMode.canNavigate ? _goToSearch : null, + child: title, + ); + } + } + + List _buildActions(BuildContext context, Selection selection, double maxWidth) { + final appMode = context.watch>().value; + final isSelecting = selection.isSelecting; + final selectedItemCount = selection.selectedItems.length; + + bool isVisible(EntrySetAction action) => _actionDelegate.isVisible( + action, + appMode: appMode, + isSelecting: isSelecting, + itemCount: collection.entryCount, + selectedItemCount: selectedItemCount, + isTrash: isTrash, + ); + bool canApply(EntrySetAction action) => _actionDelegate.canApply( + action, + isSelecting: isSelecting, + collection: collection, + selectedItemCount: selectedItemCount, + ); + + return settings.useTvLayout + ? _buildTelevisionActions( + context: context, + appMode: appMode, + selection: selection, + isVisible: isVisible, + canApply: canApply, + ) + : _buildMobileActions( + context: context, + appMode: appMode, + selection: selection, + maxWidth: maxWidth, + isVisible: isVisible, + canApply: canApply, + ); + } + + List _buildTelevisionActions({ + required BuildContext context, + required AppMode appMode, + required Selection selection, + required bool Function(EntrySetAction action) isVisible, + required bool Function(EntrySetAction action) canApply, + }) { + final isSelecting = selection.isSelecting; + + return [ + ...EntrySetActions.general, + ...isSelecting ? EntrySetActions.pageSelection : EntrySetActions.pageBrowsing, + ].nonNulls.where(isVisible).map((action) { + final enabled = canApply(action); + return CaptionedButton( + iconButtonBuilder: (context, focusNode) => _buildButtonIcon( + context, + action, + enabled: enabled, + selection: selection, + focusNode: focusNode, + ), + captionText: _buildButtonCaption(context, action, enabled: enabled), + onPressed: enabled ? () => _onActionSelected(action) : null, + ); + }).toList(); + } + + static double _iconButtonWidth(BuildContext context) { + const defaultPadding = EdgeInsets.all(8); + const defaultIconSize = 24.0; + return defaultPadding.horizontal + MediaQuery.textScalerOf(context).scale(defaultIconSize); + } + + List _buildMobileActions({ + required BuildContext context, + required AppMode appMode, + required Selection selection, + required double maxWidth, + required bool Function(EntrySetAction action) isVisible, + required bool Function(EntrySetAction action) canApply, + }) { + final availableCount = (maxWidth / _iconButtonWidth(context)).floor(); + + final isSelecting = selection.isSelecting; + final selectedItemCount = selection.selectedItems.length; + final hasSelection = selectedItemCount > 0; + + final browsingQuickActions = settings.collectionBrowsingQuickActions; + final selectionQuickActions = isTrash ? _trashSelectionQuickActions : settings.collectionSelectionQuickActions; + final quickActions = (isSelecting ? selectionQuickActions : browsingQuickActions).take(max(0, availableCount - 1)).toList(); + final quickActionButtons = quickActions + .where(isVisible) + .map((action) => _buildButtonIcon(context, action, enabled: canApply(action), selection: selection)); + + final animations = context.select((v) => v.accessibilityAnimations); + return [ + ...quickActionButtons, + PopupMenuButton( + key: const Key('appbar-menu-button'), + itemBuilder: (context) { + bool _isValidForMenu(EntrySetAction? v) => v == null || (!quickActions.contains(v) && isVisible(v)); + final generalMenuItems = EntrySetActions.general + .where(_isValidForMenu) + .map((action) => _toMenuItem(action, enabled: canApply(action), selection: selection)); + + final allContextualActions = isSelecting ? EntrySetActions.pageSelection : EntrySetActions.pageBrowsing; + final contextualMenuActions = allContextualActions.where(_isValidForMenu).fold>([], (prev, v) { + if (v == null && (prev.isEmpty || prev.last == null)) return prev; + return [...prev, v]; + }); + if (contextualMenuActions.isNotEmpty && contextualMenuActions.last == null) { + contextualMenuActions.removeLast(); + } + + final contextualMenuItems = >[ + ...contextualMenuActions.map((action) { + if (action == null) return const PopupMenuDivider(); + return _toMenuItem(action, enabled: canApply(action), selection: selection); + }), + if (isSelecting && !settings.isReadOnly && appMode == AppMode.main && !isTrash) + PopupMenuExpansionPanel( + enabled: hasSelection, + value: 'edit', + icon: AIcons.edit, + title: context.l10n.collectionActionEdit, + items: [ + _buildRotateAndFlipMenuItems(context, canApply: canApply), + ...EntrySetActions.edit + .where((v) => isVisible(v) && !quickActions.contains(v)) + .map((action) => _toMenuItem(action, enabled: canApply(action), selection: selection)), + ], + ), + ]; + + return [ + ...generalMenuItems, + if (contextualMenuItems.isNotEmpty) ...[ + const PopupMenuDivider(), + ...contextualMenuItems, + ], + ]; + }, + onSelected: (action) async { + await Future.delayed(animations.popUpAnimationDelay * timeDilation); + await _onActionSelected(action); + }, + popUpAnimationStyle: animations.popUpAnimationStyle, + ), + ]; + } + + Set _getExpandedSelectedItems(Selection selection) { + return selection.selectedItems.expand((entry) => entry.stackedEntries ?? {entry}).toSet(); + } + + Key _getActionKey(EntrySetAction action) => Key('menu-${action.name}'); + + Widget _buildButtonIcon( + BuildContext context, + EntrySetAction action, { + required bool enabled, + FocusNode? focusNode, + required Selection selection, + }) { + final blurred = settings.enableBlurEffect; + final onPressed = enabled ? () => _onActionSelected(action) : null; + switch (action) { + case EntrySetAction.toggleTitleSearch: + return Selector( + selector: (context, query) => query?.enabled ?? false, + builder: (context, queryEnabled, child) { + return TitleSearchToggler( + queryEnabled: queryEnabled, + onPressed: onPressed, + focusNode: focusNode, + ); + }, + ); + case EntrySetAction.copy: + return MoveButton( + copy: true, + blurred: blurred, + onChooserValue: (album) => _actionDelegate.quickMove(context, album, copy: true), + onPressed: onPressed, + ); + case EntrySetAction.move: + return MoveButton( + copy: false, + blurred: blurred, + onChooserValue: (album) => _actionDelegate.quickMove(context, album, copy: false), + onPressed: onPressed, + ); + case EntrySetAction.editRating: + return RateButton( + blurred: blurred, + onChooserValue: (rating) => _actionDelegate.quickRate(context, rating), + focusNode: focusNode, + onPressed: onPressed, + ); + case EntrySetAction.editTags: + return TagButton( + blurred: blurred, + onChooserValue: (filter) => _actionDelegate.quickTag(context, filter), + focusNode: focusNode, + onPressed: onPressed, + ); + case EntrySetAction.toggleFavourite: + return FavouriteToggler( + entries: _getExpandedSelectedItems(selection), + focusNode: focusNode, + onPressed: onPressed, + ); + default: + return IconButton( + key: _getActionKey(action), + icon: action.getIcon(), + onPressed: onPressed, + focusNode: focusNode, + tooltip: action.getText(context), + ); + } + } + + Widget _buildButtonCaption( + BuildContext context, + EntrySetAction action, { + required bool enabled, + }) { + switch (action) { + case EntrySetAction.toggleTitleSearch: + return TitleSearchTogglerCaption( + enabled: enabled, + ); + default: + return CaptionedButtonText( + text: action.getText(context), + enabled: enabled, + ); + } + } + + PopupMenuItem _toMenuItem(EntrySetAction action, {required bool enabled, required Selection selection}) { + late Widget child; + switch (action) { + case EntrySetAction.toggleTitleSearch: + child = TitleSearchToggler( + queryEnabled: context.read().enabled, + isMenuItem: true, + ); + case EntrySetAction.toggleFavourite: + child = FavouriteToggler( + entries: _getExpandedSelectedItems(selection), + isMenuItem: true, + ); + default: + child = MenuRow(text: action.getText(context), icon: action.getIcon()); + } + return PopupMenuItem( + key: _getActionKey(action), + value: action, + enabled: enabled, + child: child, + ); + } + + PopupMenuEntry _buildRotateAndFlipMenuItems( + BuildContext context, { + required bool Function(EntrySetAction action) canApply, + }) { + Widget buildDivider() => const SizedBox( + height: 16, + child: VerticalDivider( + width: 1, + thickness: 1, + ), + ); + + Widget buildItem(EntrySetAction action) => Expanded( + child: Material( + color: Colors.transparent, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + clipBehavior: Clip.antiAlias, + child: PopupMenuItem( + value: action, + enabled: canApply(action), + child: Tooltip( + message: action.getText(context), + child: Center(child: action.getIcon()), + ), + ), + ), + ); + + return PopupMenuItemContainer( + child: Row( + children: [ + buildDivider(), + buildItem(EntrySetAction.rotateCCW), + buildDivider(), + buildItem(EntrySetAction.rotateCW), + buildDivider(), + buildItem(EntrySetAction.flip), + buildDivider(), + ], + ), + ); + } + + void _onActivityChanged() { + if (context.read>().isSelecting) { + _browseToSelectAnimation.forward(); + } else { + _browseToSelectAnimation.reverse(); + } + } + + void _onFilterChanged() { + _updateAppBarHeight(); + + final filters = collection.filters; + if (filters.isNotEmpty) { + final selection = context.read>(); + if (selection.isSelecting) { + final toRemove = selection.selectedItems.where((entry) => !filters.every((f) => f.test(entry))).toSet(); + selection.removeFromSelection(toRemove); + } + } + } + + void _onQueryFocusRequest() => _queryBarFocusNode.requestFocus(); + + void _onQueryBarFocusChanged() { + if (_queryBarFocusNode.hasFocus) { + _scrollToTop(); + } + } + + void _scrollToTop() => widget.scrollController.jumpTo(0); + + void _updateStatusBarHeight() { + if (!mounted) { + return; + } + _statusBarHeight = MediaQuery.paddingOf(context).top; + _updateAppBarHeight(); + } + + void _updateAppBarHeight() { + widget.appBarHeightNotifier.value = _statusBarHeight + AvesAppBar.appBarHeightForContentHeight(appBarContentHeight); + } + + Future _onActionSelected(EntrySetAction action) async { + switch (action) { + case EntrySetAction.configureView: + await _configureView(); + case EntrySetAction.select: + context.read>().select(); + case EntrySetAction.selectAll: + context.read>().addToSelection(collection.sortedEntries); + case EntrySetAction.selectNone: + context.read>().clearSelection(); + case EntrySetAction.searchCollection: + case EntrySetAction.toggleTitleSearch: + case EntrySetAction.addDynamicAlbum: + case EntrySetAction.addShortcut: + case EntrySetAction.setHome: + case EntrySetAction.map: + case EntrySetAction.slideshow: + case EntrySetAction.stats: + case EntrySetAction.rescan: + case EntrySetAction.emptyBin: + case EntrySetAction.share: + case EntrySetAction.delete: + case EntrySetAction.restore: + case EntrySetAction.copy: + case EntrySetAction.move: + case EntrySetAction.rename: + case EntrySetAction.convert: + case EntrySetAction.toggleFavourite: + case EntrySetAction.rotateCCW: + case EntrySetAction.rotateCW: + case EntrySetAction.flip: + case EntrySetAction.editDate: + case EntrySetAction.editLocation: + case EntrySetAction.editTitleDescription: + case EntrySetAction.editRating: + case EntrySetAction.editTags: + case EntrySetAction.removeMetadata: + _actionDelegate.onActionSelected(context, action); + } + } + + Future _configureView() async { + final initialValue = ( + settings.collectionSortFactor, + settings.collectionSectionFactor, + settings.getTileLayout(CollectionPage.routeName), + settings.collectionSortReverse, + ); + final extentController = context.read(); + final value = await showDialog<(EntrySortFactor?, EntrySectionFactor?, TileLayout?, bool)>( + context: context, + builder: (context) { + return TileViewDialog( + initialValue: initialValue, + sortOptions: _sortOptions.map((v) => TileViewDialogOption(value: v, title: v.getName(context), icon: v.icon)).toList(), + sectionOptions: _sectionOptions.map((v) => TileViewDialogOption(value: v, title: v.getName(context), icon: v.icon)).toList(), + layoutOptions: _layoutOptions.map((v) => TileViewDialogOption(value: v, title: v.getName(context), icon: v.icon)).toList(), + sortOrder: (factor, reverse) => factor.getOrderName(context, reverse), + canSection: (s, g, l) => s == EntrySortFactor.date, + tileExtentController: extentController, + ); + }, + routeSettings: const RouteSettings(name: TileViewDialog.routeName), + ); + await Future.delayed(ADurations.dialogTransitionLoose * timeDilation); + if (value != null && initialValue != value) { + settings.collectionSortFactor = value.$1!; + settings.collectionSectionFactor = value.$2!; + settings.setTileLayout(CollectionPage.routeName, value.$3!); + settings.collectionSortReverse = value.$4; + } + } + + void _goToSearch() { + Navigator.maybeOf(context)?.push( + SearchPageRoute( + delegate: CollectionSearchDelegate( + searchFieldLabel: context.l10n.searchCollectionFieldHint, + searchFieldStyle: Themes.searchFieldStyle(context), + source: collection.source, + parentCollection: collection, + ), + ), + ); + } +} diff --git a/lib/widgets/collection/collection_grid.dart b/lib/widgets/collection/collection_grid.dart index 4319ea79..024c75e9 100644 --- a/lib/widgets/collection/collection_grid.dart +++ b/lib/widgets/collection/collection_grid.dart @@ -56,6 +56,9 @@ import 'package:intl/intl.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; +// ✅ NEW: accesso al DB per capire se esiste cache (evita "scanner" alle riaperture) +import 'package:aves/services/common/services.dart'; + // REMOTE: import per le thumb di rete import 'package:aves/remote/remote_image_tile.dart'; @@ -91,7 +94,11 @@ class _CollectionGridState extends State { @override Widget build(BuildContext context) { - final spacing = context.select((v) => v.getTileLayout(settingsRouteKey) == TileLayout.mosaic ? CollectionGrid.mosaicLayoutSpacing : CollectionGrid.fixedExtentLayoutSpacing); + final spacing = context.select((v) => + v.getTileLayout(settingsRouteKey) == TileLayout.mosaic + ? CollectionGrid.mosaicLayoutSpacing + : CollectionGrid.fixedExtentLayoutSpacing); + if (_tileExtentController?.spacing != spacing) { _tileExtentController = TileExtentController( settingsRouteKey: settingsRouteKey, @@ -102,6 +109,7 @@ class _CollectionGridState extends State { horizontalPadding: 2, ); } + return TileExtentControllerProvider( controller: _tileExtentController!, child: const _CollectionGridContent(), @@ -119,12 +127,14 @@ class _CollectionGridContent extends StatefulWidget { class _CollectionGridContentState extends State<_CollectionGridContent> { final ValueNotifier _focusedItemNotifier = ValueNotifier(null); final ValueNotifier _isScrollingNotifier = ValueNotifier(false); - final ValueNotifier _selectingAppModeNotifier = ValueNotifier(AppMode.pickFilteredMediaInternal); + final ValueNotifier _selectingAppModeNotifier = + ValueNotifier(AppMode.pickFilteredMediaInternal); @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) => context.read().value = null); + WidgetsBinding.instance + .addPostFrameCallback((_) => context.read().value = null); } @override @@ -137,20 +147,26 @@ class _CollectionGridContentState extends State<_CollectionGridContent> { @override Widget build(BuildContext context) { - final selectable = context.select, bool>((v) => v.value.canSelectMedia); + final selectable = + context.select, bool>((v) => v.value.canSelectMedia); final settingsRouteKey = context.read().settingsRouteKey; - final tileLayout = context.select((v) => v.getTileLayout(settingsRouteKey)); + final tileLayout = + context.select((v) => v.getTileLayout(settingsRouteKey)); + return Consumer( builder: (context, collection, child) { final sectionedListLayoutProvider = ValueListenableBuilder( - valueListenable: context.select>((controller) => controller.extentNotifier), + valueListenable: context.select>( + (controller) => controller.extentNotifier), builder: (context, thumbnailExtent, child) { assert(thumbnailExtent > 0); return Selector( - selector: (context, c) => (c.viewportSize.width, c.columnCount, c.spacing, c.horizontalPadding), + selector: (context, c) => + (c.viewportSize.width, c.columnCount, c.spacing, c.horizontalPadding), builder: (context, c, child) { final (scrollableWidth, columnCount, tileSpacing, horizontalPadding) = c; final source = collection.source; + return GridTheme( extent: thumbnailExtent, child: EntryListDetailsTheme( @@ -160,9 +176,10 @@ class _CollectionGridContentState extends State<_CollectionGridContent> { builder: (context, sourceState, child) { late final Duration tileAnimationDelay; if (sourceState == SourceState.ready) { - // do not listen for animation delay change final target = context.read().staggeredAnimationPageTarget; - tileAnimationDelay = context.read().getTileAnimationDelay(target); + tileAnimationDelay = context + .read() + .getTileAnimationDelay(target); } else { tileAnimationDelay = Duration.zero; } @@ -223,7 +240,8 @@ class _CollectionGridContentState extends State<_CollectionGridContent> { return AnimatedScale( scale: focusedItem == entry ? 1 : .9, curve: Curves.fastOutSlowIn, - duration: context.select((v) => v.tvImageFocusAnimation), + duration: context.select( + (v) => v.tvImageFocusAnimation), child: child!, ); }, @@ -261,12 +279,8 @@ class _CollectionGridContentState extends State<_CollectionGridContent> { } Future _goToViewer(CollectionLens collection, AvesEntry entry) async { - // track viewer entry for dynamic hero placeholder final viewerEntryNotifier = context.read(); - // prevent navigating again to the same entry until fully back, - // as a workaround for the hero pop/push diversion animation issue - // (cf `ThumbnailImage` `Hero` usage) if (viewerEntryNotifier.value == entry) return; WidgetsBinding.instance.addPostFrameCallback((_) => viewerEntryNotifier.value = entry); @@ -298,10 +312,8 @@ class _CollectionGridContentState extends State<_CollectionGridContent> { ), ); - // reset track viewer entry final animate = context.read().animate; if (animate) { - // TODO TLAD fix timing when transition is incomplete, e.g. when going back while going to the viewer await Future.delayed(ADurations.pageTransitionExact * timeDilation); } viewerEntryNotifier.value = null; @@ -409,7 +421,9 @@ class _CollectionScaler extends StatelessWidget { @override Widget build(BuildContext context) { - final (tileSpacing, horizontalPadding) = context.select((v) => (v.spacing, v.horizontalPadding)); + final (tileSpacing, horizontalPadding) = + context.select( + (v) => (v.spacing, v.horizontalPadding)); final brightness = Theme.of(context).brightness; final borderColor = DecoratedThumbnail.borderColor(context); final borderWidth = DecoratedThumbnail.borderWidth(context); @@ -435,7 +449,6 @@ class _CollectionScaler extends StatelessWidget { extent: tileSize.height, child: Builder( builder: (_) { - // REMOTE: ramo dedicato in layout "fixed scale" if (entry.origin == 1) { return RemoteInteractiveTile( key: ValueKey('remote_scaled_${entry.id}'), @@ -443,7 +456,6 @@ class _CollectionScaler extends StatelessWidget { thumbnailExtent: context.read().effectiveExtentMax, ); } - // Locale: flusso preesistente return Tile( entry: entry, thumbnailExtent: context.read().effectiveExtentMax, @@ -454,7 +466,8 @@ class _CollectionScaler extends StatelessWidget { ), mosaicItemBuilder: (index, targetExtent) => DecoratedBox( decoration: BoxDecoration( - color: ThumbnailImage.computeLoadingBackgroundColor(index * 10, brightness).withValues(alpha: .9), + color: ThumbnailImage.computeLoadingBackgroundColor(index * 10, brightness) + .withValues(alpha: .9), border: Border.all( color: borderColor, width: borderWidth, @@ -491,13 +504,28 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge Timer? _scrollMonitoringTimer; bool _checkingStoragePermission = false; + // ✅ NEW: memoizza se esiste cache DB (evita lo "scanner" alle riaperture) + late final Future _hasAnyDbCacheFuture; + @override void initState() { super.initState(); + _hasAnyDbCacheFuture = _hasAnyDbCache(); _registerWidget(widget); WidgetsBinding.instance.addObserver(this); } + Future _hasAnyDbCache() async { + try { + final rows = await localMediaDb.rawDb.rawQuery( + 'SELECT 1 FROM entry WHERE trashed=0 LIMIT 1', + ); + return rows.isNotEmpty; + } catch (_) { + return false; + } + } + @override void didUpdateWidget(covariant _CollectionScrollView oldWidget) { super.didUpdateWidget(oldWidget); @@ -553,14 +581,16 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge return Selector( selector: (context, s) => s.enableBottomNavigationBar, builder: (context, enableBottomNavigationBar, child) { - final canNavigate = context.select, bool>((v) => v.value.canNavigate); + final canNavigate = + context.select, bool>((v) => v.value.canNavigate); final showBottomNavigationBar = canNavigate && enableBottomNavigationBar; final navBarHeight = showBottomNavigationBar ? AppBottomNavBar.height : 0; return Selector, List>( selector: (context, layout) => layout.sectionLayouts, builder: (context, sectionLayouts, child) { final scrollController = widget.scrollController; - final offsetIncrementSnapThreshold = context.select((v) => (v.extentNotifier.value + v.spacing) / 4); + final offsetIncrementSnapThreshold = + context.select((v) => (v.extentNotifier.value + v.spacing) / 4); return DraggableScrollbar( backgroundColor: Colors.white, scrollThumbSize: Size(avesScrollThumbWidth, avesScrollThumbHeight), @@ -570,14 +600,15 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge ), controller: scrollController, dragOffsetSnapper: (scrollOffset, offsetIncrement) { - if (offsetIncrement > offsetIncrementSnapThreshold && scrollOffset < scrollController.position.maxScrollExtent) { - final section = sectionLayouts.firstWhereOrNull((section) => section.hasChildAtOffset(scrollOffset)); + if (offsetIncrement > offsetIncrementSnapThreshold && + scrollOffset < scrollController.position.maxScrollExtent) { + final section = + sectionLayouts.firstWhereOrNull((section) => section.hasChildAtOffset(scrollOffset)); if (section != null) { - if (section.maxOffset - section.minOffset < scrollController.position.viewportDimension) { - // snap to section header + if (section.maxOffset - section.minOffset < + scrollController.position.viewportDimension) { return section.minOffset; } else { - // snap to content row final index = section.getMinChildIndexForScrollOffset(scrollOffset); return section.indexToLayoutOffset(index); } @@ -587,7 +618,6 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge }, crumbsBuilder: () => _getCrumbs(sectionLayouts), padding: EdgeInsets.only( - // padding to keep scroll thumb between app bar above and nav bar below top: appBarHeight, bottom: navBarHeight + mqPaddingBottom, ), @@ -612,15 +642,19 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge return CustomScrollView( key: widget.scrollableKey, primary: true, - // workaround to prevent scrolling the app bar away - // when there is no content and we use `SliverFillRemaining` physics: collection.isEmpty ? const NeverScrollableScrollPhysics() : SloppyScrollPhysics( gestureSettings: MediaQuery.gestureSettingsOf(context), parent: const AlwaysScrollableScrollPhysics(), ), - cacheExtent: context.select((controller) => controller.effectiveExtentMax), + // ✅ MOD: preload viewport + ahead (senza prefetchRelPaths) + cacheExtent: (() { + final base = context.select((c) => c.effectiveExtentMax); + final h = MediaQuery.of(context).size.height; + final target = h * 2; // ~2 schermate avanti + return target < base ? base : target; + })(), slivers: [ appBar, collection.isEmpty @@ -642,7 +676,23 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge valueListenable: source.stateNotifier, builder: (context, sourceState, child) { if (sourceState == SourceState.loading) { - return LoadingEmptyContent(source: source); + // ✅ MOD: se DB ha cache, non mostrare "scanner" ma solo spinner piccolo + return FutureBuilder( + future: _hasAnyDbCacheFuture, + builder: (context, snapshot) { + final hasCache = snapshot.data ?? false; + if (hasCache) { + return const Center( + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 1.6), + ), + ); + } + return LoadingEmptyContent(source: source); + }, + ); } return FutureBuilder( @@ -670,7 +720,8 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge bottom: bottom, ); } - if (collection.filters.any((filter) => filter is MimeFilter && filter.mime == MimeTypes.anyVideo)) { + if (collection.filters.any((filter) => + filter is MimeFilter && filter.mime == MimeTypes.anyVideo)) { return EmptyContent( icon: AIcons.video, text: context.l10n.collectionEmptyVideos, @@ -705,7 +756,8 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge if (sectionLayouts.length <= 1) return crumbs; final maxOffset = sectionLayouts.last.maxOffset; - void addAlbums(CollectionLens collection, List sectionLayouts, Map crumbs) { + void addAlbums(CollectionLens collection, List sectionLayouts, + Map crumbs) { final source = collection.source; sectionLayouts.forEach((section) { final directory = (section.sectionKey as EntryAlbumSectionKey).directory; @@ -731,7 +783,9 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge final oldest = lastKey.date; if (newest != null && oldest != null) { final locale = context.locale; - final dateFormat = (newest.difference(oldest).inHumanDays).abs() > 365 ? DateFormat.y(locale) : DateFormat.MMM(locale); + final dateFormat = (newest.difference(oldest).inHumanDays).abs() > 365 + ? DateFormat.y(locale) + : DateFormat.MMM(locale); String? lastLabel; sectionLayouts.forEach((section) { final date = (section.sectionKey as EntryDateSectionKey).date; @@ -759,7 +813,9 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge return crumbs; } - Future get _isStoragePermissionGranted => Future.wait(Permissions.storage.map((v) => v.status)).then((v) => v.any((status) => status.isGranted)); + Future get _isStoragePermissionGranted => + Future.wait(Permissions.storage.map((v) => v.status)) + .then((v) => v.any((status) => status.isGranted)); } // REMOTE: mini-tile che mostra la thumb remota e apre il viewer con OpenViewerNotification @@ -775,8 +831,6 @@ class RemoteInteractiveTile extends StatelessWidget { @override Widget build(BuildContext context) { - // Nota: usiamo OpenViewerNotification perché la Collection già la intercetta - // e apre il viewer col lens corretto (stesso comportamento dei locali). return GestureDetector( onTap: () => OpenViewerNotification(entry).dispatch(context), child: ClipRRect( diff --git a/lib/widgets/collection/collection_grid.dart.old b/lib/widgets/collection/collection_grid.dart.old index 3926a669..4319ea79 100644 --- a/lib/widgets/collection/collection_grid.dart.old +++ b/lib/widgets/collection/collection_grid.dart.old @@ -56,6 +56,9 @@ import 'package:intl/intl.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; +// REMOTE: import per le thumb di rete +import 'package:aves/remote/remote_image_tile.dart'; + class CollectionGrid extends StatefulWidget { final String settingsRouteKey; @@ -182,6 +185,17 @@ class _CollectionGridContentState extends State<_CollectionGridContent> { tileExtent: thumbnailExtent, tileBuilder: (entry, tileSize) { final extent = tileSize.shortestSide; + + // REMOTE: ramo dedicato per le entry remote (origin=1) + if (entry.origin == 1) { + return RemoteInteractiveTile( + key: ValueKey('remote_${entry.id}'), + entry: entry, + thumbnailExtent: extent, + ); + } + + // Locale: flusso preesistente return AnimatedBuilder( animation: favourites, builder: (context, child) { @@ -419,10 +433,23 @@ class _CollectionScaler extends StatelessWidget { ), scaledItemBuilder: (entry, tileSize) => EntryListDetailsTheme( extent: tileSize.height, - child: Tile( - entry: entry, - thumbnailExtent: context.read().effectiveExtentMax, - tileLayout: tileLayout, + child: Builder( + builder: (_) { + // REMOTE: ramo dedicato in layout "fixed scale" + if (entry.origin == 1) { + return RemoteInteractiveTile( + key: ValueKey('remote_scaled_${entry.id}'), + entry: entry, + thumbnailExtent: context.read().effectiveExtentMax, + ); + } + // Locale: flusso preesistente + return Tile( + entry: entry, + thumbnailExtent: context.read().effectiveExtentMax, + tileLayout: tileLayout, + ); + }, ), ), mosaicItemBuilder: (index, targetExtent) => DecoratedBox( @@ -734,3 +761,32 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge Future get _isStoragePermissionGranted => Future.wait(Permissions.storage.map((v) => v.status)).then((v) => v.any((status) => status.isGranted)); } + +// REMOTE: mini-tile che mostra la thumb remota e apre il viewer con OpenViewerNotification +class RemoteInteractiveTile extends StatelessWidget { + final AvesEntry entry; + final double thumbnailExtent; + + const RemoteInteractiveTile({ + super.key, + required this.entry, + required this.thumbnailExtent, + }); + + @override + Widget build(BuildContext context) { + // Nota: usiamo OpenViewerNotification perché la Collection già la intercetta + // e apre il viewer col lens corretto (stesso comportamento dei locali). + return GestureDetector( + onTap: () => OpenViewerNotification(entry).dispatch(context), + child: ClipRRect( + borderRadius: BorderRadius.zero, + child: SizedBox( + width: thumbnailExtent, + height: thumbnailExtent, + child: RemoteImageTile(entry: entry), + ), + ), + ); + } +} diff --git a/lib/widgets/collection/collection_page.dart b/lib/widgets/collection/collection_page.dart index 24d87db6..5fa6c6b5 100644 --- a/lib/widgets/collection/collection_page.dart +++ b/lib/widgets/collection/collection_page.dart @@ -33,6 +33,9 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +// ✅ STEP 2: banner progresso remoti (overlay sopra la griglia) +import 'package:aves/widgets/collection/remote_progress_banner.dart'; + class CollectionPage extends StatefulWidget { static const routeName = '/collection'; @@ -54,7 +57,8 @@ class CollectionPage extends StatefulWidget { class _CollectionPageState extends State { final Set _subscriptions = {}; late CollectionLens _collection; - final StreamController _draggableScrollBarEventStreamController = StreamController.broadcast(); + final StreamController _draggableScrollBarEventStreamController = + StreamController.broadcast(); @override void initState() { @@ -66,7 +70,9 @@ class _CollectionPageState extends State { ); super.initState(); _subscriptions.add( - settings.updateStream.where((event) => event.key == SettingKeys.enableBinKey).listen((_) { + settings.updateStream + .where((event) => event.key == SettingKeys.enableBinKey) + .listen((_) { if (!settings.enableBin) { _collection.removeFilter(TrashFilter.instance); } @@ -87,7 +93,8 @@ class _CollectionPageState extends State { @override Widget build(BuildContext context) { final useTvLayout = settings.useTvLayout; - final liveFilter = _collection.filters.firstWhereOrNull((v) => v is QueryFilter && v.live) as QueryFilter?; + final liveFilter = + _collection.filters.firstWhereOrNull((v) => v is QueryFilter && v.live) as QueryFilter?; return SelectionProvider( child: Selector, bool>( selector: (context, selection) => selection.selectedItems.isNotEmpty, @@ -107,15 +114,21 @@ class _CollectionPageState extends State { doubleBackPopHandler, ], child: GestureAreaProtectorStack( - child: DirectionalSafeArea( - start: !useTvLayout, - top: false, - bottom: false, - child: const CollectionGrid( - // key is expected by test driver - key: Key('collection-grid'), - settingsRouteKey: CollectionPage.routeName, - ), + // ✅ STEP 2: overlay in Stack (griglia + banner progresso remoti) + child: Stack( + children: [ + DirectionalSafeArea( + start: !useTvLayout, + top: false, + bottom: false, + child: const CollectionGrid( + // key is expected by test driver + key: Key('collection-grid'), + settingsRouteKey: CollectionPage.routeName, + ), + ), + const RemoteProgressBanner(), + ], ), ), ); @@ -142,7 +155,8 @@ class _CollectionPageState extends State { page = Selector( selector: (context, s) => s.enableBottomNavigationBar, builder: (context, enableBottomNavigationBar, child) { - final canNavigate = context.select, bool>((v) => v.value.canNavigate); + final canNavigate = + context.select, bool>((v) => v.value.canNavigate); final showBottomNavigationBar = canNavigate && enableBottomNavigationBar; return NotificationListener( @@ -167,6 +181,7 @@ class _CollectionPageState extends State { }, ); } + // this provider should be above `TvRail` return ChangeNotifierProvider.value( value: _collection, diff --git a/lib/widgets/collection/collection_page.dart.old b/lib/widgets/collection/collection_page.dart.old new file mode 100644 index 00000000..24d87db6 --- /dev/null +++ b/lib/widgets/collection/collection_page.dart.old @@ -0,0 +1,229 @@ +import 'dart:async'; + +import 'package:aves/app_mode.dart'; +import 'package:aves/model/entry/entry.dart'; +import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/filters/query.dart'; +import 'package:aves/model/filters/trash.dart'; +import 'package:aves/model/highlight.dart'; +import 'package:aves/model/selection.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/services/app_service.dart'; +import 'package:aves/services/intent_service.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/widgets/collection/collection_grid.dart'; +import 'package:aves/widgets/common/basic/draggable_scrollbar/notifications.dart'; +import 'package:aves/widgets/common/basic/insets.dart'; +import 'package:aves/widgets/common/basic/scaffold.dart'; +import 'package:aves/widgets/common/behaviour/pop/double_back.dart'; +import 'package:aves/widgets/common/behaviour/pop/scope.dart'; +import 'package:aves/widgets/common/behaviour/pop/tv_navigation.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/aves_fab.dart'; +import 'package:aves/widgets/common/providers/query_provider.dart'; +import 'package:aves/widgets/common/providers/selection_provider.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:aves/widgets/navigation/drawer/app_drawer.dart'; +import 'package:aves/widgets/navigation/nav_bar/nav_bar.dart'; +import 'package:aves/widgets/navigation/tv_rail.dart'; +import 'package:aves_model/aves_model.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class CollectionPage extends StatefulWidget { + static const routeName = '/collection'; + + final CollectionSource source; + final Set? filters; + final bool Function(AvesEntry element)? highlightTest; + + const CollectionPage({ + super.key, + required this.source, + required this.filters, + this.highlightTest, + }); + + @override + State createState() => _CollectionPageState(); +} + +class _CollectionPageState extends State { + final Set _subscriptions = {}; + late CollectionLens _collection; + final StreamController _draggableScrollBarEventStreamController = StreamController.broadcast(); + + @override + void initState() { + // do not seed this widget with the collection, but control its lifecycle here instead, + // as the collection properties may change and they should not be reset by a widget update (e.g. with theme change) + _collection = CollectionLens( + source: widget.source, + filters: widget.filters, + ); + super.initState(); + _subscriptions.add( + settings.updateStream.where((event) => event.key == SettingKeys.enableBinKey).listen((_) { + if (!settings.enableBin) { + _collection.removeFilter(TrashFilter.instance); + } + }), + ); + WidgetsBinding.instance.addPostFrameCallback((_) => _checkInitHighlight()); + } + + @override + void dispose() { + _subscriptions + ..forEach((sub) => sub.cancel()) + ..clear(); + _collection.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final useTvLayout = settings.useTvLayout; + final liveFilter = _collection.filters.firstWhereOrNull((v) => v is QueryFilter && v.live) as QueryFilter?; + return SelectionProvider( + child: Selector, bool>( + selector: (context, selection) => selection.selectedItems.isNotEmpty, + builder: (context, hasSelection, child) { + final body = QueryProvider( + startEnabled: settings.getShowTitleQuery(context.currentRouteName!), + initialQuery: liveFilter?.query, + child: Builder( + builder: (context) { + return AvesPopScope( + handlers: [ + APopHandler( + canPop: (context) => context.select, bool>((v) => !v.isSelecting), + onPopBlocked: (context) => context.read>().browse(), + ), + tvNavigationPopHandler, + doubleBackPopHandler, + ], + child: GestureAreaProtectorStack( + child: DirectionalSafeArea( + start: !useTvLayout, + top: false, + bottom: false, + child: const CollectionGrid( + // key is expected by test driver + key: Key('collection-grid'), + settingsRouteKey: CollectionPage.routeName, + ), + ), + ), + ); + }, + ), + ); + + Widget page; + if (useTvLayout) { + page = AvesScaffold( + body: Row( + children: [ + TvRail( + controller: context.read(), + currentCollection: _collection, + ), + Expanded(child: body), + ], + ), + resizeToAvoidBottomInset: false, + extendBody: true, + ); + } else { + page = Selector( + selector: (context, s) => s.enableBottomNavigationBar, + builder: (context, enableBottomNavigationBar, child) { + final canNavigate = context.select, bool>((v) => v.value.canNavigate); + final showBottomNavigationBar = canNavigate && enableBottomNavigationBar; + + return NotificationListener( + onNotification: (notification) { + _draggableScrollBarEventStreamController.add(notification.event); + return false; + }, + child: AvesScaffold( + body: body, + floatingActionButton: _buildFab(context, hasSelection), + drawer: canNavigate ? AppDrawer(currentCollection: _collection) : null, + bottomNavigationBar: showBottomNavigationBar + ? AppBottomNavBar( + events: _draggableScrollBarEventStreamController.stream, + currentCollection: _collection, + ) + : null, + resizeToAvoidBottomInset: false, + extendBody: true, + ), + ); + }, + ); + } + // this provider should be above `TvRail` + return ChangeNotifierProvider.value( + value: _collection, + child: page, + ); + }, + ), + ); + } + + Widget? _buildFab(BuildContext context, bool hasSelection) { + final appMode = context.watch>().value; + final l10n = context.l10n; + switch (appMode) { + case AppMode.pickMultipleMediaExternal: + return hasSelection + ? AvesFab( + tooltip: l10n.pickTooltip, + onPressed: () async { + final items = context.read>().selectedItems; + final uris = items.map((entry) => entry.uri).toList(); + try { + await IntentService.submitPickedItems(uris); + } on TooManyItemsException catch (_) { + await showWarningDialog( + context: context, + message: l10n.tooManyItemsErrorDialogMessage, + ); + } + }, + ) + : null; + case AppMode.pickCollectionFiltersExternal: + return AvesFab( + tooltip: l10n.pickTooltip, + onPressed: () { + final filters = _collection.filters; + IntentService.submitPickedCollectionFilters(filters); + }, + ); + default: + return null; + } + } + + Future _checkInitHighlight() async { + final highlightTest = widget.highlightTest; + if (highlightTest == null) return; + + final item = _collection.sortedEntries.firstWhereOrNull(highlightTest); + if (item == null) return; + + final delayDuration = context.read().staggeredAnimationPageTarget; + await Future.delayed(delayDuration + ADurations.highlightScrollInitDelay); + + if (!mounted) return; + final animate = context.read().animate; + context.read().trackItem(item, animate: animate, highlightItem: item); + } +} diff --git a/lib/widgets/collection/loading.dart b/lib/widgets/collection/loading.dart index 390752e4..dbe42985 100644 --- a/lib/widgets/collection/loading.dart +++ b/lib/widgets/collection/loading.dart @@ -1,5 +1,6 @@ import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/events.dart'; +import 'package:aves/remote/remote_sync_bus.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/empty.dart'; @@ -26,20 +27,51 @@ class LoadingEmptyContent extends StatelessWidget { text: context.l10n.sourceStateLoading, bottom: Padding( padding: const EdgeInsets.only(top: 16), - child: Stack( - alignment: Alignment.center, + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - const ReportProgressIndicator(), - ValueListenableBuilder( - valueListenable: source.progressNotifier, - builder: (context, progress, snapshot) { - final done = progress.done; - return done > 0 - ? Text( - countFormatter.format(done), - style: progressTextStyle, - ) - : const SizedBox(); + // === PROGRESS LOCALE (Aves originale) === + Stack( + alignment: Alignment.center, + children: [ + const ReportProgressIndicator(), + ValueListenableBuilder( + valueListenable: source.progressNotifier, + builder: (context, progress, snapshot) { + final done = progress.done; + return done > 0 + ? Text( + countFormatter.format(done), + style: progressTextStyle, + ) + : const SizedBox(); + }, + ), + ], + ), + + // === PROGRESS REMOTO (solo bootstrap, stile "Aves") === + ValueListenableBuilder( + valueListenable: RemoteSyncBus.instance.progressNotifier, + builder: (context, prog, _) { + if (prog == null || !prog.showOverlay) return const SizedBox.shrink(); + + final done = prog.done; + final total = prog.total; + + // stesso stile "numerone", ma per remoti preferiamo X/Y + final text = total > 0 + ? 'Agg remoti ${countFormatter.format(done)}/${countFormatter.format(total)}' + : 'Agg remoti ${countFormatter.format(done)}'; + + return Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + text, + style: progressTextStyle, + textAlign: TextAlign.center, + ), + ); }, ), ], @@ -48,3 +80,4 @@ class LoadingEmptyContent extends StatelessWidget { ); } } + diff --git a/lib/widgets/collection/loading.dart.old b/lib/widgets/collection/loading.dart.old new file mode 100644 index 00000000..390752e4 --- /dev/null +++ b/lib/widgets/collection/loading.dart.old @@ -0,0 +1,50 @@ +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/model/source/events.dart'; +import 'package:aves/widgets/common/action_mixins/feedback.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/empty.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +class LoadingEmptyContent extends StatelessWidget { + final CollectionSource source; + + const LoadingEmptyContent({ + super.key, + required this.source, + }); + + @override + Widget build(BuildContext context) { + final countFormatter = NumberFormat.decimalPattern(context.locale); + final progressTextStyle = TextStyle( + color: Theme.of(context).colorScheme.primary.withValues(alpha: .5), + fontSize: 18, + ); + + return EmptyContent( + text: context.l10n.sourceStateLoading, + bottom: Padding( + padding: const EdgeInsets.only(top: 16), + child: Stack( + alignment: Alignment.center, + children: [ + const ReportProgressIndicator(), + ValueListenableBuilder( + valueListenable: source.progressNotifier, + builder: (context, progress, snapshot) { + final done = progress.done; + return done > 0 + ? Text( + countFormatter.format(done), + style: progressTextStyle, + ) + : const SizedBox(); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/collection/remote_progress_banner.dart b/lib/widgets/collection/remote_progress_banner.dart new file mode 100644 index 00000000..2c23bc9a --- /dev/null +++ b/lib/widgets/collection/remote_progress_banner.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:aves/remote/remote_sync_bus.dart'; + +class RemoteProgressBanner extends StatelessWidget { + const RemoteProgressBanner({super.key}); + + @override + Widget build(BuildContext context) { + final bus = RemoteSyncBus.instance; + + return ValueListenableBuilder( + valueListenable: bus.progressNotifier, + builder: (context, prog, _) { + // Mostra SOLO quando è bootstrap (showOverlay=true) + if (prog == null || !prog.showOverlay) return const SizedBox.shrink(); + + final total = prog.total; + final done = prog.done; + final value = total > 0 ? done / total : null; + + final label = 'Agg remoti $done/$total'; + + return SafeArea( + child: Align( + alignment: Alignment.topCenter, + child: Container( + margin: const EdgeInsets.all(12), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + width: 380, + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(color: Colors.white)), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + value: value, // determinata se total>0, altrimenti indeterminata + minHeight: 4, + backgroundColor: Colors.white24, + color: Colors.lightBlueAccent, + ), + ), + ], + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/widgets/collection/remote_progress_banner.dart.old b/lib/widgets/collection/remote_progress_banner.dart.old new file mode 100644 index 00000000..cba8bd5a --- /dev/null +++ b/lib/widgets/collection/remote_progress_banner.dart.old @@ -0,0 +1,51 @@ +// lib/widgets/collection/remote_progress_banner.dart +import 'package:flutter/material.dart'; +import 'package:aves/remote/remote_sync_bus.dart'; + +class RemoteProgressBanner extends StatelessWidget { + const RemoteProgressBanner({super.key}); + + @override + Widget build(BuildContext context) { + final bus = RemoteSyncBus.instance; + return ValueListenableBuilder( + valueListenable: bus.notifier, + builder: (context, prog, _) { + if (prog == null) return const SizedBox.shrink(); + + final label = '${prog.phase} ${prog.done}/${prog.total}'; + return SafeArea( + child: Align( + alignment: Alignment.topCenter, + child: Container( + margin: const EdgeInsets.all(12), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + width: 380, + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(color: Colors.white)), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + value: prog.value, // determinata + minHeight: 4, + backgroundColor: Colors.white24, + color: Colors.lightBlueAccent, + ), + ), + ], + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/widgets/collection/remote_status_button.dart b/lib/widgets/collection/remote_status_button.dart new file mode 100644 index 00000000..e4e35c05 --- /dev/null +++ b/lib/widgets/collection/remote_status_button.dart @@ -0,0 +1,155 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/remote/remote_sync_bus.dart'; +import 'package:aves/remote/remote_controller.dart'; +import 'package:aves/remote/remote_settings_dialog.dart'; + +class RemoteStatusButton extends StatefulWidget { + final CollectionSource source; + const RemoteStatusButton({super.key, required this.source}); + + @override + State createState() => _RemoteStatusButtonState(); +} + +class _RemoteStatusButtonState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _blink = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 700), + lowerBound: 0.25, + upperBound: 1.0, + ); + + bool _busy = false; + + // --- long press manuale --- + Timer? _lpTimer; + bool _longPressFired = false; + Offset? _downPos; + + static const _longPressDelay = Duration(milliseconds: 600); + static const double _moveSlop = 10.0; // px: tolleranza movimento prima di cancellare + + @override + void dispose() { + _lpTimer?.cancel(); + _blink.dispose(); + super.dispose(); + } + + Future _toggle() async { + if (_busy) return; + setState(() => _busy = true); + try { + await RemoteController.instance.toggleRemote(source: widget.source); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _openSettings() async { + if (_busy) return; + await RemoteSettingsDialog.show(context); + } + + void _startLongPressTimer(Offset globalPos) { + _lpTimer?.cancel(); + _longPressFired = false; + _downPos = globalPos; + + _lpTimer = Timer(_longPressDelay, () async { + if (!mounted || _busy) return; + _longPressFired = true; + await _openSettings(); + }); + } + + void _cancelLongPressTimer() { + _lpTimer?.cancel(); + _lpTimer = null; + } + + @override + Widget build(BuildContext context) { + final bus = RemoteSyncBus.instance; + + return ValueListenableBuilder( + valueListenable: bus.stateNotifier, + builder: (context, st, _) { + Color color; + bool blinking; + + switch (st) { + case RemoteSyncState.disabled: + color = Colors.grey; + blinking = false; + break; + case RemoteSyncState.syncing: + color = Colors.orangeAccent; + blinking = true; + break; + case RemoteSyncState.upToDate: + color = Colors.greenAccent; + blinking = false; + break; + case RemoteSyncState.serverDown: + color = Colors.redAccent; + blinking = true; + break; + } + + if (blinking) { + if (!_blink.isAnimating) _blink.repeat(reverse: true); + } else { + if (_blink.isAnimating) _blink.stop(); + _blink.value = 1.0; + } + + final icon = FadeTransition( + opacity: _blink, + child: Icon(Icons.satellite_alt_rounded, color: color), + ); + + // ✅ area touch standard AppBar 48x48: non prende tutto l’header + return SizedBox.square( + dimension: kMinInteractiveDimension, + child: Listener( + onPointerDown: (e) { + if (_busy) return; + _startLongPressTimer(e.position); + }, + onPointerMove: (e) { + final start = _downPos; + if (start != null) { + final dx = (e.position.dx - start.dx).abs(); + final dy = (e.position.dy - start.dy).abs(); + if (dx > _moveSlop || dy > _moveSlop) { + _cancelLongPressTimer(); + } + } + }, + onPointerUp: (e) async { + if (_busy) return; + _cancelLongPressTimer(); + + // se il long press è già scattato, NON fare toggle + if (_longPressFired) { + _longPressFired = false; + return; + } + + await _toggle(); + }, + onPointerCancel: (e) { + _cancelLongPressTimer(); + }, + behavior: HitTestBehavior.opaque, + child: Center(child: icon), + ), + ); + }, + ); + } +} diff --git a/lib/widgets/collection/remote_status_icon.dart b/lib/widgets/collection/remote_status_icon.dart new file mode 100644 index 00000000..62150d43 --- /dev/null +++ b/lib/widgets/collection/remote_status_icon.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:aves/remote/remote_sync_bus.dart'; + +class RemoteStatusIcon extends StatefulWidget { + const RemoteStatusIcon({super.key}); + + @override + State createState() => _RemoteStatusIconState(); +} + +class _RemoteStatusIconState extends State with SingleTickerProviderStateMixin { + late final AnimationController _blink = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 700), + lowerBound: 0.25, + upperBound: 1.0, + ); + + @override + void dispose() { + _blink.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final bus = RemoteSyncBus.instance; + + return ValueListenableBuilder( + valueListenable: bus.stateNotifier, + builder: (context, st, _) { + Color color; + bool blinking = false; + + switch (st) { + case RemoteSyncState.syncing: + color = Colors.redAccent; + blinking = true; + break; + case RemoteSyncState.upToDate: + color = Colors.greenAccent; + blinking = false; + break; + case RemoteSyncState.error: + color = Colors.amberAccent; + blinking = true; + break; + case RemoteSyncState.idle: + default: + // se vuoi "non aggiornato" rosso fisso: + color = Colors.redAccent; + blinking = false; + break; + } + + if (blinking) { + if (!_blink.isAnimating) _blink.repeat(reverse: true); + } else { + if (_blink.isAnimating) _blink.stop(); + _blink.value = 1.0; + } + + return FadeTransition( + opacity: _blink, + // icona "parabola" (puoi cambiare se vuoi) + child: Icon(Icons.wifi_tethering_rounded, color: color), + ); + }, + ); + } +} diff --git a/lib/widgets/home/home_page.dart b/lib/widgets/home/home_page.dart index c7a0b06e..bdc4e9f3 100644 --- a/lib/widgets/home/home_page.dart +++ b/lib/widgets/home/home_page.dart @@ -1,7 +1,5 @@ -// lib/widgets/home/home_page.dart import 'dart:async'; -import 'package:aves/remote/collection_source_remote_ext.dart'; import 'package:aves/app_mode.dart'; import 'package:aves/geo/uri.dart'; import 'package:aves/model/app/intent.dart'; @@ -47,26 +45,14 @@ import 'package:latlong2/latlong.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; -// --- IMPORT aggiunti per integrazione remota / telemetria --- -import 'package:flutter/foundation.dart' show kDebugMode; -import 'package:sqflite/sqflite.dart'; -import 'package:path/path.dart' as p; -import 'package:aves/remote/remote_test_page.dart' as rtp; -import 'package:aves/remote/run_remote_sync.dart' as rrs; +// ✅ Remote +import 'package:aves/remote/remote_controller.dart'; import 'package:aves/remote/remote_settings.dart'; -import 'package:aves/remote/remote_http.dart'; // PERF/REMOTE: warm-up headers -import 'package:aves/remote/remote_models.dart'; // RemotePhotoItem - -// --- IMPORT per client reale --- -import 'package:aves/remote/remote_client.dart'; -import 'package:aves/remote/auth_client.dart'; - -// secure storage import (used only in debug helper) -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; class HomePage extends StatefulWidget { static const routeName = '/'; - // untyped map as it is coming from the platform + + // compatibile con aves_app.dart final Map? intentData; const HomePage({ @@ -88,14 +74,6 @@ class _HomePageState extends State { List? _secureUris; (Object, StackTrace)? _setupError; - // guard UI per schedulare UNA sola run del sync da Home - bool _remoteSyncScheduled = false; - // indica se il sync è effettivamente in corso - bool _remoteSyncActive = false; - - // guard per evitare doppi push della pagina di test remota - bool _remoteTestOpen = false; - static const allowedShortcutRoutes = [ AlbumListPage.routeName, CollectionPage.routeName, @@ -121,20 +99,32 @@ class _HomePageState extends State { : null, ); + Map _safeCastIntentMap(Object? raw) { + if (raw is Map) { + final out = {}; + for (final entry in raw.entries) { + final k = entry.key; + if (k is String) out[k] = entry.value as Object?; + } + return out; + } + return {}; + } + Future _setup() async { try { final stopwatch = Stopwatch()..start(); if (await windowService.isActivity()) { - // do not check whether permission was granted, because some app stores - // hide in some countries apps that force quit on permission denial + // ✅ Permessi Aves originali await Permissions.mediaAccess.request(); } var appMode = AppMode.main; var error = false; - final intentData = widget.intentData ?? await IntentService.getIntentData(); + final rawIntentData = widget.intentData ?? await IntentService.getIntentData(); + final intentData = _safeCastIntentMap(rawIntentData); final intentAction = intentData[IntentDataKeys.action] as String?; _initialFilters = null; @@ -144,24 +134,11 @@ class _HomePageState extends State { await availability.onNewIntent(); await androidFileUtils.init(); - // PERF/REMOTE: warm-up headers (Bearer) in background — safe version - unawaited(Future(() async { - try { - final s = await _safeLoadRemoteSettings(); - if (s.enabled && s.baseUrl.trim().isNotEmpty) { - await _safeHeaders(); // popola la cache per peekHeaders() in modo sicuro - debugPrint('[startup] remote headers warm-up done (safe)'); - } - } catch (e) { - debugPrint('[startup] remote headers warm-up skipped: $e'); - } - })); - if (!{ - IntentActions.edit, - IntentActions.screenSaver, - IntentActions.setWallpaper, - }.contains(intentAction) && + IntentActions.edit, + IntentActions.screenSaver, + IntentActions.setWallpaper, + }.contains(intentAction) && settings.isInstalledAppAccessAllowed) { unawaited(appInventory.initAppNames()); } @@ -187,34 +164,40 @@ class _HomePageState extends State { } } break; + case IntentActions.edit: appMode = AppMode.edit; case IntentActions.setWallpaper: appMode = AppMode.setWallpaper; + case IntentActions.pickItems: - // some apps define multiple types, separated by a space final multiple = (intentData[IntentDataKeys.allowMultiple] as bool?) ?? false; debugPrint('pick mimeType=$intentMimeType multiple=$multiple'); appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal; + case IntentActions.pickCollectionFilters: appMode = AppMode.pickCollectionFiltersExternal; + case IntentActions.screenSaver: appMode = AppMode.screenSaver; _initialRouteName = ScreenSaverPage.routeName; + case IntentActions.screenSaverSettings: _initialRouteName = ScreenSaverSettingsPage.routeName; + case IntentActions.search: _initialRouteName = SearchPage.routeName; _initialSearchQuery = intentData[IntentDataKeys.query] as String?; + case IntentActions.widgetSettings: _initialRouteName = HomeWidgetSettingsPage.routeName; _widgetId = (intentData[IntentDataKeys.widgetId] as int?) ?? 0; + case IntentActions.widgetOpen: final widgetId = intentData[IntentDataKeys.widgetId] as int?; if (widgetId == null) { error = true; } else { - // widget settings may be modified in a different process after channel setup await settings.reload(); final page = settings.getWidgetOpenPage(widgetId); switch (page) { @@ -229,6 +212,7 @@ class _HomePageState extends State { } unawaited(WidgetService.update(widgetId)); } + default: final extraRoute = intentData[IntentDataKeys.page] as String?; if (allowedShortcutRoutes.contains(extraRoute)) { @@ -240,7 +224,6 @@ class _HomePageState extends State { final extraFilters = (intentData[IntentDataKeys.filters] as List?)?.cast(); _initialFilters = extraFilters?.map(CollectionFilter.fromJson).nonNulls.toSet(); } - _initialExplorerPath = intentData[IntentDataKeys.explorerPath] as String?; switch (appMode) { @@ -248,10 +231,7 @@ class _HomePageState extends State { case AppMode.edit: case AppMode.setWallpaper: if (intentUri != null) { - _viewerEntry = await _initViewerEntry( - uri: intentUri, - mimeType: intentMimeType, - ); + _viewerEntry = await _initViewerEntry(uri: intentUri, mimeType: intentMimeType); } error = _viewerEntry == null; default: @@ -267,6 +247,10 @@ class _HomePageState extends State { context.read>().value = appMode; unawaited(reportService.setCustomKey('app_mode', appMode.toString())); + // ✅ Remote: seed debug + icona coerente + unawaited(RemoteSettings.debugSeedIfEmpty()); + unawaited(RemoteController.instance.initBusFromSettings()); + switch (appMode) { case AppMode.main: case AppMode.pickCollectionFiltersExternal: @@ -276,127 +260,30 @@ class _HomePageState extends State { unawaited(AnalysisService.registerCallback()); final source = context.read(); + + // ✅ Aves originale: init SOLO se non già full scope if (source.loadedScope != CollectionSource.fullScope) { await reportService.log( 'Initialize source to start app with mode=$appMode, loaded scope=${source.loadedScope}', ); final loadTopEntriesFirst = - settings.homeNavItem.route == CollectionPage.routeName && settings.homeCustomCollection.isEmpty; - - // PERF: UI-first → niente analisi prima della prima paint - source.canAnalyze = false; - final swInit = Stopwatch()..start(); - await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst); - swInit.stop(); - debugPrint('[startup] source.init done in ${swInit.elapsedMilliseconds}ms'); - } - - // REMOTE: aggiungi remoti visibili (origin=1, trashed=0) - final swAppend1 = Stopwatch()..start(); - await source.appendRemoteEntriesFromDb(); - swAppend1.stop(); - debugPrint('[startup] appendRemoteEntries (pre-sync) in ${swAppend1.elapsedMilliseconds}ms'); - - // === DIAGNOSTICA PRE- SYNC === - await _printRemoteDiag(source, when: ' PRE'); - - // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> - // PATCH A: se ci sono remoti in DB, forza la Collection "All items" - try { - final remCount = (await localMediaDb.rawDb - .rawQuery('SELECT COUNT(*) AS c FROM entry WHERE origin=1 AND trashed=0')) - .first['c'] as int? ?? 0; - if (remCount > 0) { - _initialRouteName = CollectionPage.routeName; - _initialFilters = {}; // All items (nessun filtro) - debugPrint('[startup] forcing CollectionPage All-items (remoti=$remCount)'); - } - } catch (e) { - debugPrint('[startup] unable to count remotes: $e'); - } - // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< - - // PERF: riattiva l’analisi in background appena la UI è pronta - unawaited(Future.delayed(const Duration(milliseconds: 300)).then((_) { + settings.homeNavItem.route == CollectionPage.routeName && + settings.homeCustomCollection.isEmpty; source.canAnalyze = true; - debugPrint('[startup] analysis re-enabled in background'); - })); - - // === SYNC REMOTO post-init (non blocca la UI) === - if (!_remoteSyncScheduled) { - _remoteSyncScheduled = true; // una sola schedulazione per avvio - unawaited(Future(() async { - try { - await RemoteSettings.debugSeedIfEmpty(); - final rs = await _safeLoadRemoteSettings(); - if (!rs.enabled) return; - - // attesa fine loading - final notifier = source.stateNotifier; - if (notifier.value == SourceState.loading) { - final completer = Completer(); - void onState() { - if (notifier.value != SourceState.loading) { - notifier.removeListener(onState); - completer.complete(); - } - } - - notifier.addListener(onState); - // nel caso non sia già loading: - onState(); - await completer.future; - } - - // piccolo margine per step secondari (tag, ecc.) - await Future.delayed(const Duration(milliseconds: 400)); - - // ⬇️ SYNC su **stessa connessione** + FETCH (obbligatorio) - debugPrint('[remote-sync] START (scheduled=$_remoteSyncScheduled)'); - _remoteSyncActive = true; - try { - final swSync = Stopwatch()..start(); - final imported = await rrs.runRemoteSyncOnceManaged( - fetch: _fetchAllRemoteItems, // 👈 PASSIAMO IL FETCH - ).timeout(const Duration(seconds: 60)); // timeout regolabile - swSync.stop(); - debugPrint('[remote-sync] completed in ${swSync.elapsedMilliseconds}ms, imported=$imported'); - } on TimeoutException catch (e) { - debugPrint('[remote-sync] TIMEOUT after 60s: $e'); - } catch (e, st) { - debugPrint('[remote-sync] error: $e\n$st'); - } finally { - _remoteSyncActive = false; - debugPrint('[remote-sync] END (active=$_remoteSyncActive)'); - } - - // REMOTE: dopo il sync, append di eventuali nuovi remoti - if (mounted) { - final swAppend2 = Stopwatch()..start(); - await source.appendRemoteEntriesFromDb(); - swAppend2.stop(); - debugPrint('[remote-sync] appendRemoteEntries (post-sync) in ${swAppend2.elapsedMilliseconds}ms'); - - // 🔎 Conteggio di debug usando una CollectionLens temporanea - final c = _countRemotesInSource(source); - debugPrint('[check] remoti in CollectionSource = $c'); - - // === DIAGNOSTICA POST- SYNC === - await _printRemoteDiag(source, when: ' POST'); - } - } catch (e, st) { - debugPrint('[remote-sync] outer error: $e\n$st'); - } - })); + await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst); } - break; + + // ✅ Remote: dopo init locale, ma non blocca + unawaited(RemoteController.instance.onAppStart( + source: source, + resumeBootstrapIfEnabled: true, + )); case AppMode.screenSaver: await reportService.log('Initialize source to start screen saver'); - final source2 = context.read(); - source2.canAnalyze = false; - await source2.init(scope: settings.screenSaverCollectionFilters); - break; + final source = context.read(); + source.canAnalyze = false; + await source.init(scope: settings.screenSaverCollectionFilters); case AppMode.view: if (_isViewerSourceable(_viewerEntry) && _secureUris == null) { @@ -405,19 +292,16 @@ class _HomePageState extends State { unawaited(AnalysisService.registerCallback()); await reportService.log('Initialize source to view item in directory $directory'); final source = context.read(); - // analysis is necessary to display neighbour items when the initial item is a new one source.canAnalyze = true; await source.init(scope: {StoredAlbumFilter(directory, null)}); } } else { await _initViewerEssentials(); } - break; case AppMode.edit: case AppMode.setWallpaper: await _initViewerEssentials(); - break; default: break; @@ -425,8 +309,6 @@ class _HomePageState extends State { debugPrint('Home setup complete in ${stopwatch.elapsed.inMilliseconds}ms'); - // `pushReplacement` is not enough in some edge cases - // e.g. when opening the viewer in `view` mode should replace a viewer in `main` mode unawaited( Navigator.maybeOf(context)?.pushAndRemoveUntil( await _getRedirectRoute(appMode), @@ -439,65 +321,7 @@ class _HomePageState extends State { } } - // === FETCH per il sync (implementazione reale usando RemoteJsonClient) === - Future> _fetchAllRemoteItems() async { - try { - final rs = await _safeLoadRemoteSettings(); - if (!rs.enabled || rs.baseUrl.trim().isEmpty) { - debugPrint('[remote-sync][fetch] disabled or baseUrl empty'); - return []; - } - - // Costruisci l'auth solo se sono presenti credenziali - RemoteAuth? auth; - if (rs.email.isNotEmpty && rs.password.isNotEmpty) { - auth = RemoteAuth(baseUrl: rs.baseUrl, email: rs.email, password: rs.password); - } - - final client = RemoteJsonClient(rs.baseUrl, rs.indexPath, auth: auth); - - try { - final items = await client.fetchAll(); - debugPrint('[remote-sync][fetch] fetched ${items.length} items from ${rs.baseUrl}'); - return items; - } catch (e, st) { - debugPrint('[remote-sync][fetch] client.fetchAll ERROR: $e\n$st'); - return []; - } - } catch (e, st) { - debugPrint('[remote-sync][fetch] ERROR: $e\n$st'); - return []; - } - } - - // --- Helper di debug: crea una lens temporanea, conta i remoti, poi dispose - int _countRemotesInSource(CollectionSource source) { - final lens = CollectionLens(source: source, filters: {}); - try { - return lens.sortedEntries.where((e) => e.origin == 1 && e.trashed == 0).length; - } finally { - lens.dispose(); - } - } - - // === DIAG: stampa conteggi remoti DB/Source/visibleEntries === - Future _printRemoteDiag(CollectionSource source, {String when = ''}) async { - try { - final dbRem = await localMediaDb.loadEntries(origin: 1); - final dbCount = dbRem.length; - final displayable = dbRem.where((e) => !e.trashed && e.isDisplayable).length; - final inSource = source.allEntries.where((e) => e.origin == 1 && !e.trashed).length; - final inVisible = source.visibleEntries.where((e) => e.origin == 1 && !e.trashed).length; - - debugPrint('[diag$when] DB remoti=$dbCount, displayable=$displayable, ' - 'inSource=$inSource, inVisible=$inVisible'); - } catch (e, st) { - debugPrint('[diag$when] ERROR: $e\n$st'); - } - } - Future _initViewerEssentials() async { - // for video playback storage await localMediaDb.init(); } @@ -509,204 +333,15 @@ class _HomePageState extends State { Future _initViewerEntry({required String uri, required String? mimeType}) async { if (uri.startsWith('/')) { - // convert this file path to a proper URI uri = Uri.file(uri).toString(); } final entry = await mediaFetchService.getEntry(uri, mimeType); if (entry != null) { - // cataloguing is essential for coordinates and video rotation await entry.catalog(background: false, force: false, persist: false); } return entry; } - // === DEBUG: apre la pagina di test remota con una seconda connessione al DB === - Future _openRemoteTestPage(BuildContext context) async { - if (_remoteTestOpen) return; // evita doppi push/sovrapposizioni - // blocca solo se il sync è effettivamente in corso - if (_remoteSyncActive) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Attendi fine sync remoto in corso prima di aprire RemoteTest')), - ); - return; - } - - _remoteTestOpen = true; - - Database? debugDb; - try { - final dbDir = await getDatabasesPath(); - final dbPath = p.join(dbDir, 'metadata.db'); - - // Apri il DB in R/W (istanza indipendente) → niente "read only database" - debugDb = await openDatabase( - dbPath, - singleInstance: false, - onConfigure: (db) async { - await db.rawQuery('PRAGMA journal_mode=WAL'); - await db.rawQuery('PRAGMA foreign_keys=ON'); - }, - ); - if (!context.mounted) return; - - final rs = await _safeLoadRemoteSettings(); - final baseUrl = rs.baseUrl.isNotEmpty ? rs.baseUrl : RemoteSettings.defaultBaseUrl; - - await Navigator.of(context).push(MaterialPageRoute( - builder: (_) => rtp.RemoteTestPage( - db: debugDb!, - baseUrl: baseUrl, - ), - )); - } catch (e, st) { - // ignore: avoid_print - print('[RemoteTest] errore apertura DB/pagina: $e\n$st'); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Errore RemoteTest: $e')), - ); - } finally { - try { - await debugDb?.close(); - } catch (_) {} - _remoteTestOpen = false; - } - } - - // === DEBUG: dialog impostazioni remote (semplice) === - Future _openRemoteSettingsDialog(BuildContext context) async { - final s = await _safeLoadRemoteSettings(); - final formKey = GlobalKey(); - bool enabled = s.enabled; - final baseUrlC = TextEditingController(text: s.baseUrl); - final indexC = TextEditingController(text: s.indexPath); - final emailC = TextEditingController(text: s.email); - final pwC = TextEditingController(text: s.password); - - await showDialog( - context: context, - builder: (_) => AlertDialog( - title: const Text('Remote Settings'), - content: Form( - key: formKey, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SwitchListTile( - title: const Text('Abilita sync remoto'), - value: enabled, - onChanged: (v) { - enabled = v; - }, - contentPadding: EdgeInsets.zero, - ), - const SizedBox(height: 8), - TextFormField( - controller: baseUrlC, - decoration: const InputDecoration( - labelText: 'Base URL', - hintText: 'https://prova.patachina.it', - ), - validator: (v) => (v == null || v.isEmpty) ? 'Obbligatorio' : null, - ), - const SizedBox(height: 8), - TextFormField( - controller: indexC, - decoration: const InputDecoration( - labelText: 'Index path', - hintText: 'photos/', - ), - validator: (v) => (v == null || v.isEmpty) ? 'Obbligatorio' : null, - ), - const SizedBox(height: 8), - TextFormField( - controller: emailC, - decoration: const InputDecoration(labelText: 'User/Email'), - ), - const SizedBox(height: 8), - TextFormField( - controller: pwC, - obscureText: true, - decoration: const InputDecoration(labelText: 'Password'), - ), - ], - ), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).maybePop(), - child: const Text('Annulla'), - ), - ElevatedButton.icon( - onPressed: () async { - if (!formKey.currentState!.validate()) return; - final upd = RemoteSettings( - enabled: enabled, - baseUrl: baseUrlC.text.trim(), - indexPath: indexC.text.trim(), - email: emailC.text.trim(), - password: pwC.text, - ); - await upd.save(); - - // forza refresh immediato delle impostazioni e headers - await RemoteHttp.refreshFromSettings(); - unawaited(RemoteHttp.warmUp()); - - if (context.mounted) Navigator.of(context).pop(); - if (context.mounted) { - ScaffoldMessenger.of(context) - .showSnackBar(const SnackBar(content: Text('Impostazioni salvate'))); - } - }, - icon: const Icon(Icons.save), - label: const Text('Salva'), - ), - ], - ), - ); - - baseUrlC.dispose(); - indexC.dispose(); - emailC.dispose(); - pwC.dispose(); - } - - // --- DEBUG: wrapper che aggiunge 2 FAB (Settings + Remote Test) --- - Widget _wrapWithRemoteDebug(BuildContext context, Widget child) { - if (!kDebugMode) return child; - return Stack( - children: [ - child, - Positioned( - right: 16, - bottom: 16, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - FloatingActionButton( - heroTag: 'remote_debug_settings_fab', - mini: true, - onPressed: () => _openRemoteSettingsDialog(context), - tooltip: 'Remote Settings', - child: const Icon(Icons.settings), - ), - const SizedBox(height: 12), - FloatingActionButton( - heroTag: 'remote_debug_test_fab', - onPressed: () => _openRemoteTestPage(context), - tooltip: 'Remote Test', - child: const Icon(Icons.image_search), - ), - ], - ), - ), - ], - ); - } - Future _getRedirectRoute(AppMode appMode) async { String routeName; Set? filters; @@ -715,19 +350,16 @@ class _HomePageState extends State { case AppMode.setWallpaper: return DirectMaterialPageRoute( settings: const RouteSettings(name: WallpaperPage.routeName), - builder: (_) { - return WallpaperPage( - entry: _viewerEntry, - ); - }, + builder: (_) => WallpaperPage(entry: _viewerEntry), ); + case AppMode.view: AvesEntry viewerEntry = _viewerEntry!; CollectionLens? collection; + final source = context.read(); final album = viewerEntry.directory; if (album != null) { - // wait for collection to pass the `loading` state final loadingCompleter = Completer(); final stateNotifier = source.stateNotifier; void _onSourceStateChanged() { @@ -741,16 +373,10 @@ class _HomePageState extends State { _onSourceStateChanged(); await loadingCompleter.future; - // ⚠️ NON lanciamo più il sync qui (evita contese durante il viewer) - // unawaited(rrs.runRemoteSyncOnceManaged()); - collection = CollectionLens( source: source, filters: {StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))}, listenToSource: false, - // if we group bursts, opening a burst sub-entry should: - // - identify and select the containing main entry, - // - select the sub-entry in the Viewer page. stackBursts: false, ); @@ -760,39 +386,22 @@ class _HomePageState extends State { if (collectionEntry != null) { viewerEntry = collectionEntry; } else { - debugPrint('collection does not contain viewerEntry=$viewerEntry'); collection = null; } } + return DirectMaterialPageRoute( settings: const RouteSettings(name: EntryViewerPage.routeName), - builder: (_) { - return EntryViewerPage( - collection: collection, - initialEntry: viewerEntry, - ); - }, + builder: (_) => EntryViewerPage(collection: collection, initialEntry: viewerEntry), ); + case AppMode.edit: return DirectMaterialPageRoute( settings: const RouteSettings(name: EntryViewerPage.routeName), - builder: (_) { - return ImageEditorPage( - entry: _viewerEntry!, - ); - }, + builder: (_) => ImageEditorPage(entry: _viewerEntry!), ); - case AppMode.initialization: - case AppMode.main: - case AppMode.pickCollectionFiltersExternal: - case AppMode.pickSingleMediaExternal: - case AppMode.pickMultipleMediaExternal: - case AppMode.pickFilteredMediaInternal: - case AppMode.pickUnfilteredMediaInternal: - case AppMode.pickFilterInternal: - case AppMode.previewMap: - case AppMode.screenSaver: - case AppMode.slideshow: + + default: routeName = _initialRouteName ?? settings.homeNavItem.route; filters = _initialFilters ?? (settings.homeNavItem.route == CollectionPage.routeName ? settings.homeCustomCollection : {}); @@ -804,7 +413,6 @@ class _HomePageState extends State { ); final source = context.read(); - switch (routeName) { case AlbumListPage.routeName: return buildRoute((context) => const AlbumListPage(initialGroup: null)); @@ -846,60 +454,8 @@ class _HomePageState extends State { ); case CollectionPage.routeName: default: - // Wrapper di debug che aggiunge i due FAB (solo in debug) - return buildRoute( - (context) => _wrapWithRemoteDebug( - context, - CollectionPage(source: source, filters: filters), - ), - ); - } - } - - // ------------------------- - // Utility sicure per remote - // ------------------------- - - // safe load of RemoteSettings with timeout and fallback - Future _safeLoadRemoteSettings({Duration timeout = const Duration(seconds: 5)}) async { - try { - return await RemoteSettings.load().timeout(timeout); - } catch (e) { - debugPrint('[remote] RemoteSettings.load failed: $e — using defaults'); - return RemoteSettings( - enabled: RemoteSettings.defaultEnabled, - baseUrl: RemoteSettings.defaultBaseUrl, - indexPath: RemoteSettings.defaultIndexPath, - email: RemoteSettings.defaultEmail, - password: RemoteSettings.defaultPassword, - ); - } - } - - // safe headers retrieval with timeout and empty fallback - Future> _safeHeaders({Duration timeout = const Duration(seconds: 6)}) async { - try { - return await RemoteHttp.headers().timeout(timeout); - } catch (e) { - debugPrint('[remote] RemoteHttp.headers failed: $e — returning empty headers'); - return const {}; - } - } - - // debug helper: clear remote keys from secure storage (debug only) - Future _debugClearRemoteKeys() async { - if (!kDebugMode) return; - try { - // FlutterSecureStorage non è const - final storage = FlutterSecureStorage(); - await storage.delete(key: 'remote_base_url'); - await storage.delete(key: 'remote_index_path'); - await storage.delete(key: 'remote_email'); - await storage.delete(key: 'remote_password'); - await storage.delete(key: 'remote_enabled'); - debugPrint('[remote] debugClearRemoteKeys executed'); - } catch (e) { - debugPrint('[remote] debugClearRemoteKeys failed: $e'); + return buildRoute((context) => CollectionPage(source: source, filters: filters)); } } } + diff --git a/lib/widgets/home/home_page.dart.ok b/lib/widgets/home/home_page.dart.ok index d4294934..e4996253 100644 --- a/lib/widgets/home/home_page.dart.ok +++ b/lib/widgets/home/home_page.dart.ok @@ -47,22 +47,20 @@ import 'package:latlong2/latlong.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; -// --- IMPORT aggiunti per integrazione remota / telemetria --- +// --- REMOTO / DEBUG --- import 'package:flutter/foundation.dart' show kDebugMode; import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart' as p; import 'package:aves/remote/remote_test_page.dart' as rtp; import 'package:aves/remote/run_remote_sync.dart' as rrs; import 'package:aves/remote/remote_settings.dart'; -import 'package:aves/remote/remote_http.dart'; // PERF/REMOTE: warm-up headers -import 'package:aves/remote/remote_models.dart'; // RemotePhotoItem - -// --- IMPORT per client reale --- +import 'package:aves/remote/remote_http.dart'; +import 'package:aves/remote/remote_models.dart'; import 'package:aves/remote/remote_client.dart'; import 'package:aves/remote/auth_client.dart'; - -// secure storage import (used only in debug helper) import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:aves/remote/remote_sync_bus.dart'; +import 'package:aves/remote/remote_repository.dart'; class HomePage extends StatefulWidget { static const routeName = '/'; @@ -88,12 +86,11 @@ class _HomePageState extends State { List? _secureUris; (Object, StackTrace)? _setupError; - // guard UI per schedulare UNA sola run del sync da Home + // sync remoto: singola esecuzione bool _remoteSyncScheduled = false; - // indica se il sync è effettivamente in corso bool _remoteSyncActive = false; - // guard per evitare doppi push della pagina di test remota + // pagina test remoto (FAB debug) bool _remoteTestOpen = false; static const allowedShortcutRoutes = [ @@ -126,8 +123,6 @@ class _HomePageState extends State { final stopwatch = Stopwatch()..start(); if (await windowService.isActivity()) { - // do not check whether permission was granted, because some app stores - // hide in some countries apps that force quit on permission denial await Permissions.mediaAccess.request(); } @@ -144,17 +139,14 @@ class _HomePageState extends State { await availability.onNewIntent(); await androidFileUtils.init(); - // PERF/REMOTE: warm-up headers (Bearer) in background — safe version + // Warm-up header remoti (non blocca UI) unawaited(Future(() async { try { final s = await _safeLoadRemoteSettings(); if (s.enabled && s.baseUrl.trim().isNotEmpty) { - await _safeHeaders(); // popola la cache per peekHeaders() in modo sicuro - debugPrint('[startup] remote headers warm-up done (safe)'); + await _safeHeaders(); } - } catch (e) { - debugPrint('[startup] remote headers warm-up skipped: $e'); - } + } catch (_) {} })); if (!{ @@ -192,7 +184,6 @@ class _HomePageState extends State { case IntentActions.setWallpaper: appMode = AppMode.setWallpaper; case IntentActions.pickItems: - // some apps define multiple types, separated by a space final multiple = (intentData[IntentDataKeys.allowMultiple] as bool?) ?? false; debugPrint('pick mimeType=$intentMimeType multiple=$multiple'); appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal; @@ -214,7 +205,6 @@ class _HomePageState extends State { if (widgetId == null) { error = true; } else { - // widget settings may be modified in a different process after channel setup await settings.reload(); final page = settings.getWidgetOpenPage(widgetId); switch (page) { @@ -276,97 +266,97 @@ class _HomePageState extends State { unawaited(AnalysisService.registerCallback()); final source = context.read(); + + ////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + // STEP 1 STARTUP VELOCE (MARKER 1/6) - NON MODIFICARE FUORI DA QUESTI MARKER + // STEP 1 STARTUP VELOCE (MARKER 2/6) - DB cache -> init in background + // STEP 1 STARTUP VELOCE (MARKER 3/6) - DB vuoto -> init standard con progress + ////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + // Capisco se c'è cache nel DB (locali e/o remoti) + bool hasAnyCache = false; + try { + await localMediaDb.init(); // assicura DB pronto + final rows = await localMediaDb.rawDb.rawQuery( + 'SELECT 1 FROM entry WHERE trashed=0 LIMIT 1', + ); + hasAnyCache = rows.isNotEmpty; + } catch (_) {} + + final loadTopEntriesFirst = + settings.homeNavItem.route == CollectionPage.routeName && + settings.homeCustomCollection.isEmpty; + + // Se la source non è full scope, dobbiamo comunque fare init almeno una volta if (source.loadedScope != CollectionSource.fullScope) { await reportService.log( 'Initialize source to start app with mode=$appMode, loaded scope=${source.loadedScope}', ); - final loadTopEntriesFirst = - settings.homeNavItem.route == CollectionPage.routeName && settings.homeCustomCollection.isEmpty; - // PERF: UI-first → niente analisi prima della prima paint - source.canAnalyze = false; - final swInit = Stopwatch()..start(); - await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst); - swInit.stop(); - debugPrint('[startup] source.init done in ${swInit.elapsedMilliseconds}ms'); - } + if (hasAnyCache) { + // ✅ DB ha dati: avvio veloce -> init in background (non blocca UI) + debugPrint('[startup] DB cache present -> init in background (fast start)'); + source.canAnalyze = true; - // REMOTE: aggiungi remoti visibili (origin=1, trashed=0) - final swAppend1 = Stopwatch()..start(); - await source.appendRemoteEntriesFromDb(); - swAppend1.stop(); - debugPrint('[startup] appendRemoteEntries (pre-sync) in ${swAppend1.elapsedMilliseconds}ms'); - - // PERF: riattiva l’analisi in background appena la UI è pronta - unawaited(Future.delayed(const Duration(milliseconds: 300)).then((_) { - source.canAnalyze = true; - debugPrint('[startup] analysis re-enabled in background'); - })); - - // === SYNC REMOTO post-init (non blocca la UI) === - if (!_remoteSyncScheduled) { - _remoteSyncScheduled = true; // una sola schedulazione per avvio - unawaited(Future(() async { - try { - await RemoteSettings.debugSeedIfEmpty(); - final rs = await _safeLoadRemoteSettings(); - if (!rs.enabled) return; - - // attesa fine loading - final notifier = source.stateNotifier; - if (notifier.value == SourceState.loading) { - final completer = Completer(); - void onState() { - if (notifier.value != SourceState.loading) { - notifier.removeListener(onState); - completer.complete(); - } - } - - notifier.addListener(onState); - // nel caso non sia già loading: - onState(); - await completer.future; - } - - // piccolo margine per step secondari (tag, ecc.) - await Future.delayed(const Duration(milliseconds: 400)); - - // ⬇️ SYNC su **stessa connessione** + FETCH (obbligatorio) - debugPrint('[remote-sync] START (scheduled=$_remoteSyncScheduled)'); - _remoteSyncActive = true; - try { - final swSync = Stopwatch()..start(); - final imported = await rrs.runRemoteSyncOnceManaged( - fetch: _fetchAllRemoteItems, // 👈 PASSIAMO IL FETCH - ).timeout(const Duration(seconds: 60)); // timeout regolabile - swSync.stop(); - debugPrint('[remote-sync] completed in ${swSync.elapsedMilliseconds}ms, imported=$imported'); - } on TimeoutException catch (e) { - debugPrint('[remote-sync] TIMEOUT after 60s: $e'); - } catch (e, st) { - debugPrint('[remote-sync] error: $e\n$st'); - } finally { - _remoteSyncActive = false; - debugPrint('[remote-sync] END (active=$_remoteSyncActive)'); - } - - // REMOTE: dopo il sync, append di eventuali nuovi remoti - if (mounted) { - final swAppend2 = Stopwatch()..start(); + unawaited( + source + .init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst) + .then((_) async { + // ✅ SOLO DOPO init: possiamo usare addEntries/append remoti senza crash await source.appendRemoteEntriesFromDb(); - swAppend2.stop(); - debugPrint('[remote-sync] appendRemoteEntries (post-sync) in ${swAppend2.elapsedMilliseconds}ms'); + debugPrint('[startup][bg] remote append after init done'); - // 🔎 Conteggio di debug usando una CollectionLens temporanea - final c = _countRemotesInSource(source); - debugPrint('[check] remoti in CollectionSource = $c'); - } - } catch (e, st) { - debugPrint('[remote-sync] outer error: $e\n$st'); + if (!_remoteSyncScheduled) { + _remoteSyncScheduled = true; + final sourceRef = source; + unawaited(Future.microtask(() => _runRemoteSync(sourceRef))); + } + }), + ); + } else { + // ✅ DB vuoto: comportamento Aves standard -> await init (progress locale) + debugPrint('[startup] DB empty -> await init (Aves standard)'); + source.canAnalyze = true; + await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst); + + // ora init è avvenuta -> safe append remoti da DB (se presenti) + await source.appendRemoteEntriesFromDb(); + + if (!_remoteSyncScheduled) { + _remoteSyncScheduled = true; + final sourceRef = source; + unawaited(Future.microtask(() => _runRemoteSync(sourceRef))); } - })); + } + } else { + // Source già full scope (hot state): safe append remoti + sync + debugPrint('[startup] source already fullScope'); + await source.appendRemoteEntriesFromDb(); + + if (!_remoteSyncScheduled) { + _remoteSyncScheduled = true; + final sourceRef = source; + unawaited(Future.microtask(() => _runRemoteSync(sourceRef))); + } } + + // DIAG: stato prima/dopo (facoltativo) + unawaited(_printRemoteDiag(source, when: ' PRE')); + + ////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + // STEP 1 STARTUP VELOCE (MARKER 4/6) - FINE BLOCCO + // STEP 1 STARTUP VELOCE (MARKER 5/6) - QUI PUOI AGGIUNGERE LOG/DIAG + // STEP 1 STARTUP VELOCE (MARKER 6/6) - NON MODIFICARE FUORI DA QUESTI MARKER + ////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + break; case AppMode.screenSaver: @@ -383,7 +373,6 @@ class _HomePageState extends State { unawaited(AnalysisService.registerCallback()); await reportService.log('Initialize source to view item in directory $directory'); final source = context.read(); - // analysis is necessary to display neighbour items when the initial item is a new one source.canAnalyze = true; await source.init(scope: {StoredAlbumFilter(directory, null)}); } @@ -403,8 +392,7 @@ class _HomePageState extends State { debugPrint('Home setup complete in ${stopwatch.elapsed.inMilliseconds}ms'); - // `pushReplacement` is not enough in some edge cases - // e.g. when opening the viewer in `view` mode should replace a viewer in `main` mode + // navigazione finale unawaited( Navigator.maybeOf(context)?.pushAndRemoveUntil( await _getRedirectRoute(appMode), @@ -417,26 +405,102 @@ class _HomePageState extends State { } } - // === FETCH per il sync (implementazione reale usando RemoteJsonClient) === + // === SYNC REMOTO (indipendente dal context della Home) === +Future _runRemoteSync(CollectionSource source) async { + try { + final rs = await _safeLoadRemoteSettings(); + if (!rs.enabled) { + debugPrint('[remote-sync] disabled → skip'); + return; + } + + // Se locali ancora in loading, attendi + try { + if (source.stateNotifier.value == SourceState.loading) { + final c = Completer(); + void onState() { + if (source.stateNotifier.value != SourceState.loading) { + source.stateNotifier.removeListener(onState); + c.complete(); + } + } + source.stateNotifier.addListener(onState); + onState(); + await c.future; + } + } catch (_) {} + + _remoteSyncActive = true; + + // FULL fetch dal server + final items = await _fetchAllRemoteItems(); + final total = items.length; + final serverIds = items.map((e) => e.id).where((v) => v.isNotEmpty).toSet(); + + // progress start + RemoteSyncBus.instance.start(phase: 'Sync remoto…', total: total); + + // upsert chunked (progresso reale X/Y) + final repo = RemoteRepository(localMediaDb.rawDb); + const chunkSize = 200; + int done = 0; + + final sw = Stopwatch()..start(); + for (var offset = 0; offset < total; offset += chunkSize) { + final end = (offset + chunkSize < total) ? offset + chunkSize : total; + final chunk = items.sublist(offset, end); + + // re-usa il tuo upsertAll esistente + await repo.upsertAll(chunk, chunkSize: chunkSize); + + done = end; + RemoteSyncBus.instance.update( + phase: 'Sync remoto…', + done: done, + total: total, + ); + } + + // prune hard-delete (solo perché questa è una FULL LIST) + final pruned = await repo.pruneMissingRemotes(serverIds); + debugPrint('[remote-sync] prune deleted=$pruned'); + + sw.stop(); + debugPrint('[remote-sync] completed in ${sw.elapsedMilliseconds}ms, total=$total'); + + // append remoti alla source + await source.appendRemoteEntriesFromDb(); + + RemoteSyncBus.instance.finish(); + unawaited(_printRemoteDiag(source, when: ' POST')); + } on TimeoutException catch (e) { + debugPrint('[remote-sync] TIMEOUT: $e'); + RemoteSyncBus.instance.clear(); + } catch (e, st) { + debugPrint('[remote-sync] error: $e\n$st'); + RemoteSyncBus.instance.clear(); + } finally { + _remoteSyncActive = false; + } +} + + // === FETCH remoto reale === Future> _fetchAllRemoteItems() async { try { final rs = await _safeLoadRemoteSettings(); if (!rs.enabled || rs.baseUrl.trim().isEmpty) { - debugPrint('[remote-sync][fetch] disabled or baseUrl empty'); return []; } - // Costruisci l'auth solo se sono presenti credenziali RemoteAuth? auth; if (rs.email.isNotEmpty && rs.password.isNotEmpty) { auth = RemoteAuth(baseUrl: rs.baseUrl, email: rs.email, password: rs.password); } final client = RemoteJsonClient(rs.baseUrl, rs.indexPath, auth: auth); - try { final items = await client.fetchAll(); - debugPrint('[remote-sync][fetch] fetched ${items.length} items from ${rs.baseUrl}'); + debugPrint('[remote-sync][fetch] fetched ${items.length} items'); return items; } catch (e, st) { debugPrint('[remote-sync][fetch] client.fetchAll ERROR: $e\n$st'); @@ -448,18 +512,23 @@ class _HomePageState extends State { } } - // --- Helper di debug: crea una lens temporanea, conta i remoti, poi dispose - int _countRemotesInSource(CollectionSource source) { - final lens = CollectionLens(source: source, filters: {}); + // --- DIAGNOSTICA --- + Future _printRemoteDiag(CollectionSource source, {String when = ''}) async { try { - return lens.sortedEntries.where((e) => e.origin == 1 && e.trashed == 0).length; - } finally { - lens.dispose(); + final dbRem = await localMediaDb.loadEntries(origin: 1); + final dbCount = dbRem.length; + final displayable = dbRem.where((e) => !e.trashed && e.isDisplayable).length; + final inSource = source.allEntries.where((e) => e.origin == 1 && !e.trashed).length; + final inVisible = source.visibleEntries.where((e) => e.origin == 1 && !e.trashed).length; + + debugPrint('[diag$when] DB remoti=$dbCount, displayable=$displayable, ' + 'inSource=$inSource, inVisible=$inVisible'); + } catch (e, st) { + debugPrint('[diag$when] ERROR: $e\n$st'); } } Future _initViewerEssentials() async { - // for video playback storage await localMediaDb.init(); } @@ -471,21 +540,18 @@ class _HomePageState extends State { Future _initViewerEntry({required String uri, required String? mimeType}) async { if (uri.startsWith('/')) { - // convert this file path to a proper URI uri = Uri.file(uri).toString(); } final entry = await mediaFetchService.getEntry(uri, mimeType); if (entry != null) { - // cataloguing is essential for coordinates and video rotation await entry.catalog(background: false, force: false, persist: false); } return entry; } - // === DEBUG: apre la pagina di test remota con una seconda connessione al DB === + // === DEBUG: pagina test remoto con DB indipendente === Future _openRemoteTestPage(BuildContext context) async { - if (_remoteTestOpen) return; // evita doppi push/sovrapposizioni - // blocca solo se il sync è effettivamente in corso + if (_remoteTestOpen) return; if (_remoteSyncActive) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Attendi fine sync remoto in corso prima di aprire RemoteTest')), @@ -500,7 +566,6 @@ class _HomePageState extends State { final dbDir = await getDatabasesPath(); final dbPath = p.join(dbDir, 'metadata.db'); - // Apri il DB in R/W (istanza indipendente) → niente "read only database" debugDb = await openDatabase( dbPath, singleInstance: false, @@ -613,7 +678,6 @@ class _HomePageState extends State { ); await upd.save(); - // forza refresh immediato delle impostazioni e headers await RemoteHttp.refreshFromSettings(); unawaited(RemoteHttp.warmUp()); @@ -689,7 +753,6 @@ class _HomePageState extends State { final source = context.read(); final album = viewerEntry.directory; if (album != null) { - // wait for collection to pass the `loading` state final loadingCompleter = Completer(); final stateNotifier = source.stateNotifier; void _onSourceStateChanged() { @@ -703,16 +766,10 @@ class _HomePageState extends State { _onSourceStateChanged(); await loadingCompleter.future; - // ⚠️ NON lanciamo più il sync qui (evita contese durante il viewer) - // unawaited(rrs.runRemoteSyncOnceManaged()); - collection = CollectionLens( source: source, filters: {StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))}, listenToSource: false, - // if we group bursts, opening a burst sub-entry should: - // - identify and select the containing main entry, - // - select the sub-entry in the Viewer page. stackBursts: false, ); @@ -726,6 +783,7 @@ class _HomePageState extends State { collection = null; } } + return DirectMaterialPageRoute( settings: const RouteSettings(name: EntryViewerPage.routeName), builder: (_) { @@ -808,7 +866,6 @@ class _HomePageState extends State { ); case CollectionPage.routeName: default: - // Wrapper di debug che aggiunge i due FAB (solo in debug) return buildRoute( (context) => _wrapWithRemoteDebug( context, @@ -822,7 +879,6 @@ class _HomePageState extends State { // Utility sicure per remote // ------------------------- - // safe load of RemoteSettings with timeout and fallback Future _safeLoadRemoteSettings({Duration timeout = const Duration(seconds: 5)}) async { try { return await RemoteSettings.load().timeout(timeout); @@ -838,7 +894,6 @@ class _HomePageState extends State { } } - // safe headers retrieval with timeout and empty fallback Future> _safeHeaders({Duration timeout = const Duration(seconds: 6)}) async { try { return await RemoteHttp.headers().timeout(timeout); @@ -848,11 +903,9 @@ class _HomePageState extends State { } } - // debug helper: clear remote keys from secure storage (debug only) Future _debugClearRemoteKeys() async { if (!kDebugMode) return; try { - // FlutterSecureStorage non è const final storage = FlutterSecureStorage(); await storage.delete(key: 'remote_base_url'); await storage.delete(key: 'remote_index_path'); diff --git a/lib/widgets/home/home_page.dart.old2 b/lib/widgets/home/home_page.dart.ok_quasi similarity index 73% rename from lib/widgets/home/home_page.dart.old2 rename to lib/widgets/home/home_page.dart.ok_quasi index 44860325..bfd8f418 100644 --- a/lib/widgets/home/home_page.dart.old2 +++ b/lib/widgets/home/home_page.dart.ok_quasi @@ -43,19 +43,18 @@ import 'package:aves_model/aves_model.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:latlong2/latlong.dart'; -import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; -// --- IMPORT per debug page remota --- -import 'package:flutter/foundation.dart' show kDebugMode; -import 'package:sqflite/sqflite.dart'; -import 'package:path/path.dart' as p; -import 'package:aves/remote/remote_test_page.dart'; +// ✅ Permissions (platform interface) perché nel tuo branch Permissions.mediaAccess è List platform_interface +import 'package:permission_handler_platform_interface/permission_handler_platform_interface.dart'; + +// ✅ Remote controller +import 'package:aves/remote/remote_controller.dart'; class HomePage extends StatefulWidget { static const routeName = '/'; - // untyped map as it is coming from the platform + // ✅ torna a Map? (compatibile con aves_app.dart che passa Map?) final Map? intentData; const HomePage({ @@ -105,22 +104,27 @@ class _HomePageState extends State { Future _setup() async { try { final stopwatch = Stopwatch()..start(); + if (await windowService.isActivity()) { - // do not check whether permission was granted, because some app stores - // hide in some countries apps that force quit on permission denial - await Permissions.mediaAccess.request(); + // ✅ come Aves: non forzare quit se utente nega permesso + // ma nel tuo branch serve la platform-interface API + await PermissionHandlerPlatform.instance.requestPermissions(Permissions.mediaAccess); } var appMode = AppMode.main; var error = false; - final intentData = widget.intentData ?? await IntentService.getIntentData(); + + // ✅ torna a Map (come Aves originale) + final Map intentData = widget.intentData ?? await IntentService.getIntentData(); final intentAction = intentData[IntentDataKeys.action] as String?; + _initialFilters = null; _initialExplorerPath = null; _secureUris = null; await availability.onNewIntent(); await androidFileUtils.init(); + if (!{ IntentActions.edit, IntentActions.screenSaver, @@ -132,6 +136,7 @@ class _HomePageState extends State { if (intentData.values.nonNulls.isNotEmpty) { await reportService.log('Intent data=$intentData'); + var intentUri = intentData[IntentDataKeys.uri] as String?; final intentMimeType = intentData[IntentDataKeys.mimeType] as String?; @@ -150,35 +155,40 @@ class _HomePageState extends State { } } break; + case IntentActions.edit: appMode = AppMode.edit; case IntentActions.setWallpaper: appMode = AppMode.setWallpaper; + case IntentActions.pickItems: - // TODO TLAD apply pick mimetype(s) - // some apps define multiple types, separated by a space (maybe other signs too, like `,` `;`?) final multiple = (intentData[IntentDataKeys.allowMultiple] as bool?) ?? false; debugPrint('pick mimeType=$intentMimeType multiple=$multiple'); appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal; + case IntentActions.pickCollectionFilters: appMode = AppMode.pickCollectionFiltersExternal; + case IntentActions.screenSaver: appMode = AppMode.screenSaver; _initialRouteName = ScreenSaverPage.routeName; + case IntentActions.screenSaverSettings: _initialRouteName = ScreenSaverSettingsPage.routeName; + case IntentActions.search: _initialRouteName = SearchPage.routeName; _initialSearchQuery = intentData[IntentDataKeys.query] as String?; + case IntentActions.widgetSettings: _initialRouteName = HomeWidgetSettingsPage.routeName; _widgetId = (intentData[IntentDataKeys.widgetId] as int?) ?? 0; + case IntentActions.widgetOpen: final widgetId = intentData[IntentDataKeys.widgetId] as int?; if (widgetId == null) { error = true; } else { - // widget settings may be modified in a different process after channel setup await settings.reload(); final page = settings.getWidgetOpenPage(widgetId); switch (page) { @@ -193,17 +203,19 @@ class _HomePageState extends State { } unawaited(WidgetService.update(widgetId)); } + default: - // do not use 'route' as extra key, as the Flutter framework acts on it final extraRoute = intentData[IntentDataKeys.page] as String?; if (allowedShortcutRoutes.contains(extraRoute)) { _initialRouteName = extraRoute; } } + if (_initialFilters == null) { final extraFilters = (intentData[IntentDataKeys.filters] as List?)?.cast(); _initialFilters = extraFilters?.map(CollectionFilter.fromJson).nonNulls.toSet(); } + _initialExplorerPath = intentData[IntentDataKeys.explorerPath] as String?; switch (appMode) { @@ -211,10 +223,7 @@ class _HomePageState extends State { case AppMode.edit: case AppMode.setWallpaper: if (intentUri != null) { - _viewerEntry = await _initViewerEntry( - uri: intentUri, - mimeType: intentMimeType, - ); + _viewerEntry = await _initViewerEntry(uri: intentUri, mimeType: intentMimeType); } error = _viewerEntry == null; default: @@ -230,6 +239,9 @@ class _HomePageState extends State { context.read>().value = appMode; unawaited(reportService.setCustomKey('app_mode', appMode.toString())); + // ✅ Remote: inizializza stato icona (grigio/verde) + unawaited(RemoteController.instance.initBusFromSettings()); + switch (appMode) { case AppMode.main: case AppMode.pickCollectionFiltersExternal: @@ -237,18 +249,36 @@ class _HomePageState extends State { case AppMode.pickMultipleMediaExternal: unawaited(GlobalSearch.registerCallback()); unawaited(AnalysisService.registerCallback()); + final source = context.read(); + + // ✅ Aves originale: init SOLO se non già full scope (riaperture istantanee) if (source.loadedScope != CollectionSource.fullScope) { - await reportService.log('Initialize source to start app with mode=$appMode, loaded scope=${source.loadedScope}'); - final loadTopEntriesFirst = settings.homeNavItem.route == CollectionPage.routeName && settings.homeCustomCollection.isEmpty; + await reportService.log( + 'Initialize source to start app with mode=$appMode, loaded scope=${source.loadedScope}', + ); + final loadTopEntriesFirst = + settings.homeNavItem.route == CollectionPage.routeName && + settings.homeCustomCollection.isEmpty; source.canAnalyze = true; - await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst); + await source.init( + scope: CollectionSource.fullScope, + loadTopEntriesFirst: loadTopEntriesFirst, + ); } + + // ✅ Remote: gestito dal controller, non blocca e non rompe UX Aves + unawaited(RemoteController.instance.onAppStart( + source: source, + resumeBootstrapIfEnabled: true, + )); + case AppMode.screenSaver: await reportService.log('Initialize source to start screen saver'); final source = context.read(); source.canAnalyze = false; await source.init(scope: settings.screenSaverCollectionFilters); + case AppMode.view: if (_isViewerSourceable(_viewerEntry) && _secureUris == null) { final directory = _viewerEntry?.directory; @@ -256,24 +286,23 @@ class _HomePageState extends State { unawaited(AnalysisService.registerCallback()); await reportService.log('Initialize source to view item in directory $directory'); final source = context.read(); - // analysis is necessary to display neighbour items when the initial item is a new one source.canAnalyze = true; await source.init(scope: {StoredAlbumFilter(directory, null)}); } } else { await _initViewerEssentials(); } + case AppMode.edit: case AppMode.setWallpaper: await _initViewerEssentials(); + default: break; } debugPrint('Home setup complete in ${stopwatch.elapsed.inMilliseconds}ms'); - // `pushReplacement` is not enough in some edge cases - // e.g. when opening the viewer in `view` mode should replace a viewer in `main` mode unawaited( Navigator.maybeOf(context)?.pushAndRemoveUntil( await _getRedirectRoute(appMode), @@ -287,100 +316,37 @@ class _HomePageState extends State { } Future _initViewerEssentials() async { - // for video playback storage await localMediaDb.init(); } bool _isViewerSourceable(AvesEntry? viewerEntry) { - return viewerEntry != null && viewerEntry.directory != null && !settings.hiddenFilters.any((filter) => filter.test(viewerEntry)); + return viewerEntry != null && + viewerEntry.directory != null && + !settings.hiddenFilters.any((filter) => filter.test(viewerEntry)); } Future _initViewerEntry({required String uri, required String? mimeType}) async { if (uri.startsWith('/')) { - // convert this file path to a proper URI uri = Uri.file(uri).toString(); } final entry = await mediaFetchService.getEntry(uri, mimeType); if (entry != null) { - // cataloguing is essential for coordinates and video rotation await entry.catalog(background: false, force: false, persist: false); } return entry; } -// --- DEBUG: apre la pagina di test remota con una seconda connessione al DB --- -// --- DEBUG: apre la pagina di test remota con una seconda connessione al DB --- -Future _openRemoteTestPage(BuildContext context) async { - Database? debugDb; - try { - final dbDir = await getDatabasesPath(); - final dbPath = p.join(dbDir, 'metadata.db'); - - // Apri il DB in sola lettura (evita lock e conflitti) - debugDb = await openDatabase(dbPath, readOnly: true); - - if (!context.mounted) return; - - // Base URL per i remote: se esiste in settings lo usa, altrimenti fallback -// final baseUrl = (settings as dynamic).remoteBaseUrl as String? -// ?? 'https://prova.patachina.it'; - final baseUrl = 'https://prova.patachina.it'; - - - await Navigator.of(context).push(MaterialPageRoute( - builder: (_) => RemoteTestPage( - db: debugDb!, - baseUrl: baseUrl, - ), - )); - } catch (e, st) { - print('[RemoteTest] errore apertura DB/pagina: $e\n$st'); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Errore RemoteTest: $e')), - ); - } finally { - try { - await debugDb?.close(); - } catch (_) {} - } -} - - - // --- DEBUG: wrapper che aggiunge il FAB - // solo in debug --- - Widget _wrapWithRemoteDebug(BuildContext context, Widget child) { - if (!kDebugMode) return child; - return Stack( - children: [ - child, - Positioned( - right: 16, - bottom: 16, - child: FloatingActionButton( - heroTag: 'remote_debug_fab', - onPressed: () => _openRemoteTestPage(context), - tooltip: 'Remote Test', - child: const Icon(Icons.image_search), - ), - ), - ], - ); - } - Future _getRedirectRoute(AppMode appMode) async { String routeName; Set? filters; + switch (appMode) { case AppMode.setWallpaper: return DirectMaterialPageRoute( settings: const RouteSettings(name: WallpaperPage.routeName), - builder: (_) { - return WallpaperPage( - entry: _viewerEntry, - ); - }, + builder: (_) => WallpaperPage(entry: _viewerEntry), ); + case AppMode.view: AvesEntry viewerEntry = _viewerEntry!; CollectionLens? collection; @@ -388,7 +354,6 @@ Future _openRemoteTestPage(BuildContext context) async { final source = context.read(); final album = viewerEntry.directory; if (album != null) { - // wait for collection to pass the `loading` state final loadingCompleter = Completer(); final stateNotifier = source.stateNotifier; void _onSourceStateChanged() { @@ -406,13 +371,13 @@ Future _openRemoteTestPage(BuildContext context) async { source: source, filters: {StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))}, listenToSource: false, - // if we group bursts, opening a burst sub-entry should: - // - identify and select the containing main entry, - // - select the sub-entry in the Viewer page. stackBursts: false, ); + final viewerEntryPath = viewerEntry.path; - final collectionEntry = collection.sortedEntries.firstWhereOrNull((entry) => entry.path == viewerEntryPath); + final collectionEntry = collection.sortedEntries.firstWhereOrNull( + (entry) => entry.path == viewerEntryPath, + ); if (collectionEntry != null) { viewerEntry = collectionEntry; } else { @@ -423,36 +388,21 @@ Future _openRemoteTestPage(BuildContext context) async { return DirectMaterialPageRoute( settings: const RouteSettings(name: EntryViewerPage.routeName), - builder: (_) { - return EntryViewerPage( - collection: collection, - initialEntry: viewerEntry, - ); - }, + builder: (_) => EntryViewerPage(collection: collection, initialEntry: viewerEntry), ); + case AppMode.edit: return DirectMaterialPageRoute( settings: const RouteSettings(name: EntryViewerPage.routeName), - builder: (_) { - return ImageEditorPage( - entry: _viewerEntry!, - ); - }, + builder: (_) => ImageEditorPage(entry: _viewerEntry!), ); - case AppMode.initialization: - case AppMode.main: - case AppMode.pickCollectionFiltersExternal: - case AppMode.pickSingleMediaExternal: - case AppMode.pickMultipleMediaExternal: - case AppMode.pickFilteredMediaInternal: - case AppMode.pickUnfilteredMediaInternal: - case AppMode.pickFilterInternal: - case AppMode.previewMap: - case AppMode.screenSaver: - case AppMode.slideshow: + + default: routeName = _initialRouteName ?? settings.homeNavItem.route; - filters = _initialFilters ?? (settings.homeNavItem.route == CollectionPage.routeName ? settings.homeCustomCollection : {}); + filters = _initialFilters ?? + (settings.homeNavItem.route == CollectionPage.routeName ? settings.homeCustomCollection : {}); } + Route buildRoute(WidgetBuilder builder) => DirectMaterialPageRoute( settings: RouteSettings(name: routeName), builder: builder, @@ -500,14 +450,7 @@ Future _openRemoteTestPage(BuildContext context) async { ); case CollectionPage.routeName: default: - // <<--- QUI AVVOLGO LA COLLECTION CON IL WRAPPER DI DEBUG - return buildRoute( - (context) => _wrapWithRemoteDebug( - context, - CollectionPage(source: source, filters: filters), - ), - ); + return buildRoute((context) => CollectionPage(source: source, filters: filters)); } } } - diff --git a/lib/widgets/home/home_page.dart.old b/lib/widgets/home/home_page.dart.old index 1ce1439c..61a2aafc 100644 --- a/lib/widgets/home/home_page.dart.old +++ b/lib/widgets/home/home_page.dart.old @@ -1,6 +1,7 @@ // lib/widgets/home/home_page.dart import 'dart:async'; +import 'package:aves/remote/collection_source_remote_ext.dart'; import 'package:aves/app_mode.dart'; import 'package:aves/geo/uri.dart'; import 'package:aves/model/app/intent.dart'; @@ -46,13 +47,21 @@ import 'package:latlong2/latlong.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; -// --- IMPORT aggiunti/aggiornati per integrazione remota (Fase 1) --- +// --- REMOTO / DEBUG --- import 'package:flutter/foundation.dart' show kDebugMode; import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart' as p; import 'package:aves/remote/remote_test_page.dart' as rtp; -import 'package:aves/remote/run_remote_sync.dart' as rrs; import 'package:aves/remote/remote_settings.dart'; +import 'package:aves/remote/remote_http.dart'; +import 'package:aves/remote/remote_models.dart'; +import 'package:aves/remote/remote_client.dart'; +import 'package:aves/remote/auth_client.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +// Step 2: progress bus + repository +import 'package:aves/remote/remote_sync_bus.dart'; +import 'package:aves/remote/remote_repository.dart'; class HomePage extends StatefulWidget { static const routeName = '/'; @@ -78,8 +87,12 @@ class _HomePageState extends State { List? _secureUris; (Object, StackTrace)? _setupError; - // guard UI per schedulare UNA sola run del sync da Home + // guard sync remoto: singola esecuzione per avvio bool _remoteSyncScheduled = false; + bool _remoteSyncActive = false; + + // guard pagina test remota + bool _remoteTestOpen = false; static const allowedShortcutRoutes = [ AlbumListPage.routeName, @@ -106,13 +119,26 @@ class _HomePageState extends State { : null, ); + // ============================================================ + // BOOTSTRAP FLAG (Remote progress ONLY first time) + // ============================================================ + Future _isRemoteBootstrapDone() async { + final storage = FlutterSecureStorage(); + final v = await storage.read(key: 'remote_bootstrap_done'); + return v == '1'; + } + + Future _setRemoteBootstrapDone() async { + final storage = FlutterSecureStorage(); + await storage.write(key: 'remote_bootstrap_done', value: '1'); + } + // ============================================================ + Future _setup() async { try { final stopwatch = Stopwatch()..start(); if (await windowService.isActivity()) { - // do not check whether permission was granted, because some app stores - // hide in some countries apps that force quit on permission denial await Permissions.mediaAccess.request(); } @@ -129,11 +155,21 @@ class _HomePageState extends State { await availability.onNewIntent(); await androidFileUtils.init(); + // Warm-up header remoti (non blocca UI) + unawaited(Future(() async { + try { + final s = await _safeLoadRemoteSettings(); + if (s.enabled && s.baseUrl.trim().isNotEmpty) { + await _safeHeaders(); + } + } catch (_) {} + })); + if (!{ - IntentActions.edit, - IntentActions.screenSaver, - IntentActions.setWallpaper, - }.contains(intentAction) && + IntentActions.edit, + IntentActions.screenSaver, + IntentActions.setWallpaper, + }.contains(intentAction) && settings.isInstalledAppAccessAllowed) { unawaited(appInventory.initAppNames()); } @@ -164,8 +200,6 @@ class _HomePageState extends State { case IntentActions.setWallpaper: appMode = AppMode.setWallpaper; case IntentActions.pickItems: - // TODO TLAD apply pick mimetype(s) - // some apps define multiple types, separated by a space (maybe other signs too, like `,` `;`?) final multiple = (intentData[IntentDataKeys.allowMultiple] as bool?) ?? false; debugPrint('pick mimeType=$intentMimeType multiple=$multiple'); appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal; @@ -187,7 +221,6 @@ class _HomePageState extends State { if (widgetId == null) { error = true; } else { - // widget settings may be modified in a different process after channel setup await settings.reload(); final page = settings.getWidgetOpenPage(widgetId); switch (page) { @@ -203,7 +236,6 @@ class _HomePageState extends State { unawaited(WidgetService.update(widgetId)); } default: - // do not use 'route' as extra key, as the Flutter framework acts on it final extraRoute = intentData[IntentDataKeys.page] as String?; if (allowedShortcutRoutes.contains(extraRoute)) { _initialRouteName = extraRoute; @@ -250,54 +282,123 @@ class _HomePageState extends State { unawaited(AnalysisService.registerCallback()); final source = context.read(); + + ////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + // STEP 1 STARTUP VELOCE (MARKER 1/6) - NON MODIFICARE FUORI DA QUESTI MARKER + // STEP 1 STARTUP VELOCE (MARKER 2/6) - DB cache -> init in background + // STEP 1 STARTUP VELOCE (MARKER 3/6) - DB vuoto -> init standard con progress + ////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + // Capisco se c'è cache nel DB (locali e/o remoti) + bool hasAnyCache = false; + try { + await localMediaDb.init(); // assicura DB pronto + final rows = await localMediaDb.rawDb.rawQuery( + 'SELECT 1 FROM entry WHERE trashed=0 LIMIT 1', + ); + hasAnyCache = rows.isNotEmpty; + } catch (_) {} + + final loadTopEntriesFirst = + settings.homeNavItem.route == CollectionPage.routeName && + settings.homeCustomCollection.isEmpty; + + // Bootstrap flag remoti (progress SOLO prima volta) + final bootstrapDone = await _isRemoteBootstrapDone(); + final bootstrap = !bootstrapDone; + + // Se la source non è full scope, dobbiamo fare init almeno una volta if (source.loadedScope != CollectionSource.fullScope) { await reportService.log( 'Initialize source to start app with mode=$appMode, loaded scope=${source.loadedScope}', ); - final loadTopEntriesFirst = - settings.homeNavItem.route == CollectionPage.routeName && settings.homeCustomCollection.isEmpty; - source.canAnalyze = true; - await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst); - } - // === FASE 1: SYNC REMOTO POST-INIT (non blocca la UI) === - // In DEBUG: fai seed dei settings se sono vuoti, poi lancia il sync SOLO quando - // la sorgente ha finito il loading, con un micro delay di sicurezza. - if (!_remoteSyncScheduled) { - _remoteSyncScheduled = true; // una sola schedulazione per avvio - unawaited(Future(() async { - try { - await RemoteSettings.debugSeedIfEmpty(); - final rs = await RemoteSettings.load(); - if (!rs.enabled) return; + if (hasAnyCache) { + // ✅ DB ha dati: avvio veloce -> init in background (non blocca UI) + debugPrint('[startup] DB cache present -> init in background (fast start)'); + source.canAnalyze = true; - // attesa fine loading - final notifier = source.stateNotifier; - if (notifier.value == SourceState.loading) { - final completer = Completer(); - void onState() { - if (notifier.value != SourceState.loading) { - notifier.removeListener(onState); - completer.complete(); - } + unawaited( + source + .init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst) + .then((_) async { + // ✅ Remoti: opzione 1 + // - se bootstrap DONE -> mostra subito dal DB + // - se bootstrap NOT done -> NON mostrare finché non finisce il bootstrap sync + final bd = await _isRemoteBootstrapDone(); + if (bd) { + await source.appendRemoteEntriesFromDb(); + debugPrint('[startup][bg] remote append after init done'); + } else { + debugPrint('[startup][bg] bootstrap not done -> skip remote append (will appear after bootstrap sync)'); } - notifier.addListener(onState); - // nel caso non sia già loading: - onState(); - await completer.future; - } + // Schedula sync remoto UNA volta + if (!_remoteSyncScheduled) { + _remoteSyncScheduled = true; + final sourceRef = source; + unawaited(Future.microtask(() => _runRemoteSync(sourceRef, bootstrap: !bd))); + } + }), + ); + } else { + // ✅ DB vuoto: comportamento Aves standard -> await init (progress locale) + debugPrint('[startup] DB empty -> await init (Aves standard)'); + source.canAnalyze = true; + await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst); - // piccolo margine per step secondari (tag, ecc.) - await Future.delayed(const Duration(milliseconds: 400)); - - // sync in background (la managed ha già il suo guard interno) - await rrs.runRemoteSyncOnceManaged(); - } catch (e, st) { - debugPrint('[remote-sync] error: $e\n$st'); + // ✅ Remoti: opzione 1 + // - se bootstrap DONE -> mostra subito dal DB + // - se bootstrap NOT done -> non mostrare finché bootstrap sync finisce + if (!bootstrap) { + // bootstrap==true => primo avvio remoto + debugPrint('[startup] bootstrap not done -> skip remote append (will appear after bootstrap sync)'); + } else { + await source.appendRemoteEntriesFromDb(); } - })); + + if (!_remoteSyncScheduled) { + _remoteSyncScheduled = true; + final sourceRef = source; + unawaited(Future.microtask(() => _runRemoteSync(sourceRef, bootstrap: bootstrap))); + } + } + } else { + // Source già full scope (hot state) + debugPrint('[startup] source already fullScope'); + + // ✅ Remoti: opzione 1 + // se bootstrap done -> mostra subito dal DB + if (bootstrapDone) { + await source.appendRemoteEntriesFromDb(); + } else { + debugPrint('[startup] bootstrap not done -> skip remote append (will appear after bootstrap sync)'); + } + + if (!_remoteSyncScheduled) { + _remoteSyncScheduled = true; + final sourceRef = source; + unawaited(Future.microtask(() => _runRemoteSync(sourceRef, bootstrap: bootstrap))); + } } + + // DIAG: stato (facoltativo) + unawaited(_printRemoteDiag(source, when: ' PRE')); + + ////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + // STEP 1 STARTUP VELOCE (MARKER 4/6) - FINE BLOCCO + // STEP 1 STARTUP VELOCE (MARKER 5/6) - QUI PUOI AGGIUNGERE LOG/DIAG + // STEP 1 STARTUP VELOCE (MARKER 6/6) - NON MODIFICARE FUORI DA QUESTI MARKER + ////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + break; case AppMode.screenSaver: @@ -314,7 +415,6 @@ class _HomePageState extends State { unawaited(AnalysisService.registerCallback()); await reportService.log('Initialize source to view item in directory $directory'); final source = context.read(); - // analysis is necessary to display neighbour items when the initial item is a new one source.canAnalyze = true; await source.init(scope: {StoredAlbumFilter(directory, null)}); } @@ -334,8 +434,7 @@ class _HomePageState extends State { debugPrint('Home setup complete in ${stopwatch.elapsed.inMilliseconds}ms'); - // `pushReplacement` is not enough in some edge cases - // e.g. when opening the viewer in `view` mode should replace a viewer in `main` mode + // navigazione finale unawaited( Navigator.maybeOf(context)?.pushAndRemoveUntil( await _getRedirectRoute(appMode), @@ -348,8 +447,142 @@ class _HomePageState extends State { } } + // ============================================================ + // === SYNC REMOTO (Step 2) + // - progress bar SOLO bootstrap (prima volta) + // - remoti visibili SOLO dopo bootstrap completato + // - dopo bootstrap: niente full sync automatico (fino a Step 3 delta/ws) + // ============================================================ + Future _runRemoteSync(CollectionSource source, {required bool bootstrap}) async { + try { + final rs = await _safeLoadRemoteSettings(); + if (!rs.enabled) { + debugPrint('[remote-sync] disabled → skip'); + return; + } + + // Se NON bootstrap: per ora non facciamo full fetch ogni avvio + if (!bootstrap) { + debugPrint('[remote-sync] bootstrap already done -> skip full sync (until Step 3 delta/ws)'); + return; + } + + // Se locali ancora in loading, attendi + try { + if (source.stateNotifier.value == SourceState.loading) { + final c = Completer(); + void onState() { + if (source.stateNotifier.value != SourceState.loading) { + source.stateNotifier.removeListener(onState); + c.complete(); + } + } + source.stateNotifier.addListener(onState); + onState(); + await c.future; + } + } catch (_) {} + + _remoteSyncActive = true; + + // FULL fetch dal server (bootstrap) + final items = await _fetchAllRemoteItems(); + final total = items.length; + final serverIds = items.map((e) => e.id).where((v) => v.isNotEmpty).toSet(); + + // progress start SOLO bootstrap + RemoteSyncBus.instance.start(phase: 'Sync remoto…', total: total); + + final repo = RemoteRepository(localMediaDb.rawDb); + + // Bootstrap: pulizia totale remoti prima di importare + await repo.deleteAllRemotes(); + + // upsert chunked con progress reale X/Y + const chunkSize = 200; + int done = 0; + + for (var offset = 0; offset < total; offset += chunkSize) { + final end = (offset + chunkSize < total) ? offset + chunkSize : total; + final chunk = items.sublist(offset, end); + + await repo.upsertAll(chunk, chunkSize: chunkSize); + + done = end; + RemoteSyncBus.instance.update( + phase: 'Sync remoto…', + done: done, + total: total, + ); + } + + // prune hard-delete (full list autorevole) + final pruned = await repo.pruneMissingRemotes(serverIds); + debugPrint('[remote-sync] prune deleted=$pruned'); + + // ✅ Remoti compaiono SOLO ORA (dopo caricamento completo) + await source.appendRemoteEntriesFromDb(); + + // segna bootstrap done + await _setRemoteBootstrapDone(); + + RemoteSyncBus.instance.finish(); + unawaited(_printRemoteDiag(source, when: ' POST')); + } on TimeoutException catch (e) { + debugPrint('[remote-sync] TIMEOUT: $e'); + RemoteSyncBus.instance.clear(); + } catch (e, st) { + debugPrint('[remote-sync] error: $e\n$st'); + RemoteSyncBus.instance.clear(); + } finally { + _remoteSyncActive = false; + } + } + + // === FETCH remoto reale === + Future> _fetchAllRemoteItems() async { + try { + final rs = await _safeLoadRemoteSettings(); + if (!rs.enabled || rs.baseUrl.trim().isEmpty) { + return []; + } + + RemoteAuth? auth; + if (rs.email.isNotEmpty && rs.password.isNotEmpty) { + auth = RemoteAuth(baseUrl: rs.baseUrl, email: rs.email, password: rs.password); + } + + final client = RemoteJsonClient(rs.baseUrl, rs.indexPath, auth: auth); + try { + final items = await client.fetchAll(); + debugPrint('[remote-sync][fetch] fetched ${items.length} items'); + return items; + } catch (e, st) { + debugPrint('[remote-sync][fetch] client.fetchAll ERROR: $e\n$st'); + return []; + } + } catch (e, st) { + debugPrint('[remote-sync][fetch] ERROR: $e\n$st'); + return []; + } + } + + // --- DIAGNOSTICA --- + Future _printRemoteDiag(CollectionSource source, {String when = ''}) async { + try { + final dbRem = await localMediaDb.loadEntries(origin: 1); + final dbCount = dbRem.length; + final displayable = dbRem.where((e) => !e.trashed && e.isDisplayable).length; + final inSource = source.allEntries.where((e) => e.origin == 1 && !e.trashed).length; + final inVisible = source.visibleEntries.where((e) => e.origin == 1 && !e.trashed).length; + + debugPrint('[diag$when] DB remoti=$dbCount, displayable=$displayable, inSource=$inSource, inVisible=$inVisible'); + } catch (e, st) { + debugPrint('[diag$when] ERROR: $e\n$st'); + } + } + Future _initViewerEssentials() async { - // for video playback storage await localMediaDb.init(); } @@ -361,44 +594,43 @@ class _HomePageState extends State { Future _initViewerEntry({required String uri, required String? mimeType}) async { if (uri.startsWith('/')) { - // convert this file path to a proper URI uri = Uri.file(uri).toString(); } final entry = await mediaFetchService.getEntry(uri, mimeType); if (entry != null) { - // cataloguing is essential for coordinates and video rotation await entry.catalog(background: false, force: false, persist: false); } return entry; } - // === DEBUG: apre la pagina di test remota con una seconda connessione al DB === + // === DEBUG: pagina test remoto con DB indipendente === Future _openRemoteTestPage(BuildContext context) async { + if (_remoteTestOpen) return; + if (_remoteSyncActive) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Attendi fine sync remoto in corso prima di aprire RemoteTest')), + ); + return; + } + + _remoteTestOpen = true; + Database? debugDb; try { final dbDir = await getDatabasesPath(); final dbPath = p.join(dbDir, 'metadata.db'); - // Apri il DB in sola lettura (evita lock e conflitti) - //debugDb = await openDatabase(dbPath, readOnly: true); - -// DOPO (R/W, istanza indipendente) -debugDb = await openDatabase( - dbPath, - singleInstance: false, - onConfigure: (db) async { - // opzionale ma utile per coerenza con il resto - await db.rawQuery('PRAGMA journal_mode=WAL'); - await db.rawQuery('PRAGMA foreign_keys=ON'); - }, -); - - - - + debugDb = await openDatabase( + dbPath, + singleInstance: false, + onConfigure: (db) async { + await db.rawQuery('PRAGMA journal_mode=WAL'); + await db.rawQuery('PRAGMA foreign_keys=ON'); + }, + ); if (!context.mounted) return; - final rs = await RemoteSettings.load(); + final rs = await _safeLoadRemoteSettings(); final baseUrl = rs.baseUrl.isNotEmpty ? rs.baseUrl : RemoteSettings.defaultBaseUrl; await Navigator.of(context).push(MaterialPageRoute( @@ -418,12 +650,13 @@ debugDb = await openDatabase( try { await debugDb?.close(); } catch (_) {} + _remoteTestOpen = false; } } // === DEBUG: dialog impostazioni remote (semplice) === Future _openRemoteSettingsDialog(BuildContext context) async { - final s = await RemoteSettings.load(); + final s = await _safeLoadRemoteSettings(); final formKey = GlobalKey(); bool enabled = s.enabled; final baseUrlC = TextEditingController(text: s.baseUrl); @@ -498,6 +731,10 @@ debugDb = await openDatabase( password: pwC.text, ); await upd.save(); + + await RemoteHttp.refreshFromSettings(); + unawaited(RemoteHttp.warmUp()); + if (context.mounted) Navigator.of(context).pop(); if (context.mounted) { ScaffoldMessenger.of(context) @@ -559,20 +796,20 @@ debugDb = await openDatabase( return DirectMaterialPageRoute( settings: const RouteSettings(name: WallpaperPage.routeName), builder: (_) { - return WallpaperPage( - entry: _viewerEntry, - ); + return WallpaperPage(entry: _viewerEntry); }, ); + case AppMode.view: AvesEntry viewerEntry = _viewerEntry!; CollectionLens? collection; + final source = context.read(); final album = viewerEntry.directory; if (album != null) { - // wait for collection to pass the `loading` state final loadingCompleter = Completer(); final stateNotifier = source.stateNotifier; + void _onSourceStateChanged() { if (stateNotifier.value != SourceState.loading) { stateNotifier.removeListener(_onSourceStateChanged); @@ -584,16 +821,10 @@ debugDb = await openDatabase( _onSourceStateChanged(); await loadingCompleter.future; - // ⚠️ NON lanciamo più il sync qui (evita contese durante il viewer) - // unawaited(rrs.runRemoteSyncOnceManaged()); - collection = CollectionLens( source: source, filters: {StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))}, listenToSource: false, - // if we group bursts, opening a burst sub-entry should: - // - identify and select the containing main entry, - // - select the sub-entry in the Viewer page. stackBursts: false, ); @@ -607,24 +838,18 @@ debugDb = await openDatabase( collection = null; } } + return DirectMaterialPageRoute( settings: const RouteSettings(name: EntryViewerPage.routeName), - builder: (_) { - return EntryViewerPage( - collection: collection, - initialEntry: viewerEntry, - ); - }, + builder: (_) => EntryViewerPage(collection: collection, initialEntry: viewerEntry), ); + case AppMode.edit: return DirectMaterialPageRoute( settings: const RouteSettings(name: EntryViewerPage.routeName), - builder: (_) { - return ImageEditorPage( - entry: _viewerEntry!, - ); - }, + builder: (_) => ImageEditorPage(entry: _viewerEntry!), ); + case AppMode.initialization: case AppMode.main: case AppMode.pickCollectionFiltersExternal: @@ -659,7 +884,7 @@ debugDb = await openDatabase( source: source, filters: { LocationFilter.located, - if (filters != null) ...filters, + if (filters != null) ...filters!, }, ); return MapPage( @@ -689,7 +914,6 @@ debugDb = await openDatabase( ); case CollectionPage.routeName: default: - // <<--- Wrapper di debug che aggiunge i due FAB (solo in debug) return buildRoute( (context) => _wrapWithRemoteDebug( context, @@ -698,4 +922,51 @@ debugDb = await openDatabase( ); } } + + // ------------------------- + // Utility sicure per remote + // ------------------------- + + Future _safeLoadRemoteSettings({Duration timeout = const Duration(seconds: 5)}) async { + try { + return await RemoteSettings.load().timeout(timeout); + } catch (e) { + debugPrint('[remote] RemoteSettings.load failed: $e — using defaults'); + return RemoteSettings( + enabled: RemoteSettings.defaultEnabled, + baseUrl: RemoteSettings.defaultBaseUrl, + indexPath: RemoteSettings.defaultIndexPath, + email: RemoteSettings.defaultEmail, + password: RemoteSettings.defaultPassword, + ); + } + } + + Future> _safeHeaders({Duration timeout = const Duration(seconds: 6)}) async { + try { + return await RemoteHttp.headers().timeout(timeout); + } catch (e) { + debugPrint('[remote] RemoteHttp.headers failed: $e — returning empty headers'); + return const {}; + } + } + + Future _debugClearRemoteKeys() async { + if (!kDebugMode) return; + try { + final storage = FlutterSecureStorage(); + await storage.delete(key: 'remote_base_url'); + await storage.delete(key: 'remote_index_path'); + await storage.delete(key: 'remote_email'); + await storage.delete(key: 'remote_password'); + await storage.delete(key: 'remote_enabled'); + + // utile anche per test bootstrap: + await storage.delete(key: 'remote_bootstrap_done'); + + debugPrint('[remote] debugClearRemoteKeys executed'); + } catch (e) { + debugPrint('[remote] debugClearRemoteKeys failed: $e'); + } + } } diff --git a/lib/widgets/home/home_page.dart.orig b/lib/widgets/home/home_page.dart.orig new file mode 100644 index 00000000..4c46c158 --- /dev/null +++ b/lib/widgets/home/home_page.dart.orig @@ -0,0 +1,775 @@ +// lib/widgets/home/home_page.dart +import 'dart:async'; + +import 'package:aves/remote/collection_source_remote_ext.dart'; +import 'package:aves/app_mode.dart'; +import 'package:aves/geo/uri.dart'; +import 'package:aves/model/app/intent.dart'; +import 'package:aves/model/app/permissions.dart'; +import 'package:aves/model/app_inventory.dart'; +import 'package:aves/model/entry/entry.dart'; +import 'package:aves/model/entry/extensions/catalog.dart'; +import 'package:aves/model/filters/covered/location.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; +import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/services/analysis_service.dart'; +import 'package:aves/services/common/services.dart'; +import 'package:aves/services/global_search.dart'; +import 'package:aves/services/intent_service.dart'; +import 'package:aves/services/widget_service.dart'; +import 'package:aves/theme/themes.dart'; +import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/common/basic/scaffold.dart'; +import 'package:aves/widgets/common/behaviour/routes.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/search/page.dart'; +import 'package:aves/widgets/common/search/route.dart'; +import 'package:aves/widgets/editor/entry_editor_page.dart'; +import 'package:aves/widgets/explorer/explorer_page.dart'; +import 'package:aves/widgets/filter_grids/albums_page.dart'; +import 'package:aves/widgets/filter_grids/tags_page.dart'; +import 'package:aves/widgets/home/home_error.dart'; +import 'package:aves/widgets/map/map_page.dart'; +import 'package:aves/widgets/search/collection_search_delegate.dart'; +import 'package:aves/widgets/settings/home_widget_settings_page.dart'; +import 'package:aves/widgets/settings/screen_saver_settings_page.dart'; +import 'package:aves/widgets/viewer/entry_viewer_page.dart'; +import 'package:aves/widgets/viewer/screen_saver_page.dart'; +import 'package:aves/widgets/wallpaper_page.dart'; +import 'package:aves_model/aves_model.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:provider/provider.dart'; + +// --- REMOTO / DEBUG --- +import 'package:flutter/foundation.dart' show kDebugMode; +import 'package:sqflite/sqflite.dart'; +import 'package:path/path.dart' as p; +import 'package:aves/remote/remote_test_page.dart' as rtp; +import 'package:aves/remote/remote_settings.dart'; +import 'package:aves/remote/remote_http.dart'; +import 'package:aves/remote/remote_models.dart'; +import 'package:aves/remote/remote_client.dart'; +import 'package:aves/remote/auth_client.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +// Step 2: progress bus + repository +import 'package:aves/remote/remote_sync_bus.dart'; +import 'package:aves/remote/remote_repository.dart'; + +class HomePage extends StatefulWidget { + static const routeName = '/'; + final Map? intentData; + + const HomePage({ + super.key, + this.intentData, + }); + + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + AvesEntry? _viewerEntry; + int? _widgetId; + String? _initialRouteName, _initialSearchQuery; + Set? _initialFilters; + String? _initialExplorerPath; + (LatLng, double?)? _initialLocationZoom; + List? _secureUris; + (Object, StackTrace)? _setupError; + + bool _remoteSyncScheduled = false; + bool _remoteSyncActive = false; + bool _remoteTestOpen = false; + + static const allowedShortcutRoutes = [ + AlbumListPage.routeName, + CollectionPage.routeName, + ExplorerPage.routeName, + MapPage.routeName, + SearchPage.routeName, + ]; + + @override + void initState() { + super.initState(); + _setup(); + imageCache.maximumSizeBytes = 512 * (1 << 20); + } + + @override + Widget build(BuildContext context) => AvesScaffold( + body: _setupError != null + ? HomeError( + error: _setupError!.$1, + stack: _setupError!.$2, + ) + : null, + ); + + // ============================================================ + // BOOTSTRAP FLAG (Remote progress ONLY first time) + // ============================================================ + Future _isRemoteBootstrapDone() async { + final storage = FlutterSecureStorage(); + final v = await storage.read(key: 'remote_bootstrap_done'); + return v == '1'; + } + + Future _setRemoteBootstrapDone() async { + final storage = FlutterSecureStorage(); + await storage.write(key: 'remote_bootstrap_done', value: '1'); + } + + // ============================================================ + // ✅ NEW: wait for locals to be READY before starting remote bootstrap + // ============================================================ + Future _waitSourceReady(CollectionSource source) async { + if (source.stateNotifier.value == SourceState.ready) return; + + final c = Completer(); + void onState() { + if (source.stateNotifier.value == SourceState.ready) { + source.stateNotifier.removeListener(onState); + c.complete(); + } + } + + source.stateNotifier.addListener(onState); + onState(); + await c.future; + } + + // ============================================================ + // INIT DEBUG (optional): SourceState + polling entry counts (3s) + // ============================================================ + VoidCallback _attachInitDebug(CollectionSource source, String label) { + final sw = Stopwatch()..start(); + int lastAll = -1; + int lastVis = -1; + + void logState() { + debugPrint( + '[$label] state=${source.stateNotifier.value} ' + 't=${sw.elapsedMilliseconds}ms ' + 'all=${source.allEntries.length} vis=${source.visibleEntries.length} ' + 'loadedScope=${source.loadedScope}', + ); + } + + void pollCounts() { + final all = source.allEntries.length; + final vis = source.visibleEntries.length; + if (all != lastAll || vis != lastVis) { + lastAll = all; + lastVis = vis; + debugPrint('[$label] CHANGE t=${sw.elapsedMilliseconds}ms all=$all vis=$vis state=${source.stateNotifier.value}'); + } + } + + debugPrint('[$label] attach listeners'); + logState(); + pollCounts(); + + source.stateNotifier.addListener(logState); + final timer = Timer.periodic(const Duration(milliseconds: 100), (_) => pollCounts()); + + return () { + timer.cancel(); + try { + source.stateNotifier.removeListener(logState); + } catch (_) {} + debugPrint('[$label] detach listeners at t=${sw.elapsedMilliseconds}ms'); + }; + } + + Future _setup() async { + try { + final stopwatch = Stopwatch()..start(); + + if (await windowService.isActivity()) { + await Permissions.mediaAccess.request(); + } + + var appMode = AppMode.main; + var error = false; + + final intentData = widget.intentData ?? await IntentService.getIntentData(); + final intentAction = intentData[IntentDataKeys.action] as String?; + + _initialFilters = null; + _initialExplorerPath = null; + _secureUris = null; + + await availability.onNewIntent(); + await androidFileUtils.init(); + + // Warm-up header remoti (non blocca UI) + unawaited(Future(() async { + try { + final s = await _safeLoadRemoteSettings(); + if (s.enabled && s.baseUrl.trim().isNotEmpty) { + await _safeHeaders(); + } + } catch (_) {} + })); + + if (!{ + IntentActions.edit, + IntentActions.screenSaver, + IntentActions.setWallpaper, + }.contains(intentAction) && + settings.isInstalledAppAccessAllowed) { + unawaited(appInventory.initAppNames()); + } + + if (intentData.values.nonNulls.isNotEmpty) { + await reportService.log('Intent data=$intentData'); + + var intentUri = intentData[IntentDataKeys.uri] as String?; + final intentMimeType = intentData[IntentDataKeys.mimeType] as String?; + + switch (intentAction) { + case IntentActions.view: + appMode = AppMode.view; + _secureUris = (intentData[IntentDataKeys.secureUris] as List?)?.cast(); + case IntentActions.viewGeo: + error = true; + if (intentUri != null) { + final locationZoom = parseGeoUri(intentUri); + if (locationZoom != null) { + _initialRouteName = MapPage.routeName; + _initialLocationZoom = locationZoom; + error = false; + } + } + break; + case IntentActions.edit: + appMode = AppMode.edit; + case IntentActions.setWallpaper: + appMode = AppMode.setWallpaper; + case IntentActions.pickItems: + final multiple = (intentData[IntentDataKeys.allowMultiple] as bool?) ?? false; + debugPrint('pick mimeType=$intentMimeType multiple=$multiple'); + appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal; + case IntentActions.pickCollectionFilters: + appMode = AppMode.pickCollectionFiltersExternal; + case IntentActions.screenSaver: + appMode = AppMode.screenSaver; + _initialRouteName = ScreenSaverPage.routeName; + case IntentActions.screenSaverSettings: + _initialRouteName = ScreenSaverSettingsPage.routeName; + case IntentActions.search: + _initialRouteName = SearchPage.routeName; + _initialSearchQuery = intentData[IntentDataKeys.query] as String?; + case IntentActions.widgetSettings: + _initialRouteName = HomeWidgetSettingsPage.routeName; + _widgetId = (intentData[IntentDataKeys.widgetId] as int?) ?? 0; + case IntentActions.widgetOpen: + final widgetId = intentData[IntentDataKeys.widgetId] as int?; + if (widgetId == null) { + error = true; + } else { + await settings.reload(); + final page = settings.getWidgetOpenPage(widgetId); + switch (page) { + case WidgetOpenPage.collection: + _initialFilters = settings.getWidgetCollectionFilters(widgetId); + case WidgetOpenPage.viewer: + appMode = AppMode.view; + intentUri = settings.getWidgetUri(widgetId); + case WidgetOpenPage.home: + case WidgetOpenPage.updateWidget: + break; + } + unawaited(WidgetService.update(widgetId)); + } + default: + final extraRoute = intentData[IntentDataKeys.page] as String?; + if (allowedShortcutRoutes.contains(extraRoute)) { + _initialRouteName = extraRoute; + } + } + + if (_initialFilters == null) { + final extraFilters = (intentData[IntentDataKeys.filters] as List?)?.cast(); + _initialFilters = extraFilters?.map(CollectionFilter.fromJson).nonNulls.toSet(); + } + + _initialExplorerPath = intentData[IntentDataKeys.explorerPath] as String?; + + switch (appMode) { + case AppMode.view: + case AppMode.edit: + case AppMode.setWallpaper: + if (intentUri != null) { + _viewerEntry = await _initViewerEntry(uri: intentUri, mimeType: intentMimeType); + } + error = _viewerEntry == null; + default: + break; + } + } + + if (error) { + debugPrint('Failed to init app mode=$appMode for intent data=$intentData. Fallback to main mode.'); + appMode = AppMode.main; + } + + context.read>().value = appMode; + unawaited(reportService.setCustomKey('app_mode', appMode.toString())); + + switch (appMode) { + case AppMode.main: + case AppMode.pickCollectionFiltersExternal: + case AppMode.pickSingleMediaExternal: + case AppMode.pickMultipleMediaExternal: + unawaited(GlobalSearch.registerCallback()); + unawaited(AnalysisService.registerCallback()); + + final source = context.read(); + + // cache DB? + bool hasAnyCache = false; + try { + await localMediaDb.init(); + final rows = await localMediaDb.rawDb.rawQuery( + 'SELECT 1 FROM entry WHERE trashed=0 LIMIT 1', + ); + hasAnyCache = rows.isNotEmpty; + } catch (_) {} + + final bootstrapDone = await _isRemoteBootstrapDone(); + final bootstrap = !bootstrapDone; + + debugPrint('[BOOT] hasAnyCache=$hasAnyCache bootstrapDone=$bootstrapDone bootstrap=$bootstrap ' + 'loadedScope=${source.loadedScope} state=${source.stateNotifier.value}'); + + final loadTopEntriesFirst = + settings.homeNavItem.route == CollectionPage.routeName && + settings.homeCustomCollection.isEmpty; + + final detach = _attachInitDebug(source, 'INIT'); + + // INIT + final swInit = Stopwatch()..start(); + debugPrint('[INIT] calling source.init(...) loadTopEntriesFirst=$loadTopEntriesFirst'); + await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst); + swInit.stop(); + debugPrint('[INIT] source.init DONE in ${swInit.elapsedMilliseconds}ms all=${source.allEntries.length} vis=${source.visibleEntries.length}'); + + // LOCAL-HYDRATE + try { + final curCount = source.visibleEntries.isNotEmpty ? source.visibleEntries.length : source.allEntries.length; + if (curCount < 50) { + final locals = await localMediaDb.loadEntries(origin: 0); + debugPrint('[LOCAL-HYDRATE] db locals=${locals.length} curCount=$curCount'); + + if (locals.isNotEmpty) { + final existingUris = source.allEntries + .where((e) => e.origin == 0 && !e.trashed) + .map((e) => e.uri) + .whereType() + .toSet(); + + final toAdd = locals.where((e) { + if (e.trashed) return false; + if (!e.isDisplayable) return false; + final u = e.uri; + if (u == null || u.isEmpty) return true; + return !existingUris.contains(u); + }).toSet(); + + if (toAdd.isNotEmpty) { + source.addEntries(toAdd); + debugPrint('[LOCAL-HYDRATE] added=${toAdd.length}'); + } else { + debugPrint('[LOCAL-HYDRATE] nothing to add (duplicates/filtered)'); + } + } + } + } catch (e, st) { + debugPrint('[LOCAL-HYDRATE] error: $e\n$st'); + } + + Future.delayed(const Duration(seconds: 3), detach); + + // Remoti: + if (await _isRemoteBootstrapDone()) { + debugPrint('[REMOTE] append from DB (bootstrap done)'); + await source.appendRemoteEntriesFromDb(); + } else { + debugPrint('[REMOTE] skip append from DB (bootstrap not done)'); + } + + // ✅ scheduling sync remoto: + // - se bootstrap -> aspetta che i LOCALI siano READY, poi avvia bootstrap remoto + // - se non bootstrap -> chiama pure (torna subito) oppure lascia com’è + if (!_remoteSyncScheduled) { + _remoteSyncScheduled = true; + final sourceRef = source; + + if (bootstrap) { + unawaited(() async { + debugPrint('[remote-sync] bootstrap requested -> wait local READY first'); + await _waitSourceReady(sourceRef); + debugPrint('[remote-sync] local READY -> start bootstrap remote'); + await _runRemoteSync(sourceRef, bootstrap: true); + }()); + } else { + unawaited(Future.microtask(() => _runRemoteSync(sourceRef, bootstrap: false))); + } + } + + break; + + case AppMode.screenSaver: + await reportService.log('Initialize source to start screen saver'); + final source2 = context.read(); + source2.canAnalyze = false; + await source2.init(scope: settings.screenSaverCollectionFilters); + break; + + case AppMode.view: + if (_isViewerSourceable(_viewerEntry) && _secureUris == null) { + final directory = _viewerEntry?.directory; + if (directory != null) { + unawaited(AnalysisService.registerCallback()); + await reportService.log('Initialize source to view item in directory $directory'); + final source = context.read(); + source.canAnalyze = true; + await source.init(scope: {StoredAlbumFilter(directory, null)}); + } + } else { + await _initViewerEssentials(); + } + break; + + case AppMode.edit: + case AppMode.setWallpaper: + await _initViewerEssentials(); + break; + + default: + break; + } + + debugPrint('Home setup complete in ${stopwatch.elapsed.inMilliseconds}ms'); + + unawaited( + Navigator.maybeOf(context)?.pushAndRemoveUntil( + await _getRedirectRoute(appMode), + (route) => false, + ), + ); + } catch (error, stack) { + debugPrint('failed to setup app with error=$error\n$stack'); + setState(() => _setupError = (error, stack)); + } + } + + // ============================================================ + // === SYNC REMOTO (Step 2) + // ============================================================ + Future _runRemoteSync(CollectionSource source, {required bool bootstrap}) async { + try { + final rs = await _safeLoadRemoteSettings(); + if (!rs.enabled) { + debugPrint('[remote-sync] disabled → skip'); + return; + } + + if (!bootstrap) { + debugPrint('[remote-sync] bootstrap already done -> skip full sync (until Step 3 delta/ws)'); + return; + } + + _remoteSyncActive = true; + + final items = await _fetchAllRemoteItems(); + final total = items.length; + final serverIds = items.map((e) => e.id).where((v) => v.isNotEmpty).toSet(); + + RemoteSyncBus.instance.start( + phase: 'Agg remoti…', + total: total, + showOverlay: bootstrap, // per punto 2, al bootstrap mostreremo anche contatore + ); + + + final repo = RemoteRepository(localMediaDb.rawDb); + await repo.deleteAllRemotes(); + + const chunkSize = 200; + int done = 0; + + for (var offset = 0; offset < total; offset += chunkSize) { + final end = (offset + chunkSize < total) ? offset + chunkSize : total; + final chunk = items.sublist(offset, end); + + await repo.upsertAll(chunk, chunkSize: chunkSize); + + done = end; + RemoteSyncBus.instance.update(phase: 'Sync remoti…', done: done, total: total); + } + + final pruned = await repo.pruneMissingRemotes(serverIds); + debugPrint('[remote-sync] prune deleted=$pruned'); + + // remoti compaiono ora (bootstrap completato) + await source.appendRemoteEntriesFromDb(); + await _setRemoteBootstrapDone(); + + RemoteSyncBus.instance.finish(phase: 'Remoti aggiornati'); + } catch (e, st) { + debugPrint('[remote-sync] error: $e\n$st'); + RemoteSyncBus.instance.fail(e); + } finally { + _remoteSyncActive = false; + } + } + + Future> _fetchAllRemoteItems() async { + try { + final rs = await _safeLoadRemoteSettings(); + if (!rs.enabled || rs.baseUrl.trim().isEmpty) { + return []; + } + + RemoteAuth? auth; + if (rs.email.isNotEmpty && rs.password.isNotEmpty) { + auth = RemoteAuth(baseUrl: rs.baseUrl, email: rs.email, password: rs.password); + } + + final client = RemoteJsonClient(rs.baseUrl, rs.indexPath, auth: auth); + try { + final items = await client.fetchAll(); + debugPrint('[remote-sync][fetch] fetched ${items.length} items'); + return items; + } catch (e, st) { + debugPrint('[remote-sync][fetch] client.fetchAll ERROR: $e\n$st'); + return []; + } + } catch (e, st) { + debugPrint('[remote-sync][fetch] ERROR: $e\n$st'); + return []; + } + } + + Future _initViewerEssentials() async { + await localMediaDb.init(); + } + + bool _isViewerSourceable(AvesEntry? viewerEntry) { + return viewerEntry != null && + viewerEntry.directory != null && + !settings.hiddenFilters.any((filter) => filter.test(viewerEntry)); + } + + Future _initViewerEntry({required String uri, required String? mimeType}) async { + if (uri.startsWith('/')) { + uri = Uri.file(uri).toString(); + } + final entry = await mediaFetchService.getEntry(uri, mimeType); + if (entry != null) { + await entry.catalog(background: false, force: false, persist: false); + } + return entry; + } + + // === DEBUG: pagina test remoto con DB indipendente === + Future _openRemoteTestPage(BuildContext context) async { + if (_remoteTestOpen) return; + if (_remoteSyncActive) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Attendi fine sync remoto in corso prima di aprire RemoteTest')), + ); + return; + } + + _remoteTestOpen = true; + + Database? debugDb; + try { + final dbDir = await getDatabasesPath(); + final dbPath = p.join(dbDir, 'metadata.db'); + + debugDb = await openDatabase( + dbPath, + singleInstance: false, + onConfigure: (db) async { + await db.rawQuery('PRAGMA journal_mode=WAL'); + await db.rawQuery('PRAGMA foreign_keys=ON'); + }, + ); + if (!context.mounted) return; + + final rs = await _safeLoadRemoteSettings(); + final baseUrl = rs.baseUrl.isNotEmpty ? rs.baseUrl : RemoteSettings.defaultBaseUrl; + + await Navigator.of(context).push(MaterialPageRoute( + builder: (_) => rtp.RemoteTestPage( + db: debugDb!, + baseUrl: baseUrl, + ), + )); + } catch (e, st) { + // ignore: avoid_print + print('[RemoteTest] errore apertura DB/pagina: $e\n$st'); + } finally { + try { + await debugDb?.close(); + } catch (_) {} + _remoteTestOpen = false; + } + } + + Future _getRedirectRoute(AppMode appMode) async { + String routeName; + Set? filters; + + switch (appMode) { + case AppMode.setWallpaper: + return DirectMaterialPageRoute( + settings: const RouteSettings(name: WallpaperPage.routeName), + builder: (_) => WallpaperPage(entry: _viewerEntry), + ); + + case AppMode.view: + AvesEntry viewerEntry = _viewerEntry!; + CollectionLens? collection; + + final source = context.read(); + final album = viewerEntry.directory; + if (album != null) { + final loadingCompleter = Completer(); + final stateNotifier = source.stateNotifier; + void _onSourceStateChanged() { + if (stateNotifier.value != SourceState.loading) { + stateNotifier.removeListener(_onSourceStateChanged); + loadingCompleter.complete(); + } + } + + stateNotifier.addListener(_onSourceStateChanged); + _onSourceStateChanged(); + await loadingCompleter.future; + + collection = CollectionLens( + source: source, + filters: {StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))}, + listenToSource: false, + stackBursts: false, + ); + + final viewerEntryPath = viewerEntry.path; + final collectionEntry = + collection.sortedEntries.firstWhereOrNull((entry) => entry.path == viewerEntryPath); + if (collectionEntry != null) { + viewerEntry = collectionEntry; + } else { + collection = null; + } + } + + return DirectMaterialPageRoute( + settings: const RouteSettings(name: EntryViewerPage.routeName), + builder: (_) => EntryViewerPage(collection: collection, initialEntry: viewerEntry), + ); + + case AppMode.edit: + return DirectMaterialPageRoute( + settings: const RouteSettings(name: EntryViewerPage.routeName), + builder: (_) => ImageEditorPage(entry: _viewerEntry!), + ); + + default: + routeName = _initialRouteName ?? settings.homeNavItem.route; + filters = _initialFilters ?? + (settings.homeNavItem.route == CollectionPage.routeName ? settings.homeCustomCollection : {}); + } + + Route buildRoute(WidgetBuilder builder) => DirectMaterialPageRoute( + settings: RouteSettings(name: routeName), + builder: builder, + ); + + final source = context.read(); + + switch (routeName) { + case AlbumListPage.routeName: + return buildRoute((context) => const AlbumListPage(initialGroup: null)); + case TagListPage.routeName: + return buildRoute((context) => const TagListPage(initialGroup: null)); + case MapPage.routeName: + return buildRoute((context) { + final mapCollection = CollectionLens( + source: source, + filters: { + LocationFilter.located, + if (filters != null) ...filters!, + }, + ); + return MapPage( + collection: mapCollection, + initialLocation: _initialLocationZoom?.$1, + initialZoom: _initialLocationZoom?.$2, + ); + }); + case ExplorerPage.routeName: + final path = _initialExplorerPath ?? settings.homeCustomExplorerPath; + return buildRoute((context) => ExplorerPage(path: path)); + case HomeWidgetSettingsPage.routeName: + return buildRoute((context) => HomeWidgetSettingsPage(widgetId: _widgetId!)); + case ScreenSaverPage.routeName: + return buildRoute((context) => ScreenSaverPage(source: source)); + case ScreenSaverSettingsPage.routeName: + return buildRoute((context) => const ScreenSaverSettingsPage()); + case SearchPage.routeName: + return SearchPageRoute( + delegate: CollectionSearchDelegate( + searchFieldLabel: context.l10n.searchCollectionFieldHint, + searchFieldStyle: Themes.searchFieldStyle(context), + source: source, + canPop: false, + initialQuery: _initialSearchQuery, + ), + ); + case CollectionPage.routeName: + default: + return buildRoute((context) => CollectionPage(source: source, filters: filters)); + } + } + + Future _safeLoadRemoteSettings({Duration timeout = const Duration(seconds: 5)}) async { + try { + return await RemoteSettings.load().timeout(timeout); + } catch (e) { + debugPrint('[remote] RemoteSettings.load failed: $e — using defaults'); + return RemoteSettings( + enabled: RemoteSettings.defaultEnabled, + baseUrl: RemoteSettings.defaultBaseUrl, + indexPath: RemoteSettings.defaultIndexPath, + email: RemoteSettings.defaultEmail, + password: RemoteSettings.defaultPassword, + ); + } + } + + Future> _safeHeaders({Duration timeout = const Duration(seconds: 6)}) async { + try { + return await RemoteHttp.headers().timeout(timeout); + } catch (e) { + debugPrint('[remote] RemoteHttp.headers failed: $e — returning empty headers'); + return const {}; + } + } +} diff --git a/lib/widgets/home/home_page.dart.save b/lib/widgets/home/home_page.dart.super_ok similarity index 57% rename from lib/widgets/home/home_page.dart.save rename to lib/widgets/home/home_page.dart.super_ok index e26a8817..26a78046 100644 --- a/lib/widgets/home/home_page.dart.save +++ b/lib/widgets/home/home_page.dart.super_ok @@ -1,6 +1,7 @@ // lib/widgets/home/home_page.dart import 'dart:async'; +import 'package:aves/remote/collection_source_remote_ext.dart'; import 'package:aves/app_mode.dart'; import 'package:aves/geo/uri.dart'; import 'package:aves/model/app/intent.dart'; @@ -46,17 +47,24 @@ import 'package:latlong2/latlong.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; -// --- IMPORT aggiunti per integrazione remota (Fase 1) --- +// --- REMOTO / DEBUG --- import 'package:flutter/foundation.dart' show kDebugMode; import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart' as p; -import 'package:aves/remote/remote_test_page.dart'; -import 'package:aves/remote/run_remote_sync.dart'; +import 'package:aves/remote/remote_test_page.dart' as rtp; import 'package:aves/remote/remote_settings.dart'; +import 'package:aves/remote/remote_http.dart'; +import 'package:aves/remote/remote_models.dart'; +import 'package:aves/remote/remote_client.dart'; +import 'package:aves/remote/auth_client.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +// Step 2: progress bus + repository +import 'package:aves/remote/remote_sync_bus.dart'; +import 'package:aves/remote/remote_repository.dart'; class HomePage extends StatefulWidget { static const routeName = '/'; - // untyped map as it is coming from the platform final Map? intentData; const HomePage({ @@ -78,6 +86,10 @@ class _HomePageState extends State { List? _secureUris; (Object, StackTrace)? _setupError; + bool _remoteSyncScheduled = false; + bool _remoteSyncActive = false; + bool _remoteTestOpen = false; + static const allowedShortcutRoutes = [ AlbumListPage.routeName, CollectionPage.routeName, @@ -103,13 +115,68 @@ class _HomePageState extends State { : null, ); + // ============================================================ + // BOOTSTRAP FLAG (Remote progress ONLY first time) + // ============================================================ + Future _isRemoteBootstrapDone() async { + final storage = FlutterSecureStorage(); + final v = await storage.read(key: 'remote_bootstrap_done'); + return v == '1'; + } + + Future _setRemoteBootstrapDone() async { + final storage = FlutterSecureStorage(); + await storage.write(key: 'remote_bootstrap_done', value: '1'); + } + + // ============================================================ + // INIT DEBUG (optional): SourceState + polling entry counts (3s) + // ============================================================ + VoidCallback _attachInitDebug(CollectionSource source, String label) { + final sw = Stopwatch()..start(); + int lastAll = -1; + int lastVis = -1; + + void logState() { + debugPrint( + '[$label] state=${source.stateNotifier.value} ' + 't=${sw.elapsedMilliseconds}ms ' + 'all=${source.allEntries.length} vis=${source.visibleEntries.length} ' + 'loadedScope=${source.loadedScope}', + ); + } + + void pollCounts() { + final all = source.allEntries.length; + final vis = source.visibleEntries.length; + if (all != lastAll || vis != lastVis) { + lastAll = all; + lastVis = vis; + debugPrint('[$label] CHANGE t=${sw.elapsedMilliseconds}ms all=$all vis=$vis state=${source.stateNotifier.value}'); + } + } + + debugPrint('[$label] attach listeners'); + logState(); + pollCounts(); + + source.stateNotifier.addListener(logState); + final timer = Timer.periodic(const Duration(milliseconds: 100), (_) => pollCounts()); + + return () { + timer.cancel(); + try { + source.stateNotifier.removeListener(logState); + } catch (_) {} + debugPrint('[$label] detach listeners at t=${sw.elapsedMilliseconds}ms'); + }; + } + Future _setup() async { try { final stopwatch = Stopwatch()..start(); if (await windowService.isActivity()) { - // do not check whether permission was granted, because some app stores - // hide in some countries apps that force quit on permission denial await Permissions.mediaAccess.request(); } @@ -126,6 +193,16 @@ class _HomePageState extends State { await availability.onNewIntent(); await androidFileUtils.init(); + // Warm-up header remoti (non blocca UI) + unawaited(Future(() async { + try { + final s = await _safeLoadRemoteSettings(); + if (s.enabled && s.baseUrl.trim().isNotEmpty) { + await _safeHeaders(); + } + } catch (_) {} + })); + if (!{ IntentActions.edit, IntentActions.screenSaver, @@ -161,8 +238,6 @@ class _HomePageState extends State { case IntentActions.setWallpaper: appMode = AppMode.setWallpaper; case IntentActions.pickItems: - // TODO TLAD apply pick mimetype(s) - // some apps define multiple types, separated by a space (maybe other signs too, like `,` `;`?) final multiple = (intentData[IntentDataKeys.allowMultiple] as bool?) ?? false; debugPrint('pick mimeType=$intentMimeType multiple=$multiple'); appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal; @@ -184,7 +259,6 @@ class _HomePageState extends State { if (widgetId == null) { error = true; } else { - // widget settings may be modified in a different process after channel setup await settings.reload(); final page = settings.getWidgetOpenPage(widgetId); switch (page) { @@ -200,7 +274,6 @@ class _HomePageState extends State { unawaited(WidgetService.update(widgetId)); } default: - // do not use 'route' as extra key, as the Flutter framework acts on it final extraRoute = intentData[IntentDataKeys.page] as String?; if (allowedShortcutRoutes.contains(extraRoute)) { _initialRouteName = extraRoute; @@ -219,10 +292,7 @@ class _HomePageState extends State { case AppMode.edit: case AppMode.setWallpaper: if (intentUri != null) { - _viewerEntry = await _initViewerEntry( - uri: intentUri, - mimeType: intentMimeType, - ); + _viewerEntry = await _initViewerEntry(uri: intentUri, mimeType: intentMimeType); } error = _viewerEntry == null; default: @@ -245,42 +315,109 @@ class _HomePageState extends State { case AppMode.pickMultipleMediaExternal: unawaited(GlobalSearch.registerCallback()); unawaited(AnalysisService.registerCallback()); + final source = context.read(); - if (source.loadedScope != CollectionSource.fullScope) { - await reportService.log('Initialize source to start app with mode=$appMode, loaded scope=${source.loadedScope}'); - final loadTopEntriesFirst = - settings.homeNavItem.route == CollectionPage.routeName && settings.homeCustomCollection.isEmpty; - source.canAnalyze = true; - await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst); + + // ========================================================= + // MAIN INIT + LOCAL-HYDRATE + REMOTE APPEND/SYNC + // ========================================================= + + // cache DB? + bool hasAnyCache = false; + try { + await localMediaDb.init(); + final rows = await localMediaDb.rawDb.rawQuery( + 'SELECT 1 FROM entry WHERE trashed=0 LIMIT 1', + ); + hasAnyCache = rows.isNotEmpty; + } catch (_) {} + + final bootstrapDone = await _isRemoteBootstrapDone(); + final bootstrap = !bootstrapDone; + + debugPrint('[BOOT] hasAnyCache=$hasAnyCache bootstrapDone=$bootstrapDone bootstrap=$bootstrap ' + 'loadedScope=${source.loadedScope} state=${source.stateNotifier.value}'); + + final loadTopEntriesFirst = + settings.homeNavItem.route == CollectionPage.routeName && + settings.homeCustomCollection.isEmpty; + + final detach = _attachInitDebug(source, 'INIT'); + + // INIT (serve per inizializzare strutture interne) + final swInit = Stopwatch()..start(); + debugPrint('[INIT] calling source.init(...) loadTopEntriesFirst=$loadTopEntriesFirst'); + await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst); + swInit.stop(); + debugPrint('[INIT] source.init DONE in ${swInit.elapsedMilliseconds}ms all=${source.allEntries.length} vis=${source.visibleEntries.length}'); + + // --------------------------------------------------------- + // ✅ LOCAL-HYDRATE (key feature): mostra subito i locali dal DB + // --------------------------------------------------------- + // Se dopo init la Source è ancora vuota/quasi vuota, iniettiamo cache DB origin=0. + // (Il tuo DB ha origin=0 -> 6957, quindi qui la galleria diventa istantanea.) + try { + final curCount = source.visibleEntries.isNotEmpty ? source.visibleEntries.length : source.allEntries.length; + if (curCount < 50) { + final locals = await localMediaDb.loadEntries(origin: 0); // Set nella tua codebase + debugPrint('[LOCAL-HYDRATE] db locals=${locals.length} curCount=$curCount'); + + if (locals.isNotEmpty) { + final existingUris = source.allEntries + .where((e) => e.origin == 0 && !e.trashed) + .map((e) => e.uri) + .whereType() + .toSet(); + + final toAdd = locals.where((e) { + if (e.trashed) return false; + if (!e.isDisplayable) return false; + final u = e.uri; + if (u == null || u.isEmpty) return true; + return !existingUris.contains(u); + }).toSet(); + + if (toAdd.isNotEmpty) { + source.addEntries(toAdd); + debugPrint('[LOCAL-HYDRATE] added=${toAdd.length}'); + } else { + debugPrint('[LOCAL-HYDRATE] nothing to add (duplicates/filtered)'); + } + } + } + } catch (e, st) { + debugPrint('[LOCAL-HYDRATE] error: $e\n$st'); } - // === FASE 1: SYNC REMOTO POST-INIT (non blocca la UI) === - // In DEBUG facciamo prima un seed dei setting se sono vuoti. - unawaited(Future(() async { - try { - await RemoteSettings.debugSeedIfEmpty(); - final rs = await RemoteSettings.load(); - if (!rs.enabled) return; + // stop debug logs after 3s + Future.delayed(const Duration(seconds: 3), detach); - final dbDir = await getDatabasesPath(); - final dbPath = p.join(dbDir, 'metadata.db'); - final db = await openDatabase(dbPath); - try { - // Prende baseUrl/index/email/pw da RemoteSettings - await runRemoteSyncOnce(db: db); - } finally { - await db.close(); - } - } catch (e, st) { - debugPrint('[remote-sync] error: $e\n$st'); - } - })); + // Remoti: + // - bootstrap done -> mostra subito dal DB + // - bootstrap not done -> compariranno alla fine del bootstrap sync + if (await _isRemoteBootstrapDone()) { + debugPrint('[REMOTE] append from DB (bootstrap done)'); + await source.appendRemoteEntriesFromDb(); + } else { + debugPrint('[REMOTE] skip append from DB (bootstrap not done)'); + } + + // schedule remote sync once + if (!_remoteSyncScheduled) { + _remoteSyncScheduled = true; + final sourceRef = source; + unawaited(Future.microtask(() => _runRemoteSync(sourceRef, bootstrap: bootstrap))); + } + + break; case AppMode.screenSaver: await reportService.log('Initialize source to start screen saver'); final source2 = context.read(); source2.canAnalyze = false; await source2.init(scope: settings.screenSaverCollectionFilters); + break; + case AppMode.view: if (_isViewerSourceable(_viewerEntry) && _secureUris == null) { final directory = _viewerEntry?.directory; @@ -288,24 +425,25 @@ class _HomePageState extends State { unawaited(AnalysisService.registerCallback()); await reportService.log('Initialize source to view item in directory $directory'); final source = context.read(); - // analysis is necessary to display neighbour items when the initial item is a new one source.canAnalyze = true; await source.init(scope: {StoredAlbumFilter(directory, null)}); } } else { await _initViewerEssentials(); } + break; + case AppMode.edit: case AppMode.setWallpaper: await _initViewerEssentials(); + break; + default: break; } debugPrint('Home setup complete in ${stopwatch.elapsed.inMilliseconds}ms'); - // `pushReplacement` is not enough in some edge cases - // e.g. when opening the viewer in `view` mode should replace a viewer in `main` mode unawaited( Navigator.maybeOf(context)?.pushAndRemoveUntil( await _getRedirectRoute(appMode), @@ -318,8 +456,93 @@ class _HomePageState extends State { } } + // ============================================================ + // === SYNC REMOTO (Step 2) + // - full sync SOLO bootstrap + // - progress bar SOLO bootstrap + // - remoti visibili SOLO dopo bootstrap completato + // ============================================================ + Future _runRemoteSync(CollectionSource source, {required bool bootstrap}) async { + try { + final rs = await _safeLoadRemoteSettings(); + if (!rs.enabled) { + debugPrint('[remote-sync] disabled → skip'); + return; + } + + if (!bootstrap) { + debugPrint('[remote-sync] bootstrap already done -> skip full sync (until Step 3 delta/ws)'); + return; + } + + _remoteSyncActive = true; + + final items = await _fetchAllRemoteItems(); + final total = items.length; + final serverIds = items.map((e) => e.id).where((v) => v.isNotEmpty).toSet(); + + RemoteSyncBus.instance.start(phase: 'Sync remoto…', total: total); + + final repo = RemoteRepository(localMediaDb.rawDb); + await repo.deleteAllRemotes(); + + const chunkSize = 200; + int done = 0; + + for (var offset = 0; offset < total; offset += chunkSize) { + final end = (offset + chunkSize < total) ? offset + chunkSize : total; + final chunk = items.sublist(offset, end); + + await repo.upsertAll(chunk, chunkSize: chunkSize); + + done = end; + RemoteSyncBus.instance.update(phase: 'Sync remoto…', done: done, total: total); + } + + final pruned = await repo.pruneMissingRemotes(serverIds); + debugPrint('[remote-sync] prune deleted=$pruned'); + + // remoti compaiono ora (bootstrap completato) + await source.appendRemoteEntriesFromDb(); + await _setRemoteBootstrapDone(); + + RemoteSyncBus.instance.finish(); + } catch (e, st) { + debugPrint('[remote-sync] error: $e\n$st'); + RemoteSyncBus.instance.clear(); + } finally { + _remoteSyncActive = false; + } + } + + Future> _fetchAllRemoteItems() async { + try { + final rs = await _safeLoadRemoteSettings(); + if (!rs.enabled || rs.baseUrl.trim().isEmpty) { + return []; + } + + RemoteAuth? auth; + if (rs.email.isNotEmpty && rs.password.isNotEmpty) { + auth = RemoteAuth(baseUrl: rs.baseUrl, email: rs.email, password: rs.password); + } + + final client = RemoteJsonClient(rs.baseUrl, rs.indexPath, auth: auth); + try { + final items = await client.fetchAll(); + debugPrint('[remote-sync][fetch] fetched ${items.length} items'); + return items; + } catch (e, st) { + debugPrint('[remote-sync][fetch] client.fetchAll ERROR: $e\n$st'); + return []; + } + } catch (e, st) { + debugPrint('[remote-sync][fetch] ERROR: $e\n$st'); + return []; + } + } + Future _initViewerEssentials() async { - // for video playback storage await localMediaDb.init(); } @@ -331,32 +554,47 @@ class _HomePageState extends State { Future _initViewerEntry({required String uri, required String? mimeType}) async { if (uri.startsWith('/')) { - // convert this file path to a proper URI uri = Uri.file(uri).toString(); } final entry = await mediaFetchService.getEntry(uri, mimeType); if (entry != null) { - // cataloguing is essential for coordinates and video rotation await entry.catalog(background: false, force: false, persist: false); } return entry; } - // === DEBUG: apre la pagina di test remota con una seconda connessione al DB === + // === DEBUG: pagina test remoto con DB indipendente === Future _openRemoteTestPage(BuildContext context) async { + if (_remoteTestOpen) return; + if (_remoteSyncActive) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Attendi fine sync remoto in corso prima di aprire RemoteTest')), + ); + return; + } + + _remoteTestOpen = true; + Database? debugDb; try { final dbDir = await getDatabasesPath(); final dbPath = p.join(dbDir, 'metadata.db'); - // Apri il DB in sola lettura (evita lock e conflitti) - debugDb = await openDatabase(dbPath, readOnly: true); + + debugDb = await openDatabase( + dbPath, + singleInstance: false, + onConfigure: (db) async { + await db.rawQuery('PRAGMA journal_mode=WAL'); + await db.rawQuery('PRAGMA foreign_keys=ON'); + }, + ); if (!context.mounted) return; - final rs = await RemoteSettings.load(); + final rs = await _safeLoadRemoteSettings(); final baseUrl = rs.baseUrl.isNotEmpty ? rs.baseUrl : RemoteSettings.defaultBaseUrl; await Navigator.of(context).push(MaterialPageRoute( - builder: (_) => RemoteTestPage( + builder: (_) => rtp.RemoteTestPage( db: debugDb!, baseUrl: baseUrl, ), @@ -364,146 +602,14 @@ class _HomePageState extends State { } catch (e, st) { // ignore: avoid_print print('[RemoteTest] errore apertura DB/pagina: $e\n$st'); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Errore RemoteTest: $e')), - ); } finally { try { await debugDb?.close(); } catch (_) {} + _remoteTestOpen = false; } } - // === DEBUG: dialog impostazioni remote (semplice) === - Future _openRemoteSettingsDialog(BuildContext context) async { - final s = await RemoteSettings.load(); - final formKey = GlobalKey(); - bool enabled = s.enabled; - final baseUrlC = TextEditingController(text: s.baseUrl); - final indexC = TextEditingController(text: s.indexPath); - final emailC = TextEditingController(text: s.email); - final pwC = TextEditingController(text: s.password); - - await showDialog( - context: context, - builder: (_) => AlertDialog( - title: const Text('Remote Settings'), - content: Form( - key: formKey, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SwitchListTile( - title: const Text('Abilita sync remoto'), - value: enabled, - onChanged: (v) { - enabled = v; - }, - contentPadding: EdgeInsets.zero, - ), - const SizedBox(height: 8), - TextFormField( - controller: baseUrlC, - decoration: const InputDecoration( - labelText: 'Base URL', - hintText: 'https://prova.patachina.it', - ), - validator: (v) => (v == null || v.isEmpty) ? 'Obbligatorio' : null, - ), - const SizedBox(height: 8), - TextFormField( - controller: indexC, - decoration: const InputDecoration( - labelText: 'Index path', - hintText: 'photos/', - ), - validator: (v) => (v == null || v.isEmpty) ? 'Obbligatorio' : null, - ), - const SizedBox(height: 8), - TextFormField( - controller: emailC, - decoration: const InputDecoration(labelText: 'User/Email'), - ), - const SizedBox(height: 8), - TextFormField( - controller: pwC, - obscureText: true, - decoration: const InputDecoration(labelText: 'Password'), - ), - ], - ), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).maybePop(), - child: const Text('Annulla'), - ), - ElevatedButton.icon( - onPressed: () async { - if (!formKey.currentState!.validate()) return; - final upd = RemoteSettings( - enabled: enabled, - baseUrl: baseUrlC.text.trim(), - indexPath: indexC.text.trim(), - email: emailC.text.trim(), - password: pwC.text, - ); - await upd.save(); - if (context.mounted) Navigator.of(context).pop(); - if (context.mounted) { - ScaffoldMessenger.of(context) - .showSnackBar(const SnackBar(content: Text('Impostazioni salvate'))); - } - }, - icon: const Icon(Icons.save), - label: const Text('Salva'), - ), - ], - ), - ); - - baseUrlC.dispose(); - indexC.dispose(); - emailC.dispose(); - pwC.dispose(); - } - - // --- DEBUG: wrapper che aggiunge 2 FAB (Settings + Remote Test) --- - Widget _wrapWithRemoteDebug(BuildContext context, Widget child) { - if (!kDebugMode) return child; - return Stack( - children: [ - child, - Positioned( - right: 16, - bottom: 16, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - FloatingActionButton( - heroTag: 'remote_debug_settings_fab', - mini: true, - onPressed: () => _openRemoteSettingsDialog(context), - tooltip: 'Remote Settings', - child: const Icon(Icons.settings), - ), - const SizedBox(height: 12), - FloatingActionButton( - heroTag: 'remote_debug_test_fab', - onPressed: () => _openRemoteTestPage(context), - tooltip: 'Remote Test', - child: const Icon(Icons.image_search), - ), - ], - ), - ), - ], - ); - } - Future _getRedirectRoute(AppMode appMode) async { String routeName; Set? filters; @@ -512,19 +618,16 @@ class _HomePageState extends State { case AppMode.setWallpaper: return DirectMaterialPageRoute( settings: const RouteSettings(name: WallpaperPage.routeName), - builder: (_) { - return WallpaperPage( - entry: _viewerEntry, - ); - }, + builder: (_) => WallpaperPage(entry: _viewerEntry), ); + case AppMode.view: AvesEntry viewerEntry = _viewerEntry!; CollectionLens? collection; + final source = context.read(); final album = viewerEntry.directory; if (album != null) { - // wait for collection to pass the `loading` state final loadingCompleter = Completer(); final stateNotifier = source.stateNotifier; void _onSourceStateChanged() { @@ -542,9 +645,6 @@ class _HomePageState extends State { source: source, filters: {StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))}, listenToSource: false, - // if we group bursts, opening a burst sub-entry should: - // - identify and select the containing main entry, - // - select the sub-entry in the Viewer page. stackBursts: false, ); @@ -554,39 +654,22 @@ class _HomePageState extends State { if (collectionEntry != null) { viewerEntry = collectionEntry; } else { - debugPrint('collection does not contain viewerEntry=$viewerEntry'); collection = null; } } + return DirectMaterialPageRoute( settings: const RouteSettings(name: EntryViewerPage.routeName), - builder: (_) { - return EntryViewerPage( - collection: collection, - initialEntry: viewerEntry, - ); - }, + builder: (_) => EntryViewerPage(collection: collection, initialEntry: viewerEntry), ); + case AppMode.edit: return DirectMaterialPageRoute( settings: const RouteSettings(name: EntryViewerPage.routeName), - builder: (_) { - return ImageEditorPage( - entry: _viewerEntry!, - ); - }, + builder: (_) => ImageEditorPage(entry: _viewerEntry!), ); - case AppMode.initialization: - case AppMode.main: - case AppMode.pickCollectionFiltersExternal: - case AppMode.pickSingleMediaExternal: - case AppMode.pickMultipleMediaExternal: - case AppMode.pickFilteredMediaInternal: - case AppMode.pickUnfilteredMediaInternal: - case AppMode.pickFilterInternal: - case AppMode.previewMap: - case AppMode.screenSaver: - case AppMode.slideshow: + + default: routeName = _initialRouteName ?? settings.homeNavItem.route; filters = _initialFilters ?? (settings.homeNavItem.route == CollectionPage.routeName ? settings.homeCustomCollection : {}); @@ -610,7 +693,7 @@ class _HomePageState extends State { source: source, filters: { LocationFilter.located, - if (filters != null) ...filters, + if (filters != null) ...filters!, }, ); return MapPage( @@ -640,13 +723,31 @@ class _HomePageState extends State { ); case CollectionPage.routeName: default: - // <<--- Wrapper di debug che aggiunge i due FAB (solo in debug) - return buildRoute( - (context) => _wrapWithRemoteDebug( - context, - CollectionPage(source: source, filters: filters), - ), - ); + return buildRoute((context) => CollectionPage(source: source, filters: filters)); + } + } + + Future _safeLoadRemoteSettings({Duration timeout = const Duration(seconds: 5)}) async { + try { + return await RemoteSettings.load().timeout(timeout); + } catch (e) { + debugPrint('[remote] RemoteSettings.load failed: $e — using defaults'); + return RemoteSettings( + enabled: RemoteSettings.defaultEnabled, + baseUrl: RemoteSettings.defaultBaseUrl, + indexPath: RemoteSettings.defaultIndexPath, + email: RemoteSettings.defaultEmail, + password: RemoteSettings.defaultPassword, + ); + } + } + + Future> _safeHeaders({Duration timeout = const Duration(seconds: 6)}) async { + try { + return await RemoteHttp.headers().timeout(timeout); + } catch (e) { + debugPrint('[remote] RemoteHttp.headers failed: $e — returning empty headers'); + return const {}; } } } diff --git a/metadata.db b/metadata.db index a8477ae7..57e036a1 100644 Binary files a/metadata.db and b/metadata.db differ