super
Some checks are pending
Quality check / Flutter analysis (push) Waiting to run
Quality check / CodeQL analysis (java-kotlin) (push) Waiting to run

This commit is contained in:
FabioMich66 2026-04-10 10:07:04 +02:00
parent 084fa184da
commit deb7b4c6dd
42 changed files with 7061 additions and 2222 deletions

BIN
aves10.zip Normal file

Binary file not shown.

BIN
aves12.apk Normal file

Binary file not shown.

BIN
aves12.zip Normal file

Binary file not shown.

BIN
aves13.apk Normal file

Binary file not shown.

BIN
aves13d.apk Normal file

Binary file not shown.

View file

@ -208,9 +208,35 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
updateTags(); updateTags();
} }
void addEntries(Set<AvesEntry> entries, {bool notify = true}) { void addEntries(Set<AvesEntry> entries, {bool notify = true}) {
if (entries.isEmpty) return; if (entries.isEmpty) return;
// DEDUPE per URI (evita raddoppi quando lo stesso media entra con ID diverso)
final newUris = entries
.map((e) => e.uri)
.whereType<String>()
.where((u) => u.isNotEmpty)
.toSet();
if (newUris.isNotEmpty && _rawEntries.isNotEmpty) {
final removedByUri = <AvesEntry>{};
_rawEntries.removeWhere((entry) {
final u = entry.uri;
final match = u != null && newUris.contains(u);
if (match) removedByUri.add(entry);
return match;
});
// 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))); final newIdMapEntries = Map.fromEntries(entries.map((entry) => MapEntry(entry.id, entry)));
if (_rawEntries.isNotEmpty) { if (_rawEntries.isNotEmpty) {
final newIds = newIdMapEntries.keys.toSet(); final newIds = newIdMapEntries.keys.toSet();
@ -225,11 +251,27 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
_rawEntries.addAll(entries); _rawEntries.addAll(entries);
_invalidate(entries: entries, notify: notify); _invalidate(entries: entries, notify: notify);
addDirectories(albums: _applyHiddenFilters(entries).map((entry) => entry.directory).toSet(), notify: notify); addDirectories(
albums: _applyHiddenFilters(entries).map((entry) => entry.directory).toSet(),
notify: notify,
);
if (notify) { if (notify) {
eventBus.fire(EntryAddedEvent(entries)); eventBus.fire(EntryAddedEvent(entries));
} }
}
void removeEntriesFromMemory(Set<AvesEntry> 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<void> removeEntries(Set<String> uris, {required bool includeTrash}) async { Future<void> removeEntries(Set<String> uris, {required bool includeTrash}) async {
if (uris.isEmpty) return; if (uris.isEmpty) return;

View file

@ -1,3 +1,4 @@
// lib/model/source/collection_source.dart
import 'dart:async'; import 'dart:async';
import 'dart:ui'; import 'dart:ui';
@ -40,6 +41,9 @@ import 'package:leak_tracker/leak_tracker.dart';
typedef SourceScope = Set<CollectionFilter>?; typedef SourceScope = Set<CollectionFilter>?;
// Trace opzionale: mostra chi nasconderebbe i remoti (non li nascondiamo)
const bool kTraceHiddenRemotes = true;
mixin SourceBase { mixin SourceBase {
EventBus get eventBus; EventBus get eventBus;
@ -156,7 +160,22 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
TrashFilter.instance, TrashFilter.instance,
..._getAppHiddenFilters(), ..._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<AvesEntry> _applyTrashFilter(Iterable<AvesEntry> entries) { Iterable<AvesEntry> _applyTrashFilter(Iterable<AvesEntry> entries) {
@ -189,9 +208,35 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
updateTags(); updateTags();
} }
void addEntries(Set<AvesEntry> entries, {bool notify = true}) { void addEntries(Set<AvesEntry> entries, {bool notify = true}) {
if (entries.isEmpty) return; if (entries.isEmpty) return;
// DEDUPE per URI (evita raddoppi quando lo stesso media entra con ID diverso)
final newUris = entries
.map((e) => e.uri)
.whereType<String>()
.where((u) => u.isNotEmpty)
.toSet();
if (newUris.isNotEmpty && _rawEntries.isNotEmpty) {
final removedByUri = <AvesEntry>{};
_rawEntries.removeWhere((entry) {
final u = entry.uri;
final match = u != null && newUris.contains(u);
if (match) removedByUri.add(entry);
return match;
});
// 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))); final newIdMapEntries = Map.fromEntries(entries.map((entry) => MapEntry(entry.id, entry)));
if (_rawEntries.isNotEmpty) { if (_rawEntries.isNotEmpty) {
final newIds = newIdMapEntries.keys.toSet(); final newIds = newIdMapEntries.keys.toSet();
@ -206,11 +251,14 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
_rawEntries.addAll(entries); _rawEntries.addAll(entries);
_invalidate(entries: entries, notify: notify); _invalidate(entries: entries, notify: notify);
addDirectories(albums: _applyHiddenFilters(entries).map((entry) => entry.directory).toSet(), notify: notify); addDirectories(
albums: _applyHiddenFilters(entries).map((entry) => entry.directory).toSet(),
notify: notify,
);
if (notify) { if (notify) {
eventBus.fire(EntryAddedEvent(entries)); eventBus.fire(EntryAddedEvent(entries));
} }
} }
Future<void> removeEntries(Set<String> uris, {required bool includeTrash}) async { Future<void> removeEntries(Set<String> uris, {required bool includeTrash}) async {
if (uris.isEmpty) return; 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 // 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<void> 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<void> _moveEntry(AvesEntry entry, Map newFields, {required bool persist}) async { Future<void> _moveEntry(AvesEntry entry, Map newFields, {required bool persist}) async {
newFields.keys.forEach((key) { newFields.keys.forEach((key) {
final newValue = newFields[key]; final newValue = newFields[key];

View file

@ -1,5 +1,3 @@
// lib/model/source/media_store_source.dart
import 'dart:async'; import 'dart:async';
import 'package:aves/model/covers.dart'; 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:aves_model/aves_model.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.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; const int ORIGIN_REMOTE = 1;
class MediaStoreSource extends CollectionSource { class MediaStoreSource extends CollectionSource {
@ -98,31 +96,13 @@ class MediaStoreSource extends CollectionSource {
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
state = SourceState.loading; state = SourceState.loading;
// STEP A: come Aves originale pulizia SOLO in memoria, NON nel DB
clearEntries();
final scopeAlbumFilters = _targetScope?.whereType<StoredAlbumFilter>(); final scopeAlbumFilters = _targetScope?.whereType<StoredAlbumFilter>();
final scopeDirectory = final scopeDirectory =
scopeAlbumFilters != null && scopeAlbumFilters.length == 1 ? scopeAlbumFilters.first.album : null; 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<AvesEntry> topEntries = {}; final Set<AvesEntry> topEntries = {};
if (loadTopEntriesFirst) { if (loadTopEntriesFirst) {
final topIds = settings.topEntryIds?.toSet(); final topIds = settings.topEntryIds?.toSet();
@ -424,6 +404,8 @@ class MediaStoreSource extends CollectionSource {
_lastGeneration = await mediaStoreService.getGeneration(); _lastGeneration = await mediaStoreService.getGeneration();
} }
// vault
Future<void> _loadVaultEntries(String? directory) async { Future<void> _loadVaultEntries(String? directory) async {
addEntries(await localMediaDb.loadEntries(origin: EntryOrigins.vault, directory: directory)); addEntries(await localMediaDb.loadEntries(origin: EntryOrigins.vault, directory: directory));
} }

View file

@ -1,25 +1,64 @@
// lib/remote/collection_source_remote_ext.dart // 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/model/source/collection_source.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
extension CollectionSourceRemoteExt on CollectionSource { extension CollectionSourceRemoteExt on CollectionSource {
/// Carica dal DB tutte le entry remote (origin=1) non cestinate /// 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<void> appendRemoteEntriesFromDb() async { Future<void> appendRemoteEntriesFromDb() async {
// 1) carica dal DB // 1) carica dal DB (qui è Set nella tua base di codice)
final remoti = await localMediaDb.loadEntries(origin: 1); final Set<AvesEntry> remoti = await localMediaDb.loadEntries(origin: 1);
debugPrint('[remote-append] candidati=${remoti.length}'); debugPrint('[remote-append] candidati=${remoti.length}');
// 2) filtra visibili (!!! booleano, NON e.trashed == 0) // 2) filtra visibili
final visibili = remoti.where((e) => !e.trashed).toSet(); final Iterable<AvesEntry> visibili = remoti.where((e) => !e.trashed);
debugPrint('[remote-append] visibili=${visibili.length}'); final int visCount = visibili.length;
debugPrint('[remote-append] visibili=$visCount');
// 3) chiavi già presenti nella Source (per evitare doppioni in memoria)
final Set<String> existingRemoteIds = allEntries
.where((e) => e.origin == 1 && !e.trashed)
.map((e) => e.remoteId)
.whereType<String>()
.toSet();
final Set<String> existingUris = allEntries
.where((e) => e.origin == 1 && !e.trashed)
.map((e) => e.uri)
.whereType<String>()
.toSet();
// 4) dedupe deterministica dentro il batch per remoteId/uri
final Map<String, AvesEntry> byKey = <String, AvesEntry>{};
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<AvesEntry> toAdd = <AvesEntry>{};
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; 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; final dopo = allEntries.where((e) => e.origin == 1 && !e.trashed).length;
debugPrint('[remote-append] appese=${dopo - prima} (prima=$prima -> dopo=$dopo)'); debugPrint('[remote-append] appese=${dopo - prima} (prima=$prima -> dopo=$dopo)');
} }
} }

View file

@ -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<void> 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)');
}
}

View file

@ -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<void> 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<void> 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)');
}
}

View file

@ -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<bool> bootstrapDone() async {
final storage = FlutterSecureStorage();
return (await storage.read(key: _kBootstrapDone)) == '1';
}
Future<void> _setBootstrapDone() async {
final storage = FlutterSecureStorage();
await storage.write(key: _kBootstrapDone, value: '1');
}
/// Chiamare allavvio: imposta lo stato icona (grigio/verde) coerente con settings.
Future<void> 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 davvio 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<void> 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<void> 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<void> 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;
}
}
}

View file

@ -1,35 +1,290 @@
// lib/remote/remote_image_tile.dart // lib/remote/remote_image_tile.dart
import 'package:flutter/material.dart'; import 'dart:ui' show FontFeature;
import 'remote_http.dart';
import 'package:aves/model/entry/entry.dart';
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; 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<String>? 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<RemoteImageTile> createState() => _RemoteImageTileState();
}
class _RemoteImageTileState extends State<RemoteImageTile> {
late Future<Map<String, String>> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Usa SOLO campi remoti, mai entry.path final entry = widget.entry;
final rel = entry.remoteThumb2 ?? entry.remoteThumb1 ?? entry.remotePath;
if (rel == null || rel.isEmpty) {
return const ColoredBox(color: Colors.black12);
}
final url = RemoteHttp.absUrl(rel);
return FutureBuilder<Map<String, String>>( final rel = entry.remoteThumb2 ?? entry.remoteThumb1 ?? entry.remotePath ?? entry.path;
future: RemoteHttp.headers(), 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<Map<String, String>>(
future: _headersFuture,
builder: (context, snap) { builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) { if (snap.connectionState != ConnectionState.done) {
return const ColoredBox(color: Colors.black12); return _frame(context, const ColoredBox(color: Colors.black12), showProgress: true);
} }
final hdrs = snap.data ?? const {}; final hdrs = snap.data ?? const {};
return Image.network(
url, // ImageProvider canonico (serve anche per precache)
fit: BoxFit.cover, final provider = NetworkImage(url, headers: hdrs.isEmpty ? null : hdrs);
headers: hdrs.isEmpty ? null : hdrs,
errorBuilder: (_, __, ___) => const Icon(Icons.broken_image), // 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),
),
),
); );
} }
} }

View file

@ -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<Map<String, String>>(
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),
),
),
);
}
}

View file

@ -43,10 +43,13 @@ class RemotePhotoItem {
// Costruzione URL assoluto delegata a utility (in base alle impostazioni) // Costruzione URL assoluto delegata a utility (in base alle impostazioni)
String absoluteUrl(String baseUrl) => buildAbsoluteUri(baseUrl, path).toString(); String absoluteUrl(String baseUrl) => buildAbsoluteUri(baseUrl, path).toString();
static DateTime? _tryParseIsoUtc(dynamic v) { static DateTime? _tryParseIsoUtc(dynamic v) {
if (v == null) return null; 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) { static double? _toDouble(dynamic v) {
@ -55,6 +58,7 @@ class RemotePhotoItem {
return double.tryParse(v.toString()); return double.tryParse(v.toString());
} }
/// Converte secondims se < 1000, altrimenti assume già millisecondi.
static int? _toMillis(dynamic v) { static int? _toMillis(dynamic v) {
if (v == null) return null; if (v == null) return null;
final num? n = (v is num) ? v : num.tryParse(v.toString()); final num? n = (v is num) ? v : num.tryParse(v.toString());
@ -85,7 +89,10 @@ class RemotePhotoItem {
lng: gps != null ? _toDouble(gps['lng']) : null, lng: gps != null ? _toDouble(gps['lng']) : null,
alt: gps != null ? _toDouble(gps['alt']) : null, alt: gps != null ? _toDouble(gps['alt']) : null,
user: j['user']?.toString(), user: j['user']?.toString(),
durationMillis: _toMillis(j['duration']),
// QUI LA MODIFICA: usiamo duration_ms (ms dal server)
durationMillis: _toMillis(j['duration_ms']),
location: loc, location: loc,
); );
} }
@ -120,9 +127,9 @@ class RemoteLocation {
region: j['region']?.toString(), region: j['region']?.toString(),
postcode: j['postcode']?.toString(), postcode: j['postcode']?.toString(),
city: j['city']?.toString(), city: j['city']?.toString(),
countyCode:j['county_code']?.toString(), countyCode: j['county_code']?.toString(),
address: j['address']?.toString(), address: j['address']?.toString(),
timezone: j['timezone']?.toString(), timezone: j['timezone']?.toString(),
timeOffset:j['time']?.toString(), timeOffset: j['time']?.toString(),
); );
} }

View file

