super
This commit is contained in:
parent
084fa184da
commit
deb7b4c6dd
42 changed files with 7061 additions and 2222 deletions
BIN
aves10.zip
Normal file
BIN
aves10.zip
Normal file
Binary file not shown.
BIN
aves12.apk
Normal file
BIN
aves12.apk
Normal file
Binary file not shown.
BIN
aves12.zip
Normal file
BIN
aves12.zip
Normal file
Binary file not shown.
BIN
aves13.apk
Normal file
BIN
aves13.apk
Normal file
Binary file not shown.
BIN
aves13d.apk
Normal file
BIN
aves13d.apk
Normal file
Binary file not shown.
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
25
lib/remote/collection_source_remote_ext.dart.ok
Normal file
25
lib/remote/collection_source_remote_ext.dart.ok
Normal 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)');
|
||||
}
|
||||
}
|
||||
36
lib/remote/collection_source_remote_ext.dart.old
Normal file
36
lib/remote/collection_source_remote_ext.dart.old
Normal 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)');
|
||||
}
|
||||
}
|
||||
221
lib/remote/remote_controller.dart
Normal file
221
lib/remote/remote_controller.dart
Normal 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 all’avvio: 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 d’avvio app:
|
||||
/// - se remote OFF -> nascondi remoti dalla UI (memoria only) e stop
|
||||
/// - se remote ON:
|
||||
/// - se bootstrap done -> append DB immediato + sync silenzioso
|
||||
/// - se bootstrap NOT done -> opzionale resume bootstrap
|
||||
Future<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
189
lib/remote/remote_image_tile.dart.old
Normal file
189
lib/remote/remote_image_tile.dart.old
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -43,10 +43,13 @@ class RemotePhotoItem {
|
|||
// Costruzione URL assoluto delegata a utility (in base alle impostazioni)
|
||||
String absoluteUrl(String baseUrl) => buildAbsoluteUri(baseUrl, path).toString();
|
||||
|
||||
|
||||
static DateTime? _tryParseIsoUtc(dynamic v) {
|
||||
if (v == null) return null;
|
||||
try { return DateTime.parse(v.toString()).toUtc(); } catch (_) { return null; }
|
||||
try {
|
||||
return DateTime.parse(v.toString()).toUtc();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static double? _toDouble(dynamic v) {
|
||||
|
|
@ -55,6 +58,7 @@ class RemotePhotoItem {
|
|||
return double.tryParse(v.toString());
|
||||
}
|
||||
|
||||
/// Converte secondi→ms se < 1000, altrimenti assume già millisecondi.
|
||||
static int? _toMillis(dynamic v) {
|
||||
if (v == null) return null;
|
||||
final num? n = (v is num) ? v : num.tryParse(v.toString());
|
||||
|
|
@ -85,7 +89,10 @@ class RemotePhotoItem {
|
|||
lng: gps != null ? _toDouble(gps['lng']) : null,
|
||||
alt: gps != null ? _toDouble(gps['alt']) : null,
|
||||
user: j['user']?.toString(),
|
||||
durationMillis: _toMillis(j['duration']),
|
||||
|
||||
// ⬇️ QUI LA MODIFICA: usiamo duration_ms (ms dal server)
|
||||
durationMillis: _toMillis(j['duration_ms']),
|
||||
|
||||
location: loc,
|
||||
);
|
||||
}
|
||||
|
|
@ -115,14 +122,14 @@ class RemoteLocation {
|
|||
});
|
||||
|
||||
factory RemoteLocation.fromJson(Map<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(),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 l’ultima 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 l’ultima 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;
|
||||
|
|
|
|||
567
lib/remote/remote_repository.dart.old
Normal file
567
lib/remote/remote_repository.dart.old
Normal 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 l’ultima 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 l’ultima 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
185
lib/remote/remote_settings_dialog.dart
Normal file
185
lib/remote/remote_settings_dialog.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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> {
|
|||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
67
lib/remote/remote_sync_bus.dart
Normal file
67
lib/remote/remote_sync_bus.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
51
lib/remote/remote_sync_bus.dart.ok
Normal file
51
lib/remote/remote_sync_bus.dart.ok
Normal 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
801
lib/widgets/collection/app_bar.dart.ok
Normal file
801
lib/widgets/collection/app_bar.dart.ok
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
817
lib/widgets/collection/app_bar.dart.orig
Normal file
817
lib/widgets/collection/app_bar.dart.orig
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
229
lib/widgets/collection/collection_page.dart.old
Normal file
229
lib/widgets/collection/collection_page.dart.old
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
50
lib/widgets/collection/loading.dart.old
Normal file
50
lib/widgets/collection/loading.dart.old
Normal 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();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
57
lib/widgets/collection/remote_progress_banner.dart
Normal file
57
lib/widgets/collection/remote_progress_banner.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
51
lib/widgets/collection/remote_progress_banner.dart.old
Normal file
51
lib/widgets/collection/remote_progress_banner.dart.old
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
155
lib/widgets/collection/remote_status_button.dart
Normal file
155
lib/widgets/collection/remote_status_button.dart
Normal 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 l’header
|
||||
return SizedBox.square(
|
||||
dimension: kMinInteractiveDimension,
|
||||
child: Listener(
|
||||
onPointerDown: (e) {
|
||||
if (_busy) return;
|
||||
_startLongPressTimer(e.position);
|
||||
},
|
||||
onPointerMove: (e) {
|
||||
final start = _downPos;
|
||||
if (start != null) {
|
||||
final dx = (e.position.dx - start.dx).abs();
|
||||
final dy = (e.position.dy - start.dy).abs();
|
||||
if (dx > _moveSlop || dy > _moveSlop) {
|
||||
_cancelLongPressTimer();
|
||||
}
|
||||
}
|
||||
},
|
||||
onPointerUp: (e) async {
|
||||
if (_busy) return;
|
||||
_cancelLongPressTimer();
|
||||
|
||||
// se il long press è già scattato, NON fare toggle
|
||||
if (_longPressFired) {
|
||||
_longPressFired = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await _toggle();
|
||||
},
|
||||
onPointerCancel: (e) {
|
||||
_cancelLongPressTimer();
|
||||
},
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Center(child: icon),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
71
lib/widgets/collection/remote_status_icon.dart
Normal file
71
lib/widgets/collection/remote_status_icon.dart
Normal 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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 l’analisi in background appena la UI è pronta
|
||||
unawaited(Future.delayed(const Duration(milliseconds: 300)).then((_) {
|
||||
settings.homeNavItem.route == CollectionPage.routeName &&
|
||||
settings.homeCustomCollection.isEmpty;
|
||||
source.canAnalyze = true;
|
||||
debugPrint('[startup] analysis re-enabled in background');
|
||||
}));
|
||||
|
||||
// === SYNC REMOTO post-init (non blocca la UI) ===
|
||||
if (!_remoteSyncScheduled) {
|
||||
_remoteSyncScheduled = true; // una sola schedulazione per avvio
|
||||
unawaited(Future(() async {
|
||||
try {
|
||||
await RemoteSettings.debugSeedIfEmpty();
|
||||
final rs = await _safeLoadRemoteSettings();
|
||||
if (!rs.enabled) return;
|
||||
|
||||
// attesa fine loading
|
||||
final notifier = source.stateNotifier;
|
||||
if (notifier.value == SourceState.loading) {
|
||||
final completer = Completer<void>();
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 l’analisi in background appena la UI è pronta
|
||||
unawaited(Future.delayed(const Duration(milliseconds: 300)).then((_) {
|
||||
source.canAnalyze = true;
|
||||
debugPrint('[startup] analysis re-enabled in background');
|
||||
}));
|
||||
|
||||
// === SYNC REMOTO post-init (non blocca la UI) ===
|
||||
if (!_remoteSyncScheduled) {
|
||||
_remoteSyncScheduled = true; // una sola schedulazione per avvio
|
||||
unawaited(Future(() async {
|
||||
try {
|
||||
await RemoteSettings.debugSeedIfEmpty();
|
||||
final rs = await _safeLoadRemoteSettings();
|
||||
if (!rs.enabled) return;
|
||||
|
||||
// attesa fine loading
|
||||
final notifier = source.stateNotifier;
|
||||
if (notifier.value == SourceState.loading) {
|
||||
final completer = Completer<void>();
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
775
lib/widgets/home/home_page.dart.orig
Normal file
775
lib/widgets/home/home_page.dart.orig
Normal 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 {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {};
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
metadata.db
BIN
metadata.db
Binary file not shown.
Loading…
Reference in a new issue