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,29 +208,71 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
updateTags();
}
void addEntries(Set<AvesEntry> entries, {bool notify = true}) {
if (entries.isEmpty) return;
void addEntries(Set<AvesEntry> entries, {bool notify = true}) {
if (entries.isEmpty) return;
final newIdMapEntries = Map.fromEntries(entries.map((entry) => MapEntry(entry.id, entry)));
if (_rawEntries.isNotEmpty) {
final newIds = newIdMapEntries.keys.toSet();
_rawEntries.removeWhere((entry) => newIds.contains(entry.id));
}
// DEDUPE per URI (evita raddoppi quando lo stesso media entra con ID diverso)
final newUris = entries
.map((e) => e.uri)
.whereType<String>()
.where((u) => u.isNotEmpty)
.toSet();
entries.where((entry) => entry.catalogDateMillis == null).forEach((entry) {
entry.catalogDateMillis = _savedDates[entry.id];
if (newUris.isNotEmpty && _rawEntries.isNotEmpty) {
final removedByUri = <AvesEntry>{};
_rawEntries.removeWhere((entry) {
final u = entry.uri;
final match = u != null && newUris.contains(u);
if (match) removedByUri.add(entry);
return match;
});
_entryById.addAll(newIdMapEntries);
_rawEntries.addAll(entries);
_invalidate(entries: entries, notify: notify);
addDirectories(albums: _applyHiddenFilters(entries).map((entry) => entry.directory).toSet(), notify: notify);
if (notify) {
eventBus.fire(EntryAddedEvent(entries));
// rimuovi anche dalla mappa id->entry per non lasciare "zombie"
if (removedByUri.isNotEmpty) {
for (final old in removedByUri) {
_entryById.remove(old.id);
}
}
}
// Deduplica per ID (comportamento originale)
final newIdMapEntries = Map.fromEntries(entries.map((entry) => MapEntry(entry.id, entry)));
if (_rawEntries.isNotEmpty) {
final newIds = newIdMapEntries.keys.toSet();
_rawEntries.removeWhere((entry) => newIds.contains(entry.id));
}
entries.where((entry) => entry.catalogDateMillis == null).forEach((entry) {
entry.catalogDateMillis = _savedDates[entry.id];
});
_entryById.addAll(newIdMapEntries);
_rawEntries.addAll(entries);
_invalidate(entries: entries, notify: notify);
addDirectories(
albums: _applyHiddenFilters(entries).map((entry) => entry.directory).toSet(),
notify: notify,
);
if (notify) {
eventBus.fire(EntryAddedEvent(entries));
}
}
void removeEntriesFromMemory(Set<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 {
if (uris.isEmpty) return;

View file

@ -1,3 +1,4 @@
// lib/model/source/collection_source.dart
import 'dart:async';
import 'dart:ui';
@ -40,6 +41,9 @@ import 'package:leak_tracker/leak_tracker.dart';
typedef SourceScope = Set<CollectionFilter>?;
// Trace opzionale: mostra chi nasconderebbe i remoti (non li nascondiamo)
const bool kTraceHiddenRemotes = true;
mixin SourceBase {
EventBus get eventBus;
@ -147,16 +151,31 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
}
Set<CollectionFilter> _getAppHiddenFilters() => {
...settings.hiddenFilters,
...vaults.vaultDirectories.where(vaults.isLocked).map((v) => StoredAlbumFilter(v, null)),
};
...settings.hiddenFilters,
...vaults.vaultDirectories.where(vaults.isLocked).map((v) => StoredAlbumFilter(v, null)),
};
Iterable<AvesEntry> _applyHiddenFilters(Iterable<AvesEntry> entries) {
final hiddenFilters = {
TrashFilter.instance,
..._getAppHiddenFilters(),
};
return entries.where((entry) => !hiddenFilters.any((filter) => filter.test(entry)));
// PATCH B: non nascondere i remoti con i filtri "nascosti" (tranne Trash)
return entries.where((entry) {
if (entry.origin == 1) {
if (kTraceHiddenRemotes) {
final hiddenBy = hiddenFilters.firstWhereOrNull((f) => f.test(entry));
if (hiddenBy != null && !TrashFilter.instance.test(entry)) {
debugPrint('[hidden][trace] remote id=${entry.id} rid=${entry.remoteId} by=${hiddenBy.runtimeType}');
}
}
// remoti: nascondi solo se nel cestino
return !TrashFilter.instance.test(entry);
}
// locali: logica originale
return !hiddenFilters.any((filter) => filter.test(entry));
});
}
Iterable<AvesEntry> _applyTrashFilter(Iterable<AvesEntry> entries) {
@ -189,29 +208,58 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
updateTags();
}
void addEntries(Set<AvesEntry> entries, {bool notify = true}) {
if (entries.isEmpty) return;
void addEntries(Set<AvesEntry> entries, {bool notify = true}) {
if (entries.isEmpty) return;
final newIdMapEntries = Map.fromEntries(entries.map((entry) => MapEntry(entry.id, entry)));
if (_rawEntries.isNotEmpty) {
final newIds = newIdMapEntries.keys.toSet();
_rawEntries.removeWhere((entry) => newIds.contains(entry.id));
}
// DEDUPE per URI (evita raddoppi quando lo stesso media entra con ID diverso)
final newUris = entries
.map((e) => e.uri)
.whereType<String>()
.where((u) => u.isNotEmpty)
.toSet();
entries.where((entry) => entry.catalogDateMillis == null).forEach((entry) {
entry.catalogDateMillis = _savedDates[entry.id];
if (newUris.isNotEmpty && _rawEntries.isNotEmpty) {
final removedByUri = <AvesEntry>{};
_rawEntries.removeWhere((entry) {
final u = entry.uri;
final match = u != null && newUris.contains(u);
if (match) removedByUri.add(entry);
return match;
});
_entryById.addAll(newIdMapEntries);
_rawEntries.addAll(entries);
_invalidate(entries: entries, notify: notify);
addDirectories(albums: _applyHiddenFilters(entries).map((entry) => entry.directory).toSet(), notify: notify);
if (notify) {
eventBus.fire(EntryAddedEvent(entries));
// rimuovi anche dalla mappa id->entry per non lasciare "zombie"
if (removedByUri.isNotEmpty) {
for (final old in removedByUri) {
_entryById.remove(old.id);
}
}
}
// Deduplica per ID (comportamento originale)
final newIdMapEntries = Map.fromEntries(entries.map((entry) => MapEntry(entry.id, entry)));
if (_rawEntries.isNotEmpty) {
final newIds = newIdMapEntries.keys.toSet();
_rawEntries.removeWhere((entry) => newIds.contains(entry.id));
}
entries.where((entry) => entry.catalogDateMillis == null).forEach((entry) {
entry.catalogDateMillis = _savedDates[entry.id];
});
_entryById.addAll(newIdMapEntries);
_rawEntries.addAll(entries);
_invalidate(entries: entries, notify: notify);
addDirectories(
albums: _applyHiddenFilters(entries).map((entry) => entry.directory).toSet(),
notify: notify,
);
if (notify) {
eventBus.fire(EntryAddedEvent(entries));
}
}
Future<void> removeEntries(Set<String> uris, {required bool includeTrash}) async {
if (uris.isEmpty) return;
@ -242,6 +290,27 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
// caller should take care of updating these at the right time
}
/// Carica dal DB tutte le entry **remote** (`origin=1`) non cestinate
/// e le aggiunge alla sorgente corrente (evitando duplicati per ID).
///
/// 👉 Va chiamato **dopo** che la sorgente locale è stata inizializzata
/// (es. subito dopo `await source.init(...)` nel tuo `home_page.dart`).
Future<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 {
newFields.keys.forEach((key) {
final newValue = newFields[key];

View file

@ -1,5 +1,3 @@
// lib/model/source/media_store_source.dart
import 'dart:async';
import 'package:aves/model/covers.dart';
@ -20,9 +18,9 @@ import 'package:aves/utils/debouncer.dart';
import 'package:aves_model/aves_model.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:sqflite/sqflite.dart' show Sqflite;
// AGGIUNTA: definizione origine remota
// (temporaneo) origine remota come nel tuo corrente.
// ATTENZIONE: poi la cambieremo perché colliderebbe con EntryOrigins.unknownContent (=1)
const int ORIGIN_REMOTE = 1;
class MediaStoreSource extends CollectionSource {
@ -98,31 +96,13 @@ class MediaStoreSource extends CollectionSource {
final stopwatch = Stopwatch()..start();
state = SourceState.loading;
// STEP A: come Aves originale pulizia SOLO in memoria, NON nel DB
clearEntries();
final scopeAlbumFilters = _targetScope?.whereType<StoredAlbumFilter>();
final scopeDirectory =
scopeAlbumFilters != null && scopeAlbumFilters.length == 1 ? scopeAlbumFilters.first.album : null;
// 🔒 Sentinella: conteggio remoti PRIMA
final preRem = Sqflite.firstIntValue(
await localMediaDb.rawDb.rawQuery('SELECT COUNT(*) FROM entry WHERE origin=$ORIGIN_REMOTE'),
) ??
0;
debugPrint('[cleanup][pre] remoti in DB = $preRem');
// 🔧 PATCH: cancella SOLO i locali
final swClear = Stopwatch()..start();
final deletedLocal = await localMediaDb.rawDb
.rawDelete('DELETE FROM entry WHERE origin = ?', [EntryOrigins.mediaStoreContent]);
swClear.stop();
debugPrint('$runtimeType load ${swClear.elapsed} clear local entries deleted $deletedLocal rows');
// 🔒 Sentinella: conteggio remoti DOPO
final postRem = Sqflite.firstIntValue(
await localMediaDb.rawDb.rawQuery('SELECT COUNT(*) FROM entry WHERE origin=$ORIGIN_REMOTE'),
) ??
0;
debugPrint('[cleanup][post] remoti in DB = $postRem (Δ=${postRem - preRem})');
final Set<AvesEntry> topEntries = {};
if (loadTopEntriesFirst) {
final topIds = settings.topEntryIds?.toSet();
@ -424,6 +404,8 @@ class MediaStoreSource extends CollectionSource {
_lastGeneration = await mediaStoreService.getGeneration();
}
// vault
Future<void> _loadVaultEntries(String? directory) async {
addEntries(await localMediaDb.loadEntries(origin: EntryOrigins.vault, directory: directory));
}

View file

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

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
import 'package:flutter/material.dart';
import 'remote_http.dart';
import 'package:aves/model/entry/entry.dart';
import 'dart:ui' show FontFeature;
class RemoteImageTile extends StatelessWidget {
import 'package:flutter/material.dart';
import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/props.dart'; // entry.isVideo, durationText
import 'package:aves/remote/remote_http.dart';
/// Miniatura per contenuti **remoti** con overlay coerente a quello dei locali:
/// - Icona Play in basso-sinistra se è video
/// - Chip durata in basso-destra se `entry.durationMillis` è disponibile
///
/// Fix principali:
/// 1) Mai "grigio stuck": retry automatico se la prima richiesta fallisce
/// 2) Preload: precache del thumb corrente + (opzionale) prefetch dei prossimi N thumb
class RemoteImageTile extends StatefulWidget {
final AvesEntry entry;
const RemoteImageTile({super.key, required this.entry});
final double borderRadius;
final BoxFit fit;
// personalizzazioni overlay
final Color? overlayIconBg;
final Color? overlayIconFg;
final Color? durationBg;
final Color? durationFg;
/// (Opzionale) lista di path relativi (thumb/path) da precaricare
/// Es: [nextRel1, nextRel2, ...] in ordine di priorità.
final List<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
Widget build(BuildContext context) {
// Usa SOLO campi remoti, mai entry.path
final rel = entry.remoteThumb2 ?? entry.remoteThumb1 ?? entry.remotePath;
if (rel == null || rel.isEmpty) {
return const ColoredBox(color: Colors.black12);
}
final url = RemoteHttp.absUrl(rel);
final entry = widget.entry;
return FutureBuilder<Map<String, String>>(
future: RemoteHttp.headers(),
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const ColoredBox(color: Colors.black12);
}
final hdrs = snap.data ?? const {};
return Image.network(
url,
fit: BoxFit.cover,
headers: hdrs.isEmpty ? null : hdrs,
errorBuilder: (_, __, ___) => const Icon(Icons.broken_image),
);
},
final rel = entry.remoteThumb2 ?? entry.remoteThumb1 ?? entry.remotePath ?? entry.path;
if (!_isRemote || rel == null || rel.isEmpty) {
return _frame(context, const ColoredBox(color: Colors.black12));
}
final url = RemoteHttp.absUrl(rel);
final ar = (entry.displayAspectRatio > 0) ? entry.displayAspectRatio : 1.0;
return AspectRatio(
aspectRatio: ar,
child: FutureBuilder<Map<String, String>>(
future: _headersFuture,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return _frame(context, const ColoredBox(color: Colors.black12), showProgress: true);
}
final hdrs = snap.data ?? const {};
// ImageProvider canonico (serve anche per precache)
final provider = NetworkImage(url, headers: hdrs.isEmpty ? null : hdrs);
// Precache del thumb corrente (una volta sola)
if (!_selfPrecached) {
_selfPrecached = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
precacheImage(provider, context);
});
}
// Prefetch dei prossimi N (se forniti)
if (!_neighborsPrecached && widget.prefetchRelPaths != null && widget.prefetchRelPaths!.isNotEmpty) {
_neighborsPrecached = true;
final next = widget.prefetchRelPaths!
.where((p) => p.isNotEmpty)
.take(widget.prefetchCount)
.map((p) => RemoteHttp.absUrl(p))
.toList(growable: false);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
for (final u in next) {
precacheImage(NetworkImage(u, headers: hdrs.isEmpty ? null : hdrs), context);
}
});
}
final img = Image(
image: provider,
fit: widget.fit,
// mentre scarica: spinner (così non è "grigio stuck")
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return _frame(context, const ColoredBox(color: Colors.black12), showProgress: true);
},
// se fallisce: retry automatico (1 volta di default) poi fallback soft
errorBuilder: (_, __, ___) {
if (_attempt < widget.maxRetry) {
// piccolo delay per evitare loop immediati
Future.delayed(const Duration(milliseconds: 150), () {
if (!mounted) return;
setState(() => _attempt++);
});
// nel frattempo spinner (non grigio fisso)
return _frame(context, const ColoredBox(color: Colors.black12), showProgress: true);
}
// fallback definitivo: box neutro (ma NON rimane bloccato al primo errore)
return _frame(context, const ColoredBox(color: Colors.black26));
},
);
return _frame(context, img);
},
),
);
}
Widget _frame(BuildContext context, Widget child, {bool showProgress = false}) {
final theme = Theme.of(context);
final radius = BorderRadius.circular(widget.borderRadius);
// Video detection robusta (isVideo + mime + estensione path)
final mime = (widget.entry.sourceMimeType ?? widget.entry.mimeType ?? '').toLowerCase();
final p = (widget.entry.path ?? widget.entry.remotePath ?? '').toLowerCase();
final looksVideo = p.endsWith('.mp4') || p.endsWith('.mov') || p.endsWith('.m4v') || p.endsWith('.mkv') || p.endsWith('.webm');
final isVideo = widget.entry.isVideo || mime.startsWith('video/') || looksVideo;
final showDuration = isVideo && (widget.entry.durationMillis ?? 0) > 0;
return ClipRRect(
borderRadius: radius,
child: Stack(
fit: StackFit.expand,
children: [
Positioned.fill(child: child),
if (showProgress)
const Center(
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 1.5),
),
),
if (isVideo) ...[
Positioned(
left: 6,
bottom: 6,
child: _PlayBadge(
bg: widget.overlayIconBg ?? Colors.black.withOpacity(.55),
fg: widget.overlayIconFg ?? Colors.white,
),
),
if (showDuration)
Positioned(
right: 6,
bottom: 6,
child: _DurationChip(
text: widget.entry.durationText,
bg: widget.durationBg ?? Colors.black.withOpacity(.65),
fg: widget.durationFg ?? Colors.white,
borderRadius: 10,
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
textStyle: theme.textTheme.labelSmall?.copyWith(
color: widget.durationFg ?? Colors.white,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
],
],
),
);
}
}
class _PlayBadge extends StatelessWidget {
final Color bg;
final Color fg;
const _PlayBadge({
required this.bg,
required this.fg,
});
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(color: bg, shape: BoxShape.circle),
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(Icons.play_arrow_rounded, color: fg, size: 16),
),
);
}
}
class _DurationChip extends StatelessWidget {
final String text;
final Color bg;
final Color fg;
final double borderRadius;
final EdgeInsets padding;
final TextStyle? textStyle;
const _DurationChip({
super.key,
required this.text,
required this.bg,
required this.fg,
this.borderRadius = 10,
this.padding = const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
this.textStyle,
});
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(borderRadius),
),
child: Padding(
padding: padding,
child: Text(
text,
style: textStyle ?? Theme.of(context).textTheme.labelSmall?.copyWith(color: fg),
),
),
);
}
}

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)
String absoluteUrl(String baseUrl) => buildAbsoluteUri(baseUrl, path).toString();
static DateTime? _tryParseIsoUtc(dynamic v) {
if (v == null) return null;
try { return DateTime.parse(v.toString()).toUtc(); } catch (_) { return null; }
try {
return DateTime.parse(v.toString()).toUtc();
} catch (_) {
return null;
}
}
static double? _toDouble(dynamic v) {
@ -55,6 +58,7 @@ class RemotePhotoItem {
return double.tryParse(v.toString());
}
/// Converte secondims se < 1000, altrimenti assume già millisecondi.
static int? _toMillis(dynamic v) {
if (v == null) return null;
final num? n = (v is num) ? v : num.tryParse(v.toString());
@ -85,7 +89,10 @@ class RemotePhotoItem {
lng: gps != null ? _toDouble(gps['lng']) : null,
alt: gps != null ? _toDouble(gps['alt']) : null,
user: j['user']?.toString(),
durationMillis: _toMillis(j['duration']),
// QUI LA MODIFICA: usiamo duration_ms (ms dal server)
durationMillis: _toMillis(j['duration_ms']),
location: loc,
);
}
@ -115,14 +122,14 @@ class RemoteLocation {
});
factory RemoteLocation.fromJson(Map<String, dynamic> j) => RemoteLocation(
continent: j['continent']?.toString(),
country: j['country']?.toString(),
region: j['region']?.toString(),
postcode: j['postcode']?.toString(),
city: j['city']?.toString(),
countyCode:j['county_code']?.toString(),
address: j['address']?.toString(),
timezone: j['timezone']?.toString(),
timeOffset:j['time']?.toString(),
);
continent: j['continent']?.toString(),
country: j['country']?.toString(),
region: j['region']?.toString(),
postcode: j['postcode']?.toString(),
city: j['city']?.toString(),
countyCode: j['county_code']?.toString(),
address: j['address']?.toString(),
timezone: j['timezone']?.toString(),
timeOffset: j['time']?.toString(),
);
}