@ -3,7 +3,7 @@ import 'package:flutter/foundation.dart' show debugPrint;
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
import 'remote_models.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 { class RemoteRepository {
final Database db; final Database db;
@ -40,27 +40,28 @@ class RemoteRepository {
} }
} }
/// Assicura che tutte le entry remote abbiano un uri costruito da remoteId. /// Assicura che tutte le entry remote abbiano un uri costruito da remoteId.
Future<void> ensureRemoteUris(DatabaseExecutor dbExec) async { /// (coerente con aves-remote://rid/...)
Future<void> ensureRemoteUris(DatabaseExecutor dbExec) async {
try { try {
await dbExec.execute(''' await dbExec.execute('''
UPDATE entry UPDATE entry
SET uri = 'remote://' || remoteId SET uri = 'aves-remote://rid/' || replace(remoteId, ' ', '')
WHERE origin = 1 WHERE origin = 1
AND remoteId IS NOT NULL AND remoteId IS NOT NULL
AND remoteId != '' AND trim(remoteId) != ''
AND (uri IS NULL OR uri = '' OR uri NOT LIKE 'remote://%'); AND (uri IS NULL OR trim(uri) = '' OR uri NOT LIKE 'aves-remote://%');
'''); ''');
debugPrint('[RemoteRepository] ensureRemoteUris: migration applied'); debugPrint('[RemoteRepository] ensureRemoteUris: migration applied');
} catch (e, st) { } catch (e, st) {
debugPrint('[RemoteRepository] ensureRemoteUris error: $e\n$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<void> _ensureEntryColumns(DatabaseExecutor dbExec) async { Future<void> _ensureEntryColumns(DatabaseExecutor dbExec) async {
await _ensureColumns(dbExec, table: 'entry', columnsAndTypes: const { await _ensureColumns(dbExec, table: 'entry', columnsAndTypes: const {
// Core (alcune basi legacy potrebbero non averle ancora) // Core
'uri': 'TEXT', 'uri': 'TEXT',
// GPS // GPS
@ -77,13 +78,14 @@ Future<void> ensureRemoteUris(DatabaseExecutor dbExec) async {
'provider': 'TEXT', 'provider': 'TEXT',
'trashed': 'INTEGER', 'trashed': 'INTEGER',
'remoteRotation': 'INTEGER', 'remoteRotation': 'INTEGER',
// Durata video (ms)
'durationMillis': 'INTEGER',
}); });
// Indice "normale" per velocizzare il lookup su remoteId // indice lookup
try { try {
await dbExec.execute( await dbExec.execute('CREATE INDEX IF NOT EXISTS idx_entry_remoteId ON entry(remoteId);');
'CREATE INDEX IF NOT EXISTS idx_entry_remoteId ON entry(remoteId);',
);
} catch (e, st) { } catch (e, st) {
debugPrint('[RemoteRepository] create index error: $e\n$st'); debugPrint('[RemoteRepository] create index error: $e\n$st');
} }
@ -107,10 +109,9 @@ Future<void> ensureRemoteUris(DatabaseExecutor dbExec) async {
} catch (e) { } catch (e) {
if (!_isBusy(e) || i == maxAttempts - 1) rethrow; if (!_isBusy(e) || i == maxAttempts - 1) rethrow;
await Future.delayed(delay); await Future.delayed(delay);
delay *= 2; // 250 500 1000 ms delay *= 2;
} }
} }
// non dovrebbe arrivare qui
return await fn(); return await fn();
} }
@ -125,8 +126,8 @@ Future<void> ensureRemoteUris(DatabaseExecutor dbExec) async {
return s; return s;
} }
/// Candidato "canonico" (inserisce '/original/' dopo '/photos/<User>/' /// Candidato canonico: inserisce '/original/' dopo '/photos/<User>/'
/// se manca). Usato per lookup/fallback. /// se manca. Usato per lookup/fallback.
String _canonCandidate(String? rawPath, String fileName) { String _canonCandidate(String? rawPath, String fileName) {
var s = _normPath(rawPath); var s = _normPath(rawPath);
final seg = s.split('/'); // ['', 'photos', '<User>', maybe 'original', ...] final seg = s.split('/'); // ['', 'photos', '<User>', maybe 'original', ...]
@ -145,7 +146,7 @@ Future<void> ensureRemoteUris(DatabaseExecutor dbExec) async {
bool _isVideoItem(RemotePhotoItem it) { bool _isVideoItem(RemotePhotoItem it) {
final mt = (it.mimeType ?? '').toLowerCase(); final mt = (it.mimeType ?? '').toLowerCase();
final p = (it.path).toLowerCase(); final p = it.path.toLowerCase();
return mt.startsWith('video/') || return mt.startsWith('video/') ||
p.endsWith('.mp4') || p.endsWith('.mp4') ||
p.endsWith('.mov') || p.endsWith('.mov') ||
@ -154,16 +155,8 @@ Future<void> ensureRemoteUris(DatabaseExecutor dbExec) async {
p.endsWith('.webm'); p.endsWith('.webm');
} }
Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) { Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
// ============================================================
// REMARK ORIGINALE (da ripristinare quando avrai ImageProvider)
final syntheticUri = RemoteDbUris.make(remoteId: it.id, remotePath: it.path); 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() { int _makeContentId() {
final base = (it.id.isNotEmpty ? it.id : it.path); final base = (it.id.isNotEmpty ? it.id : it.path);
@ -178,18 +171,15 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
'id': existingId, 'id': existingId,
'contentId': _makeContentId(), 'contentId': _makeContentId(),
// URI HTTP temporaneo
'uri': syntheticUri, 'uri': syntheticUri,
// MIME sempre valorizzato
'path': it.path, 'path': it.path,
'sourceMimeType': it.mimeType ?? 'image/jpeg',
// width/height sempre valorizzati // fallback MIME video se mancante
'sourceMimeType': it.mimeType ?? (_isVideoItem(it) ? 'video/mp4' : 'image/jpeg'),
'width': it.width ?? 0, 'width': it.width ?? 0,
'height': it.height ?? 0, 'height': it.height ?? 0,
// rotation sempre valorizzata
'sourceRotationDegrees': it.rotation ?? 0, 'sourceRotationDegrees': it.rotation ?? 0,
'sizeBytes': it.sizeBytes, 'sizeBytes': it.sizeBytes,
@ -198,6 +188,8 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
'dateAddedSecs': nowMs ~/ 1000, 'dateAddedSecs': nowMs ~/ 1000,
'dateModifiedMillis': dateModMs, 'dateModifiedMillis': dateModMs,
'sourceDateTakenMillis': it.takenAtUtc?.millisecondsSinceEpoch, 'sourceDateTakenMillis': it.takenAtUtc?.millisecondsSinceEpoch,
// durata video (ms) (può essere null per foto)
'durationMillis': it.durationMillis, 'durationMillis': it.durationMillis,
'trashed': 0, 'trashed': 0,
@ -214,11 +206,10 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
'remoteThumb2': it.thub2, 'remoteThumb2': it.thub2,
'remoteRotation': it.rotation ?? 0, 'remoteRotation': it.rotation ?? 0,
// remoteWidth/remoteHeight sempre valorizzati
'remoteWidth': it.width ?? 0, 'remoteWidth': it.width ?? 0,
'remoteHeight': it.height ?? 0, 'remoteHeight': it.height ?? 0,
}; };
} }
Map<String, Object?> _buildAddressRow(int newId, RemoteLocation location) { Map<String, Object?> _buildAddressRow(int newId, RemoteLocation location) {
return <String, Object?>{ return <String, Object?>{
@ -262,15 +253,14 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
final batch = txn.batch(); final batch = txn.batch();
for (final it in chunk) { for (final it in chunk) {
// Log essenziale (puoi silenziare dopo i test)
final raw = it.path; final raw = it.path;
final norm = _normPath(raw); final norm = _normPath(raw);
final cand = _canonCandidate(raw, it.name); 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"'); debugPrint('[repo-upsert] in: rid=${it.id.substring(0, 8)} name=${it.name} raw="$raw"');
// Lookup record esistente:
// 1) per remoteId
int? existingId; int? existingId;
// 1) lookup per remoteId
try { try {
final existing = await txn.query( final existing = await txn.query(
'entry', 'entry',
@ -284,7 +274,7 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
debugPrint('[RemoteRepository] lookup by remoteId failed for remoteId=${it.id}: $e\n$st'); 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) { if (existingId == null) {
try { try {
final byCanon = await txn.query( final byCanon = await txn.query(
@ -294,15 +284,13 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
whereArgs: [cand], whereArgs: [cand],
limit: 1, limit: 1,
); );
if (byCanon.isNotEmpty) { if (byCanon.isNotEmpty) existingId = byCanon.first['id'] as int?;
existingId = byCanon.first['id'] as int?;
}
} catch (e, st) { } catch (e, st) {
debugPrint('[RemoteRepository] lookup by canonical path failed "$cand": $e\n$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) { if (existingId == null) {
try { try {
final byNorm = await txn.query( final byNorm = await txn.query(
@ -312,15 +300,12 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
whereArgs: [norm], whereArgs: [norm],
limit: 1, limit: 1,
); );
if (byNorm.isNotEmpty) { if (byNorm.isNotEmpty) existingId = byNorm.first['id'] as int?;
existingId = byNorm.first['id'] as int?;
}
} catch (e, st) { } catch (e, st) {
debugPrint('[RemoteRepository] lookup by normalized path failed "$norm": $e\n$st'); debugPrint('[RemoteRepository] lookup by normalized path failed "$norm": $e\n$st');
} }
} }
// Riga completa e REPLACE
final row = _buildEntryRow(it, existingId: existingId); final row = _buildEntryRow(it, existingId: existingId);
try { try {
@ -332,6 +317,7 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
} on DatabaseException catch (e, st) { } on DatabaseException catch (e, st) {
debugPrint('[RemoteRepository] batch insert failed for remoteId=${it.id}: $e\n$st'); debugPrint('[RemoteRepository] batch insert failed for remoteId=${it.id}: $e\n$st');
// fallback: rimuovi gps se causa problemi
final rowNoGps = Map<String, Object?>.from(row) final rowNoGps = Map<String, Object?>.from(row)
..remove('latitude') ..remove('latitude')
..remove('longitude') ..remove('longitude')
@ -374,7 +360,7 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
} }
})); }));
} catch (e, st) { } 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; rethrow;
} }
} }
@ -384,7 +370,6 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
// Unicità & deduplica // Unicità & deduplica
// ========================= // =========================
/// Indice UNICO su `remoteId` limitato alle righe remote (origin=1).
Future<void> ensureUniqueRemoteId() async { Future<void> ensureUniqueRemoteId() async {
try { try {
await db.execute( await db.execute(
@ -397,7 +382,6 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
} }
} }
/// Indice UNICO su `remotePath` (solo remoti) per prevenire doppi.
Future<void> ensureUniqueRemotePath() async { Future<void> ensureUniqueRemotePath() async {
try { try {
await db.execute( await db.execute(
@ -410,7 +394,6 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
} }
} }
/// Dedup per `remoteId`, tenendo lultima riga.
Future<int> deduplicateRemotes() async { Future<int> deduplicateRemotes() async {
try { try {
final deleted = await db.rawDelete( final deleted = await db.rawDelete(
@ -429,7 +412,6 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
} }
} }
/// Dedup per `remotePath` (match esatto), tenendo lultima riga.
Future<int> deduplicateByRemotePath() async { Future<int> deduplicateByRemotePath() async {
try { try {
final deleted = await db.rawDelete( final deleted = await db.rawDelete(
@ -448,15 +430,64 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
} }
} }
// =========================
// Bootstrap / prune remoti
// =========================
/// Bootstrap: cancella TUTTI i remoti
Future<int> 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<int> pruneMissingRemotes(Set<String> 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 // 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<void> backfillRemoteUris() async { Future<void> backfillRemoteUris() async {
// 1) Backfill via SQL per chi ha remoteId (più veloce)
try { try {
final updated = await db.rawUpdate( final updated = await db.rawUpdate(
"UPDATE entry " "UPDATE entry "
@ -468,13 +499,12 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
debugPrint('[RemoteRepository] backfill URIs (SQL) error: $e\n$st'); debugPrint('[RemoteRepository] backfill URIs (SQL) error: $e\n$st');
} }
// 2) Loop Dart per i (pochi) rimanenti senza remoteId ma con remotePath
try { try {
final rows = await db.rawQuery( final rows = await db.rawQuery(
"SELECT id, remotePath FROM entry " "SELECT id, remotePath FROM entry "
"WHERE origin=1 AND (uri IS NULL OR trim(uri)='') " "WHERE origin=1 AND (uri IS NULL OR trim(uri)='') "
"AND (remoteId IS NULL OR trim(remoteId)='') " "AND (remoteId IS NULL OR trim(remoteId)='') "
"AND remotePath IS NOT NULL" "AND remotePath IS NOT NULL",
); );
if (rows.isNotEmpty) { if (rows.isNotEmpty) {
@ -498,15 +528,14 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
} }
// ========================= // =========================
// Backfill ESSENZIALI (contentId, dateModifiedMillis) per remoti legacy // Backfill essentials per remoti legacy
// ========================= // =========================
Future<void> backfillRemoteEssentials() async { Future<void> backfillRemoteEssentials() async {
// 1) backfill contentId sintetico per remoti con contentId NULL/<=0
try { try {
final rows = await db.rawQuery( final rows = await db.rawQuery(
"SELECT id, remoteId, remotePath FROM entry " "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) { if (rows.isNotEmpty) {
for (final r in rows) { for (final r in rows) {
@ -514,7 +543,7 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
final rid = (r['remoteId'] as String?) ?? ''; final rid = (r['remoteId'] as String?) ?? '';
final rpath = (r['remotePath'] as String?) ?? ''; final rpath = (r['remotePath'] as String?) ?? '';
final base = rid.isNotEmpty ? rid : rpath; 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); final cid = 1_000_000_000 + (h % 900_000_000);
await db.update('entry', {'contentId': cid}, where: 'id=?', whereArgs: [id]); await db.update('entry', {'contentId': cid}, where: 'id=?', whereArgs: [id]);
} }
@ -524,7 +553,6 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
debugPrint('[RemoteRepository] backfill contentId error: $e\n$st'); debugPrint('[RemoteRepository] backfill contentId error: $e\n$st');
} }
// 2) backfill dateModifiedMillis (usa sourceDateTakenMillis se presente, altrimenti now)
try { try {
final nowMs = DateTime.now().millisecondsSinceEpoch; final nowMs = DateTime.now().millisecondsSinceEpoch;
final updated = await db.rawUpdate( final updated = await db.rawUpdate(
@ -538,27 +566,15 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
} }
} }
// =========================
// Helper combinato: pulizia + indici + backfill URI/ESSENZIALI
// =========================
Future<void> sanitizeRemotes() async { Future<void> sanitizeRemotes() async {
await deduplicateRemotes(); await deduplicateRemotes();
await deduplicateByRemotePath(); // opzionale ma utile await deduplicateByRemotePath();
await ensureUniqueRemoteId(); await ensureUniqueRemoteId();
await ensureUniqueRemotePath(); await ensureUniqueRemotePath();
// Assicura che ogni remoto abbia un uri fittizio valorizzato
await backfillRemoteUris(); await backfillRemoteUris();
// Assicura che i remoti abbiano contentId/dateModifiedMillis validi
await backfillRemoteEssentials(); await backfillRemoteEssentials();
} }
// =========================
// Utils
// =========================
Future<int> countRemote() async { Future<int> countRemote() async {
final rows = await db.rawQuery('SELECT COUNT(1) AS c FROM entry WHERE origin=1'); final rows = await db.rawQuery('SELECT COUNT(1) AS c FROM entry WHERE origin=1');
return (rows.first['c'] as int?) ?? 0; return (rows.first['c'] as int?) ?? 0;

View file

@ -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<void> _ensureColumns(
DatabaseExecutor dbExec, {
required String table,
required Map<String, String> 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<void> 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<void> _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<T> _withRetryBusy<T>(Future<T> 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/<User>/'
/// se manca). Usato per lookup/fallback.
String _canonCandidate(String? rawPath, String fileName) {
var s = _normPath(rawPath);
final seg = s.split('/'); // ['', 'photos', '<User>', 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<String, Object?> _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 <String, Object?>{
'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<String, Object?> _buildAddressRow(int newId, RemoteLocation location) {
return <String, Object?>{
'id': newId,
'addressLine': location.address,
'countryCode': null,
'countryName': location.country,
'adminArea': location.region,
'locality': location.city,
};
}
// =========================
// Upsert a chunk (con fallback robusti)
// =========================
Future<void> upsertAll(List<RemotePhotoItem> 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 = <RemotePhotoItem>[];
final videos = <RemotePhotoItem>[];
for (final it in items) {
(_isVideoItem(it) ? videos : images).add(it);
}
final ordered = <RemotePhotoItem>[...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<String, Object?>.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<void> 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<void> 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 lultima riga.
Future<int> 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 lultima riga.
Future<int> 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<void> 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<void> 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<void> 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<int> countRemote() async {
final rows = await db.rawQuery('SELECT COUNT(1) AS c FROM entry WHERE origin=1');
return (rows.first['c'] as int?) ?? 0;
}
}

View file

@ -7,7 +7,7 @@ class RemoteSettings {
static const _storage = FlutterSecureStorage( static const _storage = FlutterSecureStorage(
aOptions: AndroidOptions( aOptions: AndroidOptions(
encryptedSharedPreferences: true, encryptedSharedPreferences: true,
resetOnError: true, // auto-reset della singola voce cifrata se fallisce la decrittazione resetOnError: true,
), ),
); );
@ -17,7 +17,10 @@ class RemoteSettings {
static const _kEmail = 'remote_email'; static const _kEmail = 'remote_email';
static const _kPassword = 'remote_password'; 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 defaultBaseUrl = kDebugMode ? 'https://prova.patachina.it' : '';
static final String defaultIndexPath = kDebugMode ? 'photos/' : ''; static final String defaultIndexPath = kDebugMode ? 'photos/' : '';
static final String defaultEmail = kDebugMode ? 'fabio@gmail.com' : ''; static final String defaultEmail = kDebugMode ? 'fabio@gmail.com' : '';
@ -37,36 +40,30 @@ class RemoteSettings {
required this.password, required this.password,
}); });
// 🔎 helper: leggi una chiave in modo safe e, se fallisce, cancella solo quella
static Future<String?> _readKeySafe(String key) async { static Future<String?> _readKeySafe(String key) async {
try { try {
return await _storage.read(key: key); return await _storage.read(key: key);
} on PlatformException { } on PlatformException {
// solo questa chiave è corrotta la pulisco
await _storage.delete(key: key); await _storage.delete(key: key);
return null; return null;
} }
} }
// 🧼 helper: rimuove caratteri invisibili/di controllo tipici che sporcano gli URL
static String _sanitizeUrl(String s) { 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]'; const _invisibles = r'[\u200B-\u200F\u202A-\u202E\u2060-\u2064\uFEFF]';
final cleaned = s.replaceAll(RegExp(_invisibles), ''); final cleaned = s.replaceAll(RegExp(_invisibles), '');
return cleaned.trim(); return cleaned.trim();
} }
static Future<RemoteSettings> load() async { static Future<RemoteSettings> load() async {
// legge *per singola chiave* con fallback ai default
final enabledStr = await _readKeySafe(_kEnabled); final enabledStr = await _readKeySafe(_kEnabled);
final rawBase = await _readKeySafe(_kBaseUrl); final rawBase = await _readKeySafe(_kBaseUrl);
final indexPath = await _readKeySafe(_kIndexPath) ?? defaultIndexPath; final indexPath = await _readKeySafe(_kIndexPath) ?? defaultIndexPath;
final email = await _readKeySafe(_kEmail) ?? defaultEmail; final email = await _readKeySafe(_kEmail) ?? defaultEmail;
final password = await _readKeySafe(_kPassword) ?? defaultPassword; final password = await _readKeySafe(_kPassword) ?? defaultPassword;
// defaultEnabled è false sempre
final enabled = (enabledStr ?? (defaultEnabled ? 'true' : 'false')) == 'true'; final enabled = (enabledStr ?? (defaultEnabled ? 'true' : 'false')) == 'true';
// sanitize della base URL (toglie caratteri non alfabetici invisibili)
final baseUrl = _sanitizeUrl(rawBase ?? defaultBaseUrl); final baseUrl = _sanitizeUrl(rawBase ?? defaultBaseUrl);
return RemoteSettings( return RemoteSettings(
@ -79,7 +76,6 @@ class RemoteSettings {
} }
Future<void> save() async { Future<void> 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: _kEnabled, value: enabled ? 'true' : 'false');
await _storage.write(key: _kBaseUrl, value: _sanitizeUrl(baseUrl)); await _storage.write(key: _kBaseUrl, value: _sanitizeUrl(baseUrl));
await _storage.write(key: _kIndexPath, value: indexPath.trim()); await _storage.write(key: _kIndexPath, value: indexPath.trim());
@ -97,13 +93,15 @@ class RemoteSettings {
await _storage.write(key: key, value: value); await _storage.write(key: key, value: value);
} }
} on PlatformException { } on PlatformException {
// chiave sporca reset di quella sola chiave e poi scrittura
await _storage.delete(key: key); await _storage.delete(key: key);
await _storage.write(key: key, value: value); 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(_kBaseUrl, defaultBaseUrl);
await _seed(_kIndexPath, defaultIndexPath); await _seed(_kIndexPath, defaultIndexPath);
await _seed(_kEmail, defaultEmail); await _seed(_kEmail, defaultEmail);

View file

@ -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<void> show(BuildContext context) async {
final s = await RemoteSettings.load();
final formKey = GlobalKey<FormState>();
// 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<void> 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<CollectionSource>();
} 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<void>(
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();
}
}

View file

@ -1,4 +1,11 @@
import 'package:flutter/material.dart'; 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_settings.dart';
import 'remote_http.dart'; import 'remote_http.dart';
@ -49,7 +56,6 @@ class _RemoteSettingsPageState extends State<RemoteSettingsPage> {
_loaded = true; _loaded = true;
}); });
} catch (e) { } catch (e) {
// Fail-open: apri comunque con default/blank e notifica
_showSnack('Impossibile leggere le impostazioni sicure: $e'); _showSnack('Impossibile leggere le impostazioni sicure: $e');
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@ -70,7 +76,6 @@ class _RemoteSettingsPageState extends State<RemoteSettingsPage> {
if (uri == null || !(uri.hasScheme && (uri.scheme == 'http' || uri.scheme == 'https'))) { if (uri == null || !(uri.hasScheme && (uri.scheme == 'http' || uri.scheme == 'https'))) {
return 'URL non valida (deve iniziare con http/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)) { if (RegExp(r'[\u200B-\u200F\u202A-\u202E\u2060-\u2064\uFEFF]').hasMatch(s)) {
return 'URL contiene caratteri non validi (invisibili)'; return 'URL contiene caratteri non validi (invisibili)';
} }
@ -83,6 +88,28 @@ class _RemoteSettingsPageState extends State<RemoteSettingsPage> {
return null; return null;
} }
Future<void> _applyRuntimeEffects() async {
// Applica subito l'effetto ON/OFF in UI
try {
final source = context.read<CollectionSource>();
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<void> _save() async { Future<void> _save() async {
if (!(_form.currentState?.validate() ?? false)) return; if (!(_form.currentState?.validate() ?? false)) return;
@ -98,9 +125,12 @@ class _RemoteSettingsPageState extends State<RemoteSettingsPage> {
await s.save(); await s.save();
// forza Aves a usare SUBITO base URL & token aggiornati // aggiorna headers/token
await RemoteHttp.refreshFromSettings(); 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; if (!mounted) return;
_showSnack('Impostazioni remote salvate'); _showSnack('Impostazioni remote salvate');
@ -116,7 +146,7 @@ class _RemoteSettingsPageState extends State<RemoteSettingsPage> {
void _showSnack(String msg) { void _showSnack(String msg) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
behavior: SnackBarBehavior.fixed, // evita "floating off screen" behavior: SnackBarBehavior.fixed,
content: Text(msg), content: Text(msg),
duration: const Duration(seconds: 3), duration: const Duration(seconds: 3),
), ),
@ -208,4 +238,3 @@ class _RemoteSettingsPageState extends State<RemoteSettingsPage> {
); );
} }
} }

View file

@ -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<RemoteSyncState> stateNotifier = ValueNotifier(RemoteSyncState.disabled);
final ValueNotifier<RemoteSyncProgress?> 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;
}
}

View file

@ -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<RemoteSyncProgress?> 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;
}

View file

@ -46,6 +46,9 @@ import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:provider/provider.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 { class CollectionAppBar extends StatefulWidget {
final ValueNotifier<double> appBarHeightNotifier; final ValueNotifier<double> appBarHeightNotifier;
final ScrollController scrollController; final ScrollController scrollController;
@ -62,7 +65,8 @@ class CollectionAppBar extends StatefulWidget {
State<CollectionAppBar> createState() => _CollectionAppBarState(); State<CollectionAppBar> createState() => _CollectionAppBarState();
} }
class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, SingleTickerProviderStateMixin, WidgetsBindingObserver { class _CollectionAppBarState extends State<CollectionAppBar>
with RouteAware, SingleTickerProviderStateMixin, WidgetsBindingObserver {
final Set<StreamSubscription> _subscriptions = {}; final Set<StreamSubscription> _subscriptions = {};
final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate(); final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate();
late AnimationController _browseToSelectAnimation; late AnimationController _browseToSelectAnimation;
@ -77,7 +81,8 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
CollectionSource get source => collection.source; CollectionSource get source => collection.source;
Set<CollectionFilter> get visibleFilters => collection.filters.where((v) => !(v is QueryFilter && v.live) && v is! TrashFilter).toSet(); Set<CollectionFilter> get visibleFilters =>
collection.filters.where((v) => !(v is QueryFilter && v.live) && v is! TrashFilter).toSet();
bool get showFilterBar => visibleFilters.isNotEmpty; bool get showFilterBar => visibleFilters.isNotEmpty;
@ -171,14 +176,11 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
@override @override
void didPushNext() { 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(); _queryBarFocusNode.unfocus();
} }
@override @override
void didChangeMetrics() { void didChangeMetrics() {
// when top padding or text scale factor change
WidgetsBinding.instance.addPostFrameCallback((_) => _updateStatusBarHeight()); WidgetsBinding.instance.addPostFrameCallback((_) => _updateStatusBarHeight());
} }
@ -188,9 +190,8 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
final selection = context.watch<Selection<AvesEntry>>(); final selection = context.watch<Selection<AvesEntry>>();
final isSelecting = selection.isSelecting; final isSelecting = selection.isSelecting;
_isSelectingNotifier.value = isSelecting; _isSelectingNotifier.value = isSelecting;
return NotificationListener<ScrollNotification>( return NotificationListener<ScrollNotification>(
// cancel notification bubbling so that the draggable scroll bar
// does not misinterpret filter bar scrolling for collection scrolling
onNotification: (notification) => true, onNotification: (notification) => true,
child: AnimatedBuilder( child: AnimatedBuilder(
animation: collection.filterChangeNotifier, animation: collection.filterChangeNotifier,
@ -204,6 +205,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
builder: (context, _, child) { builder: (context, _, child) {
final useTvLayout = settings.useTvLayout; final useTvLayout = settings.useTvLayout;
final onFilterTap = canRemoveFilters ? collection.removeFilter : null; final onFilterTap = canRemoveFilters ? collection.removeFilter : null;
return AvesAppBar( return AvesAppBar(
contentHeight: appBarContentHeight, contentHeight: appBarContentHeight,
pinned: context.select<Selection<AvesEntry>, bool>((selection) => selection.isSelecting), pinned: context.select<Selection<AvesEntry>, bool>((selection) => selection.isSelecting),
@ -212,7 +214,15 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
isSelecting: isSelecting, isSelecting: isSelecting,
), ),
title: _buildAppBarTitle(isSelecting), title: _buildAppBarTitle(isSelecting),
actions: (context, maxWidth) => useTvLayout ? [] : _buildActions(context, selection, maxWidth),
// actions: parabola + azioni originali (mobile); solo parabola (tv)
actions: (context, maxWidth) => _buildAppBarActions(
context: context,
selection: selection,
maxWidth: maxWidth,
useTvLayout: useTvLayout,
),
bottom: Column( bottom: Column(
children: [ children: [
if (useTvLayout) if (useTvLayout)
@ -266,6 +276,29 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
); );
} }
// NEW: actions con RemoteStatusButton sempre visibile
List<Widget> _buildAppBarActions({
required BuildContext context,
required Selection<AvesEntry> 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 { double get appBarContentHeight {
final textScaler = MediaQuery.textScalerOf(context); final textScaler = MediaQuery.textScalerOf(context);
double height = textScaler.scale(kToolbarHeight); double height = textScaler.scale(kToolbarHeight);
@ -298,7 +331,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
tooltip = MaterialLocalizations.of(context).openAppDrawerTooltip; tooltip = MaterialLocalizations.of(context).openAppDrawerTooltip;
} }
return IconButton( return IconButton(
// key is expected by test driver
key: const Key('appbar-leading-button'), key: const Key('appbar-leading-button'),
icon: AnimatedIcon( icon: AnimatedIcon(
icon: AnimatedIcons.menu_arrow, icon: AnimatedIcons.menu_arrow,
@ -313,7 +345,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
final l10n = context.l10n; final l10n = context.l10n;
if (isSelecting) { if (isSelecting) {
// `Selection` may not be available during hero
return Selector<Selection<AvesEntry>?, int>( return Selector<Selection<AvesEntry>?, int>(
selector: (context, selection) => selection?.selectedItems.length ?? 0, selector: (context, selection) => selection?.selectedItems.length ?? 0,
builder: (context, count, child) => Text( builder: (context, count, child) => Text(
@ -435,26 +466,21 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
final quickActions = (isSelecting ? selectionQuickActions : browsingQuickActions).take(max(0, availableCount - 1)).toList(); final quickActions = (isSelecting ? selectionQuickActions : browsingQuickActions).take(max(0, availableCount - 1)).toList();
final quickActionButtons = quickActions final quickActionButtons = quickActions
.where(isVisible) .where(isVisible)
.map( .map((action) => _buildButtonIcon(context, action, enabled: canApply(action), selection: selection));
(action) => _buildButtonIcon(context, action, enabled: canApply(action), selection: selection),
);
final animations = context.select<Settings, AccessibilityAnimations>((v) => v.accessibilityAnimations); final animations = context.select<Settings, AccessibilityAnimations>((v) => v.accessibilityAnimations);
return [ return [
...quickActionButtons, ...quickActionButtons,
PopupMenuButton<EntrySetAction>( PopupMenuButton<EntrySetAction>(
// key is expected by test driver
key: const Key('appbar-menu-button'), key: const Key('appbar-menu-button'),
itemBuilder: (context) { itemBuilder: (context) {
bool _isValidForMenu(EntrySetAction? v) => v == null || (!quickActions.contains(v) && isVisible(v)); bool _isValidForMenu(EntrySetAction? v) => v == null || (!quickActions.contains(v) && isVisible(v));
final generalMenuItems = EntrySetActions.general final generalMenuItems = EntrySetActions.general
.where(_isValidForMenu) .where(_isValidForMenu)
.map( .map((action) => _toMenuItem(action, enabled: canApply(action), selection: selection));
(action) => _toMenuItem(action, enabled: canApply(action), selection: selection),
);
final allContextualActions = isSelecting ? EntrySetActions.pageSelection : EntrySetActions.pageBrowsing; final allContextualActions = isSelecting ? EntrySetActions.pageSelection : EntrySetActions.pageBrowsing;
final contextualMenuActions = allContextualActions.where(_isValidForMenu).fold(<EntrySetAction?>[], (prev, v) { final contextualMenuActions = allContextualActions.where(_isValidForMenu).fold<List<EntrySetAction?>>([], (prev, v) {
if (v == null && (prev.isEmpty || prev.last == null)) return prev; if (v == null && (prev.isEmpty || prev.last == null)) return prev;
return [...prev, v]; return [...prev, v];
}); });
@ -463,12 +489,10 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
} }
final contextualMenuItems = <PopupMenuEntry<EntrySetAction>>[ final contextualMenuItems = <PopupMenuEntry<EntrySetAction>>[
...contextualMenuActions.map( ...contextualMenuActions.map((action) {
(action) {
if (action == null) return const PopupMenuDivider(); if (action == null) return const PopupMenuDivider();
return _toMenuItem(action, enabled: canApply(action), selection: selection); return _toMenuItem(action, enabled: canApply(action), selection: selection);
}, }),
),
if (isSelecting && !settings.isReadOnly && appMode == AppMode.main && !isTrash) if (isSelecting && !settings.isReadOnly && appMode == AppMode.main && !isTrash)
PopupMenuExpansionPanel<EntrySetAction>( PopupMenuExpansionPanel<EntrySetAction>(
enabled: hasSelection, enabled: hasSelection,
@ -477,7 +501,9 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
title: context.l10n.collectionActionEdit, title: context.l10n.collectionActionEdit,
items: [ items: [
_buildRotateAndFlipMenuItems(context, canApply: canApply), _buildRotateAndFlipMenuItems(context, canApply: canApply),
...EntrySetActions.edit.where((v) => isVisible(v) && !quickActions.contains(v)).map((action) => _toMenuItem(action, enabled: canApply(action), selection: selection)), ...EntrySetActions.edit
.where((v) => isVisible(v) && !quickActions.contains(v))
.map((action) => _toMenuItem(action, enabled: canApply(action), selection: selection)),
], ],
), ),
]; ];
@ -491,7 +517,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
]; ];
}, },
onSelected: (action) async { onSelected: (action) async {
// wait for the popup menu to hide before proceeding with the action
await Future.delayed(animations.popUpAnimationDelay * timeDilation); await Future.delayed(animations.popUpAnimationDelay * timeDilation);
await _onActionSelected(action); await _onActionSelected(action);
}, },
@ -504,7 +529,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
return selection.selectedItems.expand((entry) => entry.stackedEntries ?? {entry}).toSet(); return selection.selectedItems.expand((entry) => entry.stackedEntries ?? {entry}).toSet();
} }
// key is expected by test driver
Key _getActionKey(EntrySetAction action) => Key('menu-${action.name}'); Key _getActionKey(EntrySetAction action) => Key('menu-${action.name}');
Widget _buildButtonIcon( Widget _buildButtonIcon(
@ -518,7 +542,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
final onPressed = enabled ? () => _onActionSelected(action) : null; final onPressed = enabled ? () => _onActionSelected(action) : null;
switch (action) { switch (action) {
case EntrySetAction.toggleTitleSearch: case EntrySetAction.toggleTitleSearch:
// `Query` may not be available during hero
return Selector<Query?, bool>( return Selector<Query?, bool>(
selector: (context, query) => query?.enabled ?? false, selector: (context, query) => query?.enabled ?? false,
builder: (context, queryEnabled, child) { builder: (context, queryEnabled, child) {
@ -581,14 +604,9 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
}) { }) {
switch (action) { switch (action) {
case EntrySetAction.toggleTitleSearch: case EntrySetAction.toggleTitleSearch:
return TitleSearchTogglerCaption( return TitleSearchTogglerCaption(enabled: enabled);
enabled: enabled,
);
default: default:
return CaptionedButtonText( return CaptionedButtonText(text: action.getText(context), enabled: enabled);
text: action.getText(context),
enabled: enabled,
);
} }
} }
@ -622,26 +640,18 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
}) { }) {
Widget buildDivider() => const SizedBox( Widget buildDivider() => const SizedBox(
height: 16, height: 16,
child: VerticalDivider( child: VerticalDivider(width: 1, thickness: 1),
width: 1,
thickness: 1,
),
); );
Widget buildItem(EntrySetAction action) => Expanded( Widget buildItem(EntrySetAction action) => Expanded(
child: Material( child: Material(
color: Colors.transparent, color: Colors.transparent,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(8))),
borderRadius: BorderRadius.all(Radius.circular(8)),
),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: PopupMenuItem( child: PopupMenuItem(
value: action, value: action,
enabled: canApply(action), enabled: canApply(action),
child: Tooltip( child: Tooltip(message: action.getText(context), child: Center(child: action.getIcon())),
message: action.getText(context),
child: Center(child: action.getIcon()),
),
), ),
), ),
); );
@ -686,10 +696,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
void _onQueryBarFocusChanged() { void _onQueryBarFocusChanged() {
if (_queryBarFocusNode.hasFocus) { 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(); _scrollToTop();
} }
} }
@ -697,9 +703,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
void _scrollToTop() => widget.scrollController.jumpTo(0); void _scrollToTop() => widget.scrollController.jumpTo(0);
void _updateStatusBarHeight() { void _updateStatusBarHeight() {
if (!mounted) { if (!mounted) return;
return;
}
_statusBarHeight = MediaQuery.paddingOf(context).top; _statusBarHeight = MediaQuery.paddingOf(context).top;
_updateAppBarHeight(); _updateAppBarHeight();
} }
@ -710,7 +714,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
Future<void> _onActionSelected(EntrySetAction action) async { Future<void> _onActionSelected(EntrySetAction action) async {
switch (action) { switch (action) {
// general
case EntrySetAction.configureView: case EntrySetAction.configureView:
await _configureView(); await _configureView();
case EntrySetAction.select: case EntrySetAction.select:
@ -719,36 +722,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
context.read<Selection<AvesEntry>>().addToSelection(collection.sortedEntries); context.read<Selection<AvesEntry>>().addToSelection(collection.sortedEntries);
case EntrySetAction.selectNone: case EntrySetAction.selectNone:
context.read<Selection<AvesEntry>>().clearSelection(); context.read<Selection<AvesEntry>>().clearSelection();
// browsing default:
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); _actionDelegate.onActionSelected(context, action);
} }
} }
@ -776,7 +750,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
}, },
routeSettings: const RouteSettings(name: TileViewDialog.routeName), routeSettings: const RouteSettings(name: TileViewDialog.routeName),
); );
// wait for the dialog to hide
await Future.delayed(ADurations.dialogTransitionLoose * timeDilation); await Future.delayed(ADurations.dialogTransitionLoose * timeDilation);
if (value != null && initialValue != value) { if (value != null && initialValue != value) {
settings.collectionSortFactor = value.$1!; settings.collectionSortFactor = value.$1!;

View file

@ -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<double> appBarHeightNotifier;
final ScrollController scrollController;
final CollectionLens collection;
const CollectionAppBar({
super.key,
required this.appBarHeightNotifier,
required this.scrollController,
required this.collection,
});
@override
State<CollectionAppBar> createState() => _CollectionAppBarState();
}
class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, SingleTickerProviderStateMixin, WidgetsBindingObserver {
final Set<StreamSubscription> _subscriptions = {};
final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate();
late AnimationController _browseToSelectAnimation;
final ValueNotifier<bool> _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<CollectionFilter> 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<Query>();
_subscriptions.add(query.enabledStream.listen((e) => _updateAppBarHeight()));
_queryFocusRequestNotifier = query.focusRequestNotifier;
_queryFocusRequestNotifier.addListener(_onQueryFocusRequest);
_queryBarFocusNode.addListener(_onQueryBarFocusChanged);
_browseToSelectAnimation = AnimationController(
duration: context.read<DurationsData>().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<ValueNotifier<AppMode>>().value;
final selection = context.watch<Selection<AvesEntry>>();
final isSelecting = selection.isSelecting;
_isSelectingNotifier.value = isSelecting;
return NotificationListener<ScrollNotification>(
// 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<Query, bool>(
selector: (context, query) => query.enabled,
builder: (context, queryEnabled, child) {
return Selector<Settings, List<EntrySetAction>>(
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<Selection<AvesEntry>, 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, ValueNotifier<String>>((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<Query>().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<Selection<AvesEntry>>().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<Selection<AvesEntry>?, 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<ValueNotifier<AppMode>>().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<Widget> _buildActions(BuildContext context, Selection<AvesEntry> selection, double maxWidth) {
final appMode = context.watch<ValueNotifier<AppMode>>().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<Widget> _buildTelevisionActions({
required BuildContext context,
required AppMode appMode,
required Selection<AvesEntry> 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<Widget> _buildMobileActions({
required BuildContext context,
required AppMode appMode,
required Selection<AvesEntry> 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<Settings, AccessibilityAnimations>((v) => v.accessibilityAnimations);
return [
...quickActionButtons,
PopupMenuButton<EntrySetAction>(
// 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(<EntrySetAction?>[], (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 = <PopupMenuEntry<EntrySetAction>>[
...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<EntrySetAction>(
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<AvesEntry> _getExpandedSelectedItems(Selection<AvesEntry> 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<AvesEntry> 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<Query?, bool>(
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<EntrySetAction> _toMenuItem(EntrySetAction action, {required bool enabled, required Selection<AvesEntry> selection}) {
late Widget child;
switch (action) {
case EntrySetAction.toggleTitleSearch:
child = TitleSearchToggler(
queryEnabled: context.read<Query>().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<EntrySetAction> _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<Selection<AvesEntry>>().isSelecting) {
_browseToSelectAnimation.forward();
} else {
_browseToSelectAnimation.reverse();
}
}
void _onFilterChanged() {
_updateAppBarHeight();
final filters = collection.filters;
if (filters.isNotEmpty) {
final selection = context.read<Selection<AvesEntry>>();
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<void> _onActionSelected(EntrySetAction action) async {
switch (action) {
// general
case EntrySetAction.configureView:
await _configureView();
case EntrySetAction.select:
context.read<Selection<AvesEntry>>().select();
case EntrySetAction.selectAll:
context.read<Selection<AvesEntry>>().addToSelection(collection.sortedEntries);
case EntrySetAction.selectNone:
context.read<Selection<AvesEntry>>().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<void> _configureView() async {
final initialValue = (
settings.collectionSortFactor,
settings.collectionSectionFactor,
settings.getTileLayout(CollectionPage.routeName),
settings.collectionSortReverse,
);
final extentController = context.read<TileExtentController>();
final value = await showDialog<(EntrySortFactor?, EntrySectionFactor?, TileLayout?, bool)>(
context: context,
builder: (context) {
return TileViewDialog<EntrySortFactor, EntrySectionFactor, TileLayout>(
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,
),
),
);
}
}

View file

@ -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<double> appBarHeightNotifier;
final ScrollController scrollController;
final CollectionLens collection;
const CollectionAppBar({
super.key,
required this.appBarHeightNotifier,
required this.scrollController,
required this.collection,
});
@override
State<CollectionAppBar> createState() => _CollectionAppBarState();
}
class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, SingleTickerProviderStateMixin, WidgetsBindingObserver {
final Set<StreamSubscription> _subscriptions = {};
final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate();
late AnimationController _browseToSelectAnimation;
final ValueNotifier<bool> _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<CollectionFilter> 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<Query>();
_subscriptions.add(query.enabledStream.listen((e) => _updateAppBarHeight()));
_queryFocusRequestNotifier = query.focusRequestNotifier;
_queryFocusRequestNotifier.addListener(_onQueryFocusRequest);
_queryBarFocusNode.addListener(_onQueryBarFocusChanged);
_browseToSelectAnimation = AnimationController(
duration: context.read<DurationsData>().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<ValueNotifier<AppMode>>().value;
final selection = context.watch<Selection<AvesEntry>>();
final isSelecting = selection.isSelecting;
_isSelectingNotifier.value = isSelecting;
return NotificationListener<ScrollNotification>(
onNotification: (notification) => true,
child: AnimatedBuilder(
animation: collection.filterChangeNotifier,
builder: (context, child) {
final canRemoveFilters = appMode != AppMode.pickFilteredMediaInternal;
return Selector<Query, bool>(
selector: (context, query) => query.enabled,
builder: (context, queryEnabled, child) {
return Selector<Settings, List<EntrySetAction>>(
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<Selection<AvesEntry>, 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, ValueNotifier<String>>((query) => query.queryNotifier),
focusNode: _queryBarFocusNode,
),
],
),
transitionKey: isSelecting,
);
},
);
},
);
},
),
);
}
// NEW: costruisce la lista actions con la parabola sempre presente
List<Widget> _buildAppBarActions({
required BuildContext context,
required Selection<AvesEntry> 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<Query>().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<Selection<AvesEntry>>().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<Selection<AvesEntry>?, 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<ValueNotifier<AppMode>>().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<Widget> _buildActions(BuildContext context, Selection<AvesEntry> selection, double maxWidth) {
final appMode = context.watch<ValueNotifier<AppMode>>().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<Widget> _buildTelevisionActions({
required BuildContext context,
required AppMode appMode,
required Selection<AvesEntry> 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<Widget> _buildMobileActions({
required BuildContext context,
required AppMode appMode,
required Selection<AvesEntry> 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<Settings, AccessibilityAnimations>((v) => v.accessibilityAnimations);
return [
...quickActionButtons,
PopupMenuButton<EntrySetAction>(
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<List<EntrySetAction?>>([], (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 = <PopupMenuEntry<EntrySetAction>>[
...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<EntrySetAction>(
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<AvesEntry> _getExpandedSelectedItems(Selection<AvesEntry> 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<AvesEntry> selection,
}) {
final blurred = settings.enableBlurEffect;
final onPressed = enabled ? () => _onActionSelected(action) : null;
switch (action) {
case EntrySetAction.toggleTitleSearch:
return Selector<Query?, bool>(
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<EntrySetAction> _toMenuItem(EntrySetAction action, {required bool enabled, required Selection<AvesEntry> selection}) {
late Widget child;
switch (action) {
case EntrySetAction.toggleTitleSearch:
child = TitleSearchToggler(
queryEnabled: context.read<Query>().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<EntrySetAction> _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<Selection<AvesEntry>>().isSelecting) {
_browseToSelectAnimation.forward();
} else {
_browseToSelectAnimation.reverse();
}
}
void _onFilterChanged() {
_updateAppBarHeight();
final filters = collection.filters;
if (filters.isNotEmpty) {
final selection = context.read<Selection<AvesEntry>>();
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<void> _onActionSelected(EntrySetAction action) async {
switch (action) {
case EntrySetAction.configureView:
await _configureView();
case EntrySetAction.select:
context.read<Selection<AvesEntry>>().select();
case EntrySetAction.selectAll:
context.read<Selection<AvesEntry>>().addToSelection(collection.sortedEntries);
case EntrySetAction.selectNone:
context.read<Selection<AvesEntry>>().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<void> _configureView() async {
final initialValue = (
settings.collectionSortFactor,
settings.collectionSectionFactor,
settings.getTileLayout(CollectionPage.routeName),
settings.collectionSortReverse,
);
final extentController = context.read<TileExtentController>();
final value = await showDialog<(EntrySortFactor?, EntrySectionFactor?, TileLayout?, bool)>(
context: context,
builder: (context) {
return TileViewDialog<EntrySortFactor, EntrySectionFactor, TileLayout>(
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,
),
),
);
}
}

View file

@ -56,6 +56,9 @@ import 'package:intl/intl.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.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 // REMOTE: import per le thumb di rete
import 'package:aves/remote/remote_image_tile.dart'; import 'package:aves/remote/remote_image_tile.dart';
@ -91,7 +94,11 @@ class _CollectionGridState extends State<CollectionGrid> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final spacing = context.select<Settings, double>((v) => v.getTileLayout(settingsRouteKey) == TileLayout.mosaic ? CollectionGrid.mosaicLayoutSpacing : CollectionGrid.fixedExtentLayoutSpacing); final spacing = context.select<Settings, double>((v) =>
v.getTileLayout(settingsRouteKey) == TileLayout.mosaic
? CollectionGrid.mosaicLayoutSpacing
: CollectionGrid.fixedExtentLayoutSpacing);
if (_tileExtentController?.spacing != spacing) { if (_tileExtentController?.spacing != spacing) {
_tileExtentController = TileExtentController( _tileExtentController = TileExtentController(
settingsRouteKey: settingsRouteKey, settingsRouteKey: settingsRouteKey,
@ -102,6 +109,7 @@ class _CollectionGridState extends State<CollectionGrid> {
horizontalPadding: 2, horizontalPadding: 2,
); );
} }
return TileExtentControllerProvider( return TileExtentControllerProvider(
controller: _tileExtentController!, controller: _tileExtentController!,
child: const _CollectionGridContent(), child: const _CollectionGridContent(),
@ -119,12 +127,14 @@ class _CollectionGridContent extends StatefulWidget {
class _CollectionGridContentState extends State<_CollectionGridContent> { class _CollectionGridContentState extends State<_CollectionGridContent> {
final ValueNotifier<AvesEntry?> _focusedItemNotifier = ValueNotifier(null); final ValueNotifier<AvesEntry?> _focusedItemNotifier = ValueNotifier(null);
final ValueNotifier<bool> _isScrollingNotifier = ValueNotifier(false); final ValueNotifier<bool> _isScrollingNotifier = ValueNotifier(false);
final ValueNotifier<AppMode> _selectingAppModeNotifier = ValueNotifier(AppMode.pickFilteredMediaInternal); final ValueNotifier<AppMode> _selectingAppModeNotifier =
ValueNotifier(AppMode.pickFilteredMediaInternal);
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => context.read<ViewerEntryNotifier>().value = null); WidgetsBinding.instance
.addPostFrameCallback((_) => context.read<ViewerEntryNotifier>().value = null);
} }
@override @override
@ -137,20 +147,26 @@ class _CollectionGridContentState extends State<_CollectionGridContent> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final selectable = context.select<ValueNotifier<AppMode>, bool>((v) => v.value.canSelectMedia); final selectable =
context.select<ValueNotifier<AppMode>, bool>((v) => v.value.canSelectMedia);
final settingsRouteKey = context.read<TileExtentController>().settingsRouteKey; final settingsRouteKey = context.read<TileExtentController>().settingsRouteKey;
final tileLayout = context.select<Settings, TileLayout>((v) => v.getTileLayout(settingsRouteKey)); final tileLayout =
context.select<Settings, TileLayout>((v) => v.getTileLayout(settingsRouteKey));
return Consumer<CollectionLens>( return Consumer<CollectionLens>(
builder: (context, collection, child) { builder: (context, collection, child) {
final sectionedListLayoutProvider = ValueListenableBuilder<double>( final sectionedListLayoutProvider = ValueListenableBuilder<double>(
valueListenable: context.select<TileExtentController, ValueNotifier<double>>((controller) => controller.extentNotifier), valueListenable: context.select<TileExtentController, ValueNotifier<double>>(
(controller) => controller.extentNotifier),
builder: (context, thumbnailExtent, child) { builder: (context, thumbnailExtent, child) {
assert(thumbnailExtent > 0); assert(thumbnailExtent > 0);
return Selector<TileExtentController, (double, int, double, double)>( return Selector<TileExtentController, (double, int, double, double)>(
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) { builder: (context, c, child) {
final (scrollableWidth, columnCount, tileSpacing, horizontalPadding) = c; final (scrollableWidth, columnCount, tileSpacing, horizontalPadding) = c;
final source = collection.source; final source = collection.source;
return GridTheme( return GridTheme(
extent: thumbnailExtent, extent: thumbnailExtent,
child: EntryListDetailsTheme( child: EntryListDetailsTheme(
@ -160,9 +176,10 @@ class _CollectionGridContentState extends State<_CollectionGridContent> {
builder: (context, sourceState, child) { builder: (context, sourceState, child) {
late final Duration tileAnimationDelay; late final Duration tileAnimationDelay;
if (sourceState == SourceState.ready) { if (sourceState == SourceState.ready) {
// do not listen for animation delay change
final target = context.read<DurationsData>().staggeredAnimationPageTarget; final target = context.read<DurationsData>().staggeredAnimationPageTarget;
tileAnimationDelay = context.read<TileExtentController>().getTileAnimationDelay(target); tileAnimationDelay = context
.read<TileExtentController>()
.getTileAnimationDelay(target);
} else { } else {
tileAnimationDelay = Duration.zero; tileAnimationDelay = Duration.zero;
} }
@ -223,7 +240,8 @@ class _CollectionGridContentState extends State<_CollectionGridContent> {
return AnimatedScale( return AnimatedScale(
scale: focusedItem == entry ? 1 : .9, scale: focusedItem == entry ? 1 : .9,
curve: Curves.fastOutSlowIn, curve: Curves.fastOutSlowIn,
duration: context.select<DurationsData, Duration>((v) => v.tvImageFocusAnimation), duration: context.select<DurationsData, Duration>(
(v) => v.tvImageFocusAnimation),
child: child!, child: child!,
); );
}, },
@ -261,12 +279,8 @@ class _CollectionGridContentState extends State<_CollectionGridContent> {
} }
Future<void> _goToViewer(CollectionLens collection, AvesEntry entry) async { Future<void> _goToViewer(CollectionLens collection, AvesEntry entry) async {
// track viewer entry for dynamic hero placeholder
final viewerEntryNotifier = context.read<ViewerEntryNotifier>(); final viewerEntryNotifier = context.read<ViewerEntryNotifier>();
// 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; if (viewerEntryNotifier.value == entry) return;
WidgetsBinding.instance.addPostFrameCallback((_) => viewerEntryNotifier.value = entry); WidgetsBinding.instance.addPostFrameCallback((_) => viewerEntryNotifier.value = entry);
@ -298,10 +312,8 @@ class _CollectionGridContentState extends State<_CollectionGridContent> {
), ),
); );
// reset track viewer entry
final animate = context.read<Settings>().animate; final animate = context.read<Settings>().animate;
if (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); await Future.delayed(ADurations.pageTransitionExact * timeDilation);
} }
viewerEntryNotifier.value = null; viewerEntryNotifier.value = null;
@ -409,7 +421,9 @@ class _CollectionScaler extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final (tileSpacing, horizontalPadding) = context.select<TileExtentController, (double, double)>((v) => (v.spacing, v.horizontalPadding)); final (tileSpacing, horizontalPadding) =
context.select<TileExtentController, (double, double)>(
(v) => (v.spacing, v.horizontalPadding));
final brightness = Theme.of(context).brightness; final brightness = Theme.of(context).brightness;
final borderColor = DecoratedThumbnail.borderColor(context); final borderColor = DecoratedThumbnail.borderColor(context);
final borderWidth = DecoratedThumbnail.borderWidth(context); final borderWidth = DecoratedThumbnail.borderWidth(context);
@ -435,7 +449,6 @@ class _CollectionScaler extends StatelessWidget {
extent: tileSize.height, extent: tileSize.height,
child: Builder( child: Builder(
builder: (_) { builder: (_) {
// REMOTE: ramo dedicato in layout "fixed scale"
if (entry.origin == 1) { if (entry.origin == 1) {
return RemoteInteractiveTile( return RemoteInteractiveTile(
key: ValueKey('remote_scaled_${entry.id}'), key: ValueKey('remote_scaled_${entry.id}'),
@ -443,7 +456,6 @@ class _CollectionScaler extends StatelessWidget {
thumbnailExtent: context.read<TileExtentController>().effectiveExtentMax, thumbnailExtent: context.read<TileExtentController>().effectiveExtentMax,
); );
} }
// Locale: flusso preesistente
return Tile( return Tile(
entry: entry, entry: entry,
thumbnailExtent: context.read<TileExtentController>().effectiveExtentMax, thumbnailExtent: context.read<TileExtentController>().effectiveExtentMax,
@ -454,7 +466,8 @@ class _CollectionScaler extends StatelessWidget {
), ),
mosaicItemBuilder: (index, targetExtent) => DecoratedBox( mosaicItemBuilder: (index, targetExtent) => DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: ThumbnailImage.computeLoadingBackgroundColor(index * 10, brightness).withValues(alpha: .9), color: ThumbnailImage.computeLoadingBackgroundColor(index * 10, brightness)
.withValues(alpha: .9),
border: Border.all( border: Border.all(
color: borderColor, color: borderColor,
width: borderWidth, width: borderWidth,
@ -491,13 +504,28 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge
Timer? _scrollMonitoringTimer; Timer? _scrollMonitoringTimer;
bool _checkingStoragePermission = false; bool _checkingStoragePermission = false;
// NEW: memoizza se esiste cache DB (evita lo "scanner" alle riaperture)
late final Future<bool> _hasAnyDbCacheFuture;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_hasAnyDbCacheFuture = _hasAnyDbCache();
_registerWidget(widget); _registerWidget(widget);
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
} }
Future<bool> _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 @override
void didUpdateWidget(covariant _CollectionScrollView oldWidget) { void didUpdateWidget(covariant _CollectionScrollView oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
@ -553,14 +581,16 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge
return Selector<Settings, bool>( return Selector<Settings, bool>(
selector: (context, s) => s.enableBottomNavigationBar, selector: (context, s) => s.enableBottomNavigationBar,
builder: (context, enableBottomNavigationBar, child) { builder: (context, enableBottomNavigationBar, child) {
final canNavigate = context.select<ValueNotifier<AppMode>, bool>((v) => v.value.canNavigate); final canNavigate =
context.select<ValueNotifier<AppMode>, bool>((v) => v.value.canNavigate);
final showBottomNavigationBar = canNavigate && enableBottomNavigationBar; final showBottomNavigationBar = canNavigate && enableBottomNavigationBar;
final navBarHeight = showBottomNavigationBar ? AppBottomNavBar.height : 0; final navBarHeight = showBottomNavigationBar ? AppBottomNavBar.height : 0;
return Selector<SectionedListLayout<AvesEntry>, List<SectionLayout>>( return Selector<SectionedListLayout<AvesEntry>, List<SectionLayout>>(
selector: (context, layout) => layout.sectionLayouts, selector: (context, layout) => layout.sectionLayouts,
builder: (context, sectionLayouts, child) { builder: (context, sectionLayouts, child) {
final scrollController = widget.scrollController; final scrollController = widget.scrollController;
final offsetIncrementSnapThreshold = context.select<TileExtentController, double>((v) => (v.extentNotifier.value + v.spacing) / 4); final offsetIncrementSnapThreshold =
context.select<TileExtentController, double>((v) => (v.extentNotifier.value + v.spacing) / 4);
return DraggableScrollbar( return DraggableScrollbar(
backgroundColor: Colors.white, backgroundColor: Colors.white,
scrollThumbSize: Size(avesScrollThumbWidth, avesScrollThumbHeight), scrollThumbSize: Size(avesScrollThumbWidth, avesScrollThumbHeight),
@ -570,14 +600,15 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge
), ),
controller: scrollController, controller: scrollController,
dragOffsetSnapper: (scrollOffset, offsetIncrement) { dragOffsetSnapper: (scrollOffset, offsetIncrement) {
if (offsetIncrement > offsetIncrementSnapThreshold && scrollOffset < scrollController.position.maxScrollExtent) { if (offsetIncrement > offsetIncrementSnapThreshold &&
final section = sectionLayouts.firstWhereOrNull((section) => section.hasChildAtOffset(scrollOffset)); scrollOffset < scrollController.position.maxScrollExtent) {
final section =
sectionLayouts.firstWhereOrNull((section) => section.hasChildAtOffset(scrollOffset));
if (section != null) { if (section != null) {
if (section.maxOffset - section.minOffset < scrollController.position.viewportDimension) { if (section.maxOffset - section.minOffset <
// snap to section header scrollController.position.viewportDimension) {
return section.minOffset; return section.minOffset;
} else { } else {
// snap to content row
final index = section.getMinChildIndexForScrollOffset(scrollOffset); final index = section.getMinChildIndexForScrollOffset(scrollOffset);
return section.indexToLayoutOffset(index); return section.indexToLayoutOffset(index);
} }
@ -587,7 +618,6 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge
}, },
crumbsBuilder: () => _getCrumbs(sectionLayouts), crumbsBuilder: () => _getCrumbs(sectionLayouts),
padding: EdgeInsets.only( padding: EdgeInsets.only(
// padding to keep scroll thumb between app bar above and nav bar below
top: appBarHeight, top: appBarHeight,
bottom: navBarHeight + mqPaddingBottom, bottom: navBarHeight + mqPaddingBottom,
), ),
@ -612,15 +642,19 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge
return CustomScrollView( return CustomScrollView(
key: widget.scrollableKey, key: widget.scrollableKey,
primary: true, primary: true,
// workaround to prevent scrolling the app bar away
// when there is no content and we use `SliverFillRemaining`
physics: collection.isEmpty physics: collection.isEmpty
? const NeverScrollableScrollPhysics() ? const NeverScrollableScrollPhysics()
: SloppyScrollPhysics( : SloppyScrollPhysics(
gestureSettings: MediaQuery.gestureSettingsOf(context), gestureSettings: MediaQuery.gestureSettingsOf(context),
parent: const AlwaysScrollableScrollPhysics(), parent: const AlwaysScrollableScrollPhysics(),
), ),
cacheExtent: context.select<TileExtentController, double>((controller) => controller.effectiveExtentMax), // MOD: preload viewport + ahead (senza prefetchRelPaths)
cacheExtent: (() {
final base = context.select<TileExtentController, double>((c) => c.effectiveExtentMax);
final h = MediaQuery.of(context).size.height;
final target = h * 2; // ~2 schermate avanti
return target < base ? base : target;
})(),
slivers: [ slivers: [
appBar, appBar,
collection.isEmpty collection.isEmpty
@ -642,7 +676,23 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge
valueListenable: source.stateNotifier, valueListenable: source.stateNotifier,
builder: (context, sourceState, child) { builder: (context, sourceState, child) {
if (sourceState == SourceState.loading) { if (sourceState == SourceState.loading) {
// MOD: se DB ha cache, non mostrare "scanner" ma solo spinner piccolo
return FutureBuilder<bool>(
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 LoadingEmptyContent(source: source);
},
);
} }
return FutureBuilder<bool>( return FutureBuilder<bool>(
@ -670,7 +720,8 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge
bottom: bottom, 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( return EmptyContent(
icon: AIcons.video, icon: AIcons.video,
text: context.l10n.collectionEmptyVideos, text: context.l10n.collectionEmptyVideos,
@ -705,7 +756,8 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge
if (sectionLayouts.length <= 1) return crumbs; if (sectionLayouts.length <= 1) return crumbs;
final maxOffset = sectionLayouts.last.maxOffset; final maxOffset = sectionLayouts.last.maxOffset;
void addAlbums(CollectionLens collection, List<SectionLayout> sectionLayouts, Map<double, String> crumbs) { void addAlbums(CollectionLens collection, List<SectionLayout> sectionLayouts,
Map<double, String> crumbs) {
final source = collection.source; final source = collection.source;
sectionLayouts.forEach((section) { sectionLayouts.forEach((section) {
final directory = (section.sectionKey as EntryAlbumSectionKey).directory; final directory = (section.sectionKey as EntryAlbumSectionKey).directory;
@ -731,7 +783,9 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge
final oldest = lastKey.date; final oldest = lastKey.date;
if (newest != null && oldest != null) { if (newest != null && oldest != null) {
final locale = context.locale; 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; String? lastLabel;
sectionLayouts.forEach((section) { sectionLayouts.forEach((section) {
final date = (section.sectionKey as EntryDateSectionKey).date; final date = (section.sectionKey as EntryDateSectionKey).date;
@ -759,7 +813,9 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge
return crumbs; return crumbs;
} }
Future<bool> get _isStoragePermissionGranted => Future.wait(Permissions.storage.map((v) => v.status)).then((v) => v.any((status) => status.isGranted)); Future<bool> 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 // REMOTE: mini-tile che mostra la thumb remota e apre il viewer con OpenViewerNotification
@ -775,8 +831,6 @@ class RemoteInteractiveTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { 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( return GestureDetector(
onTap: () => OpenViewerNotification(entry).dispatch(context), onTap: () => OpenViewerNotification(entry).dispatch(context),
child: ClipRRect( child: ClipRRect(

View file

@ -56,6 +56,9 @@ import 'package:intl/intl.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.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 { class CollectionGrid extends StatefulWidget {
final String settingsRouteKey; final String settingsRouteKey;
@ -182,6 +185,17 @@ class _CollectionGridContentState extends State<_CollectionGridContent> {
tileExtent: thumbnailExtent, tileExtent: thumbnailExtent,
tileBuilder: (entry, tileSize) { tileBuilder: (entry, tileSize) {
final extent = tileSize.shortestSide; 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( return AnimatedBuilder(
animation: favourites, animation: favourites,
builder: (context, child) { builder: (context, child) {
@ -419,10 +433,23 @@ class _CollectionScaler extends StatelessWidget {
), ),
scaledItemBuilder: (entry, tileSize) => EntryListDetailsTheme( scaledItemBuilder: (entry, tileSize) => EntryListDetailsTheme(
extent: tileSize.height, extent: tileSize.height,
child: Tile( 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<TileExtentController>().effectiveExtentMax,
);
}
// Locale: flusso preesistente
return Tile(
entry: entry, entry: entry,
thumbnailExtent: context.read<TileExtentController>().effectiveExtentMax, thumbnailExtent: context.read<TileExtentController>().effectiveExtentMax,
tileLayout: tileLayout, tileLayout: tileLayout,
);
},
), ),
), ),
mosaicItemBuilder: (index, targetExtent) => DecoratedBox( mosaicItemBuilder: (index, targetExtent) => DecoratedBox(
@ -734,3 +761,32 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge
Future<bool> get _isStoragePermissionGranted => Future.wait(Permissions.storage.map((v) => v.status)).then((v) => v.any((status) => status.isGranted)); Future<bool> 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),
),
),
);
}
}

View file

@ -33,6 +33,9 @@ import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.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 { class CollectionPage extends StatefulWidget {
static const routeName = '/collection'; static const routeName = '/collection';
@ -54,7 +57,8 @@ class CollectionPage extends StatefulWidget {
class _CollectionPageState extends State<CollectionPage> { class _CollectionPageState extends State<CollectionPage> {
final Set<StreamSubscription> _subscriptions = {}; final Set<StreamSubscription> _subscriptions = {};
late CollectionLens _collection; late CollectionLens _collection;
final StreamController<DraggableScrollbarEvent> _draggableScrollBarEventStreamController = StreamController.broadcast(); final StreamController<DraggableScrollbarEvent> _draggableScrollBarEventStreamController =
StreamController.broadcast();
@override @override
void initState() { void initState() {
@ -66,7 +70,9 @@ class _CollectionPageState extends State<CollectionPage> {
); );
super.initState(); super.initState();
_subscriptions.add( _subscriptions.add(
settings.updateStream.where((event) => event.key == SettingKeys.enableBinKey).listen((_) { settings.updateStream
.where((event) => event.key == SettingKeys.enableBinKey)
.listen((_) {
if (!settings.enableBin) { if (!settings.enableBin) {
_collection.removeFilter(TrashFilter.instance); _collection.removeFilter(TrashFilter.instance);
} }
@ -87,7 +93,8 @@ class _CollectionPageState extends State<CollectionPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final useTvLayout = settings.useTvLayout; 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<AvesEntry>( return SelectionProvider<AvesEntry>(
child: Selector<Selection<AvesEntry>, bool>( child: Selector<Selection<AvesEntry>, bool>(
selector: (context, selection) => selection.selectedItems.isNotEmpty, selector: (context, selection) => selection.selectedItems.isNotEmpty,
@ -107,7 +114,10 @@ class _CollectionPageState extends State<CollectionPage> {
doubleBackPopHandler, doubleBackPopHandler,
], ],
child: GestureAreaProtectorStack( child: GestureAreaProtectorStack(
child: DirectionalSafeArea( // STEP 2: overlay in Stack (griglia + banner progresso remoti)
child: Stack(
children: [
DirectionalSafeArea(
start: !useTvLayout, start: !useTvLayout,
top: false, top: false,
bottom: false, bottom: false,
@ -117,6 +127,9 @@ class _CollectionPageState extends State<CollectionPage> {
settingsRouteKey: CollectionPage.routeName, settingsRouteKey: CollectionPage.routeName,
), ),
), ),
const RemoteProgressBanner(),
],
),
), ),
); );
}, },
@ -142,7 +155,8 @@ class _CollectionPageState extends State<CollectionPage> {
page = Selector<Settings, bool>( page = Selector<Settings, bool>(
selector: (context, s) => s.enableBottomNavigationBar, selector: (context, s) => s.enableBottomNavigationBar,
builder: (context, enableBottomNavigationBar, child) { builder: (context, enableBottomNavigationBar, child) {
final canNavigate = context.select<ValueNotifier<AppMode>, bool>((v) => v.value.canNavigate); final canNavigate =
context.select<ValueNotifier<AppMode>, bool>((v) => v.value.canNavigate);
final showBottomNavigationBar = canNavigate && enableBottomNavigationBar; final showBottomNavigationBar = canNavigate && enableBottomNavigationBar;
return NotificationListener<DraggableScrollbarNotification>( return NotificationListener<DraggableScrollbarNotification>(
@ -167,6 +181,7 @@ class _CollectionPageState extends State<CollectionPage> {
}, },
); );
} }
// this provider should be above `TvRail` // this provider should be above `TvRail`
return ChangeNotifierProvider<CollectionLens>.value( return ChangeNotifierProvider<CollectionLens>.value(
value: _collection, value: _collection,

View file

@ -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<CollectionFilter?>? filters;
final bool Function(AvesEntry element)? highlightTest;
const CollectionPage({
super.key,
required this.source,
required this.filters,
this.highlightTest,
});
@override
State<CollectionPage> createState() => _CollectionPageState();
}
class _CollectionPageState extends State<CollectionPage> {
final Set<StreamSubscription> _subscriptions = {};
late CollectionLens _collection;
final StreamController<DraggableScrollbarEvent> _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<AvesEntry>(
child: Selector<Selection<AvesEntry>, 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<Selection<AvesEntry>, bool>((v) => !v.isSelecting),
onPopBlocked: (context) => context.read<Selection<AvesEntry>>().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<TvRailController>(),
currentCollection: _collection,
),
Expanded(child: body),
],
),
resizeToAvoidBottomInset: false,
extendBody: true,
);
} else {
page = Selector<Settings, bool>(
selector: (context, s) => s.enableBottomNavigationBar,
builder: (context, enableBottomNavigationBar, child) {
final canNavigate = context.select<ValueNotifier<AppMode>, bool>((v) => v.value.canNavigate);
final showBottomNavigationBar = canNavigate && enableBottomNavigationBar;
return NotificationListener<DraggableScrollbarNotification>(
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<CollectionLens>.value(
value: _collection,
child: page,
);
},
),
);
}
Widget? _buildFab(BuildContext context, bool hasSelection) {
final appMode = context.watch<ValueNotifier<AppMode>>().value;
final l10n = context.l10n;
switch (appMode) {
case AppMode.pickMultipleMediaExternal:
return hasSelection
? AvesFab(
tooltip: l10n.pickTooltip,
onPressed: () async {
final items = context.read<Selection<AvesEntry>>().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<void> _checkInitHighlight() async {
final highlightTest = widget.highlightTest;
if (highlightTest == null) return;
final item = _collection.sortedEntries.firstWhereOrNull(highlightTest);
if (item == null) return;
final delayDuration = context.read<DurationsData>().staggeredAnimationPageTarget;
await Future.delayed(delayDuration + ADurations.highlightScrollInitDelay);
if (!mounted) return;
final animate = context.read<Settings>().animate;
context.read<HighlightInfo>().trackItem(item, animate: animate, highlightItem: item);
}
}

View file

@ -1,5 +1,6 @@
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/events.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/action_mixins/feedback.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/common/identity/empty.dart';
@ -26,7 +27,11 @@ class LoadingEmptyContent extends StatelessWidget {
text: context.l10n.sourceStateLoading, text: context.l10n.sourceStateLoading,
bottom: Padding( bottom: Padding(
padding: const EdgeInsets.only(top: 16), padding: const EdgeInsets.only(top: 16),
child: Stack( child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// === PROGRESS LOCALE (Aves originale) ===
Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
const ReportProgressIndicator(), const ReportProgressIndicator(),
@ -44,7 +49,35 @@ class LoadingEmptyContent extends StatelessWidget {
), ),
], ],
), ),
// === PROGRESS REMOTO (solo bootstrap, stile "Aves") ===
ValueListenableBuilder<RemoteSyncProgress?>(
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,
),
);
},
),
],
),
), ),
); );
} }
} }

View file

@ -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<ProgressEvent>(
valueListenable: source.progressNotifier,
builder: (context, progress, snapshot) {
final done = progress.done;
return done > 0
? Text(
countFormatter.format(done),
style: progressTextStyle,
)
: const SizedBox();
},
),
],
),
),
);
}
}

View file

@ -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<RemoteSyncProgress?>(
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,
),
),
],
),
),
),
);
},
);
}
}

View file

@ -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<RemoteSyncProgress?>(
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,
),
),
],
),
),
),
);
},
);
}
}

View file

@ -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<RemoteStatusButton> createState() => _RemoteStatusButtonState();
}
class _RemoteStatusButtonState extends State<RemoteStatusButton>
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<void> _toggle() async {
if (_busy) return;
setState(() => _busy = true);
try {
await RemoteController.instance.toggleRemote(source: widget.source);
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _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<RemoteSyncState>(
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 lheader
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),
),
);
},
);
}
}

View file

@ -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<RemoteStatusIcon> createState() => _RemoteStatusIconState();
}
class _RemoteStatusIconState extends State<RemoteStatusIcon> 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<RemoteSyncState>(
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),
);
},
);
}
}

View file

@ -1,7 +1,5 @@
// lib/widgets/home/home_page.dart
import 'dart:async'; import 'dart:async';
import 'package:aves/remote/collection_source_remote_ext.dart';
import 'package:aves/app_mode.dart'; import 'package:aves/app_mode.dart';
import 'package:aves/geo/uri.dart'; import 'package:aves/geo/uri.dart';
import 'package:aves/model/app/intent.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:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
// --- IMPORT aggiunti per integrazione remota / telemetria --- // Remote
import 'package:flutter/foundation.dart' show kDebugMode; import 'package:aves/remote/remote_controller.dart';
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_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 { class HomePage extends StatefulWidget {
static const routeName = '/'; static const routeName = '/';
// untyped map as it is coming from the platform
// compatibile con aves_app.dart
final Map? intentData; final Map? intentData;
const HomePage({ const HomePage({
@ -88,14 +74,6 @@ class _HomePageState extends State<HomePage> {
List<String>? _secureUris; List<String>? _secureUris;
(Object, StackTrace)? _setupError; (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 = [ static const allowedShortcutRoutes = [
AlbumListPage.routeName, AlbumListPage.routeName,
CollectionPage.routeName, CollectionPage.routeName,
@ -121,20 +99,32 @@ class _HomePageState extends State<HomePage> {
: null, : null,
); );
Map<String, Object?> _safeCastIntentMap(Object? raw) {
if (raw is Map) {
final out = <String, Object?>{};
for (final entry in raw.entries) {
final k = entry.key;
if (k is String) out[k] = entry.value as Object?;
}
return out;
}
return <String, Object?>{};
}
Future<void> _setup() async { Future<void> _setup() async {
try { try {
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
if (await windowService.isActivity()) { if (await windowService.isActivity()) {
// do not check whether permission was granted, because some app stores // Permessi Aves originali
// hide in some countries apps that force quit on permission denial
await Permissions.mediaAccess.request(); await Permissions.mediaAccess.request();
} }
var appMode = AppMode.main; var appMode = AppMode.main;
var error = false; 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?; final intentAction = intentData[IntentDataKeys.action] as String?;
_initialFilters = null; _initialFilters = null;
@ -144,19 +134,6 @@ class _HomePageState extends State<HomePage> {
await availability.onNewIntent(); await availability.onNewIntent();
await androidFileUtils.init(); 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 (!{ if (!{
IntentActions.edit, IntentActions.edit,
IntentActions.screenSaver, IntentActions.screenSaver,
@ -187,34 +164,40 @@ class _HomePageState extends State<HomePage> {
} }
} }
break; break;
case IntentActions.edit: case IntentActions.edit:
appMode = AppMode.edit; appMode = AppMode.edit;
case IntentActions.setWallpaper: case IntentActions.setWallpaper:
appMode = AppMode.setWallpaper; appMode = AppMode.setWallpaper;
case IntentActions.pickItems: case IntentActions.pickItems:
// some apps define multiple types, separated by a space
final multiple = (intentData[IntentDataKeys.allowMultiple] as bool?) ?? false; final multiple = (intentData[IntentDataKeys.allowMultiple] as bool?) ?? false;
debugPrint('pick mimeType=$intentMimeType multiple=$multiple'); debugPrint('pick mimeType=$intentMimeType multiple=$multiple');
appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal; appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal;
case IntentActions.pickCollectionFilters: case IntentActions.pickCollectionFilters:
appMode = AppMode.pickCollectionFiltersExternal; appMode = AppMode.pickCollectionFiltersExternal;
case IntentActions.screenSaver: case IntentActions.screenSaver:
appMode = AppMode.screenSaver; appMode = AppMode.screenSaver;
_initialRouteName = ScreenSaverPage.routeName; _initialRouteName = ScreenSaverPage.routeName;
case IntentActions.screenSaverSettings: case IntentActions.screenSaverSettings:
_initialRouteName = ScreenSaverSettingsPage.routeName; _initialRouteName = ScreenSaverSettingsPage.routeName;
case IntentActions.search: case IntentActions.search:
_initialRouteName = SearchPage.routeName; _initialRouteName = SearchPage.routeName;
_initialSearchQuery = intentData[IntentDataKeys.query] as String?; _initialSearchQuery = intentData[IntentDataKeys.query] as String?;
case IntentActions.widgetSettings: case IntentActions.widgetSettings:
_initialRouteName = HomeWidgetSettingsPage.routeName; _initialRouteName = HomeWidgetSettingsPage.routeName;
_widgetId = (intentData[IntentDataKeys.widgetId] as int?) ?? 0; _widgetId = (intentData[IntentDataKeys.widgetId] as int?) ?? 0;
case IntentActions.widgetOpen: case IntentActions.widgetOpen:
final widgetId = intentData[IntentDataKeys.widgetId] as int?; final widgetId = intentData[IntentDataKeys.widgetId] as int?;
if (widgetId == null) { if (widgetId == null) {
error = true; error = true;
} else { } else {
// widget settings may be modified in a different process after channel setup
await settings.reload(); await settings.reload();
final page = settings.getWidgetOpenPage(widgetId); final page = settings.getWidgetOpenPage(widgetId);
switch (page) { switch (page) {
@ -229,6 +212,7 @@ class _HomePageState extends State<HomePage> {
} }
unawaited(WidgetService.update(widgetId)); unawaited(WidgetService.update(widgetId));
} }
default: default:
final extraRoute = intentData[IntentDataKeys.page] as String?; final extraRoute = intentData[IntentDataKeys.page] as String?;
if (allowedShortcutRoutes.contains(extraRoute)) { if (allowedShortcutRoutes.contains(extraRoute)) {
@ -240,7 +224,6 @@ class _HomePageState extends State<HomePage> {
final extraFilters = (intentData[IntentDataKeys.filters] as List?)?.cast<String>(); final extraFilters = (intentData[IntentDataKeys.filters] as List?)?.cast<String>();
_initialFilters = extraFilters?.map(CollectionFilter.fromJson).nonNulls.toSet(); _initialFilters = extraFilters?.map(CollectionFilter.fromJson).nonNulls.toSet();
} }
_initialExplorerPath = intentData[IntentDataKeys.explorerPath] as String?; _initialExplorerPath = intentData[IntentDataKeys.explorerPath] as String?;
switch (appMode) { switch (appMode) {
@ -248,10 +231,7 @@ class _HomePageState extends State<HomePage> {
case AppMode.edit: case AppMode.edit:
case AppMode.setWallpaper: case AppMode.setWallpaper:
if (intentUri != null) { if (intentUri != null) {
_viewerEntry = await _initViewerEntry( _viewerEntry = await _initViewerEntry(uri: intentUri, mimeType: intentMimeType);
uri: intentUri,
mimeType: intentMimeType,
);
} }
error = _viewerEntry == null; error = _viewerEntry == null;
default: default:
@ -267,6 +247,10 @@ class _HomePageState extends State<HomePage> {
context.read<ValueNotifier<AppMode>>().value = appMode; context.read<ValueNotifier<AppMode>>().value = appMode;
unawaited(reportService.setCustomKey('app_mode', appMode.toString())); unawaited(reportService.setCustomKey('app_mode', appMode.toString()));
// Remote: seed debug + icona coerente
unawaited(RemoteSettings.debugSeedIfEmpty());
unawaited(RemoteController.instance.initBusFromSettings());
switch (appMode) { switch (appMode) {
case AppMode.main: case AppMode.main:
case AppMode.pickCollectionFiltersExternal: case AppMode.pickCollectionFiltersExternal:
@ -276,127 +260,30 @@ class _HomePageState extends State<HomePage> {
unawaited(AnalysisService.registerCallback()); unawaited(AnalysisService.registerCallback());
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
// Aves originale: init SOLO se non già full scope
if (source.loadedScope != CollectionSource.fullScope) { if (source.loadedScope != CollectionSource.fullScope) {
await reportService.log( await reportService.log(
'Initialize source to start app with mode=$appMode, loaded scope=${source.loadedScope}', 'Initialize source to start app with mode=$appMode, loaded scope=${source.loadedScope}',
); );
final loadTopEntriesFirst = final loadTopEntriesFirst =
settings.homeNavItem.route == CollectionPage.routeName && settings.homeCustomCollection.isEmpty; 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 = <CollectionFilter>{}; // All items (nessun filtro)
debugPrint('[startup] forcing CollectionPage All-items (remoti=$remCount)');
}
} catch (e) {
debugPrint('[startup] unable to count remotes: $e');
}
// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
// PERF: riattiva lanalisi in background appena la UI è pronta
unawaited(Future.delayed(const Duration(milliseconds: 300)).then((_) {
source.canAnalyze = true; source.canAnalyze = true;
debugPrint('[startup] analysis re-enabled in background'); await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst);
}));
// === 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>();
void onState() {
if (notifier.value != SourceState.loading) {
notifier.removeListener(onState);
completer.complete();
}
} }
notifier.addListener(onState); // Remote: dopo init locale, ma non blocca
// nel caso non sia già loading: unawaited(RemoteController.instance.onAppStart(
onState(); source: source,
await completer.future; resumeBootstrapIfEnabled: true,
} ));
// 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');
}
}));
}
break;
case AppMode.screenSaver: case AppMode.screenSaver:
await reportService.log('Initialize source to start screen saver'); await reportService.log('Initialize source to start screen saver');
final source2 = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
source2.canAnalyze = false; source.canAnalyze = false;
await source2.init(scope: settings.screenSaverCollectionFilters); await source.init(scope: settings.screenSaverCollectionFilters);
break;
case AppMode.view: case AppMode.view:
if (_isViewerSourceable(_viewerEntry) && _secureUris == null) { if (_isViewerSourceable(_viewerEntry) && _secureUris == null) {
@ -405,19 +292,16 @@ class _HomePageState extends State<HomePage> {
unawaited(AnalysisService.registerCallback()); unawaited(AnalysisService.registerCallback());
await reportService.log('Initialize source to view item in directory $directory'); await reportService.log('Initialize source to view item in directory $directory');
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
// analysis is necessary to display neighbour items when the initial item is a new one
source.canAnalyze = true; source.canAnalyze = true;
await source.init(scope: {StoredAlbumFilter(directory, null)}); await source.init(scope: {StoredAlbumFilter(directory, null)});
} }
} else { } else {
await _initViewerEssentials(); await _initViewerEssentials();
} }
break;
case AppMode.edit: case AppMode.edit:
case AppMode.setWallpaper: case AppMode.setWallpaper:
await _initViewerEssentials(); await _initViewerEssentials();
break;
default: default:
break; break;
@ -425,8 +309,6 @@ class _HomePageState extends State<HomePage> {
debugPrint('Home setup complete in ${stopwatch.elapsed.inMilliseconds}ms'); 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( unawaited(
Navigator.maybeOf(context)?.pushAndRemoveUntil( Navigator.maybeOf(context)?.pushAndRemoveUntil(
await _getRedirectRoute(appMode), await _getRedirectRoute(appMode),
@ -439,65 +321,7 @@ class _HomePageState extends State<HomePage> {
} }
} }
// === FETCH per il sync (implementazione reale usando RemoteJsonClient) ===
Future<List<RemotePhotoItem>> _fetchAllRemoteItems() async {
try {
final rs = await _safeLoadRemoteSettings();
if (!rs.enabled || rs.baseUrl.trim().isEmpty) {
debugPrint('[remote-sync][fetch] disabled or baseUrl empty');
return <RemotePhotoItem>[];
}
// 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 <RemotePhotoItem>[];
}
} catch (e, st) {
debugPrint('[remote-sync][fetch] ERROR: $e\n$st');
return <RemotePhotoItem>[];
}
}
// --- 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<void> _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<void> _initViewerEssentials() async { Future<void> _initViewerEssentials() async {
// for video playback storage
await localMediaDb.init(); await localMediaDb.init();
} }
@ -509,204 +333,15 @@ class _HomePageState extends State<HomePage> {
Future<AvesEntry?> _initViewerEntry({required String uri, required String? mimeType}) async { Future<AvesEntry?> _initViewerEntry({required String uri, required String? mimeType}) async {
if (uri.startsWith('/')) { if (uri.startsWith('/')) {
// convert this file path to a proper URI
uri = Uri.file(uri).toString(); uri = Uri.file(uri).toString();
} }
final entry = await mediaFetchService.getEntry(uri, mimeType); final entry = await mediaFetchService.getEntry(uri, mimeType);
if (entry != null) { if (entry != null) {
// cataloguing is essential for coordinates and video rotation
await entry.catalog(background: false, force: false, persist: false); await entry.catalog(background: false, force: false, persist: false);
} }
return entry; return entry;
} }
// === DEBUG: apre la pagina di test remota con una seconda connessione al DB ===
Future<void> _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<void> _openRemoteSettingsDialog(BuildContext context) async {
final s = await _safeLoadRemoteSettings();
final formKey = GlobalKey<FormState>();
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<void>(
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<Route> _getRedirectRoute(AppMode appMode) async { Future<Route> _getRedirectRoute(AppMode appMode) async {
String routeName; String routeName;
Set<CollectionFilter?>? filters; Set<CollectionFilter?>? filters;
@ -715,19 +350,16 @@ class _HomePageState extends State<HomePage> {
case AppMode.setWallpaper: case AppMode.setWallpaper:
return DirectMaterialPageRoute( return DirectMaterialPageRoute(
settings: const RouteSettings(name: WallpaperPage.routeName), settings: const RouteSettings(name: WallpaperPage.routeName),
builder: (_) { builder: (_) => WallpaperPage(entry: _viewerEntry),
return WallpaperPage(
entry: _viewerEntry,
);
},
); );
case AppMode.view: case AppMode.view:
AvesEntry viewerEntry = _viewerEntry!; AvesEntry viewerEntry = _viewerEntry!;
CollectionLens? collection; CollectionLens? collection;
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
final album = viewerEntry.directory; final album = viewerEntry.directory;
if (album != null) { if (album != null) {
// wait for collection to pass the `loading` state
final loadingCompleter = Completer(); final loadingCompleter = Completer();
final stateNotifier = source.stateNotifier; final stateNotifier = source.stateNotifier;
void _onSourceStateChanged() { void _onSourceStateChanged() {
@ -741,16 +373,10 @@ class _HomePageState extends State<HomePage> {
_onSourceStateChanged(); _onSourceStateChanged();
await loadingCompleter.future; await loadingCompleter.future;
// NON lanciamo più il sync qui (evita contese durante il viewer)
// unawaited(rrs.runRemoteSyncOnceManaged());
collection = CollectionLens( collection = CollectionLens(
source: source, source: source,
filters: {StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))}, filters: {StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))},
listenToSource: false, 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, stackBursts: false,
); );
@ -760,39 +386,22 @@ class _HomePageState extends State<HomePage> {
if (collectionEntry != null) { if (collectionEntry != null) {
viewerEntry = collectionEntry; viewerEntry = collectionEntry;
} else { } else {
debugPrint('collection does not contain viewerEntry=$viewerEntry');
collection = null; collection = null;
} }
} }
return DirectMaterialPageRoute( return DirectMaterialPageRoute(
settings: const RouteSettings(name: EntryViewerPage.routeName), settings: const RouteSettings(name: EntryViewerPage.routeName),
builder: (_) { builder: (_) => EntryViewerPage(collection: collection, initialEntry: viewerEntry),
return EntryViewerPage(
collection: collection,
initialEntry: viewerEntry,
);
},
); );
case AppMode.edit: case AppMode.edit:
return DirectMaterialPageRoute( return DirectMaterialPageRoute(
settings: const RouteSettings(name: EntryViewerPage.routeName), settings: const RouteSettings(name: EntryViewerPage.routeName),
builder: (_) { builder: (_) => ImageEditorPage(entry: _viewerEntry!),
return ImageEditorPage(
entry: _viewerEntry!,
); );
},
); default:
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:
routeName = _initialRouteName ?? settings.homeNavItem.route; routeName = _initialRouteName ?? settings.homeNavItem.route;
filters = _initialFilters ?? filters = _initialFilters ??
(settings.homeNavItem.route == CollectionPage.routeName ? settings.homeCustomCollection : {}); (settings.homeNavItem.route == CollectionPage.routeName ? settings.homeCustomCollection : {});
@ -804,7 +413,6 @@ class _HomePageState extends State<HomePage> {
); );
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
switch (routeName) { switch (routeName) {
case AlbumListPage.routeName: case AlbumListPage.routeName:
return buildRoute((context) => const AlbumListPage(initialGroup: null)); return buildRoute((context) => const AlbumListPage(initialGroup: null));
@ -846,60 +454,8 @@ class _HomePageState extends State<HomePage> {
); );
case CollectionPage.routeName: case CollectionPage.routeName:
default: default:
// Wrapper di debug che aggiunge i due FAB (solo in debug) return buildRoute((context) => CollectionPage(source: source, filters: filters));
return buildRoute(
(context) => _wrapWithRemoteDebug(
context,
CollectionPage(source: source, filters: filters),
),
);
}
}
// -------------------------
// Utility sicure per remote
// -------------------------
// safe load of RemoteSettings with timeout and fallback
Future<RemoteSettings> _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<Map<String, String>> _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<void> _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');
} }
} }
} }

View file

@ -47,22 +47,20 @@ import 'package:latlong2/latlong.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
// --- IMPORT aggiunti per integrazione remota / telemetria --- // --- REMOTO / DEBUG ---
import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:aves/remote/remote_test_page.dart' as rtp; import 'package:aves/remote/remote_test_page.dart' as rtp;
import 'package:aves/remote/run_remote_sync.dart' as rrs; import 'package:aves/remote/run_remote_sync.dart' as rrs;
import 'package:aves/remote/remote_settings.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_http.dart';
import 'package:aves/remote/remote_models.dart'; // RemotePhotoItem import 'package:aves/remote/remote_models.dart';
// --- IMPORT per client reale ---
import 'package:aves/remote/remote_client.dart'; import 'package:aves/remote/remote_client.dart';
import 'package:aves/remote/auth_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: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 { class HomePage extends StatefulWidget {
static const routeName = '/'; static const routeName = '/';
@ -88,12 +86,11 @@ class _HomePageState extends State<HomePage> {
List<String>? _secureUris; List<String>? _secureUris;
(Object, StackTrace)? _setupError; (Object, StackTrace)? _setupError;
// guard UI per schedulare UNA sola run del sync da Home // sync remoto: singola esecuzione
bool _remoteSyncScheduled = false; bool _remoteSyncScheduled = false;
// indica se il sync è effettivamente in corso
bool _remoteSyncActive = false; bool _remoteSyncActive = false;
// guard per evitare doppi push della pagina di test remota // pagina test remoto (FAB debug)
bool _remoteTestOpen = false; bool _remoteTestOpen = false;
static const allowedShortcutRoutes = [ static const allowedShortcutRoutes = [
@ -126,8 +123,6 @@ class _HomePageState extends State<HomePage> {
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
if (await windowService.isActivity()) { 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(); await Permissions.mediaAccess.request();
} }
@ -144,17 +139,14 @@ class _HomePageState extends State<HomePage> {
await availability.onNewIntent(); await availability.onNewIntent();
await androidFileUtils.init(); await androidFileUtils.init();
// PERF/REMOTE: warm-up headers (Bearer) in background — safe version // Warm-up header remoti (non blocca UI)
unawaited(Future(() async { unawaited(Future(() async {
try { try {
final s = await _safeLoadRemoteSettings(); final s = await _safeLoadRemoteSettings();
if (s.enabled && s.baseUrl.trim().isNotEmpty) { if (s.enabled && s.baseUrl.trim().isNotEmpty) {
await _safeHeaders(); // popola la cache per peekHeaders() in modo sicuro await _safeHeaders();
debugPrint('[startup] remote headers warm-up done (safe)');
}
} catch (e) {
debugPrint('[startup] remote headers warm-up skipped: $e');
} }
} catch (_) {}
})); }));
if (!{ if (!{
@ -192,7 +184,6 @@ class _HomePageState extends State<HomePage> {
case IntentActions.setWallpaper: case IntentActions.setWallpaper:
appMode = AppMode.setWallpaper; appMode = AppMode.setWallpaper;
case IntentActions.pickItems: case IntentActions.pickItems:
// some apps define multiple types, separated by a space
final multiple = (intentData[IntentDataKeys.allowMultiple] as bool?) ?? false; final multiple = (intentData[IntentDataKeys.allowMultiple] as bool?) ?? false;
debugPrint('pick mimeType=$intentMimeType multiple=$multiple'); debugPrint('pick mimeType=$intentMimeType multiple=$multiple');
appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal; appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal;
@ -214,7 +205,6 @@ class _HomePageState extends State<HomePage> {
if (widgetId == null) { if (widgetId == null) {
error = true; error = true;
} else { } else {
// widget settings may be modified in a different process after channel setup
await settings.reload(); await settings.reload();
final page = settings.getWidgetOpenPage(widgetId); final page = settings.getWidgetOpenPage(widgetId);
switch (page) { switch (page) {
@ -276,97 +266,97 @@ class _HomePageState extends State<HomePage> {
unawaited(AnalysisService.registerCallback()); unawaited(AnalysisService.registerCallback());
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
//////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////
// 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) { if (source.loadedScope != CollectionSource.fullScope) {
await reportService.log( await reportService.log(
'Initialize source to start app with mode=$appMode, loaded scope=${source.loadedScope}', '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 if (hasAnyCache) {
source.canAnalyze = false; // ✅ DB ha dati: avvio veloce -> init in background (non blocca UI)
final swInit = Stopwatch()..start(); debugPrint('[startup] DB cache present -> init in background (fast 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');
// PERF: riattiva lanalisi in background appena la UI è pronta
unawaited(Future.delayed(const Duration(milliseconds: 300)).then((_) {
source.canAnalyze = true; source.canAnalyze = true;
debugPrint('[startup] analysis re-enabled in background');
}));
// === SYNC REMOTO post-init (non blocca la UI) === unawaited(
if (!_remoteSyncScheduled) { source
_remoteSyncScheduled = true; // una sola schedulazione per avvio .init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst)
unawaited(Future(() async { .then((_) async {
try { // ✅ SOLO DOPO init: possiamo usare addEntries/append remoti senza crash
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>();
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(); await source.appendRemoteEntriesFromDb();
swAppend2.stop(); debugPrint('[startup][bg] remote append after init done');
debugPrint('[remote-sync] appendRemoteEntries (post-sync) in ${swAppend2.elapsedMilliseconds}ms');
// 🔎 Conteggio di debug usando una CollectionLens temporanea if (!_remoteSyncScheduled) {
final c = _countRemotesInSource(source); _remoteSyncScheduled = true;
debugPrint('[check] remoti in CollectionSource = $c'); final sourceRef = source;
unawaited(Future.microtask(() => _runRemoteSync(sourceRef)));
} }
} catch (e, st) { }),
debugPrint('[remote-sync] outer error: $e\n$st'); );
} 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; break;
case AppMode.screenSaver: case AppMode.screenSaver:
@ -383,7 +373,6 @@ class _HomePageState extends State<HomePage> {
unawaited(AnalysisService.registerCallback()); unawaited(AnalysisService.registerCallback());
await reportService.log('Initialize source to view item in directory $directory'); await reportService.log('Initialize source to view item in directory $directory');
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
// analysis is necessary to display neighbour items when the initial item is a new one
source.canAnalyze = true; source.canAnalyze = true;
await source.init(scope: {StoredAlbumFilter(directory, null)}); await source.init(scope: {StoredAlbumFilter(directory, null)});
} }
@ -403,8 +392,7 @@ class _HomePageState extends State<HomePage> {
debugPrint('Home setup complete in ${stopwatch.elapsed.inMilliseconds}ms'); debugPrint('Home setup complete in ${stopwatch.elapsed.inMilliseconds}ms');
// `pushReplacement` is not enough in some edge cases // navigazione finale
// e.g. when opening the viewer in `view` mode should replace a viewer in `main` mode
unawaited( unawaited(
Navigator.maybeOf(context)?.pushAndRemoveUntil( Navigator.maybeOf(context)?.pushAndRemoveUntil(
await _getRedirectRoute(appMode), await _getRedirectRoute(appMode),
@ -417,26 +405,102 @@ class _HomePageState extends State<HomePage> {
} }
} }
// === FETCH per il sync (implementazione reale usando RemoteJsonClient) === // === SYNC REMOTO (indipendente dal context della Home) ===
Future<void> _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>();
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<List<RemotePhotoItem>> _fetchAllRemoteItems() async { Future<List<RemotePhotoItem>> _fetchAllRemoteItems() async {
try { try {
final rs = await _safeLoadRemoteSettings(); final rs = await _safeLoadRemoteSettings();
if (!rs.enabled || rs.baseUrl.trim().isEmpty) { if (!rs.enabled || rs.baseUrl.trim().isEmpty) {
debugPrint('[remote-sync][fetch] disabled or baseUrl empty');
return <RemotePhotoItem>[]; return <RemotePhotoItem>[];
} }
// Costruisci l'auth solo se sono presenti credenziali
RemoteAuth? auth; RemoteAuth? auth;
if (rs.email.isNotEmpty && rs.password.isNotEmpty) { if (rs.email.isNotEmpty && rs.password.isNotEmpty) {
auth = RemoteAuth(baseUrl: rs.baseUrl, email: rs.email, password: rs.password); auth = RemoteAuth(baseUrl: rs.baseUrl, email: rs.email, password: rs.password);
} }
final client = RemoteJsonClient(rs.baseUrl, rs.indexPath, auth: auth); final client = RemoteJsonClient(rs.baseUrl, rs.indexPath, auth: auth);
try { try {
final items = await client.fetchAll(); 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; return items;
} catch (e, st) { } catch (e, st) {
debugPrint('[remote-sync][fetch] client.fetchAll ERROR: $e\n$st'); debugPrint('[remote-sync][fetch] client.fetchAll ERROR: $e\n$st');
@ -448,18 +512,23 @@ class _HomePageState extends State<HomePage> {
} }
} }
// --- Helper di debug: crea una lens temporanea, conta i remoti, poi dispose // --- DIAGNOSTICA ---
int _countRemotesInSource(CollectionSource source) { Future<void> _printRemoteDiag(CollectionSource source, {String when = ''}) async {
final lens = CollectionLens(source: source, filters: {});
try { try {
return lens.sortedEntries.where((e) => e.origin == 1 && e.trashed == 0).length; final dbRem = await localMediaDb.loadEntries(origin: 1);
} finally { final dbCount = dbRem.length;
lens.dispose(); 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<void> _initViewerEssentials() async { Future<void> _initViewerEssentials() async {
// for video playback storage
await localMediaDb.init(); await localMediaDb.init();
} }
@ -471,21 +540,18 @@ class _HomePageState extends State<HomePage> {
Future<AvesEntry?> _initViewerEntry({required String uri, required String? mimeType}) async { Future<AvesEntry?> _initViewerEntry({required String uri, required String? mimeType}) async {
if (uri.startsWith('/')) { if (uri.startsWith('/')) {
// convert this file path to a proper URI
uri = Uri.file(uri).toString(); uri = Uri.file(uri).toString();
} }
final entry = await mediaFetchService.getEntry(uri, mimeType); final entry = await mediaFetchService.getEntry(uri, mimeType);
if (entry != null) { if (entry != null) {
// cataloguing is essential for coordinates and video rotation
await entry.catalog(background: false, force: false, persist: false); await entry.catalog(background: false, force: false, persist: false);
} }
return entry; return entry;
} }
// === DEBUG: apre la pagina di test remota con una seconda connessione al DB === // === DEBUG: pagina test remoto con DB indipendente ===
Future<void> _openRemoteTestPage(BuildContext context) async { Future<void> _openRemoteTestPage(BuildContext context) async {
if (_remoteTestOpen) return; // evita doppi push/sovrapposizioni if (_remoteTestOpen) return;
// blocca solo se il sync è effettivamente in corso
if (_remoteSyncActive) { if (_remoteSyncActive) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Attendi fine sync remoto in corso prima di aprire RemoteTest')), const SnackBar(content: Text('Attendi fine sync remoto in corso prima di aprire RemoteTest')),
@ -500,7 +566,6 @@ class _HomePageState extends State<HomePage> {
final dbDir = await getDatabasesPath(); final dbDir = await getDatabasesPath();
final dbPath = p.join(dbDir, 'metadata.db'); final dbPath = p.join(dbDir, 'metadata.db');
// Apri il DB in R/W (istanza indipendente) → niente "read only database"
debugDb = await openDatabase( debugDb = await openDatabase(
dbPath, dbPath,
singleInstance: false, singleInstance: false,
@ -613,7 +678,6 @@ class _HomePageState extends State<HomePage> {
); );
await upd.save(); await upd.save();
// forza refresh immediato delle impostazioni e headers
await RemoteHttp.refreshFromSettings(); await RemoteHttp.refreshFromSettings();
unawaited(RemoteHttp.warmUp()); unawaited(RemoteHttp.warmUp());
@ -689,7 +753,6 @@ class _HomePageState extends State<HomePage> {
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
final album = viewerEntry.directory; final album = viewerEntry.directory;
if (album != null) { if (album != null) {
// wait for collection to pass the `loading` state
final loadingCompleter = Completer(); final loadingCompleter = Completer();
final stateNotifier = source.stateNotifier; final stateNotifier = source.stateNotifier;
void _onSourceStateChanged() { void _onSourceStateChanged() {
@ -703,16 +766,10 @@ class _HomePageState extends State<HomePage> {
_onSourceStateChanged(); _onSourceStateChanged();
await loadingCompleter.future; await loadingCompleter.future;
// ⚠️ NON lanciamo più il sync qui (evita contese durante il viewer)
// unawaited(rrs.runRemoteSyncOnceManaged());
collection = CollectionLens( collection = CollectionLens(
source: source, source: source,
filters: {StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))}, filters: {StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))},
listenToSource: false, 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, stackBursts: false,
); );
@ -726,6 +783,7 @@ class _HomePageState extends State<HomePage> {
collection = null; collection = null;
} }
} }
return DirectMaterialPageRoute( return DirectMaterialPageRoute(
settings: const RouteSettings(name: EntryViewerPage.routeName), settings: const RouteSettings(name: EntryViewerPage.routeName),
builder: (_) { builder: (_) {
@ -808,7 +866,6 @@ class _HomePageState extends State<HomePage> {
); );
case CollectionPage.routeName: case CollectionPage.routeName:
default: default:
// Wrapper di debug che aggiunge i due FAB (solo in debug)
return buildRoute( return buildRoute(
(context) => _wrapWithRemoteDebug( (context) => _wrapWithRemoteDebug(
context, context,
@ -822,7 +879,6 @@ class _HomePageState extends State<HomePage> {
// Utility sicure per remote // Utility sicure per remote
// ------------------------- // -------------------------
// safe load of RemoteSettings with timeout and fallback
Future<RemoteSettings> _safeLoadRemoteSettings({Duration timeout = const Duration(seconds: 5)}) async { Future<RemoteSettings> _safeLoadRemoteSettings({Duration timeout = const Duration(seconds: 5)}) async {
try { try {
return await RemoteSettings.load().timeout(timeout); return await RemoteSettings.load().timeout(timeout);
@ -838,7 +894,6 @@ class _HomePageState extends State<HomePage> {
} }
} }
// safe headers retrieval with timeout and empty fallback
Future<Map<String, String>> _safeHeaders({Duration timeout = const Duration(seconds: 6)}) async { Future<Map<String, String>> _safeHeaders({Duration timeout = const Duration(seconds: 6)}) async {
try { try {
return await RemoteHttp.headers().timeout(timeout); return await RemoteHttp.headers().timeout(timeout);
@ -848,11 +903,9 @@ class _HomePageState extends State<HomePage> {
} }
} }
// debug helper: clear remote keys from secure storage (debug only)
Future<void> _debugClearRemoteKeys() async { Future<void> _debugClearRemoteKeys() async {
if (!kDebugMode) return; if (!kDebugMode) return;
try { try {
// FlutterSecureStorage non è const
final storage = FlutterSecureStorage(); final storage = FlutterSecureStorage();
await storage.delete(key: 'remote_base_url'); await storage.delete(key: 'remote_base_url');
await storage.delete(key: 'remote_index_path'); await storage.delete(key: 'remote_index_path');

View file

@ -43,19 +43,18 @@ import 'package:aves_model/aves_model.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
// --- IMPORT per debug page remota --- // ✅ Permissions (platform interface) perché nel tuo branch Permissions.mediaAccess è List<Permission> platform_interface
import 'package:flutter/foundation.dart' show kDebugMode; import 'package:permission_handler_platform_interface/permission_handler_platform_interface.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart' as p; // ✅ Remote controller
import 'package:aves/remote/remote_test_page.dart'; import 'package:aves/remote/remote_controller.dart';
class HomePage extends StatefulWidget { class HomePage extends StatefulWidget {
static const routeName = '/'; static const routeName = '/';
// untyped map as it is coming from the platform // ✅ torna a Map? (compatibile con aves_app.dart che passa Map<dynamic,dynamic>?)
final Map? intentData; final Map? intentData;
const HomePage({ const HomePage({
@ -105,22 +104,27 @@ class _HomePageState extends State<HomePage> {
Future<void> _setup() async { Future<void> _setup() async {
try { try {
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
if (await windowService.isActivity()) { if (await windowService.isActivity()) {
// do not check whether permission was granted, because some app stores // ✅ come Aves: non forzare quit se utente nega permesso
// hide in some countries apps that force quit on permission denial // ma nel tuo branch serve la platform-interface API
await Permissions.mediaAccess.request(); await PermissionHandlerPlatform.instance.requestPermissions(Permissions.mediaAccess);
} }
var appMode = AppMode.main; var appMode = AppMode.main;
var error = false; 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?; final intentAction = intentData[IntentDataKeys.action] as String?;
_initialFilters = null; _initialFilters = null;
_initialExplorerPath = null; _initialExplorerPath = null;
_secureUris = null; _secureUris = null;
await availability.onNewIntent(); await availability.onNewIntent();
await androidFileUtils.init(); await androidFileUtils.init();
if (!{ if (!{
IntentActions.edit, IntentActions.edit,
IntentActions.screenSaver, IntentActions.screenSaver,
@ -132,6 +136,7 @@ class _HomePageState extends State<HomePage> {
if (intentData.values.nonNulls.isNotEmpty) { if (intentData.values.nonNulls.isNotEmpty) {
await reportService.log('Intent data=$intentData'); await reportService.log('Intent data=$intentData');
var intentUri = intentData[IntentDataKeys.uri] as String?; var intentUri = intentData[IntentDataKeys.uri] as String?;
final intentMimeType = intentData[IntentDataKeys.mimeType] as String?; final intentMimeType = intentData[IntentDataKeys.mimeType] as String?;
@ -150,35 +155,40 @@ class _HomePageState extends State<HomePage> {
} }
} }
break; break;
case IntentActions.edit: case IntentActions.edit:
appMode = AppMode.edit; appMode = AppMode.edit;
case IntentActions.setWallpaper: case IntentActions.setWallpaper:
appMode = AppMode.setWallpaper; appMode = AppMode.setWallpaper;
case IntentActions.pickItems: 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; final multiple = (intentData[IntentDataKeys.allowMultiple] as bool?) ?? false;
debugPrint('pick mimeType=$intentMimeType multiple=$multiple'); debugPrint('pick mimeType=$intentMimeType multiple=$multiple');
appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal; appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal;
case IntentActions.pickCollectionFilters: case IntentActions.pickCollectionFilters:
appMode = AppMode.pickCollectionFiltersExternal; appMode = AppMode.pickCollectionFiltersExternal;
case IntentActions.screenSaver: case IntentActions.screenSaver:
appMode = AppMode.screenSaver; appMode = AppMode.screenSaver;
_initialRouteName = ScreenSaverPage.routeName; _initialRouteName = ScreenSaverPage.routeName;
case IntentActions.screenSaverSettings: case IntentActions.screenSaverSettings:
_initialRouteName = ScreenSaverSettingsPage.routeName; _initialRouteName = ScreenSaverSettingsPage.routeName;
case IntentActions.search: case IntentActions.search:
_initialRouteName = SearchPage.routeName; _initialRouteName = SearchPage.routeName;
_initialSearchQuery = intentData[IntentDataKeys.query] as String?; _initialSearchQuery = intentData[IntentDataKeys.query] as String?;
case IntentActions.widgetSettings: case IntentActions.widgetSettings:
_initialRouteName = HomeWidgetSettingsPage.routeName; _initialRouteName = HomeWidgetSettingsPage.routeName;
_widgetId = (intentData[IntentDataKeys.widgetId] as int?) ?? 0; _widgetId = (intentData[IntentDataKeys.widgetId] as int?) ?? 0;
case IntentActions.widgetOpen: case IntentActions.widgetOpen:
final widgetId = intentData[IntentDataKeys.widgetId] as int?; final widgetId = intentData[IntentDataKeys.widgetId] as int?;
if (widgetId == null) { if (widgetId == null) {
error = true; error = true;
} else { } else {
// widget settings may be modified in a different process after channel setup
await settings.reload(); await settings.reload();
final page = settings.getWidgetOpenPage(widgetId); final page = settings.getWidgetOpenPage(widgetId);
switch (page) { switch (page) {
@ -193,17 +203,19 @@ class _HomePageState extends State<HomePage> {
} }
unawaited(WidgetService.update(widgetId)); unawaited(WidgetService.update(widgetId));
} }
default: default:
// do not use 'route' as extra key, as the Flutter framework acts on it
final extraRoute = intentData[IntentDataKeys.page] as String?; final extraRoute = intentData[IntentDataKeys.page] as String?;
if (allowedShortcutRoutes.contains(extraRoute)) { if (allowedShortcutRoutes.contains(extraRoute)) {
_initialRouteName = extraRoute; _initialRouteName = extraRoute;
} }
} }
if (_initialFilters == null) { if (_initialFilters == null) {
final extraFilters = (intentData[IntentDataKeys.filters] as List?)?.cast<String>(); final extraFilters = (intentData[IntentDataKeys.filters] as List?)?.cast<String>();
_initialFilters = extraFilters?.map(CollectionFilter.fromJson).nonNulls.toSet(); _initialFilters = extraFilters?.map(CollectionFilter.fromJson).nonNulls.toSet();
} }
_initialExplorerPath = intentData[IntentDataKeys.explorerPath] as String?; _initialExplorerPath = intentData[IntentDataKeys.explorerPath] as String?;
switch (appMode) { switch (appMode) {
@ -211,10 +223,7 @@ class _HomePageState extends State<HomePage> {
case AppMode.edit: case AppMode.edit:
case AppMode.setWallpaper: case AppMode.setWallpaper:
if (intentUri != null) { if (intentUri != null) {
_viewerEntry = await _initViewerEntry( _viewerEntry = await _initViewerEntry(uri: intentUri, mimeType: intentMimeType);
uri: intentUri,
mimeType: intentMimeType,
);
} }
error = _viewerEntry == null; error = _viewerEntry == null;
default: default:
@ -230,6 +239,9 @@ class _HomePageState extends State<HomePage> {
context.read<ValueNotifier<AppMode>>().value = appMode; context.read<ValueNotifier<AppMode>>().value = appMode;
unawaited(reportService.setCustomKey('app_mode', appMode.toString())); unawaited(reportService.setCustomKey('app_mode', appMode.toString()));
// ✅ Remote: inizializza stato icona (grigio/verde)
unawaited(RemoteController.instance.initBusFromSettings());
switch (appMode) { switch (appMode) {
case AppMode.main: case AppMode.main:
case AppMode.pickCollectionFiltersExternal: case AppMode.pickCollectionFiltersExternal:
@ -237,18 +249,36 @@ class _HomePageState extends State<HomePage> {
case AppMode.pickMultipleMediaExternal: case AppMode.pickMultipleMediaExternal:
unawaited(GlobalSearch.registerCallback()); unawaited(GlobalSearch.registerCallback());
unawaited(AnalysisService.registerCallback()); unawaited(AnalysisService.registerCallback());
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
// ✅ Aves originale: init SOLO se non già full scope (riaperture istantanee)
if (source.loadedScope != CollectionSource.fullScope) { if (source.loadedScope != CollectionSource.fullScope) {
await reportService.log('Initialize source to start app with mode=$appMode, loaded scope=${source.loadedScope}'); await reportService.log(
final loadTopEntriesFirst = settings.homeNavItem.route == CollectionPage.routeName && settings.homeCustomCollection.isEmpty; '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; 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: case AppMode.screenSaver:
await reportService.log('Initialize source to start screen saver'); await reportService.log('Initialize source to start screen saver');
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
source.canAnalyze = false; source.canAnalyze = false;
await source.init(scope: settings.screenSaverCollectionFilters); await source.init(scope: settings.screenSaverCollectionFilters);
case AppMode.view: case AppMode.view:
if (_isViewerSourceable(_viewerEntry) && _secureUris == null) { if (_isViewerSourceable(_viewerEntry) && _secureUris == null) {
final directory = _viewerEntry?.directory; final directory = _viewerEntry?.directory;
@ -256,24 +286,23 @@ class _HomePageState extends State<HomePage> {
unawaited(AnalysisService.registerCallback()); unawaited(AnalysisService.registerCallback());
await reportService.log('Initialize source to view item in directory $directory'); await reportService.log('Initialize source to view item in directory $directory');
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
// analysis is necessary to display neighbour items when the initial item is a new one
source.canAnalyze = true; source.canAnalyze = true;
await source.init(scope: {StoredAlbumFilter(directory, null)}); await source.init(scope: {StoredAlbumFilter(directory, null)});
} }
} else { } else {
await _initViewerEssentials(); await _initViewerEssentials();
} }
case AppMode.edit: case AppMode.edit:
case AppMode.setWallpaper: case AppMode.setWallpaper:
await _initViewerEssentials(); await _initViewerEssentials();
default: default:
break; break;
} }
debugPrint('Home setup complete in ${stopwatch.elapsed.inMilliseconds}ms'); 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( unawaited(
Navigator.maybeOf(context)?.pushAndRemoveUntil( Navigator.maybeOf(context)?.pushAndRemoveUntil(
await _getRedirectRoute(appMode), await _getRedirectRoute(appMode),
@ -287,100 +316,37 @@ class _HomePageState extends State<HomePage> {
} }
Future<void> _initViewerEssentials() async { Future<void> _initViewerEssentials() async {
// for video playback storage
await localMediaDb.init(); await localMediaDb.init();
} }
bool _isViewerSourceable(AvesEntry? viewerEntry) { 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<AvesEntry?> _initViewerEntry({required String uri, required String? mimeType}) async { Future<AvesEntry?> _initViewerEntry({required String uri, required String? mimeType}) async {
if (uri.startsWith('/')) { if (uri.startsWith('/')) {
// convert this file path to a proper URI
uri = Uri.file(uri).toString(); uri = Uri.file(uri).toString();
} }
final entry = await mediaFetchService.getEntry(uri, mimeType); final entry = await mediaFetchService.getEntry(uri, mimeType);
if (entry != null) { if (entry != null) {
// cataloguing is essential for coordinates and video rotation
await entry.catalog(background: false, force: false, persist: false); await entry.catalog(background: false, force: false, persist: false);
} }
return entry; 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<void> _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<Route> _getRedirectRoute(AppMode appMode) async { Future<Route> _getRedirectRoute(AppMode appMode) async {
String routeName; String routeName;
Set<CollectionFilter?>? filters; Set<CollectionFilter?>? filters;
switch (appMode) { switch (appMode) {
case AppMode.setWallpaper: case AppMode.setWallpaper:
return DirectMaterialPageRoute( return DirectMaterialPageRoute(
settings: const RouteSettings(name: WallpaperPage.routeName), settings: const RouteSettings(name: WallpaperPage.routeName),
builder: (_) { builder: (_) => WallpaperPage(entry: _viewerEntry),
return WallpaperPage(
entry: _viewerEntry,
);
},
); );
case AppMode.view: case AppMode.view:
AvesEntry viewerEntry = _viewerEntry!; AvesEntry viewerEntry = _viewerEntry!;
CollectionLens? collection; CollectionLens? collection;
@ -388,7 +354,6 @@ Future<void> _openRemoteTestPage(BuildContext context) async {
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
final album = viewerEntry.directory; final album = viewerEntry.directory;
if (album != null) { if (album != null) {
// wait for collection to pass the `loading` state
final loadingCompleter = Completer(); final loadingCompleter = Completer();
final stateNotifier = source.stateNotifier; final stateNotifier = source.stateNotifier;
void _onSourceStateChanged() { void _onSourceStateChanged() {
@ -406,13 +371,13 @@ Future<void> _openRemoteTestPage(BuildContext context) async {
source: source, source: source,
filters: {StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))}, filters: {StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))},
listenToSource: false, 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, stackBursts: false,
); );
final viewerEntryPath = viewerEntry.path; 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) { if (collectionEntry != null) {
viewerEntry = collectionEntry; viewerEntry = collectionEntry;
} else { } else {
@ -423,36 +388,21 @@ Future<void> _openRemoteTestPage(BuildContext context) async {
return DirectMaterialPageRoute( return DirectMaterialPageRoute(
settings: const RouteSettings(name: EntryViewerPage.routeName), settings: const RouteSettings(name: EntryViewerPage.routeName),
builder: (_) { builder: (_) => EntryViewerPage(collection: collection, initialEntry: viewerEntry),
return EntryViewerPage(
collection: collection,
initialEntry: viewerEntry,
);
},
); );
case AppMode.edit: case AppMode.edit:
return DirectMaterialPageRoute( return DirectMaterialPageRoute(
settings: const RouteSettings(name: EntryViewerPage.routeName), settings: const RouteSettings(name: EntryViewerPage.routeName),
builder: (_) { builder: (_) => ImageEditorPage(entry: _viewerEntry!),
return ImageEditorPage(
entry: _viewerEntry!,
); );
},
); default:
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:
routeName = _initialRouteName ?? settings.homeNavItem.route; 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( Route buildRoute(WidgetBuilder builder) => DirectMaterialPageRoute(
settings: RouteSettings(name: routeName), settings: RouteSettings(name: routeName),
builder: builder, builder: builder,
@ -500,14 +450,7 @@ Future<void> _openRemoteTestPage(BuildContext context) async {
); );
case CollectionPage.routeName: case CollectionPage.routeName:
default: default:
// <<--- QUI AVVOLGO LA COLLECTION CON IL WRAPPER DI DEBUG return buildRoute((context) => CollectionPage(source: source, filters: filters));
return buildRoute(
(context) => _wrapWithRemoteDebug(
context,
CollectionPage(source: source, filters: filters),
),
);
} }
} }
} }

View file

@ -1,6 +1,7 @@
// lib/widgets/home/home_page.dart // lib/widgets/home/home_page.dart
import 'dart:async'; import 'dart:async';
import 'package:aves/remote/collection_source_remote_ext.dart';
import 'package:aves/app_mode.dart'; import 'package:aves/app_mode.dart';
import 'package:aves/geo/uri.dart'; import 'package:aves/geo/uri.dart';
import 'package:aves/model/app/intent.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:permission_handler/permission_handler.dart';
import 'package:provider/provider.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:flutter/foundation.dart' show kDebugMode;
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:aves/remote/remote_test_page.dart' as rtp; 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_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 { class HomePage extends StatefulWidget {
static const routeName = '/'; static const routeName = '/';
@ -78,8 +87,12 @@ class _HomePageState extends State<HomePage> {
List<String>? _secureUris; List<String>? _secureUris;
(Object, StackTrace)? _setupError; (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 _remoteSyncScheduled = false;
bool _remoteSyncActive = false;
// guard pagina test remota
bool _remoteTestOpen = false;
static const allowedShortcutRoutes = [ static const allowedShortcutRoutes = [
AlbumListPage.routeName, AlbumListPage.routeName,
@ -106,13 +119,26 @@ class _HomePageState extends State<HomePage> {
: null, : null,
); );
// ============================================================
// BOOTSTRAP FLAG (Remote progress ONLY first time)
// ============================================================
Future<bool> _isRemoteBootstrapDone() async {
final storage = FlutterSecureStorage();
final v = await storage.read(key: 'remote_bootstrap_done');
return v == '1';
}
Future<void> _setRemoteBootstrapDone() async {
final storage = FlutterSecureStorage();
await storage.write(key: 'remote_bootstrap_done', value: '1');
}
// ============================================================
Future<void> _setup() async { Future<void> _setup() async {
try { try {
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
if (await windowService.isActivity()) { 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(); await Permissions.mediaAccess.request();
} }
@ -129,6 +155,16 @@ class _HomePageState extends State<HomePage> {
await availability.onNewIntent(); await availability.onNewIntent();
await androidFileUtils.init(); 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 (!{ if (!{
IntentActions.edit, IntentActions.edit,
IntentActions.screenSaver, IntentActions.screenSaver,
@ -164,8 +200,6 @@ class _HomePageState extends State<HomePage> {
case IntentActions.setWallpaper: case IntentActions.setWallpaper:
appMode = AppMode.setWallpaper; appMode = AppMode.setWallpaper;
case IntentActions.pickItems: 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; final multiple = (intentData[IntentDataKeys.allowMultiple] as bool?) ?? false;
debugPrint('pick mimeType=$intentMimeType multiple=$multiple'); debugPrint('pick mimeType=$intentMimeType multiple=$multiple');
appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal; appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal;
@ -187,7 +221,6 @@ class _HomePageState extends State<HomePage> {
if (widgetId == null) { if (widgetId == null) {
error = true; error = true;
} else { } else {
// widget settings may be modified in a different process after channel setup
await settings.reload(); await settings.reload();
final page = settings.getWidgetOpenPage(widgetId); final page = settings.getWidgetOpenPage(widgetId);
switch (page) { switch (page) {
@ -203,7 +236,6 @@ class _HomePageState extends State<HomePage> {
unawaited(WidgetService.update(widgetId)); unawaited(WidgetService.update(widgetId));
} }
default: default:
// do not use 'route' as extra key, as the Flutter framework acts on it
final extraRoute = intentData[IntentDataKeys.page] as String?; final extraRoute = intentData[IntentDataKeys.page] as String?;
if (allowedShortcutRoutes.contains(extraRoute)) { if (allowedShortcutRoutes.contains(extraRoute)) {
_initialRouteName = extraRoute; _initialRouteName = extraRoute;
@ -250,54 +282,123 @@ class _HomePageState extends State<HomePage> {
unawaited(AnalysisService.registerCallback()); unawaited(AnalysisService.registerCallback());
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
//////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////
// 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) { if (source.loadedScope != CollectionSource.fullScope) {
await reportService.log( await reportService.log(
'Initialize source to start app with mode=$appMode, loaded scope=${source.loadedScope}', 'Initialize source to start app with mode=$appMode, loaded scope=${source.loadedScope}',
); );
final loadTopEntriesFirst =
settings.homeNavItem.route == CollectionPage.routeName && settings.homeCustomCollection.isEmpty; 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;
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)');
}
// 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; source.canAnalyze = true;
await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst); await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst);
// 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();
} }
// === 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) { if (!_remoteSyncScheduled) {
_remoteSyncScheduled = true; // una sola schedulazione per avvio _remoteSyncScheduled = true;
unawaited(Future(() async { final sourceRef = source;
try { unawaited(Future.microtask(() => _runRemoteSync(sourceRef, bootstrap: bootstrap)));
await RemoteSettings.debugSeedIfEmpty(); }
final rs = await RemoteSettings.load(); }
if (!rs.enabled) return; } else {
// Source già full scope (hot state)
debugPrint('[startup] source already fullScope');
// attesa fine loading // Remoti: opzione 1
final notifier = source.stateNotifier; // se bootstrap done -> mostra subito dal DB
if (notifier.value == SourceState.loading) { if (bootstrapDone) {
final completer = Completer<void>(); await source.appendRemoteEntriesFromDb();
void onState() { } else {
if (notifier.value != SourceState.loading) { debugPrint('[startup] bootstrap not done -> skip remote append (will appear after bootstrap sync)');
notifier.removeListener(onState); }
completer.complete();
if (!_remoteSyncScheduled) {
_remoteSyncScheduled = true;
final sourceRef = source;
unawaited(Future.microtask(() => _runRemoteSync(sourceRef, bootstrap: bootstrap)));
} }
} }
notifier.addListener(onState); // DIAG: stato (facoltativo)
// nel caso non sia già loading: unawaited(_printRemoteDiag(source, when: ' PRE'));
onState();
await completer.future;
}
// piccolo margine per step secondari (tag, ecc.) //////////////////////////////////////////////////////////////////////////////
await Future.delayed(const Duration(milliseconds: 400)); //////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////
// 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
//////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////
// sync in background (la managed ha già il suo guard interno)
await rrs.runRemoteSyncOnceManaged();
} catch (e, st) {
debugPrint('[remote-sync] error: $e\n$st');
}
}));
}
break; break;
case AppMode.screenSaver: case AppMode.screenSaver:
@ -314,7 +415,6 @@ class _HomePageState extends State<HomePage> {
unawaited(AnalysisService.registerCallback()); unawaited(AnalysisService.registerCallback());
await reportService.log('Initialize source to view item in directory $directory'); await reportService.log('Initialize source to view item in directory $directory');
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
// analysis is necessary to display neighbour items when the initial item is a new one
source.canAnalyze = true; source.canAnalyze = true;
await source.init(scope: {StoredAlbumFilter(directory, null)}); await source.init(scope: {StoredAlbumFilter(directory, null)});
} }
@ -334,8 +434,7 @@ class _HomePageState extends State<HomePage> {
debugPrint('Home setup complete in ${stopwatch.elapsed.inMilliseconds}ms'); debugPrint('Home setup complete in ${stopwatch.elapsed.inMilliseconds}ms');
// `pushReplacement` is not enough in some edge cases // navigazione finale
// e.g. when opening the viewer in `view` mode should replace a viewer in `main` mode
unawaited( unawaited(
Navigator.maybeOf(context)?.pushAndRemoveUntil( Navigator.maybeOf(context)?.pushAndRemoveUntil(
await _getRedirectRoute(appMode), await _getRedirectRoute(appMode),
@ -348,8 +447,142 @@ class _HomePageState extends State<HomePage> {
} }
} }
// ============================================================
// === 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<void> _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>();
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<List<RemotePhotoItem>> _fetchAllRemoteItems() async {
try {
final rs = await _safeLoadRemoteSettings();
if (!rs.enabled || rs.baseUrl.trim().isEmpty) {
return <RemotePhotoItem>[];
}
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 <RemotePhotoItem>[];
}
} catch (e, st) {
debugPrint('[remote-sync][fetch] ERROR: $e\n$st');
return <RemotePhotoItem>[];
}
}
// --- DIAGNOSTICA ---
Future<void> _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<void> _initViewerEssentials() async { Future<void> _initViewerEssentials() async {
// for video playback storage
await localMediaDb.init(); await localMediaDb.init();
} }
@ -361,44 +594,43 @@ class _HomePageState extends State<HomePage> {
Future<AvesEntry?> _initViewerEntry({required String uri, required String? mimeType}) async { Future<AvesEntry?> _initViewerEntry({required String uri, required String? mimeType}) async {
if (uri.startsWith('/')) { if (uri.startsWith('/')) {
// convert this file path to a proper URI
uri = Uri.file(uri).toString(); uri = Uri.file(uri).toString();
} }
final entry = await mediaFetchService.getEntry(uri, mimeType); final entry = await mediaFetchService.getEntry(uri, mimeType);
if (entry != null) { if (entry != null) {
// cataloguing is essential for coordinates and video rotation
await entry.catalog(background: false, force: false, persist: false); await entry.catalog(background: false, force: false, persist: false);
} }
return entry; return entry;
} }
// === DEBUG: apre la pagina di test remota con una seconda connessione al DB === // === DEBUG: pagina test remoto con DB indipendente ===
Future<void> _openRemoteTestPage(BuildContext context) async { Future<void> _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; Database? debugDb;
try { try {
final dbDir = await getDatabasesPath(); final dbDir = await getDatabasesPath();
final dbPath = p.join(dbDir, 'metadata.db'); 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(
debugDb = await openDatabase(
dbPath, dbPath,
singleInstance: false, singleInstance: false,
onConfigure: (db) async { onConfigure: (db) async {
// opzionale ma utile per coerenza con il resto
await db.rawQuery('PRAGMA journal_mode=WAL'); await db.rawQuery('PRAGMA journal_mode=WAL');
await db.rawQuery('PRAGMA foreign_keys=ON'); await db.rawQuery('PRAGMA foreign_keys=ON');
}, },
); );
if (!context.mounted) return; if (!context.mounted) return;
final rs = await RemoteSettings.load(); final rs = await _safeLoadRemoteSettings();
final baseUrl = rs.baseUrl.isNotEmpty ? rs.baseUrl : RemoteSettings.defaultBaseUrl; final baseUrl = rs.baseUrl.isNotEmpty ? rs.baseUrl : RemoteSettings.defaultBaseUrl;
await Navigator.of(context).push(MaterialPageRoute( await Navigator.of(context).push(MaterialPageRoute(
@ -418,12 +650,13 @@ debugDb = await openDatabase(
try { try {
await debugDb?.close(); await debugDb?.close();
} catch (_) {} } catch (_) {}
_remoteTestOpen = false;
} }
} }
// === DEBUG: dialog impostazioni remote (semplice) === // === DEBUG: dialog impostazioni remote (semplice) ===
Future<void> _openRemoteSettingsDialog(BuildContext context) async { Future<void> _openRemoteSettingsDialog(BuildContext context) async {
final s = await RemoteSettings.load(); final s = await _safeLoadRemoteSettings();
final formKey = GlobalKey<FormState>(); final formKey = GlobalKey<FormState>();
bool enabled = s.enabled; bool enabled = s.enabled;
final baseUrlC = TextEditingController(text: s.baseUrl); final baseUrlC = TextEditingController(text: s.baseUrl);
@ -498,6 +731,10 @@ debugDb = await openDatabase(
password: pwC.text, password: pwC.text,
); );
await upd.save(); await upd.save();
await RemoteHttp.refreshFromSettings();
unawaited(RemoteHttp.warmUp());
if (context.mounted) Navigator.of(context).pop(); if (context.mounted) Navigator.of(context).pop();
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context) ScaffoldMessenger.of(context)
@ -559,20 +796,20 @@ debugDb = await openDatabase(
return DirectMaterialPageRoute( return DirectMaterialPageRoute(
settings: const RouteSettings(name: WallpaperPage.routeName), settings: const RouteSettings(name: WallpaperPage.routeName),
builder: (_) { builder: (_) {
return WallpaperPage( return WallpaperPage(entry: _viewerEntry);
entry: _viewerEntry,
);
}, },
); );
case AppMode.view: case AppMode.view:
AvesEntry viewerEntry = _viewerEntry!; AvesEntry viewerEntry = _viewerEntry!;
CollectionLens? collection; CollectionLens? collection;
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
final album = viewerEntry.directory; final album = viewerEntry.directory;
if (album != null) { if (album != null) {
// wait for collection to pass the `loading` state
final loadingCompleter = Completer(); final loadingCompleter = Completer();
final stateNotifier = source.stateNotifier; final stateNotifier = source.stateNotifier;
void _onSourceStateChanged() { void _onSourceStateChanged() {
if (stateNotifier.value != SourceState.loading) { if (stateNotifier.value != SourceState.loading) {
stateNotifier.removeListener(_onSourceStateChanged); stateNotifier.removeListener(_onSourceStateChanged);
@ -584,16 +821,10 @@ debugDb = await openDatabase(
_onSourceStateChanged(); _onSourceStateChanged();
await loadingCompleter.future; await loadingCompleter.future;
// NON lanciamo più il sync qui (evita contese durante il viewer)
// unawaited(rrs.runRemoteSyncOnceManaged());
collection = CollectionLens( collection = CollectionLens(
source: source, source: source,
filters: {StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))}, filters: {StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))},
listenToSource: false, 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, stackBursts: false,
); );
@ -607,24 +838,18 @@ debugDb = await openDatabase(
collection = null; collection = null;
} }
} }
return DirectMaterialPageRoute( return DirectMaterialPageRoute(
settings: const RouteSettings(name: EntryViewerPage.routeName), settings: const RouteSettings(name: EntryViewerPage.routeName),
builder: (_) { builder: (_) => EntryViewerPage(collection: collection, initialEntry: viewerEntry),
return EntryViewerPage(
collection: collection,
initialEntry: viewerEntry,
);
},
); );
case AppMode.edit: case AppMode.edit:
return DirectMaterialPageRoute( return DirectMaterialPageRoute(
settings: const RouteSettings(name: EntryViewerPage.routeName), settings: const RouteSettings(name: EntryViewerPage.routeName),
builder: (_) { builder: (_) => ImageEditorPage(entry: _viewerEntry!),
return ImageEditorPage(
entry: _viewerEntry!,
);
},
); );
case AppMode.initialization: case AppMode.initialization:
case AppMode.main: case AppMode.main:
case AppMode.pickCollectionFiltersExternal: case AppMode.pickCollectionFiltersExternal:
@ -659,7 +884,7 @@ debugDb = await openDatabase(
source: source, source: source,
filters: { filters: {
LocationFilter.located, LocationFilter.located,
if (filters != null) ...filters, if (filters != null) ...filters!,
}, },
); );
return MapPage( return MapPage(
@ -689,7 +914,6 @@ debugDb = await openDatabase(
); );
case CollectionPage.routeName: case CollectionPage.routeName:
default: default:
// <<--- Wrapper di debug che aggiunge i due FAB (solo in debug)
return buildRoute( return buildRoute(
(context) => _wrapWithRemoteDebug( (context) => _wrapWithRemoteDebug(
context, context,
@ -698,4 +922,51 @@ debugDb = await openDatabase(
); );
} }
} }
// -------------------------
// Utility sicure per remote
// -------------------------
Future<RemoteSettings> _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<Map<String, String>> _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<void> _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');
}
}
} }

View file

@ -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<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
AvesEntry? _viewerEntry;
int? _widgetId;
String? _initialRouteName, _initialSearchQuery;
Set<CollectionFilter>? _initialFilters;
String? _initialExplorerPath;
(LatLng, double?)? _initialLocationZoom;
List<String>? _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<bool> _isRemoteBootstrapDone() async {
final storage = FlutterSecureStorage();
final v = await storage.read(key: 'remote_bootstrap_done');
return v == '1';
}
Future<void> _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<void> _waitSourceReady(CollectionSource source) async {
if (source.stateNotifier.value == SourceState.ready) return;
final c = Completer<void>();
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<void> _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<String>();
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<String>();
_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<ValueNotifier<AppMode>>().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<CollectionSource>();
// 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<String>()
.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<CollectionSource>();
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<CollectionSource>();
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<void> _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<List<RemotePhotoItem>> _fetchAllRemoteItems() async {
try {
final rs = await _safeLoadRemoteSettings();
if (!rs.enabled || rs.baseUrl.trim().isEmpty) {
return <RemotePhotoItem>[];
}
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 <RemotePhotoItem>[];
}
} catch (e, st) {
debugPrint('[remote-sync][fetch] ERROR: $e\n$st');
return <RemotePhotoItem>[];
}
}
Future<void> _initViewerEssentials() async {
await localMediaDb.init();
}
bool _isViewerSourceable(AvesEntry? viewerEntry) {
return viewerEntry != null &&
viewerEntry.directory != null &&
!settings.hiddenFilters.any((filter) => filter.test(viewerEntry));
}
Future<AvesEntry?> _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<void> _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<Route> _getRedirectRoute(AppMode appMode) async {
String routeName;
Set<CollectionFilter?>? 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<CollectionSource>();
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<CollectionSource>();
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<RemoteSettings> _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<Map<String, String>> _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 {};
}
}
}

View file

@ -1,6 +1,7 @@
// lib/widgets/home/home_page.dart // lib/widgets/home/home_page.dart
import 'dart:async'; import 'dart:async';
import 'package:aves/remote/collection_source_remote_ext.dart';
import 'package:aves/app_mode.dart'; import 'package:aves/app_mode.dart';
import 'package:aves/geo/uri.dart'; import 'package:aves/geo/uri.dart';
import 'package:aves/model/app/intent.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:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
// --- IMPORT aggiunti per integrazione remota (Fase 1) --- // --- REMOTO / DEBUG ---
import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:aves/remote/remote_test_page.dart'; import 'package:aves/remote/remote_test_page.dart' as rtp;
import 'package:aves/remote/run_remote_sync.dart';
import 'package:aves/remote/remote_settings.dart'; 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 { class HomePage extends StatefulWidget {
static const routeName = '/'; static const routeName = '/';
// untyped map as it is coming from the platform
final Map? intentData; final Map? intentData;
const HomePage({ const HomePage({
@ -78,6 +86,10 @@ class _HomePageState extends State<HomePage> {
List<String>? _secureUris; List<String>? _secureUris;
(Object, StackTrace)? _setupError; (Object, StackTrace)? _setupError;
bool _remoteSyncScheduled = false;
bool _remoteSyncActive = false;
bool _remoteTestOpen = false;
static const allowedShortcutRoutes = [ static const allowedShortcutRoutes = [
AlbumListPage.routeName, AlbumListPage.routeName,
CollectionPage.routeName, CollectionPage.routeName,
@ -103,13 +115,68 @@ class _HomePageState extends State<HomePage> {
: null, : null,
); );
// ============================================================
// BOOTSTRAP FLAG (Remote progress ONLY first time)
// ============================================================
Future<bool> _isRemoteBootstrapDone() async {
final storage = FlutterSecureStorage();
final v = await storage.read(key: 'remote_bootstrap_done');
return v == '1';
}
Future<void> _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<void> _setup() async { Future<void> _setup() async {
try { try {
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
if (await windowService.isActivity()) { 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(); await Permissions.mediaAccess.request();
} }
@ -126,6 +193,16 @@ class _HomePageState extends State<HomePage> {
await availability.onNewIntent(); await availability.onNewIntent();
await androidFileUtils.init(); 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 (!{ if (!{
IntentActions.edit, IntentActions.edit,
IntentActions.screenSaver, IntentActions.screenSaver,
@ -161,8 +238,6 @@ class _HomePageState extends State<HomePage> {
case IntentActions.setWallpaper: case IntentActions.setWallpaper:
appMode = AppMode.setWallpaper; appMode = AppMode.setWallpaper;
case IntentActions.pickItems: 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; final multiple = (intentData[IntentDataKeys.allowMultiple] as bool?) ?? false;
debugPrint('pick mimeType=$intentMimeType multiple=$multiple'); debugPrint('pick mimeType=$intentMimeType multiple=$multiple');
appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal; appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal;
@ -184,7 +259,6 @@ class _HomePageState extends State<HomePage> {
if (widgetId == null) { if (widgetId == null) {
error = true; error = true;
} else { } else {
// widget settings may be modified in a different process after channel setup
await settings.reload(); await settings.reload();
final page = settings.getWidgetOpenPage(widgetId); final page = settings.getWidgetOpenPage(widgetId);
switch (page) { switch (page) {
@ -200,7 +274,6 @@ class _HomePageState extends State<HomePage> {
unawaited(WidgetService.update(widgetId)); unawaited(WidgetService.update(widgetId));
} }
default: default:
// do not use 'route' as extra key, as the Flutter framework acts on it
final extraRoute = intentData[IntentDataKeys.page] as String?; final extraRoute = intentData[IntentDataKeys.page] as String?;
if (allowedShortcutRoutes.contains(extraRoute)) { if (allowedShortcutRoutes.contains(extraRoute)) {
_initialRouteName = extraRoute; _initialRouteName = extraRoute;
@ -219,10 +292,7 @@ class _HomePageState extends State<HomePage> {
case AppMode.edit: case AppMode.edit:
case AppMode.setWallpaper: case AppMode.setWallpaper:
if (intentUri != null) { if (intentUri != null) {
_viewerEntry = await _initViewerEntry( _viewerEntry = await _initViewerEntry(uri: intentUri, mimeType: intentMimeType);
uri: intentUri,
mimeType: intentMimeType,
);
} }
error = _viewerEntry == null; error = _viewerEntry == null;
default: default:
@ -245,42 +315,109 @@ class _HomePageState extends State<HomePage> {
case AppMode.pickMultipleMediaExternal: case AppMode.pickMultipleMediaExternal:
unawaited(GlobalSearch.registerCallback()); unawaited(GlobalSearch.registerCallback());
unawaited(AnalysisService.registerCallback()); unawaited(AnalysisService.registerCallback());
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
if (source.loadedScope != CollectionSource.fullScope) {
await reportService.log('Initialize source to start app with mode=$appMode, loaded scope=${source.loadedScope}'); // =========================================================
// 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 = final loadTopEntriesFirst =
settings.homeNavItem.route == CollectionPage.routeName && settings.homeCustomCollection.isEmpty; settings.homeNavItem.route == CollectionPage.routeName &&
source.canAnalyze = true; 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); 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<AvesEntry> 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<String>()
.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)');
}
} }
// === 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;
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) { } catch (e, st) {
debugPrint('[remote-sync] error: $e\n$st'); debugPrint('[LOCAL-HYDRATE] error: $e\n$st');
} }
}));
// stop debug logs after 3s
Future.delayed(const Duration(seconds: 3), detach);
// 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: case AppMode.screenSaver:
await reportService.log('Initialize source to start screen saver'); await reportService.log('Initialize source to start screen saver');
final source2 = context.read<CollectionSource>(); final source2 = context.read<CollectionSource>();
source2.canAnalyze = false; source2.canAnalyze = false;
await source2.init(scope: settings.screenSaverCollectionFilters); await source2.init(scope: settings.screenSaverCollectionFilters);
break;
case AppMode.view: case AppMode.view:
if (_isViewerSourceable(_viewerEntry) && _secureUris == null) { if (_isViewerSourceable(_viewerEntry) && _secureUris == null) {
final directory = _viewerEntry?.directory; final directory = _viewerEntry?.directory;
@ -288,24 +425,25 @@ class _HomePageState extends State<HomePage> {
unawaited(AnalysisService.registerCallback()); unawaited(AnalysisService.registerCallback());
await reportService.log('Initialize source to view item in directory $directory'); await reportService.log('Initialize source to view item in directory $directory');
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
// analysis is necessary to display neighbour items when the initial item is a new one
source.canAnalyze = true; source.canAnalyze = true;
await source.init(scope: {StoredAlbumFilter(directory, null)}); await source.init(scope: {StoredAlbumFilter(directory, null)});
} }
} else { } else {
await _initViewerEssentials(); await _initViewerEssentials();
} }
break;
case AppMode.edit: case AppMode.edit:
case AppMode.setWallpaper: case AppMode.setWallpaper:
await _initViewerEssentials(); await _initViewerEssentials();
break;
default: default:
break; break;
} }
debugPrint('Home setup complete in ${stopwatch.elapsed.inMilliseconds}ms'); 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( unawaited(
Navigator.maybeOf(context)?.pushAndRemoveUntil( Navigator.maybeOf(context)?.pushAndRemoveUntil(
await _getRedirectRoute(appMode), await _getRedirectRoute(appMode),
@ -318,8 +456,93 @@ class _HomePageState extends State<HomePage> {
} }
} }
// ============================================================
// === SYNC REMOTO (Step 2)
// - full sync SOLO bootstrap
// - progress bar SOLO bootstrap
// - remoti visibili SOLO dopo bootstrap completato
// ============================================================
Future<void> _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<List<RemotePhotoItem>> _fetchAllRemoteItems() async {
try {
final rs = await _safeLoadRemoteSettings();
if (!rs.enabled || rs.baseUrl.trim().isEmpty) {
return <RemotePhotoItem>[];
}
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 <RemotePhotoItem>[];
}
} catch (e, st) {
debugPrint('[remote-sync][fetch] ERROR: $e\n$st');
return <RemotePhotoItem>[];
}
}
Future<void> _initViewerEssentials() async { Future<void> _initViewerEssentials() async {
// for video playback storage
await localMediaDb.init(); await localMediaDb.init();
} }
@ -331,32 +554,47 @@ class _HomePageState extends State<HomePage> {
Future<AvesEntry?> _initViewerEntry({required String uri, required String? mimeType}) async { Future<AvesEntry?> _initViewerEntry({required String uri, required String? mimeType}) async {
if (uri.startsWith('/')) { if (uri.startsWith('/')) {
// convert this file path to a proper URI
uri = Uri.file(uri).toString(); uri = Uri.file(uri).toString();
} }
final entry = await mediaFetchService.getEntry(uri, mimeType); final entry = await mediaFetchService.getEntry(uri, mimeType);
if (entry != null) { if (entry != null) {
// cataloguing is essential for coordinates and video rotation
await entry.catalog(background: false, force: false, persist: false); await entry.catalog(background: false, force: false, persist: false);
} }
return entry; return entry;
} }
// === DEBUG: apre la pagina di test remota con una seconda connessione al DB === // === DEBUG: pagina test remoto con DB indipendente ===
Future<void> _openRemoteTestPage(BuildContext context) async { Future<void> _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; Database? debugDb;
try { try {
final dbDir = await getDatabasesPath(); final dbDir = await getDatabasesPath();
final dbPath = p.join(dbDir, 'metadata.db'); 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; if (!context.mounted) return;
final rs = await RemoteSettings.load(); final rs = await _safeLoadRemoteSettings();
final baseUrl = rs.baseUrl.isNotEmpty ? rs.baseUrl : RemoteSettings.defaultBaseUrl; final baseUrl = rs.baseUrl.isNotEmpty ? rs.baseUrl : RemoteSettings.defaultBaseUrl;
await Navigator.of(context).push(MaterialPageRoute( await Navigator.of(context).push(MaterialPageRoute(
builder: (_) => RemoteTestPage( builder: (_) => rtp.RemoteTestPage(
db: debugDb!, db: debugDb!,
baseUrl: baseUrl, baseUrl: baseUrl,
), ),
@ -364,146 +602,14 @@ class _HomePageState extends State<HomePage> {
} catch (e, st) { } catch (e, st) {
// ignore: avoid_print // ignore: avoid_print
print('[RemoteTest] errore apertura DB/pagina: $e\n$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 { } finally {
try { try {
await debugDb?.close(); await debugDb?.close();
} catch (_) {} } catch (_) {}
_remoteTestOpen = false;
} }
} }
// === DEBUG: dialog impostazioni remote (semplice) ===
Future<void> _openRemoteSettingsDialog(BuildContext context) async {
final s = await RemoteSettings.load();
final formKey = GlobalKey<FormState>();
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<void>(
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<Route> _getRedirectRoute(AppMode appMode) async { Future<Route> _getRedirectRoute(AppMode appMode) async {
String routeName; String routeName;
Set<CollectionFilter?>? filters; Set<CollectionFilter?>? filters;
@ -512,19 +618,16 @@ class _HomePageState extends State<HomePage> {
case AppMode.setWallpaper: case AppMode.setWallpaper:
return DirectMaterialPageRoute( return DirectMaterialPageRoute(
settings: const RouteSettings(name: WallpaperPage.routeName), settings: const RouteSettings(name: WallpaperPage.routeName),
builder: (_) { builder: (_) => WallpaperPage(entry: _viewerEntry),
return WallpaperPage(
entry: _viewerEntry,
);
},
); );
case AppMode.view: case AppMode.view:
AvesEntry viewerEntry = _viewerEntry!; AvesEntry viewerEntry = _viewerEntry!;
CollectionLens? collection; CollectionLens? collection;
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
final album = viewerEntry.directory; final album = viewerEntry.directory;
if (album != null) { if (album != null) {
// wait for collection to pass the `loading` state
final loadingCompleter = Completer(); final loadingCompleter = Completer();
final stateNotifier = source.stateNotifier; final stateNotifier = source.stateNotifier;
void _onSourceStateChanged() { void _onSourceStateChanged() {
@ -542,9 +645,6 @@ class _HomePageState extends State<HomePage> {
source: source, source: source,
filters: {StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))}, filters: {StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))},
listenToSource: false, 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, stackBursts: false,
); );
@ -554,39 +654,22 @@ class _HomePageState extends State<HomePage> {
if (collectionEntry != null) { if (collectionEntry != null) {
viewerEntry = collectionEntry; viewerEntry = collectionEntry;
} else { } else {
debugPrint('collection does not contain viewerEntry=$viewerEntry');
collection = null; collection = null;
} }
} }
return DirectMaterialPageRoute( return DirectMaterialPageRoute(
settings: const RouteSettings(name: EntryViewerPage.routeName), settings: const RouteSettings(name: EntryViewerPage.routeName),
builder: (_) { builder: (_) => EntryViewerPage(collection: collection, initialEntry: viewerEntry),
return EntryViewerPage(
collection: collection,
initialEntry: viewerEntry,
);
},
); );
case AppMode.edit: case AppMode.edit:
return DirectMaterialPageRoute( return DirectMaterialPageRoute(
settings: const RouteSettings(name: EntryViewerPage.routeName), settings: const RouteSettings(name: EntryViewerPage.routeName),
builder: (_) { builder: (_) => ImageEditorPage(entry: _viewerEntry!),
return ImageEditorPage(
entry: _viewerEntry!,
); );
},
); default:
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:
routeName = _initialRouteName ?? settings.homeNavItem.route; routeName = _initialRouteName ?? settings.homeNavItem.route;
filters = _initialFilters ?? filters = _initialFilters ??
(settings.homeNavItem.route == CollectionPage.routeName ? settings.homeCustomCollection : {}); (settings.homeNavItem.route == CollectionPage.routeName ? settings.homeCustomCollection : {});
@ -610,7 +693,7 @@ class _HomePageState extends State<HomePage> {
source: source, source: source,
filters: { filters: {
LocationFilter.located, LocationFilter.located,
if (filters != null) ...filters, if (filters != null) ...filters!,
}, },
); );
return MapPage( return MapPage(
@ -640,13 +723,31 @@ class _HomePageState extends State<HomePage> {
); );
case CollectionPage.routeName: case CollectionPage.routeName:
default: default:
// <<--- Wrapper di debug che aggiunge i due FAB (solo in debug) return buildRoute((context) => CollectionPage(source: source, filters: filters));
return buildRoute( }
(context) => _wrapWithRemoteDebug( }
context,
CollectionPage(source: source, filters: filters), Future<RemoteSettings> _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<Map<String, String>> _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 {};
}
}
} }

Binary file not shown.