View file

@ -3,7 +3,7 @@ import 'package:flutter/foundation.dart' show debugPrint;
import 'package:sqflite/sqflite.dart';
import 'remote_models.dart';
import 'remote_db_uris.dart'; // <-- helper per URI fittizi aves-remote://...
import 'remote_db_uris.dart'; // helper per URI fittizi aves-remote://...
class RemoteRepository {
final Database db;
@ -40,27 +40,28 @@ class RemoteRepository {
}
}
/// Assicura che tutte le entry remote abbiano un uri costruito da remoteId.
Future<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 tutte le entry remote abbiano un uri costruito da remoteId.
/// (coerente con aves-remote://rid/...)
Future<void> ensureRemoteUris(DatabaseExecutor dbExec) async {
try {
await dbExec.execute('''
UPDATE entry
SET uri = 'aves-remote://rid/' || replace(remoteId, ' ', '')
WHERE origin = 1
AND remoteId IS NOT NULL
AND trim(remoteId) != ''
AND (uri IS NULL OR trim(uri) = '' OR uri NOT LIKE 'aves-remote://%');
''');
debugPrint('[RemoteRepository] ensureRemoteUris: migration applied');
} catch (e, st) {
debugPrint('[RemoteRepository] ensureRemoteUris error: $e\n$st');
}
}
}
/// Assicura che le colonne GPS e alcune colonne "remote*" esistano nella tabella `entry`.
/// Assicura che le colonne necessarie esistano nella tabella `entry`.
Future<void> _ensureEntryColumns(DatabaseExecutor dbExec) async {
await _ensureColumns(dbExec, table: 'entry', columnsAndTypes: const {
// Core (alcune basi legacy potrebbero non averle ancora)
// Core
'uri': 'TEXT',
// GPS
@ -77,13 +78,14 @@ Future<void> ensureRemoteUris(DatabaseExecutor dbExec) async {
'provider': 'TEXT',
'trashed': 'INTEGER',
'remoteRotation': 'INTEGER',
// Durata video (ms)
'durationMillis': 'INTEGER',
});
// Indice "normale" per velocizzare il lookup su remoteId
// indice lookup
try {
await dbExec.execute(
'CREATE INDEX IF NOT EXISTS idx_entry_remoteId ON entry(remoteId);',
);
await dbExec.execute('CREATE INDEX IF NOT EXISTS idx_entry_remoteId ON entry(remoteId);');
} catch (e, st) {
debugPrint('[RemoteRepository] create index error: $e\n$st');
}
@ -107,10 +109,9 @@ Future<void> ensureRemoteUris(DatabaseExecutor dbExec) async {
} catch (e) {
if (!_isBusy(e) || i == maxAttempts - 1) rethrow;
await Future.delayed(delay);
delay *= 2; // 250 500 1000 ms
delay *= 2;
}
}
// non dovrebbe arrivare qui
return await fn();
}
@ -125,8 +126,8 @@ Future<void> ensureRemoteUris(DatabaseExecutor dbExec) async {
return s;
}
/// Candidato "canonico" (inserisce '/original/' dopo '/photos/<User>/'
/// se manca). Usato per lookup/fallback.
/// 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', ...]
@ -145,7 +146,7 @@ Future<void> ensureRemoteUris(DatabaseExecutor dbExec) async {
bool _isVideoItem(RemotePhotoItem it) {
final mt = (it.mimeType ?? '').toLowerCase();
final p = (it.path).toLowerCase();
final p = it.path.toLowerCase();
return mt.startsWith('video/') ||
p.endsWith('.mp4') ||
p.endsWith('.mov') ||
@ -154,72 +155,62 @@ Future<void> ensureRemoteUris(DatabaseExecutor dbExec) async {
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);
// ============================================================
Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
final syntheticUri = RemoteDbUris.make(remoteId: it.id, remotePath: it.path);
// TEMPORARY FIX: usare URL HTTP basato su thub2
//final syntheticUri = 'https://prova.patachina.it/${it.thub2}';
//final syntheticUri = 'https://picsum.photos/400';
int _makeContentId() {
final base = (it.id.isNotEmpty ? it.id : it.path);
final h = base.hashCode & 0x7fffffff;
return 1_000_000_000 + (h % 900_000_000);
}
final nowMs = DateTime.now().millisecondsSinceEpoch;
final dateModMs = it.takenAtUtc?.millisecondsSinceEpoch ?? nowMs;
int _makeContentId() {
final base = (it.id.isNotEmpty ? it.id : it.path);
final h = base.hashCode & 0x7fffffff;
return 1_000_000_000 + (h % 900_000_000);
return <String, Object?>{
'id': existingId,
'contentId': _makeContentId(),
'uri': syntheticUri,
'path': it.path,
// fallback MIME video se mancante
'sourceMimeType': it.mimeType ?? (_isVideoItem(it) ? 'video/mp4' : 'image/jpeg'),
'width': it.width ?? 0,
'height': it.height ?? 0,
'sourceRotationDegrees': it.rotation ?? 0,
'sizeBytes': it.sizeBytes,
'title': it.name,
'dateAddedSecs': nowMs ~/ 1000,
'dateModifiedMillis': dateModMs,
'sourceDateTakenMillis': it.takenAtUtc?.millisecondsSinceEpoch,
// durata video (ms) (può essere null per foto)
'durationMillis': it.durationMillis,
'trashed': 0,
'origin': 1,
'provider': 'json@patachina',
'latitude': it.lat,
'longitude': it.lng,
'altitude': it.alt,
'remoteId': it.id,
'remotePath': it.path,
'remoteThumb1': it.thub1,
'remoteThumb2': it.thub2,
'remoteRotation': it.rotation ?? 0,
'remoteWidth': it.width ?? 0,
'remoteHeight': it.height ?? 0,
};
}
final nowMs = DateTime.now().millisecondsSinceEpoch;
final dateModMs = it.takenAtUtc?.millisecondsSinceEpoch ?? nowMs;
return <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,
@ -262,15 +253,14 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
final batch = txn.batch();
for (final it in chunk) {
// Log essenziale (puoi silenziare dopo i test)
final raw = it.path;
final norm = _normPath(raw);
final cand = _canonCandidate(raw, it.name);
debugPrint('[repo-upsert] in: rid=${it.id.substring(0,8)} name=${it.name} raw="$raw"');
final cand = _canonCandidate(raw, it.name); // name non-null nel tuo modello
debugPrint('[repo-upsert] in: rid=${it.id.substring(0, 8)} name=${it.name} raw="$raw"');
// Lookup record esistente:
// 1) per remoteId
int? existingId;
// 1) lookup per remoteId
try {
final existing = await txn.query(
'entry',
@ -284,7 +274,7 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
debugPrint('[RemoteRepository] lookup by remoteId failed for remoteId=${it.id}: $e\n$st');
}
// 2) fallback per remotePath = candidato canonico (/original/)
// 2) fallback per remotePath canonico
if (existingId == null) {
try {
final byCanon = await txn.query(
@ -294,15 +284,13 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
whereArgs: [cand],
limit: 1,
);
if (byCanon.isNotEmpty) {
existingId = byCanon.first['id'] as int?;
}
if (byCanon.isNotEmpty) existingId = byCanon.first['id'] as int?;
} catch (e, st) {
debugPrint('[RemoteRepository] lookup by canonical path failed "$cand": $e\n$st');
}
}
// 3) ultimo fallback per remotePath "raw normalizzato" (solo slash)
// 3) fallback per remotePath normalizzato
if (existingId == null) {
try {
final byNorm = await txn.query(
@ -312,15 +300,12 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
whereArgs: [norm],
limit: 1,
);
if (byNorm.isNotEmpty) {
existingId = byNorm.first['id'] as int?;
}
if (byNorm.isNotEmpty) existingId = byNorm.first['id'] as int?;
} catch (e, st) {
debugPrint('[RemoteRepository] lookup by normalized path failed "$norm": $e\n$st');
}
}
// Riga completa e REPLACE
final row = _buildEntryRow(it, existingId: existingId);
try {
@ -332,6 +317,7 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
} on DatabaseException catch (e, st) {
debugPrint('[RemoteRepository] batch insert failed for remoteId=${it.id}: $e\n$st');
// fallback: rimuovi gps se causa problemi
final rowNoGps = Map<String, Object?>.from(row)
..remove('latitude')
..remove('longitude')
@ -374,7 +360,7 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
}
}));
} catch (e, st) {
debugPrint('[RemoteRepository] upsert chunk ${offset}..${end - 1} ERROR: $e\n$st');
debugPrint('[RemoteRepository] upsert chunk $offset..${end - 1} ERROR: $e\n$st');
rethrow;
}
}
@ -384,7 +370,6 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
// Unicità & deduplica
// =========================
/// Indice UNICO su `remoteId` limitato alle righe remote (origin=1).
Future<void> ensureUniqueRemoteId() async {
try {
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 {
try {
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 {
try {
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 {
try {
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
// =========================
/// 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 "
@ -468,13 +499,12 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
debugPrint('[RemoteRepository] backfill URIs (SQL) error: $e\n$st');
}
// 2) Loop Dart per i (pochi) rimanenti senza remoteId ma con remotePath
try {
final rows = await db.rawQuery(
"SELECT id, remotePath FROM entry "
"WHERE origin=1 AND (uri IS NULL OR trim(uri)='') "
"AND (remoteId IS NULL OR trim(remoteId)='') "
"AND remotePath IS NOT NULL"
"AND remotePath IS NOT NULL",
);
if (rows.isNotEmpty) {
@ -498,15 +528,14 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
}
// =========================
// Backfill ESSENZIALI (contentId, dateModifiedMillis) per remoti legacy
// Backfill essentials 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)"
"WHERE origin=1 AND (contentId IS NULL OR contentId<=0)",
);
if (rows.isNotEmpty) {
for (final r in rows) {
@ -514,7 +543,7 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
final rid = (r['remoteId'] as String?) ?? '';
final rpath = (r['remotePath'] as String?) ?? '';
final base = rid.isNotEmpty ? rid : rpath;
final h = base.hashCode & 0x7fffffff; // positivo
final h = base.hashCode & 0x7fffffff;
final cid = 1_000_000_000 + (h % 900_000_000);
await db.update('entry', {'contentId': cid}, where: 'id=?', whereArgs: [id]);
}
@ -524,7 +553,6 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
debugPrint('[RemoteRepository] backfill contentId error: $e\n$st');
}
// 2) backfill dateModifiedMillis (usa sourceDateTakenMillis se presente, altrimenti now)
try {
final nowMs = DateTime.now().millisecondsSinceEpoch;
final updated = await db.rawUpdate(
@ -538,27 +566,15 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
}
}
// =========================
// Helper combinato: pulizia + indici + backfill URI/ESSENZIALI
// =========================
Future<void> sanitizeRemotes() async {
await deduplicateRemotes();
await deduplicateByRemotePath(); // opzionale ma utile
await deduplicateByRemotePath();
await ensureUniqueRemoteId();
await ensureUniqueRemotePath();
// Assicura che ogni remoto abbia un uri fittizio valorizzato
await backfillRemoteUris();
// Assicura che i remoti abbiano contentId/dateModifiedMillis validi
await backfillRemoteEssentials();
}
// =========================
// Utils
// =========================
Future<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

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

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:provider/provider.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/remote/collection_source_remote_ext.dart'; // per appendRemoteEntriesFromDb()
import 'package:aves/remote/remote_controller.dart';
import 'package:aves/remote/remote_sync_bus.dart';
import 'remote_settings.dart';
import 'remote_http.dart';
@ -49,7 +56,6 @@ class _RemoteSettingsPageState extends State<RemoteSettingsPage> {
_loaded = true;
});
} catch (e) {
// Fail-open: apri comunque con default/blank e notifica
_showSnack('Impossibile leggere le impostazioni sicure: $e');
if (!mounted) return;
setState(() {
@ -70,7 +76,6 @@ class _RemoteSettingsPageState extends State<RemoteSettingsPage> {
if (uri == null || !(uri.hasScheme && (uri.scheme == 'http' || uri.scheme == 'https'))) {
return 'URL non valida (deve iniziare con http/https)';
}
// opzionale: blocca spazi/controlli interni
if (RegExp(r'[\u200B-\u200F\u202A-\u202E\u2060-\u2064\uFEFF]').hasMatch(s)) {
return 'URL contiene caratteri non validi (invisibili)';
}
@ -83,6 +88,28 @@ class _RemoteSettingsPageState extends State<RemoteSettingsPage> {
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 {
if (!(_form.currentState?.validate() ?? false)) return;
@ -98,9 +125,12 @@ class _RemoteSettingsPageState extends State<RemoteSettingsPage> {
await s.save();
// forza Aves a usare SUBITO base URL & token aggiornati
// aggiorna headers/token
await RemoteHttp.refreshFromSettings();
await RemoteHttp.warmUp(); // non bloccante: utile per loggare stato token/base
await RemoteHttp.warmUp();
// applica subito ON/OFF live
await _applyRuntimeEffects();
if (!mounted) return;
_showSnack('Impostazioni remote salvate');
@ -116,7 +146,7 @@ class _RemoteSettingsPageState extends State<RemoteSettingsPage> {
void _showSnack(String msg) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
behavior: SnackBarBehavior.fixed, // evita "floating off screen"
behavior: SnackBarBehavior.fixed,
content: Text(msg),
duration: const Duration(seconds: 3),
),
@ -207,5 +237,4 @@ class _RemoteSettingsPageState extends State<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;
}

File diff suppressed because it is too large Load diff

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

View file

@ -56,6 +56,9 @@ import 'package:intl/intl.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart';
// REMOTE: import per le thumb di rete
import 'package:aves/remote/remote_image_tile.dart';
class CollectionGrid extends StatefulWidget {
final String settingsRouteKey;
@ -182,6 +185,17 @@ class _CollectionGridContentState extends State<_CollectionGridContent> {
tileExtent: thumbnailExtent,
tileBuilder: (entry, tileSize) {
final extent = tileSize.shortestSide;
// REMOTE: ramo dedicato per le entry remote (origin=1)
if (entry.origin == 1) {
return RemoteInteractiveTile(
key: ValueKey('remote_${entry.id}'),
entry: entry,
thumbnailExtent: extent,
);
}
// Locale: flusso preesistente
return AnimatedBuilder(
animation: favourites,
builder: (context, child) {
@ -419,10 +433,23 @@ class _CollectionScaler extends StatelessWidget {
),
scaledItemBuilder: (entry, tileSize) => EntryListDetailsTheme(
extent: tileSize.height,
child: Tile(
entry: entry,
thumbnailExtent: context.read<TileExtentController>().effectiveExtentMax,
tileLayout: tileLayout,
child: Builder(
builder: (_) {
// REMOTE: ramo dedicato in layout "fixed scale"
if (entry.origin == 1) {
return RemoteInteractiveTile(
key: ValueKey('remote_scaled_${entry.id}'),
entry: entry,
thumbnailExtent: context.read<TileExtentController>().effectiveExtentMax,
);
}
// Locale: flusso preesistente
return Tile(
entry: entry,
thumbnailExtent: context.read<TileExtentController>().effectiveExtentMax,
tileLayout: tileLayout,
);
},
),
),
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));
}
// 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:provider/provider.dart';
// STEP 2: banner progresso remoti (overlay sopra la griglia)
import 'package:aves/widgets/collection/remote_progress_banner.dart';
class CollectionPage extends StatefulWidget {
static const routeName = '/collection';
@ -54,7 +57,8 @@ class CollectionPage extends StatefulWidget {
class _CollectionPageState extends State<CollectionPage> {
final Set<StreamSubscription> _subscriptions = {};
late CollectionLens _collection;
final StreamController<DraggableScrollbarEvent> _draggableScrollBarEventStreamController = StreamController.broadcast();
final StreamController<DraggableScrollbarEvent> _draggableScrollBarEventStreamController =
StreamController.broadcast();
@override
void initState() {
@ -66,7 +70,9 @@ class _CollectionPageState extends State<CollectionPage> {
);
super.initState();
_subscriptions.add(
settings.updateStream.where((event) => event.key == SettingKeys.enableBinKey).listen((_) {
settings.updateStream
.where((event) => event.key == SettingKeys.enableBinKey)
.listen((_) {
if (!settings.enableBin) {
_collection.removeFilter(TrashFilter.instance);
}
@ -87,7 +93,8 @@ class _CollectionPageState extends State<CollectionPage> {
@override
Widget build(BuildContext context) {
final useTvLayout = settings.useTvLayout;
final liveFilter = _collection.filters.firstWhereOrNull((v) => v is QueryFilter && v.live) as QueryFilter?;
final liveFilter =
_collection.filters.firstWhereOrNull((v) => v is QueryFilter && v.live) as QueryFilter?;
return SelectionProvider<AvesEntry>(
child: Selector<Selection<AvesEntry>, bool>(
selector: (context, selection) => selection.selectedItems.isNotEmpty,
@ -107,15 +114,21 @@ class _CollectionPageState extends State<CollectionPage> {
doubleBackPopHandler,
],
child: GestureAreaProtectorStack(
child: DirectionalSafeArea(
start: !useTvLayout,
top: false,
bottom: false,
child: const CollectionGrid(
// key is expected by test driver
key: Key('collection-grid'),
settingsRouteKey: CollectionPage.routeName,
),
// STEP 2: overlay in Stack (griglia + banner progresso remoti)
child: Stack(
children: [
DirectionalSafeArea(
start: !useTvLayout,
top: false,
bottom: false,
child: const CollectionGrid(
// key is expected by test driver
key: Key('collection-grid'),
settingsRouteKey: CollectionPage.routeName,
),
),
const RemoteProgressBanner(),
],
),
),
);
@ -142,7 +155,8 @@ class _CollectionPageState extends State<CollectionPage> {
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 canNavigate =
context.select<ValueNotifier<AppMode>, bool>((v) => v.value.canNavigate);
final showBottomNavigationBar = canNavigate && enableBottomNavigationBar;
return NotificationListener<DraggableScrollbarNotification>(
@ -167,6 +181,7 @@ class _CollectionPageState extends State<CollectionPage> {
},
);
}
// this provider should be above `TvRail`
return ChangeNotifierProvider<CollectionLens>.value(
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/events.dart';
import 'package:aves/remote/remote_sync_bus.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/empty.dart';
@ -26,20 +27,51 @@ class LoadingEmptyContent extends StatelessWidget {
text: context.l10n.sourceStateLoading,
bottom: Padding(
padding: const EdgeInsets.only(top: 16),
child: Stack(
alignment: Alignment.center,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const ReportProgressIndicator(),
ValueListenableBuilder<ProgressEvent>(
valueListenable: source.progressNotifier,
builder: (context, progress, snapshot) {
final done = progress.done;
return done > 0
? Text(
countFormatter.format(done),
style: progressTextStyle,
)
: const SizedBox();
// === PROGRESS LOCALE (Aves originale) ===
Stack(
alignment: Alignment.center,
children: [
const ReportProgressIndicator(),
ValueListenableBuilder<ProgressEvent>(
valueListenable: source.progressNotifier,
builder: (context, progress, snapshot) {
final done = progress.done;
return done > 0
? Text(
countFormatter.format(done),
style: progressTextStyle,
)
: const SizedBox();
},
),
],
),
// === PROGRESS REMOTO (solo bootstrap, stile "Aves") ===
ValueListenableBuilder<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,
),
);
},
),
],
@ -48,3 +80,4 @@ class LoadingEmptyContent extends StatelessWidget {
);
}
}

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 'package:aves/remote/collection_source_remote_ext.dart';
import 'package:aves/app_mode.dart';
import 'package:aves/geo/uri.dart';
import 'package:aves/model/app/intent.dart';
@ -47,26 +45,14 @@ import 'package:latlong2/latlong.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart';
// --- IMPORT aggiunti per integrazione remota / telemetria ---
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart' as p;
import 'package:aves/remote/remote_test_page.dart' as rtp;
import 'package:aves/remote/run_remote_sync.dart' as rrs;
// Remote
import 'package:aves/remote/remote_controller.dart';
import 'package:aves/remote/remote_settings.dart';
import 'package:aves/remote/remote_http.dart'; // PERF/REMOTE: warm-up headers
import 'package:aves/remote/remote_models.dart'; // RemotePhotoItem
// --- IMPORT per client reale ---
import 'package:aves/remote/remote_client.dart';
import 'package:aves/remote/auth_client.dart';
// secure storage import (used only in debug helper)
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class HomePage extends StatefulWidget {
static const routeName = '/';
// untyped map as it is coming from the platform
// compatibile con aves_app.dart
final Map? intentData;
const HomePage({
@ -88,14 +74,6 @@ class _HomePageState extends State<HomePage> {
List<String>? _secureUris;
(Object, StackTrace)? _setupError;
// guard UI per schedulare UNA sola run del sync da Home
bool _remoteSyncScheduled = false;
// indica se il sync è effettivamente in corso
bool _remoteSyncActive = false;
// guard per evitare doppi push della pagina di test remota
bool _remoteTestOpen = false;
static const allowedShortcutRoutes = [
AlbumListPage.routeName,
CollectionPage.routeName,
@ -121,20 +99,32 @@ class _HomePageState extends State<HomePage> {
: 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 {
try {
final stopwatch = Stopwatch()..start();
if (await windowService.isActivity()) {
// do not check whether permission was granted, because some app stores
// hide in some countries apps that force quit on permission denial
// Permessi Aves originali
await Permissions.mediaAccess.request();
}
var appMode = AppMode.main;
var error = false;
final intentData = widget.intentData ?? await IntentService.getIntentData();
final rawIntentData = widget.intentData ?? await IntentService.getIntentData();
final intentData = _safeCastIntentMap(rawIntentData);
final intentAction = intentData[IntentDataKeys.action] as String?;
_initialFilters = null;
@ -144,24 +134,11 @@ class _HomePageState extends State<HomePage> {
await availability.onNewIntent();
await androidFileUtils.init();
// PERF/REMOTE: warm-up headers (Bearer) in background safe version
unawaited(Future(() async {
try {
final s = await _safeLoadRemoteSettings();
if (s.enabled && s.baseUrl.trim().isNotEmpty) {
await _safeHeaders(); // popola la cache per peekHeaders() in modo sicuro
debugPrint('[startup] remote headers warm-up done (safe)');
}
} catch (e) {
debugPrint('[startup] remote headers warm-up skipped: $e');
}
}));
if (!{
IntentActions.edit,
IntentActions.screenSaver,
IntentActions.setWallpaper,
}.contains(intentAction) &&
IntentActions.edit,
IntentActions.screenSaver,
IntentActions.setWallpaper,
}.contains(intentAction) &&
settings.isInstalledAppAccessAllowed) {
unawaited(appInventory.initAppNames());
}
@ -187,34 +164,40 @@ class _HomePageState extends State<HomePage> {
}
}
break;
case IntentActions.edit:
appMode = AppMode.edit;
case IntentActions.setWallpaper:
appMode = AppMode.setWallpaper;
case IntentActions.pickItems:
// some apps define multiple types, separated by a space
final multiple = (intentData[IntentDataKeys.allowMultiple] as bool?) ?? false;
debugPrint('pick mimeType=$intentMimeType multiple=$multiple');
appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal;
case IntentActions.pickCollectionFilters:
appMode = AppMode.pickCollectionFiltersExternal;
case IntentActions.screenSaver:
appMode = AppMode.screenSaver;
_initialRouteName = ScreenSaverPage.routeName;
case IntentActions.screenSaverSettings:
_initialRouteName = ScreenSaverSettingsPage.routeName;
case IntentActions.search:
_initialRouteName = SearchPage.routeName;
_initialSearchQuery = intentData[IntentDataKeys.query] as String?;
case IntentActions.widgetSettings:
_initialRouteName = HomeWidgetSettingsPage.routeName;
_widgetId = (intentData[IntentDataKeys.widgetId] as int?) ?? 0;
case IntentActions.widgetOpen:
final widgetId = intentData[IntentDataKeys.widgetId] as int?;
if (widgetId == null) {
error = true;
} else {
// widget settings may be modified in a different process after channel setup
await settings.reload();
final page = settings.getWidgetOpenPage(widgetId);
switch (page) {
@ -229,6 +212,7 @@ class _HomePageState extends State<HomePage> {
}
unawaited(WidgetService.update(widgetId));
}
default:
final extraRoute = intentData[IntentDataKeys.page] as String?;
if (allowedShortcutRoutes.contains(extraRoute)) {
@ -240,7 +224,6 @@ class _HomePageState extends State<HomePage> {
final extraFilters = (intentData[IntentDataKeys.filters] as List?)?.cast<String>();
_initialFilters = extraFilters?.map(CollectionFilter.fromJson).nonNulls.toSet();
}
_initialExplorerPath = intentData[IntentDataKeys.explorerPath] as String?;
switch (appMode) {
@ -248,10 +231,7 @@ class _HomePageState extends State<HomePage> {
case AppMode.edit:
case AppMode.setWallpaper:
if (intentUri != null) {
_viewerEntry = await _initViewerEntry(
uri: intentUri,
mimeType: intentMimeType,
);
_viewerEntry = await _initViewerEntry(uri: intentUri, mimeType: intentMimeType);
}
error = _viewerEntry == null;
default:
@ -267,6 +247,10 @@ class _HomePageState extends State<HomePage> {
context.read<ValueNotifier<AppMode>>().value = appMode;
unawaited(reportService.setCustomKey('app_mode', appMode.toString()));
// Remote: seed debug + icona coerente
unawaited(RemoteSettings.debugSeedIfEmpty());
unawaited(RemoteController.instance.initBusFromSettings());
switch (appMode) {
case AppMode.main:
case AppMode.pickCollectionFiltersExternal:
@ -276,127 +260,30 @@ class _HomePageState extends State<HomePage> {
unawaited(AnalysisService.registerCallback());
final source = context.read<CollectionSource>();
// Aves originale: init SOLO se non già full scope
if (source.loadedScope != CollectionSource.fullScope) {
await reportService.log(
'Initialize source to start app with mode=$appMode, loaded scope=${source.loadedScope}',
);
final loadTopEntriesFirst =
settings.homeNavItem.route == CollectionPage.routeName && settings.homeCustomCollection.isEmpty;
// PERF: UI-first niente analisi prima della prima paint
source.canAnalyze = false;
final swInit = Stopwatch()..start();
await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst);
swInit.stop();
debugPrint('[startup] source.init done in ${swInit.elapsedMilliseconds}ms');
}
// REMOTE: aggiungi remoti visibili (origin=1, trashed=0)
final swAppend1 = Stopwatch()..start();
await source.appendRemoteEntriesFromDb();
swAppend1.stop();
debugPrint('[startup] appendRemoteEntries (pre-sync) in ${swAppend1.elapsedMilliseconds}ms');
// === DIAGNOSTICA PRE- SYNC ===
await _printRemoteDiag(source, when: ' PRE');
// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
// PATCH A: se ci sono remoti in DB, forza la Collection "All items"
try {
final remCount = (await localMediaDb.rawDb
.rawQuery('SELECT COUNT(*) AS c FROM entry WHERE origin=1 AND trashed=0'))
.first['c'] as int? ?? 0;
if (remCount > 0) {
_initialRouteName = CollectionPage.routeName;
_initialFilters = <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((_) {
settings.homeNavItem.route == CollectionPage.routeName &&
settings.homeCustomCollection.isEmpty;
source.canAnalyze = true;
debugPrint('[startup] analysis re-enabled in background');
}));
// === SYNC REMOTO post-init (non blocca la UI) ===
if (!_remoteSyncScheduled) {
_remoteSyncScheduled = true; // una sola schedulazione per avvio
unawaited(Future(() async {
try {
await RemoteSettings.debugSeedIfEmpty();
final rs = await _safeLoadRemoteSettings();
if (!rs.enabled) return;
// attesa fine loading
final notifier = source.stateNotifier;
if (notifier.value == SourceState.loading) {
final completer = Completer<void>();
void onState() {
if (notifier.value != SourceState.loading) {
notifier.removeListener(onState);
completer.complete();
}
}
notifier.addListener(onState);
// nel caso non sia già loading:
onState();
await completer.future;
}
// piccolo margine per step secondari (tag, ecc.)
await Future.delayed(const Duration(milliseconds: 400));
// SYNC su **stessa connessione** + FETCH (obbligatorio)
debugPrint('[remote-sync] START (scheduled=$_remoteSyncScheduled)');
_remoteSyncActive = true;
try {
final swSync = Stopwatch()..start();
final imported = await rrs.runRemoteSyncOnceManaged(
fetch: _fetchAllRemoteItems, // 👈 PASSIAMO IL FETCH
).timeout(const Duration(seconds: 60)); // timeout regolabile
swSync.stop();
debugPrint('[remote-sync] completed in ${swSync.elapsedMilliseconds}ms, imported=$imported');
} on TimeoutException catch (e) {
debugPrint('[remote-sync] TIMEOUT after 60s: $e');
} catch (e, st) {
debugPrint('[remote-sync] error: $e\n$st');
} finally {
_remoteSyncActive = false;
debugPrint('[remote-sync] END (active=$_remoteSyncActive)');
}
// REMOTE: dopo il sync, append di eventuali nuovi remoti
if (mounted) {
final swAppend2 = Stopwatch()..start();
await source.appendRemoteEntriesFromDb();
swAppend2.stop();
debugPrint('[remote-sync] appendRemoteEntries (post-sync) in ${swAppend2.elapsedMilliseconds}ms');
// 🔎 Conteggio di debug usando una CollectionLens temporanea
final c = _countRemotesInSource(source);
debugPrint('[check] remoti in CollectionSource = $c');
// === DIAGNOSTICA POST- SYNC ===
await _printRemoteDiag(source, when: ' POST');
}
} catch (e, st) {
debugPrint('[remote-sync] outer error: $e\n$st');
}
}));
await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst);
}
break;
// Remote: dopo init locale, ma non blocca
unawaited(RemoteController.instance.onAppStart(
source: source,
resumeBootstrapIfEnabled: true,
));
case AppMode.screenSaver:
await reportService.log('Initialize source to start screen saver');
final source2 = context.read<CollectionSource>();
source2.canAnalyze = false;
await source2.init(scope: settings.screenSaverCollectionFilters);
break;
final source = context.read<CollectionSource>();
source.canAnalyze = false;
await source.init(scope: settings.screenSaverCollectionFilters);
case AppMode.view:
if (_isViewerSourceable(_viewerEntry) && _secureUris == null) {
@ -405,19 +292,16 @@ class _HomePageState extends State<HomePage> {
unawaited(AnalysisService.registerCallback());
await reportService.log('Initialize source to view item in directory $directory');
final source = context.read<CollectionSource>();
// analysis is necessary to display neighbour items when the initial item is a new one
source.canAnalyze = true;
await source.init(scope: {StoredAlbumFilter(directory, null)});
}
} else {
await _initViewerEssentials();
}
break;
case AppMode.edit:
case AppMode.setWallpaper:
await _initViewerEssentials();
break;
default:
break;
@ -425,8 +309,6 @@ class _HomePageState extends State<HomePage> {
debugPrint('Home setup complete in ${stopwatch.elapsed.inMilliseconds}ms');
// `pushReplacement` is not enough in some edge cases
// e.g. when opening the viewer in `view` mode should replace a viewer in `main` mode
unawaited(
Navigator.maybeOf(context)?.pushAndRemoveUntil(
await _getRedirectRoute(appMode),
@ -439,65 +321,7 @@ class _HomePageState extends State<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 {
// for video playback storage
await localMediaDb.init();
}
@ -509,204 +333,15 @@ class _HomePageState extends State<HomePage> {
Future<AvesEntry?> _initViewerEntry({required String uri, required String? mimeType}) async {
if (uri.startsWith('/')) {
// convert this file path to a proper URI
uri = Uri.file(uri).toString();
}
final entry = await mediaFetchService.getEntry(uri, mimeType);
if (entry != null) {
// cataloguing is essential for coordinates and video rotation
await entry.catalog(background: false, force: false, persist: false);
}
return entry;
}
// === DEBUG: apre la pagina di test remota con una seconda connessione al DB ===
Future<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 {
String routeName;
Set<CollectionFilter?>? filters;
@ -715,19 +350,16 @@ class _HomePageState extends State<HomePage> {
case AppMode.setWallpaper:
return DirectMaterialPageRoute(
settings: const RouteSettings(name: WallpaperPage.routeName),
builder: (_) {
return WallpaperPage(
entry: _viewerEntry,
);
},
builder: (_) => WallpaperPage(entry: _viewerEntry),
);
case AppMode.view:
AvesEntry viewerEntry = _viewerEntry!;
CollectionLens? collection;
final source = context.read<CollectionSource>();
final album = viewerEntry.directory;
if (album != null) {
// wait for collection to pass the `loading` state
final loadingCompleter = Completer();
final stateNotifier = source.stateNotifier;
void _onSourceStateChanged() {
@ -741,16 +373,10 @@ class _HomePageState extends State<HomePage> {
_onSourceStateChanged();
await loadingCompleter.future;
// NON lanciamo più il sync qui (evita contese durante il viewer)
// unawaited(rrs.runRemoteSyncOnceManaged());
collection = CollectionLens(
source: source,
filters: {StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))},
listenToSource: false,
// if we group bursts, opening a burst sub-entry should:
// - identify and select the containing main entry,
// - select the sub-entry in the Viewer page.
stackBursts: false,
);
@ -760,39 +386,22 @@ class _HomePageState extends State<HomePage> {
if (collectionEntry != null) {
viewerEntry = collectionEntry;
} else {
debugPrint('collection does not contain viewerEntry=$viewerEntry');
collection = null;
}
}
return DirectMaterialPageRoute(
settings: const RouteSettings(name: EntryViewerPage.routeName),
builder: (_) {
return EntryViewerPage(
collection: collection,
initialEntry: viewerEntry,
);
},
builder: (_) => EntryViewerPage(collection: collection, initialEntry: viewerEntry),
);
case AppMode.edit:
return DirectMaterialPageRoute(
settings: const RouteSettings(name: EntryViewerPage.routeName),
builder: (_) {
return ImageEditorPage(
entry: _viewerEntry!,
);
},
builder: (_) => ImageEditorPage(entry: _viewerEntry!),
);
case AppMode.initialization:
case AppMode.main:
case AppMode.pickCollectionFiltersExternal:
case AppMode.pickSingleMediaExternal:
case AppMode.pickMultipleMediaExternal:
case AppMode.pickFilteredMediaInternal:
case AppMode.pickUnfilteredMediaInternal:
case AppMode.pickFilterInternal:
case AppMode.previewMap:
case AppMode.screenSaver:
case AppMode.slideshow:
default:
routeName = _initialRouteName ?? settings.homeNavItem.route;
filters = _initialFilters ??
(settings.homeNavItem.route == CollectionPage.routeName ? settings.homeCustomCollection : {});
@ -804,7 +413,6 @@ class _HomePageState extends State<HomePage> {
);
final source = context.read<CollectionSource>();
switch (routeName) {
case AlbumListPage.routeName:
return buildRoute((context) => const AlbumListPage(initialGroup: null));
@ -846,60 +454,8 @@ class _HomePageState extends State<HomePage> {
);
case CollectionPage.routeName:
default:
// Wrapper di debug che aggiunge i due FAB (solo in debug)
return buildRoute(
(context) => _wrapWithRemoteDebug(
context,
CollectionPage(source: source, filters: filters),
),
);
}
}
// -------------------------
// Utility sicure per remote
// -------------------------
// safe load of RemoteSettings with timeout and fallback
Future<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');
return buildRoute((context) => CollectionPage(source: source, filters: filters));
}
}
}

View file

@ -47,22 +47,20 @@ import 'package:latlong2/latlong.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart';
// --- IMPORT aggiunti per integrazione remota / telemetria ---
// --- REMOTO / DEBUG ---
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart' as p;
import 'package:aves/remote/remote_test_page.dart' as rtp;
import 'package:aves/remote/run_remote_sync.dart' as rrs;
import 'package:aves/remote/remote_settings.dart';
import 'package:aves/remote/remote_http.dart'; // PERF/REMOTE: warm-up headers
import 'package:aves/remote/remote_models.dart'; // RemotePhotoItem
// --- IMPORT per client reale ---
import 'package:aves/remote/remote_http.dart';
import 'package:aves/remote/remote_models.dart';
import 'package:aves/remote/remote_client.dart';
import 'package:aves/remote/auth_client.dart';
// secure storage import (used only in debug helper)
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:aves/remote/remote_sync_bus.dart';
import 'package:aves/remote/remote_repository.dart';
class HomePage extends StatefulWidget {
static const routeName = '/';
@ -88,12 +86,11 @@ class _HomePageState extends State<HomePage> {
List<String>? _secureUris;
(Object, StackTrace)? _setupError;
// guard UI per schedulare UNA sola run del sync da Home
// sync remoto: singola esecuzione
bool _remoteSyncScheduled = false;
// indica se il sync è effettivamente in corso
bool _remoteSyncActive = false;
// guard per evitare doppi push della pagina di test remota
// pagina test remoto (FAB debug)
bool _remoteTestOpen = false;
static const allowedShortcutRoutes = [
@ -126,8 +123,6 @@ class _HomePageState extends State<HomePage> {
final stopwatch = Stopwatch()..start();
if (await windowService.isActivity()) {
// do not check whether permission was granted, because some app stores
// hide in some countries apps that force quit on permission denial
await Permissions.mediaAccess.request();
}
@ -144,17 +139,14 @@ class _HomePageState extends State<HomePage> {
await availability.onNewIntent();
await androidFileUtils.init();
// PERF/REMOTE: warm-up headers (Bearer) in background — safe version
// Warm-up header remoti (non blocca UI)
unawaited(Future(() async {
try {
final s = await _safeLoadRemoteSettings();
if (s.enabled && s.baseUrl.trim().isNotEmpty) {
await _safeHeaders(); // popola la cache per peekHeaders() in modo sicuro
debugPrint('[startup] remote headers warm-up done (safe)');
await _safeHeaders();
}
} catch (e) {
debugPrint('[startup] remote headers warm-up skipped: $e');
}
} catch (_) {}
}));
if (!{
@ -192,7 +184,6 @@ class _HomePageState extends State<HomePage> {
case IntentActions.setWallpaper:
appMode = AppMode.setWallpaper;
case IntentActions.pickItems:
// some apps define multiple types, separated by a space
final multiple = (intentData[IntentDataKeys.allowMultiple] as bool?) ?? false;
debugPrint('pick mimeType=$intentMimeType multiple=$multiple');
appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal;
@ -214,7 +205,6 @@ class _HomePageState extends State<HomePage> {
if (widgetId == null) {
error = true;
} else {
// widget settings may be modified in a different process after channel setup
await settings.reload();
final page = settings.getWidgetOpenPage(widgetId);
switch (page) {
@ -276,97 +266,97 @@ class _HomePageState extends State<HomePage> {
unawaited(AnalysisService.registerCallback());
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) {
await reportService.log(
'Initialize source to start app with mode=$appMode, loaded scope=${source.loadedScope}',
);
final loadTopEntriesFirst =
settings.homeNavItem.route == CollectionPage.routeName && settings.homeCustomCollection.isEmpty;
// PERF: UI-first → niente analisi prima della prima paint
source.canAnalyze = false;
final swInit = Stopwatch()..start();
await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst);
swInit.stop();
debugPrint('[startup] source.init done in ${swInit.elapsedMilliseconds}ms');
}
if (hasAnyCache) {
// ✅ DB ha dati: avvio veloce -> init in background (non blocca UI)
debugPrint('[startup] DB cache present -> init in background (fast start)');
source.canAnalyze = true;
// REMOTE: aggiungi remoti visibili (origin=1, trashed=0)
final swAppend1 = Stopwatch()..start();
await source.appendRemoteEntriesFromDb();
swAppend1.stop();
debugPrint('[startup] appendRemoteEntries (pre-sync) in ${swAppend1.elapsedMilliseconds}ms');
// PERF: riattiva lanalisi in background appena la UI è pronta
unawaited(Future.delayed(const Duration(milliseconds: 300)).then((_) {
source.canAnalyze = true;
debugPrint('[startup] analysis re-enabled in background');
}));
// === SYNC REMOTO post-init (non blocca la UI) ===
if (!_remoteSyncScheduled) {
_remoteSyncScheduled = true; // una sola schedulazione per avvio
unawaited(Future(() async {
try {
await RemoteSettings.debugSeedIfEmpty();
final rs = await _safeLoadRemoteSettings();
if (!rs.enabled) return;
// attesa fine loading
final notifier = source.stateNotifier;
if (notifier.value == SourceState.loading) {
final completer = Completer<void>();
void onState() {
if (notifier.value != SourceState.loading) {
notifier.removeListener(onState);
completer.complete();
}
}
notifier.addListener(onState);
// nel caso non sia già loading:
onState();
await completer.future;
}
// piccolo margine per step secondari (tag, ecc.)
await Future.delayed(const Duration(milliseconds: 400));
// ⬇️ SYNC su **stessa connessione** + FETCH (obbligatorio)
debugPrint('[remote-sync] START (scheduled=$_remoteSyncScheduled)');
_remoteSyncActive = true;
try {
final swSync = Stopwatch()..start();
final imported = await rrs.runRemoteSyncOnceManaged(
fetch: _fetchAllRemoteItems, // 👈 PASSIAMO IL FETCH
).timeout(const Duration(seconds: 60)); // timeout regolabile
swSync.stop();
debugPrint('[remote-sync] completed in ${swSync.elapsedMilliseconds}ms, imported=$imported');
} on TimeoutException catch (e) {
debugPrint('[remote-sync] TIMEOUT after 60s: $e');
} catch (e, st) {
debugPrint('[remote-sync] error: $e\n$st');
} finally {
_remoteSyncActive = false;
debugPrint('[remote-sync] END (active=$_remoteSyncActive)');
}
// REMOTE: dopo il sync, append di eventuali nuovi remoti
if (mounted) {
final swAppend2 = Stopwatch()..start();
unawaited(
source
.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst)
.then((_) async {
// ✅ SOLO DOPO init: possiamo usare addEntries/append remoti senza crash
await source.appendRemoteEntriesFromDb();
swAppend2.stop();
debugPrint('[remote-sync] appendRemoteEntries (post-sync) in ${swAppend2.elapsedMilliseconds}ms');
debugPrint('[startup][bg] remote append after init done');
// 🔎 Conteggio di debug usando una CollectionLens temporanea
final c = _countRemotesInSource(source);
debugPrint('[check] remoti in CollectionSource = $c');
}
} catch (e, st) {
debugPrint('[remote-sync] outer error: $e\n$st');
if (!_remoteSyncScheduled) {
_remoteSyncScheduled = true;
final sourceRef = source;
unawaited(Future.microtask(() => _runRemoteSync(sourceRef)));
}
}),
);
} else {
// ✅ DB vuoto: comportamento Aves standard -> await init (progress locale)
debugPrint('[startup] DB empty -> await init (Aves standard)');
source.canAnalyze = true;
await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst);
// ora init è avvenuta -> safe append remoti da DB (se presenti)
await source.appendRemoteEntriesFromDb();
if (!_remoteSyncScheduled) {
_remoteSyncScheduled = true;
final sourceRef = source;
unawaited(Future.microtask(() => _runRemoteSync(sourceRef)));
}
}));
}
} else {
// Source già full scope (hot state): safe append remoti + sync
debugPrint('[startup] source already fullScope');
await source.appendRemoteEntriesFromDb();
if (!_remoteSyncScheduled) {
_remoteSyncScheduled = true;
final sourceRef = source;
unawaited(Future.microtask(() => _runRemoteSync(sourceRef)));
}
}
// DIAG: stato prima/dopo (facoltativo)
unawaited(_printRemoteDiag(source, when: ' PRE'));
//////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////
// STEP 1 STARTUP VELOCE (MARKER 4/6) - FINE BLOCCO
// STEP 1 STARTUP VELOCE (MARKER 5/6) - QUI PUOI AGGIUNGERE LOG/DIAG
// STEP 1 STARTUP VELOCE (MARKER 6/6) - NON MODIFICARE FUORI DA QUESTI MARKER
//////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////
break;
case AppMode.screenSaver:
@ -383,7 +373,6 @@ class _HomePageState extends State<HomePage> {
unawaited(AnalysisService.registerCallback());
await reportService.log('Initialize source to view item in directory $directory');
final source = context.read<CollectionSource>();
// analysis is necessary to display neighbour items when the initial item is a new one
source.canAnalyze = true;
await source.init(scope: {StoredAlbumFilter(directory, null)});
}
@ -403,8 +392,7 @@ class _HomePageState extends State<HomePage> {
debugPrint('Home setup complete in ${stopwatch.elapsed.inMilliseconds}ms');
// `pushReplacement` is not enough in some edge cases
// e.g. when opening the viewer in `view` mode should replace a viewer in `main` mode
// navigazione finale
unawaited(
Navigator.maybeOf(context)?.pushAndRemoveUntil(
await _getRedirectRoute(appMode),
@ -417,26 +405,102 @@ class _HomePageState extends State<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 {
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}');
debugPrint('[remote-sync][fetch] fetched ${items.length} items');
return items;
} catch (e, st) {
debugPrint('[remote-sync][fetch] client.fetchAll ERROR: $e\n$st');
@ -448,18 +512,23 @@ class _HomePageState extends State<HomePage> {
}
}
// --- Helper di debug: crea una lens temporanea, conta i remoti, poi dispose
int _countRemotesInSource(CollectionSource source) {
final lens = CollectionLens(source: source, filters: {});
// --- DIAGNOSTICA ---
Future<void> _printRemoteDiag(CollectionSource source, {String when = ''}) async {
try {
return lens.sortedEntries.where((e) => e.origin == 1 && e.trashed == 0).length;
} finally {
lens.dispose();
final dbRem = await localMediaDb.loadEntries(origin: 1);
final dbCount = dbRem.length;
final displayable = dbRem.where((e) => !e.trashed && e.isDisplayable).length;
final inSource = source.allEntries.where((e) => e.origin == 1 && !e.trashed).length;
final inVisible = source.visibleEntries.where((e) => e.origin == 1 && !e.trashed).length;
debugPrint('[diag$when] DB remoti=$dbCount, displayable=$displayable, '
'inSource=$inSource, inVisible=$inVisible');
} catch (e, st) {
debugPrint('[diag$when] ERROR: $e\n$st');
}
}
Future<void> _initViewerEssentials() async {
// for video playback storage
await localMediaDb.init();
}
@ -471,21 +540,18 @@ class _HomePageState extends State<HomePage> {
Future<AvesEntry?> _initViewerEntry({required String uri, required String? mimeType}) async {
if (uri.startsWith('/')) {
// convert this file path to a proper URI
uri = Uri.file(uri).toString();
}
final entry = await mediaFetchService.getEntry(uri, mimeType);
if (entry != null) {
// cataloguing is essential for coordinates and video rotation
await entry.catalog(background: false, force: false, persist: false);
}
return entry;
}
// === DEBUG: apre la pagina di test remota con una seconda connessione al DB ===
// === DEBUG: pagina test remoto con DB indipendente ===
Future<void> _openRemoteTestPage(BuildContext context) async {
if (_remoteTestOpen) return; // evita doppi push/sovrapposizioni
// blocca solo se il sync è effettivamente in corso
if (_remoteTestOpen) return;
if (_remoteSyncActive) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Attendi fine sync remoto in corso prima di aprire RemoteTest')),
@ -500,7 +566,6 @@ class _HomePageState extends State<HomePage> {
final dbDir = await getDatabasesPath();
final dbPath = p.join(dbDir, 'metadata.db');
// Apri il DB in R/W (istanza indipendente) → niente "read only database"
debugDb = await openDatabase(
dbPath,
singleInstance: false,
@ -613,7 +678,6 @@ class _HomePageState extends State<HomePage> {
);
await upd.save();
// forza refresh immediato delle impostazioni e headers
await RemoteHttp.refreshFromSettings();
unawaited(RemoteHttp.warmUp());
@ -689,7 +753,6 @@ class _HomePageState extends State<HomePage> {
final source = context.read<CollectionSource>();
final album = viewerEntry.directory;
if (album != null) {
// wait for collection to pass the `loading` state
final loadingCompleter = Completer();
final stateNotifier = source.stateNotifier;
void _onSourceStateChanged() {
@ -703,16 +766,10 @@ class _HomePageState extends State<HomePage> {
_onSourceStateChanged();
await loadingCompleter.future;
// ⚠️ NON lanciamo più il sync qui (evita contese durante il viewer)
// unawaited(rrs.runRemoteSyncOnceManaged());
collection = CollectionLens(
source: source,
filters: {StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))},
listenToSource: false,
// if we group bursts, opening a burst sub-entry should:
// - identify and select the containing main entry,
// - select the sub-entry in the Viewer page.
stackBursts: false,
);
@ -726,6 +783,7 @@ class _HomePageState extends State<HomePage> {
collection = null;
}
}
return DirectMaterialPageRoute(
settings: const RouteSettings(name: EntryViewerPage.routeName),
builder: (_) {
@ -808,7 +866,6 @@ class _HomePageState extends State<HomePage> {
);
case CollectionPage.routeName:
default:
// Wrapper di debug che aggiunge i due FAB (solo in debug)
return buildRoute(
(context) => _wrapWithRemoteDebug(
context,
@ -822,7 +879,6 @@ class _HomePageState extends State<HomePage> {
// 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);
@ -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 {
try {
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 {
if (!kDebugMode) return;
try {
// FlutterSecureStorage non è const
final storage = FlutterSecureStorage();
await storage.delete(key: 'remote_base_url');
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:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart';
// --- IMPORT per debug page remota ---
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart' as p;
import 'package:aves/remote/remote_test_page.dart';
// ✅ Permissions (platform interface) perché nel tuo branch Permissions.mediaAccess è List<Permission> platform_interface
import 'package:permission_handler_platform_interface/permission_handler_platform_interface.dart';
// ✅ Remote controller
import 'package:aves/remote/remote_controller.dart';
class HomePage extends StatefulWidget {
static const routeName = '/';
// untyped map as it is coming from the platform
// ✅ torna a Map? (compatibile con aves_app.dart che passa Map<dynamic,dynamic>?)
final Map? intentData;
const HomePage({
@ -105,22 +104,27 @@ class _HomePageState extends State<HomePage> {
Future<void> _setup() async {
try {
final stopwatch = Stopwatch()..start();
if (await windowService.isActivity()) {
// do not check whether permission was granted, because some app stores
// hide in some countries apps that force quit on permission denial
await Permissions.mediaAccess.request();
// ✅ come Aves: non forzare quit se utente nega permesso
// ma nel tuo branch serve la platform-interface API
await PermissionHandlerPlatform.instance.requestPermissions(Permissions.mediaAccess);
}
var appMode = AppMode.main;
var error = false;
final intentData = widget.intentData ?? await IntentService.getIntentData();
// ✅ torna a Map (come Aves originale)
final Map intentData = widget.intentData ?? await IntentService.getIntentData();
final intentAction = intentData[IntentDataKeys.action] as String?;
_initialFilters = null;
_initialExplorerPath = null;
_secureUris = null;
await availability.onNewIntent();
await androidFileUtils.init();
if (!{
IntentActions.edit,
IntentActions.screenSaver,
@ -132,6 +136,7 @@ class _HomePageState extends State<HomePage> {
if (intentData.values.nonNulls.isNotEmpty) {
await reportService.log('Intent data=$intentData');
var intentUri = intentData[IntentDataKeys.uri] as String?;
final intentMimeType = intentData[IntentDataKeys.mimeType] as String?;
@ -150,35 +155,40 @@ class _HomePageState extends State<HomePage> {
}
}
break;
case IntentActions.edit:
appMode = AppMode.edit;
case IntentActions.setWallpaper:
appMode = AppMode.setWallpaper;
case IntentActions.pickItems:
// TODO TLAD apply pick mimetype(s)
// some apps define multiple types, separated by a space (maybe other signs too, like `,` `;`?)
final multiple = (intentData[IntentDataKeys.allowMultiple] as bool?) ?? false;
debugPrint('pick mimeType=$intentMimeType multiple=$multiple');
appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal;
case IntentActions.pickCollectionFilters:
appMode = AppMode.pickCollectionFiltersExternal;
case IntentActions.screenSaver:
appMode = AppMode.screenSaver;
_initialRouteName = ScreenSaverPage.routeName;
case IntentActions.screenSaverSettings:
_initialRouteName = ScreenSaverSettingsPage.routeName;
case IntentActions.search:
_initialRouteName = SearchPage.routeName;
_initialSearchQuery = intentData[IntentDataKeys.query] as String?;
case IntentActions.widgetSettings:
_initialRouteName = HomeWidgetSettingsPage.routeName;
_widgetId = (intentData[IntentDataKeys.widgetId] as int?) ?? 0;
case IntentActions.widgetOpen:
final widgetId = intentData[IntentDataKeys.widgetId] as int?;
if (widgetId == null) {
error = true;
} else {
// widget settings may be modified in a different process after channel setup
await settings.reload();
final page = settings.getWidgetOpenPage(widgetId);
switch (page) {
@ -193,17 +203,19 @@ class _HomePageState extends State<HomePage> {
}
unawaited(WidgetService.update(widgetId));
}
default:
// do not use 'route' as extra key, as the Flutter framework acts on it
final extraRoute = intentData[IntentDataKeys.page] as String?;
if (allowedShortcutRoutes.contains(extraRoute)) {
_initialRouteName = extraRoute;
}
}
if (_initialFilters == null) {
final extraFilters = (intentData[IntentDataKeys.filters] as List?)?.cast<String>();
_initialFilters = extraFilters?.map(CollectionFilter.fromJson).nonNulls.toSet();
}
_initialExplorerPath = intentData[IntentDataKeys.explorerPath] as String?;
switch (appMode) {
@ -211,10 +223,7 @@ class _HomePageState extends State<HomePage> {
case AppMode.edit:
case AppMode.setWallpaper:
if (intentUri != null) {
_viewerEntry = await _initViewerEntry(
uri: intentUri,
mimeType: intentMimeType,
);
_viewerEntry = await _initViewerEntry(uri: intentUri, mimeType: intentMimeType);
}
error = _viewerEntry == null;
default:
@ -230,6 +239,9 @@ class _HomePageState extends State<HomePage> {
context.read<ValueNotifier<AppMode>>().value = appMode;
unawaited(reportService.setCustomKey('app_mode', appMode.toString()));
// ✅ Remote: inizializza stato icona (grigio/verde)
unawaited(RemoteController.instance.initBusFromSettings());
switch (appMode) {
case AppMode.main:
case AppMode.pickCollectionFiltersExternal:
@ -237,18 +249,36 @@ class _HomePageState extends State<HomePage> {
case AppMode.pickMultipleMediaExternal:
unawaited(GlobalSearch.registerCallback());
unawaited(AnalysisService.registerCallback());
final source = context.read<CollectionSource>();
// ✅ Aves originale: init SOLO se non già full scope (riaperture istantanee)
if (source.loadedScope != CollectionSource.fullScope) {
await reportService.log('Initialize source to start app with mode=$appMode, loaded scope=${source.loadedScope}');
final loadTopEntriesFirst = settings.homeNavItem.route == CollectionPage.routeName && settings.homeCustomCollection.isEmpty;
await reportService.log(
'Initialize source to start app with mode=$appMode, loaded scope=${source.loadedScope}',
);
final loadTopEntriesFirst =
settings.homeNavItem.route == CollectionPage.routeName &&
settings.homeCustomCollection.isEmpty;
source.canAnalyze = true;
await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst);
await source.init(
scope: CollectionSource.fullScope,
loadTopEntriesFirst: loadTopEntriesFirst,
);
}
// ✅ Remote: gestito dal controller, non blocca e non rompe UX Aves
unawaited(RemoteController.instance.onAppStart(
source: source,
resumeBootstrapIfEnabled: true,
));
case AppMode.screenSaver:
await reportService.log('Initialize source to start screen saver');
final source = context.read<CollectionSource>();
source.canAnalyze = false;
await source.init(scope: settings.screenSaverCollectionFilters);
case AppMode.view:
if (_isViewerSourceable(_viewerEntry) && _secureUris == null) {
final directory = _viewerEntry?.directory;
@ -256,24 +286,23 @@ class _HomePageState extends State<HomePage> {
unawaited(AnalysisService.registerCallback());
await reportService.log('Initialize source to view item in directory $directory');
final source = context.read<CollectionSource>();
// analysis is necessary to display neighbour items when the initial item is a new one
source.canAnalyze = true;
await source.init(scope: {StoredAlbumFilter(directory, null)});
}
} else {
await _initViewerEssentials();
}
case AppMode.edit:
case AppMode.setWallpaper:
await _initViewerEssentials();
default:
break;
}
debugPrint('Home setup complete in ${stopwatch.elapsed.inMilliseconds}ms');
// `pushReplacement` is not enough in some edge cases
// e.g. when opening the viewer in `view` mode should replace a viewer in `main` mode
unawaited(
Navigator.maybeOf(context)?.pushAndRemoveUntil(
await _getRedirectRoute(appMode),
@ -287,100 +316,37 @@ class _HomePageState extends State<HomePage> {
}
Future<void> _initViewerEssentials() async {
// for video playback storage
await localMediaDb.init();
}
bool _isViewerSourceable(AvesEntry? viewerEntry) {
return viewerEntry != null && viewerEntry.directory != null && !settings.hiddenFilters.any((filter) => filter.test(viewerEntry));
return viewerEntry != null &&
viewerEntry.directory != null &&
!settings.hiddenFilters.any((filter) => filter.test(viewerEntry));
}
Future<AvesEntry?> _initViewerEntry({required String uri, required String? mimeType}) async {
if (uri.startsWith('/')) {
// convert this file path to a proper URI
uri = Uri.file(uri).toString();
}
final entry = await mediaFetchService.getEntry(uri, mimeType);
if (entry != null) {
// cataloguing is essential for coordinates and video rotation
await entry.catalog(background: false, force: false, persist: false);
}
return entry;
}
// --- DEBUG: apre la pagina di test remota con una seconda connessione al DB ---
// --- DEBUG: apre la pagina di test remota con una seconda connessione al DB ---
Future<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 {
String routeName;
Set<CollectionFilter?>? filters;
switch (appMode) {
case AppMode.setWallpaper:
return DirectMaterialPageRoute(
settings: const RouteSettings(name: WallpaperPage.routeName),
builder: (_) {
return WallpaperPage(
entry: _viewerEntry,
);
},
builder: (_) => WallpaperPage(entry: _viewerEntry),
);
case AppMode.view:
AvesEntry viewerEntry = _viewerEntry!;
CollectionLens? collection;
@ -388,7 +354,6 @@ Future<void> _openRemoteTestPage(BuildContext context) async {
final source = context.read<CollectionSource>();
final album = viewerEntry.directory;
if (album != null) {
// wait for collection to pass the `loading` state
final loadingCompleter = Completer();
final stateNotifier = source.stateNotifier;
void _onSourceStateChanged() {
@ -406,13 +371,13 @@ Future<void> _openRemoteTestPage(BuildContext context) async {
source: source,
filters: {StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))},
listenToSource: false,
// if we group bursts, opening a burst sub-entry should:
// - identify and select the containing main entry,
// - select the sub-entry in the Viewer page.
stackBursts: false,
);
final viewerEntryPath = viewerEntry.path;
final collectionEntry = collection.sortedEntries.firstWhereOrNull((entry) => entry.path == viewerEntryPath);
final collectionEntry = collection.sortedEntries.firstWhereOrNull(
(entry) => entry.path == viewerEntryPath,
);
if (collectionEntry != null) {
viewerEntry = collectionEntry;
} else {
@ -423,36 +388,21 @@ Future<void> _openRemoteTestPage(BuildContext context) async {
return DirectMaterialPageRoute(
settings: const RouteSettings(name: EntryViewerPage.routeName),
builder: (_) {
return EntryViewerPage(
collection: collection,
initialEntry: viewerEntry,
);
},
builder: (_) => EntryViewerPage(collection: collection, initialEntry: viewerEntry),
);
case AppMode.edit:
return DirectMaterialPageRoute(
settings: const RouteSettings(name: EntryViewerPage.routeName),
builder: (_) {
return ImageEditorPage(
entry: _viewerEntry!,
);
},
builder: (_) => ImageEditorPage(entry: _viewerEntry!),
);
case AppMode.initialization:
case AppMode.main:
case AppMode.pickCollectionFiltersExternal:
case AppMode.pickSingleMediaExternal:
case AppMode.pickMultipleMediaExternal:
case AppMode.pickFilteredMediaInternal:
case AppMode.pickUnfilteredMediaInternal:
case AppMode.pickFilterInternal:
case AppMode.previewMap:
case AppMode.screenSaver:
case AppMode.slideshow:
default:
routeName = _initialRouteName ?? settings.homeNavItem.route;
filters = _initialFilters ?? (settings.homeNavItem.route == CollectionPage.routeName ? settings.homeCustomCollection : {});
filters = _initialFilters ??
(settings.homeNavItem.route == CollectionPage.routeName ? settings.homeCustomCollection : {});
}
Route buildRoute(WidgetBuilder builder) => DirectMaterialPageRoute(
settings: RouteSettings(name: routeName),
builder: builder,
@ -500,14 +450,7 @@ Future<void> _openRemoteTestPage(BuildContext context) async {
);
case CollectionPage.routeName:
default:
// <<--- QUI AVVOLGO LA COLLECTION CON IL WRAPPER DI DEBUG
return buildRoute(
(context) => _wrapWithRemoteDebug(
context,
CollectionPage(source: source, filters: filters),
),
);
return buildRoute((context) => CollectionPage(source: source, filters: filters));
}
}
}

View file

@ -1,6 +1,7 @@
// lib/widgets/home/home_page.dart
import 'dart:async';
import 'package:aves/remote/collection_source_remote_ext.dart';
import 'package:aves/app_mode.dart';
import 'package:aves/geo/uri.dart';
import 'package:aves/model/app/intent.dart';
@ -46,13 +47,21 @@ import 'package:latlong2/latlong.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart';
// --- IMPORT aggiunti/aggiornati per integrazione remota (Fase 1) ---
// --- REMOTO / DEBUG ---
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart' as p;
import 'package:aves/remote/remote_test_page.dart' as rtp;
import 'package:aves/remote/run_remote_sync.dart' as rrs;
import 'package:aves/remote/remote_settings.dart';
import 'package:aves/remote/remote_http.dart';
import 'package:aves/remote/remote_models.dart';
import 'package:aves/remote/remote_client.dart';
import 'package:aves/remote/auth_client.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
// Step 2: progress bus + repository
import 'package:aves/remote/remote_sync_bus.dart';
import 'package:aves/remote/remote_repository.dart';
class HomePage extends StatefulWidget {
static const routeName = '/';
@ -78,8 +87,12 @@ class _HomePageState extends State<HomePage> {
List<String>? _secureUris;
(Object, StackTrace)? _setupError;
// guard UI per schedulare UNA sola run del sync da Home
// guard sync remoto: singola esecuzione per avvio
bool _remoteSyncScheduled = false;
bool _remoteSyncActive = false;
// guard pagina test remota
bool _remoteTestOpen = false;
static const allowedShortcutRoutes = [
AlbumListPage.routeName,
@ -106,13 +119,26 @@ class _HomePageState extends State<HomePage> {
: 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 {
try {
final stopwatch = Stopwatch()..start();
if (await windowService.isActivity()) {
// do not check whether permission was granted, because some app stores
// hide in some countries apps that force quit on permission denial
await Permissions.mediaAccess.request();
}
@ -129,11 +155,21 @@ class _HomePageState extends State<HomePage> {
await availability.onNewIntent();
await androidFileUtils.init();
// Warm-up header remoti (non blocca UI)
unawaited(Future(() async {
try {
final s = await _safeLoadRemoteSettings();
if (s.enabled && s.baseUrl.trim().isNotEmpty) {
await _safeHeaders();
}
} catch (_) {}
}));
if (!{
IntentActions.edit,
IntentActions.screenSaver,
IntentActions.setWallpaper,
}.contains(intentAction) &&
IntentActions.edit,
IntentActions.screenSaver,
IntentActions.setWallpaper,
}.contains(intentAction) &&
settings.isInstalledAppAccessAllowed) {
unawaited(appInventory.initAppNames());
}
@ -164,8 +200,6 @@ class _HomePageState extends State<HomePage> {
case IntentActions.setWallpaper:
appMode = AppMode.setWallpaper;
case IntentActions.pickItems:
// TODO TLAD apply pick mimetype(s)
// some apps define multiple types, separated by a space (maybe other signs too, like `,` `;`?)
final multiple = (intentData[IntentDataKeys.allowMultiple] as bool?) ?? false;
debugPrint('pick mimeType=$intentMimeType multiple=$multiple');
appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal;
@ -187,7 +221,6 @@ class _HomePageState extends State<HomePage> {
if (widgetId == null) {
error = true;
} else {
// widget settings may be modified in a different process after channel setup
await settings.reload();
final page = settings.getWidgetOpenPage(widgetId);
switch (page) {
@ -203,7 +236,6 @@ class _HomePageState extends State<HomePage> {
unawaited(WidgetService.update(widgetId));
}
default:
// do not use 'route' as extra key, as the Flutter framework acts on it
final extraRoute = intentData[IntentDataKeys.page] as String?;
if (allowedShortcutRoutes.contains(extraRoute)) {
_initialRouteName = extraRoute;
@ -250,54 +282,123 @@ class _HomePageState extends State<HomePage> {
unawaited(AnalysisService.registerCallback());
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) {
await reportService.log(
'Initialize source to start app with mode=$appMode, loaded scope=${source.loadedScope}',
);
final loadTopEntriesFirst =
settings.homeNavItem.route == CollectionPage.routeName && settings.homeCustomCollection.isEmpty;
source.canAnalyze = true;
await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst);
}
// === FASE 1: SYNC REMOTO POST-INIT (non blocca la UI) ===
// In DEBUG: fai seed dei settings se sono vuoti, poi lancia il sync SOLO quando
// la sorgente ha finito il loading, con un micro delay di sicurezza.
if (!_remoteSyncScheduled) {
_remoteSyncScheduled = true; // una sola schedulazione per avvio
unawaited(Future(() async {
try {
await RemoteSettings.debugSeedIfEmpty();
final rs = await RemoteSettings.load();
if (!rs.enabled) return;
if (hasAnyCache) {
// DB ha dati: avvio veloce -> init in background (non blocca UI)
debugPrint('[startup] DB cache present -> init in background (fast start)');
source.canAnalyze = true;
// attesa fine loading
final notifier = source.stateNotifier;
if (notifier.value == SourceState.loading) {
final completer = Completer<void>();
void onState() {
if (notifier.value != SourceState.loading) {
notifier.removeListener(onState);
completer.complete();
}
unawaited(
source
.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst)
.then((_) async {
// Remoti: opzione 1
// - se bootstrap DONE -> mostra subito dal DB
// - se bootstrap NOT done -> NON mostrare finché non finisce il bootstrap sync
final bd = await _isRemoteBootstrapDone();
if (bd) {
await source.appendRemoteEntriesFromDb();
debugPrint('[startup][bg] remote append after init done');
} else {
debugPrint('[startup][bg] bootstrap not done -> skip remote append (will appear after bootstrap sync)');
}
notifier.addListener(onState);
// nel caso non sia già loading:
onState();
await completer.future;
}
// Schedula sync remoto UNA volta
if (!_remoteSyncScheduled) {
_remoteSyncScheduled = true;
final sourceRef = source;
unawaited(Future.microtask(() => _runRemoteSync(sourceRef, bootstrap: !bd)));
}
}),
);
} else {
// DB vuoto: comportamento Aves standard -> await init (progress locale)
debugPrint('[startup] DB empty -> await init (Aves standard)');
source.canAnalyze = true;
await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst);
// piccolo margine per step secondari (tag, ecc.)
await Future.delayed(const Duration(milliseconds: 400));
// sync in background (la managed ha già il suo guard interno)
await rrs.runRemoteSyncOnceManaged();
} catch (e, st) {
debugPrint('[remote-sync] error: $e\n$st');
// Remoti: opzione 1
// - se bootstrap DONE -> mostra subito dal DB
// - se bootstrap NOT done -> non mostrare finché bootstrap sync finisce
if (!bootstrap) {
// bootstrap==true => primo avvio remoto
debugPrint('[startup] bootstrap not done -> skip remote append (will appear after bootstrap sync)');
} else {
await source.appendRemoteEntriesFromDb();
}
}));
if (!_remoteSyncScheduled) {
_remoteSyncScheduled = true;
final sourceRef = source;
unawaited(Future.microtask(() => _runRemoteSync(sourceRef, bootstrap: bootstrap)));
}
}
} else {
// Source già full scope (hot state)
debugPrint('[startup] source already fullScope');
// Remoti: opzione 1
// se bootstrap done -> mostra subito dal DB
if (bootstrapDone) {
await source.appendRemoteEntriesFromDb();
} else {
debugPrint('[startup] bootstrap not done -> skip remote append (will appear after bootstrap sync)');
}
if (!_remoteSyncScheduled) {
_remoteSyncScheduled = true;
final sourceRef = source;
unawaited(Future.microtask(() => _runRemoteSync(sourceRef, bootstrap: bootstrap)));
}
}
// DIAG: stato (facoltativo)
unawaited(_printRemoteDiag(source, when: ' PRE'));
//////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////
// STEP 1 STARTUP VELOCE (MARKER 4/6) - FINE BLOCCO
// STEP 1 STARTUP VELOCE (MARKER 5/6) - QUI PUOI AGGIUNGERE LOG/DIAG
// STEP 1 STARTUP VELOCE (MARKER 6/6) - NON MODIFICARE FUORI DA QUESTI MARKER
//////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////
break;
case AppMode.screenSaver:
@ -314,7 +415,6 @@ class _HomePageState extends State<HomePage> {
unawaited(AnalysisService.registerCallback());
await reportService.log('Initialize source to view item in directory $directory');
final source = context.read<CollectionSource>();
// analysis is necessary to display neighbour items when the initial item is a new one
source.canAnalyze = true;
await source.init(scope: {StoredAlbumFilter(directory, null)});
}
@ -334,8 +434,7 @@ class _HomePageState extends State<HomePage> {
debugPrint('Home setup complete in ${stopwatch.elapsed.inMilliseconds}ms');
// `pushReplacement` is not enough in some edge cases
// e.g. when opening the viewer in `view` mode should replace a viewer in `main` mode
// navigazione finale
unawaited(
Navigator.maybeOf(context)?.pushAndRemoveUntil(
await _getRedirectRoute(appMode),
@ -348,8 +447,142 @@ class _HomePageState extends State<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 {
// for video playback storage
await localMediaDb.init();
}
@ -361,44 +594,43 @@ class _HomePageState extends State<HomePage> {
Future<AvesEntry?> _initViewerEntry({required String uri, required String? mimeType}) async {
if (uri.startsWith('/')) {
// convert this file path to a proper URI
uri = Uri.file(uri).toString();
}
final entry = await mediaFetchService.getEntry(uri, mimeType);
if (entry != null) {
// cataloguing is essential for coordinates and video rotation
await entry.catalog(background: false, force: false, persist: false);
}
return entry;
}
// === DEBUG: apre la pagina di test remota con una seconda connessione al DB ===
// === DEBUG: pagina test remoto con DB indipendente ===
Future<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');
// Apri il DB in sola lettura (evita lock e conflitti)
//debugDb = await openDatabase(dbPath, readOnly: true);
// DOPO (R/W, istanza indipendente)
debugDb = await openDatabase(
dbPath,
singleInstance: false,
onConfigure: (db) async {
// opzionale ma utile per coerenza con il resto
await db.rawQuery('PRAGMA journal_mode=WAL');
await db.rawQuery('PRAGMA foreign_keys=ON');
},
);
debugDb = await openDatabase(
dbPath,
singleInstance: false,
onConfigure: (db) async {
await db.rawQuery('PRAGMA journal_mode=WAL');
await db.rawQuery('PRAGMA foreign_keys=ON');
},
);
if (!context.mounted) return;
final rs = await RemoteSettings.load();
final rs = await _safeLoadRemoteSettings();
final baseUrl = rs.baseUrl.isNotEmpty ? rs.baseUrl : RemoteSettings.defaultBaseUrl;
await Navigator.of(context).push(MaterialPageRoute(
@ -418,12 +650,13 @@ debugDb = await openDatabase(
try {
await debugDb?.close();
} catch (_) {}
_remoteTestOpen = false;
}
}
// === DEBUG: dialog impostazioni remote (semplice) ===
Future<void> _openRemoteSettingsDialog(BuildContext context) async {
final s = await RemoteSettings.load();
final s = await _safeLoadRemoteSettings();
final formKey = GlobalKey<FormState>();
bool enabled = s.enabled;
final baseUrlC = TextEditingController(text: s.baseUrl);
@ -498,6 +731,10 @@ debugDb = await openDatabase(
password: pwC.text,
);
await upd.save();
await RemoteHttp.refreshFromSettings();
unawaited(RemoteHttp.warmUp());
if (context.mounted) Navigator.of(context).pop();
if (context.mounted) {
ScaffoldMessenger.of(context)
@ -559,20 +796,20 @@ debugDb = await openDatabase(
return DirectMaterialPageRoute(
settings: const RouteSettings(name: WallpaperPage.routeName),
builder: (_) {
return WallpaperPage(
entry: _viewerEntry,
);
return WallpaperPage(entry: _viewerEntry);
},
);
case AppMode.view:
AvesEntry viewerEntry = _viewerEntry!;
CollectionLens? collection;
final source = context.read<CollectionSource>();
final album = viewerEntry.directory;
if (album != null) {
// wait for collection to pass the `loading` state
final loadingCompleter = Completer();
final stateNotifier = source.stateNotifier;
void _onSourceStateChanged() {
if (stateNotifier.value != SourceState.loading) {
stateNotifier.removeListener(_onSourceStateChanged);
@ -584,16 +821,10 @@ debugDb = await openDatabase(
_onSourceStateChanged();
await loadingCompleter.future;
// NON lanciamo più il sync qui (evita contese durante il viewer)
// unawaited(rrs.runRemoteSyncOnceManaged());
collection = CollectionLens(
source: source,
filters: {StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))},
listenToSource: false,
// if we group bursts, opening a burst sub-entry should:
// - identify and select the containing main entry,
// - select the sub-entry in the Viewer page.
stackBursts: false,
);
@ -607,24 +838,18 @@ debugDb = await openDatabase(
collection = null;
}
}
return DirectMaterialPageRoute(
settings: const RouteSettings(name: EntryViewerPage.routeName),
builder: (_) {
return EntryViewerPage(
collection: collection,
initialEntry: viewerEntry,
);
},
builder: (_) => EntryViewerPage(collection: collection, initialEntry: viewerEntry),
);
case AppMode.edit:
return DirectMaterialPageRoute(
settings: const RouteSettings(name: EntryViewerPage.routeName),
builder: (_) {
return ImageEditorPage(
entry: _viewerEntry!,
);
},
builder: (_) => ImageEditorPage(entry: _viewerEntry!),
);
case AppMode.initialization:
case AppMode.main:
case AppMode.pickCollectionFiltersExternal:
@ -659,7 +884,7 @@ debugDb = await openDatabase(
source: source,
filters: {
LocationFilter.located,
if (filters != null) ...filters,
if (filters != null) ...filters!,
},
);
return MapPage(
@ -689,7 +914,6 @@ debugDb = await openDatabase(
);
case CollectionPage.routeName:
default:
// <<--- Wrapper di debug che aggiunge i due FAB (solo in debug)
return buildRoute(
(context) => _wrapWithRemoteDebug(
context,
@ -698,4 +922,51 @@ debugDb = await openDatabase(
);
}
}
// -------------------------
// Utility sicure per remote
// -------------------------
Future<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
import 'dart:async';
import 'package:aves/remote/collection_source_remote_ext.dart';
import 'package:aves/app_mode.dart';
import 'package:aves/geo/uri.dart';
import 'package:aves/model/app/intent.dart';
@ -46,17 +47,24 @@ import 'package:latlong2/latlong.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart';
// --- IMPORT aggiunti per integrazione remota (Fase 1) ---
// --- REMOTO / DEBUG ---
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart' as p;
import 'package:aves/remote/remote_test_page.dart';
import 'package:aves/remote/run_remote_sync.dart';
import 'package:aves/remote/remote_test_page.dart' as rtp;
import 'package:aves/remote/remote_settings.dart';
import 'package:aves/remote/remote_http.dart';
import 'package:aves/remote/remote_models.dart';
import 'package:aves/remote/remote_client.dart';
import 'package:aves/remote/auth_client.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
// Step 2: progress bus + repository
import 'package:aves/remote/remote_sync_bus.dart';
import 'package:aves/remote/remote_repository.dart';
class HomePage extends StatefulWidget {
static const routeName = '/';
// untyped map as it is coming from the platform
final Map? intentData;
const HomePage({
@ -78,6 +86,10 @@ class _HomePageState extends State<HomePage> {
List<String>? _secureUris;
(Object, StackTrace)? _setupError;
bool _remoteSyncScheduled = false;
bool _remoteSyncActive = false;
bool _remoteTestOpen = false;
static const allowedShortcutRoutes = [
AlbumListPage.routeName,
CollectionPage.routeName,
@ -103,13 +115,68 @@ class _HomePageState extends State<HomePage> {
: 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 {
try {
final stopwatch = Stopwatch()..start();
if (await windowService.isActivity()) {
// do not check whether permission was granted, because some app stores
// hide in some countries apps that force quit on permission denial
await Permissions.mediaAccess.request();
}
@ -126,6 +193,16 @@ class _HomePageState extends State<HomePage> {
await availability.onNewIntent();
await androidFileUtils.init();
// Warm-up header remoti (non blocca UI)
unawaited(Future(() async {
try {
final s = await _safeLoadRemoteSettings();
if (s.enabled && s.baseUrl.trim().isNotEmpty) {
await _safeHeaders();
}
} catch (_) {}
}));
if (!{
IntentActions.edit,
IntentActions.screenSaver,
@ -161,8 +238,6 @@ class _HomePageState extends State<HomePage> {
case IntentActions.setWallpaper:
appMode = AppMode.setWallpaper;
case IntentActions.pickItems:
// TODO TLAD apply pick mimetype(s)
// some apps define multiple types, separated by a space (maybe other signs too, like `,` `;`?)
final multiple = (intentData[IntentDataKeys.allowMultiple] as bool?) ?? false;
debugPrint('pick mimeType=$intentMimeType multiple=$multiple');
appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal;
@ -184,7 +259,6 @@ class _HomePageState extends State<HomePage> {
if (widgetId == null) {
error = true;
} else {
// widget settings may be modified in a different process after channel setup
await settings.reload();
final page = settings.getWidgetOpenPage(widgetId);
switch (page) {
@ -200,7 +274,6 @@ class _HomePageState extends State<HomePage> {
unawaited(WidgetService.update(widgetId));
}
default:
// do not use 'route' as extra key, as the Flutter framework acts on it
final extraRoute = intentData[IntentDataKeys.page] as String?;
if (allowedShortcutRoutes.contains(extraRoute)) {
_initialRouteName = extraRoute;
@ -219,10 +292,7 @@ class _HomePageState extends State<HomePage> {
case AppMode.edit:
case AppMode.setWallpaper:
if (intentUri != null) {
_viewerEntry = await _initViewerEntry(
uri: intentUri,
mimeType: intentMimeType,
);
_viewerEntry = await _initViewerEntry(uri: intentUri, mimeType: intentMimeType);
}
error = _viewerEntry == null;
default:
@ -245,42 +315,109 @@ class _HomePageState extends State<HomePage> {
case AppMode.pickMultipleMediaExternal:
unawaited(GlobalSearch.registerCallback());
unawaited(AnalysisService.registerCallback());
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}');
final loadTopEntriesFirst =
settings.homeNavItem.route == CollectionPage.routeName && settings.homeCustomCollection.isEmpty;
source.canAnalyze = true;
await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst);
// =========================================================
// MAIN INIT + LOCAL-HYDRATE + REMOTE APPEND/SYNC
// =========================================================
// cache DB?
bool hasAnyCache = false;
try {
await localMediaDb.init();
final rows = await localMediaDb.rawDb.rawQuery(
'SELECT 1 FROM entry WHERE trashed=0 LIMIT 1',
);
hasAnyCache = rows.isNotEmpty;
} catch (_) {}
final bootstrapDone = await _isRemoteBootstrapDone();
final bootstrap = !bootstrapDone;
debugPrint('[BOOT] hasAnyCache=$hasAnyCache bootstrapDone=$bootstrapDone bootstrap=$bootstrap '
'loadedScope=${source.loadedScope} state=${source.stateNotifier.value}');
final loadTopEntriesFirst =
settings.homeNavItem.route == CollectionPage.routeName &&
settings.homeCustomCollection.isEmpty;
final detach = _attachInitDebug(source, 'INIT');
// INIT (serve per inizializzare strutture interne)
final swInit = Stopwatch()..start();
debugPrint('[INIT] calling source.init(...) loadTopEntriesFirst=$loadTopEntriesFirst');
await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst);
swInit.stop();
debugPrint('[INIT] source.init DONE in ${swInit.elapsedMilliseconds}ms all=${source.allEntries.length} vis=${source.visibleEntries.length}');
// ---------------------------------------------------------
// ✅ LOCAL-HYDRATE (key feature): mostra subito i locali dal DB
// ---------------------------------------------------------
// Se dopo init la Source è ancora vuota/quasi vuota, iniettiamo cache DB origin=0.
// (Il tuo DB ha origin=0 -> 6957, quindi qui la galleria diventa istantanea.)
try {
final curCount = source.visibleEntries.isNotEmpty ? source.visibleEntries.length : source.allEntries.length;
if (curCount < 50) {
final locals = await localMediaDb.loadEntries(origin: 0); // Set<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)');
}
}
}
} catch (e, st) {
debugPrint('[LOCAL-HYDRATE] error: $e\n$st');
}
// === FASE 1: SYNC REMOTO POST-INIT (non blocca la UI) ===
// In DEBUG facciamo prima un seed dei setting se sono vuoti.
unawaited(Future(() async {
try {
await RemoteSettings.debugSeedIfEmpty();
final rs = await RemoteSettings.load();
if (!rs.enabled) return;
// stop debug logs after 3s
Future.delayed(const Duration(seconds: 3), detach);
final dbDir = await getDatabasesPath();
final dbPath = p.join(dbDir, 'metadata.db');
final db = await openDatabase(dbPath);
try {
// Prende baseUrl/index/email/pw da RemoteSettings
await runRemoteSyncOnce(db: db);
} finally {
await db.close();
}
} catch (e, st) {
debugPrint('[remote-sync] error: $e\n$st');
}
}));
// Remoti:
// - bootstrap done -> mostra subito dal DB
// - bootstrap not done -> compariranno alla fine del bootstrap sync
if (await _isRemoteBootstrapDone()) {
debugPrint('[REMOTE] append from DB (bootstrap done)');
await source.appendRemoteEntriesFromDb();
} else {
debugPrint('[REMOTE] skip append from DB (bootstrap not done)');
}
// schedule remote sync once
if (!_remoteSyncScheduled) {
_remoteSyncScheduled = true;
final sourceRef = source;
unawaited(Future.microtask(() => _runRemoteSync(sourceRef, bootstrap: bootstrap)));
}
break;
case AppMode.screenSaver:
await reportService.log('Initialize source to start screen saver');
final source2 = context.read<CollectionSource>();
source2.canAnalyze = false;
await source2.init(scope: settings.screenSaverCollectionFilters);
break;
case AppMode.view:
if (_isViewerSourceable(_viewerEntry) && _secureUris == null) {
final directory = _viewerEntry?.directory;
@ -288,24 +425,25 @@ class _HomePageState extends State<HomePage> {
unawaited(AnalysisService.registerCallback());
await reportService.log('Initialize source to view item in directory $directory');
final source = context.read<CollectionSource>();
// analysis is necessary to display neighbour items when the initial item is a new one
source.canAnalyze = true;
await source.init(scope: {StoredAlbumFilter(directory, null)});
}
} else {
await _initViewerEssentials();
}
break;
case AppMode.edit:
case AppMode.setWallpaper:
await _initViewerEssentials();
break;
default:
break;
}
debugPrint('Home setup complete in ${stopwatch.elapsed.inMilliseconds}ms');
// `pushReplacement` is not enough in some edge cases
// e.g. when opening the viewer in `view` mode should replace a viewer in `main` mode
unawaited(
Navigator.maybeOf(context)?.pushAndRemoveUntil(
await _getRedirectRoute(appMode),
@ -318,8 +456,93 @@ class _HomePageState extends State<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 {
// for video playback storage
await localMediaDb.init();
}
@ -331,32 +554,47 @@ class _HomePageState extends State<HomePage> {
Future<AvesEntry?> _initViewerEntry({required String uri, required String? mimeType}) async {
if (uri.startsWith('/')) {
// convert this file path to a proper URI
uri = Uri.file(uri).toString();
}
final entry = await mediaFetchService.getEntry(uri, mimeType);
if (entry != null) {
// cataloguing is essential for coordinates and video rotation
await entry.catalog(background: false, force: false, persist: false);
}
return entry;
}
// === DEBUG: apre la pagina di test remota con una seconda connessione al DB ===
// === DEBUG: pagina test remoto con DB indipendente ===
Future<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');
// Apri il DB in sola lettura (evita lock e conflitti)
debugDb = await openDatabase(dbPath, readOnly: true);
debugDb = await openDatabase(
dbPath,
singleInstance: false,
onConfigure: (db) async {
await db.rawQuery('PRAGMA journal_mode=WAL');
await db.rawQuery('PRAGMA foreign_keys=ON');
},
);
if (!context.mounted) return;
final rs = await RemoteSettings.load();
final rs = await _safeLoadRemoteSettings();
final baseUrl = rs.baseUrl.isNotEmpty ? rs.baseUrl : RemoteSettings.defaultBaseUrl;
await Navigator.of(context).push(MaterialPageRoute(
builder: (_) => RemoteTestPage(
builder: (_) => rtp.RemoteTestPage(
db: debugDb!,
baseUrl: baseUrl,
),
@ -364,146 +602,14 @@ class _HomePageState extends State<HomePage> {
} 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 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 {
String routeName;
Set<CollectionFilter?>? filters;
@ -512,19 +618,16 @@ class _HomePageState extends State<HomePage> {
case AppMode.setWallpaper:
return DirectMaterialPageRoute(
settings: const RouteSettings(name: WallpaperPage.routeName),
builder: (_) {
return WallpaperPage(
entry: _viewerEntry,
);
},
builder: (_) => WallpaperPage(entry: _viewerEntry),
);
case AppMode.view:
AvesEntry viewerEntry = _viewerEntry!;
CollectionLens? collection;
final source = context.read<CollectionSource>();
final album = viewerEntry.directory;
if (album != null) {
// wait for collection to pass the `loading` state
final loadingCompleter = Completer();
final stateNotifier = source.stateNotifier;
void _onSourceStateChanged() {
@ -542,9 +645,6 @@ class _HomePageState extends State<HomePage> {
source: source,
filters: {StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))},
listenToSource: false,
// if we group bursts, opening a burst sub-entry should:
// - identify and select the containing main entry,
// - select the sub-entry in the Viewer page.
stackBursts: false,
);
@ -554,39 +654,22 @@ class _HomePageState extends State<HomePage> {
if (collectionEntry != null) {
viewerEntry = collectionEntry;
} else {
debugPrint('collection does not contain viewerEntry=$viewerEntry');
collection = null;
}
}
return DirectMaterialPageRoute(
settings: const RouteSettings(name: EntryViewerPage.routeName),
builder: (_) {
return EntryViewerPage(
collection: collection,
initialEntry: viewerEntry,
);
},
builder: (_) => EntryViewerPage(collection: collection, initialEntry: viewerEntry),
);
case AppMode.edit:
return DirectMaterialPageRoute(
settings: const RouteSettings(name: EntryViewerPage.routeName),
builder: (_) {
return ImageEditorPage(
entry: _viewerEntry!,
);
},
builder: (_) => ImageEditorPage(entry: _viewerEntry!),
);
case AppMode.initialization:
case AppMode.main:
case AppMode.pickCollectionFiltersExternal:
case AppMode.pickSingleMediaExternal:
case AppMode.pickMultipleMediaExternal:
case AppMode.pickFilteredMediaInternal:
case AppMode.pickUnfilteredMediaInternal:
case AppMode.pickFilterInternal:
case AppMode.previewMap:
case AppMode.screenSaver:
case AppMode.slideshow:
default:
routeName = _initialRouteName ?? settings.homeNavItem.route;
filters = _initialFilters ??
(settings.homeNavItem.route == CollectionPage.routeName ? settings.homeCustomCollection : {});
@ -610,7 +693,7 @@ class _HomePageState extends State<HomePage> {
source: source,
filters: {
LocationFilter.located,
if (filters != null) ...filters,
if (filters != null) ...filters!,
},
);
return MapPage(
@ -640,13 +723,31 @@ class _HomePageState extends State<HomePage> {
);
case CollectionPage.routeName:
default:
// <<--- Wrapper di debug che aggiunge i due FAB (solo in debug)
return buildRoute(
(context) => _wrapWithRemoteDebug(
context,
CollectionPage(source: source, filters: filters),
),
);
return buildRoute((context) => CollectionPage(source: source, filters: filters));
}
}
Future<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.