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,9 +208,35 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
|
||||||
updateTags();
|
updateTags();
|
||||||
}
|
}
|
||||||
|
|
||||||
void addEntries(Set<AvesEntry> entries, {bool notify = true}) {
|
void addEntries(Set<AvesEntry> entries, {bool notify = true}) {
|
||||||
if (entries.isEmpty) return;
|
if (entries.isEmpty) return;
|
||||||
|
|
||||||
|
// ✅ DEDUPE per URI (evita raddoppi quando lo stesso media entra con ID diverso)
|
||||||
|
final newUris = entries
|
||||||
|
.map((e) => e.uri)
|
||||||
|
.whereType<String>()
|
||||||
|
.where((u) => u.isNotEmpty)
|
||||||
|
.toSet();
|
||||||
|
|
||||||
|
if (newUris.isNotEmpty && _rawEntries.isNotEmpty) {
|
||||||
|
final removedByUri = <AvesEntry>{};
|
||||||
|
|
||||||
|
_rawEntries.removeWhere((entry) {
|
||||||
|
final u = entry.uri;
|
||||||
|
final match = u != null && newUris.contains(u);
|
||||||
|
if (match) removedByUri.add(entry);
|
||||||
|
return match;
|
||||||
|
});
|
||||||
|
|
||||||
|
// rimuovi anche dalla mappa id->entry per non lasciare "zombie"
|
||||||
|
if (removedByUri.isNotEmpty) {
|
||||||
|
for (final old in removedByUri) {
|
||||||
|
_entryById.remove(old.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplica per ID (comportamento originale)
|
||||||
final newIdMapEntries = Map.fromEntries(entries.map((entry) => MapEntry(entry.id, entry)));
|
final newIdMapEntries = Map.fromEntries(entries.map((entry) => MapEntry(entry.id, entry)));
|
||||||
if (_rawEntries.isNotEmpty) {
|
if (_rawEntries.isNotEmpty) {
|
||||||
final newIds = newIdMapEntries.keys.toSet();
|
final newIds = newIdMapEntries.keys.toSet();
|
||||||
|
|
@ -225,11 +251,27 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
|
||||||
_rawEntries.addAll(entries);
|
_rawEntries.addAll(entries);
|
||||||
_invalidate(entries: entries, notify: notify);
|
_invalidate(entries: entries, notify: notify);
|
||||||
|
|
||||||
addDirectories(albums: _applyHiddenFilters(entries).map((entry) => entry.directory).toSet(), notify: notify);
|
addDirectories(
|
||||||
|
albums: _applyHiddenFilters(entries).map((entry) => entry.directory).toSet(),
|
||||||
|
notify: notify,
|
||||||
|
);
|
||||||
if (notify) {
|
if (notify) {
|
||||||
eventBus.fire(EntryAddedEvent(entries));
|
eventBus.fire(EntryAddedEvent(entries));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeEntriesFromMemory(Set<AvesEntry> entries, {bool notify = true}) {
|
||||||
|
if (entries.isEmpty) return;
|
||||||
|
|
||||||
|
for (final e in entries) {
|
||||||
|
_entryById.remove(e.id);
|
||||||
}
|
}
|
||||||
|
_rawEntries.removeAll(entries);
|
||||||
|
updateDerivedFilters(entries);
|
||||||
|
if (notify) {
|
||||||
|
eventBus.fire(EntryRemovedEvent(entries));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> removeEntries(Set<String> uris, {required bool includeTrash}) async {
|
Future<void> removeEntries(Set<String> uris, {required bool includeTrash}) async {
|
||||||
if (uris.isEmpty) return;
|
if (uris.isEmpty) return;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// lib/model/source/collection_source.dart
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
|
|
@ -40,6 +41,9 @@ import 'package:leak_tracker/leak_tracker.dart';
|
||||||
|
|
||||||
typedef SourceScope = Set<CollectionFilter>?;
|
typedef SourceScope = Set<CollectionFilter>?;
|
||||||
|
|
||||||
|
// Trace opzionale: mostra chi nasconderebbe i remoti (non li nascondiamo)
|
||||||
|
const bool kTraceHiddenRemotes = true;
|
||||||
|
|
||||||
mixin SourceBase {
|
mixin SourceBase {
|
||||||
EventBus get eventBus;
|
EventBus get eventBus;
|
||||||
|
|
||||||
|
|
@ -156,7 +160,22 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
|
||||||
TrashFilter.instance,
|
TrashFilter.instance,
|
||||||
..._getAppHiddenFilters(),
|
..._getAppHiddenFilters(),
|
||||||
};
|
};
|
||||||
return entries.where((entry) => !hiddenFilters.any((filter) => filter.test(entry)));
|
|
||||||
|
// PATCH B: non nascondere i remoti con i filtri "nascosti" (tranne Trash)
|
||||||
|
return entries.where((entry) {
|
||||||
|
if (entry.origin == 1) {
|
||||||
|
if (kTraceHiddenRemotes) {
|
||||||
|
final hiddenBy = hiddenFilters.firstWhereOrNull((f) => f.test(entry));
|
||||||
|
if (hiddenBy != null && !TrashFilter.instance.test(entry)) {
|
||||||
|
debugPrint('[hidden][trace] remote id=${entry.id} rid=${entry.remoteId} by=${hiddenBy.runtimeType}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// remoti: nascondi solo se nel cestino
|
||||||
|
return !TrashFilter.instance.test(entry);
|
||||||
|
}
|
||||||
|
// locali: logica originale
|
||||||
|
return !hiddenFilters.any((filter) => filter.test(entry));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Iterable<AvesEntry> _applyTrashFilter(Iterable<AvesEntry> entries) {
|
Iterable<AvesEntry> _applyTrashFilter(Iterable<AvesEntry> entries) {
|
||||||
|
|
@ -189,9 +208,35 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
|
||||||
updateTags();
|
updateTags();
|
||||||
}
|
}
|
||||||
|
|
||||||
void addEntries(Set<AvesEntry> entries, {bool notify = true}) {
|
void addEntries(Set<AvesEntry> entries, {bool notify = true}) {
|
||||||
if (entries.isEmpty) return;
|
if (entries.isEmpty) return;
|
||||||
|
|
||||||
|
// ✅ DEDUPE per URI (evita raddoppi quando lo stesso media entra con ID diverso)
|
||||||
|
final newUris = entries
|
||||||
|
.map((e) => e.uri)
|
||||||
|
.whereType<String>()
|
||||||
|
.where((u) => u.isNotEmpty)
|
||||||
|
.toSet();
|
||||||
|
|
||||||
|
if (newUris.isNotEmpty && _rawEntries.isNotEmpty) {
|
||||||
|
final removedByUri = <AvesEntry>{};
|
||||||
|
|
||||||
|
_rawEntries.removeWhere((entry) {
|
||||||
|
final u = entry.uri;
|
||||||
|
final match = u != null && newUris.contains(u);
|
||||||
|
if (match) removedByUri.add(entry);
|
||||||
|
return match;
|
||||||
|
});
|
||||||
|
|
||||||
|
// rimuovi anche dalla mappa id->entry per non lasciare "zombie"
|
||||||
|
if (removedByUri.isNotEmpty) {
|
||||||
|
for (final old in removedByUri) {
|
||||||
|
_entryById.remove(old.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplica per ID (comportamento originale)
|
||||||
final newIdMapEntries = Map.fromEntries(entries.map((entry) => MapEntry(entry.id, entry)));
|
final newIdMapEntries = Map.fromEntries(entries.map((entry) => MapEntry(entry.id, entry)));
|
||||||
if (_rawEntries.isNotEmpty) {
|
if (_rawEntries.isNotEmpty) {
|
||||||
final newIds = newIdMapEntries.keys.toSet();
|
final newIds = newIdMapEntries.keys.toSet();
|
||||||
|
|
@ -206,11 +251,14 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
|
||||||
_rawEntries.addAll(entries);
|
_rawEntries.addAll(entries);
|
||||||
_invalidate(entries: entries, notify: notify);
|
_invalidate(entries: entries, notify: notify);
|
||||||
|
|
||||||
addDirectories(albums: _applyHiddenFilters(entries).map((entry) => entry.directory).toSet(), notify: notify);
|
addDirectories(
|
||||||
|
albums: _applyHiddenFilters(entries).map((entry) => entry.directory).toSet(),
|
||||||
|
notify: notify,
|
||||||
|
);
|
||||||
if (notify) {
|
if (notify) {
|
||||||
eventBus.fire(EntryAddedEvent(entries));
|
eventBus.fire(EntryAddedEvent(entries));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> removeEntries(Set<String> uris, {required bool includeTrash}) async {
|
Future<void> removeEntries(Set<String> uris, {required bool includeTrash}) async {
|
||||||
if (uris.isEmpty) return;
|
if (uris.isEmpty) return;
|
||||||
|
|
@ -242,6 +290,27 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
|
||||||
// caller should take care of updating these at the right time
|
// caller should take care of updating these at the right time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Carica dal DB tutte le entry **remote** (`origin=1`) non cestinate
|
||||||
|
/// e le aggiunge alla sorgente corrente (evitando duplicati per ID).
|
||||||
|
///
|
||||||
|
/// 👉 Va chiamato **dopo** che la sorgente locale è stata inizializzata
|
||||||
|
/// (es. subito dopo `await source.init(...)` nel tuo `home_page.dart`).
|
||||||
|
Future<void> appendRemoteEntries({bool notify = true}) async {
|
||||||
|
try {
|
||||||
|
final remotes = await localMediaDb.loadEntries(origin: 1);
|
||||||
|
if (remotes.isEmpty) return;
|
||||||
|
|
||||||
|
// Manteniamo visibili solo quelli non cestinati
|
||||||
|
final visibleRemotes = remotes.where((e) => !e.trashed).toSet();
|
||||||
|
if (visibleRemotes.isEmpty) return;
|
||||||
|
|
||||||
|
// Merge usando la logica standard (aggiorna mappe, invalida, eventi, filtri, ecc.)
|
||||||
|
addEntries(visibleRemotes, notify: notify);
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('CollectionSource.appendRemoteEntries error: $e\n$st');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _moveEntry(AvesEntry entry, Map newFields, {required bool persist}) async {
|
Future<void> _moveEntry(AvesEntry entry, Map newFields, {required bool persist}) async {
|
||||||
newFields.keys.forEach((key) {
|
newFields.keys.forEach((key) {
|
||||||
final newValue = newFields[key];
|
final newValue = newFields[key];
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
// lib/model/source/media_store_source.dart
|
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:aves/model/covers.dart';
|
import 'package:aves/model/covers.dart';
|
||||||
|
|
@ -20,9 +18,9 @@ import 'package:aves/utils/debouncer.dart';
|
||||||
import 'package:aves_model/aves_model.dart';
|
import 'package:aves_model/aves_model.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:sqflite/sqflite.dart' show Sqflite;
|
|
||||||
|
|
||||||
// ⭐⭐⭐ AGGIUNTA: definizione origine remota ⭐⭐⭐
|
// ⭐⭐⭐ (temporaneo) origine remota come nel tuo corrente.
|
||||||
|
// ATTENZIONE: poi la cambieremo perché colliderebbe con EntryOrigins.unknownContent (=1)
|
||||||
const int ORIGIN_REMOTE = 1;
|
const int ORIGIN_REMOTE = 1;
|
||||||
|
|
||||||
class MediaStoreSource extends CollectionSource {
|
class MediaStoreSource extends CollectionSource {
|
||||||
|
|
@ -98,31 +96,13 @@ class MediaStoreSource extends CollectionSource {
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
state = SourceState.loading;
|
state = SourceState.loading;
|
||||||
|
|
||||||
|
// ✅ STEP A: come Aves originale — pulizia SOLO in memoria, NON nel DB
|
||||||
|
clearEntries();
|
||||||
|
|
||||||
final scopeAlbumFilters = _targetScope?.whereType<StoredAlbumFilter>();
|
final scopeAlbumFilters = _targetScope?.whereType<StoredAlbumFilter>();
|
||||||
final scopeDirectory =
|
final scopeDirectory =
|
||||||
scopeAlbumFilters != null && scopeAlbumFilters.length == 1 ? scopeAlbumFilters.first.album : null;
|
scopeAlbumFilters != null && scopeAlbumFilters.length == 1 ? scopeAlbumFilters.first.album : null;
|
||||||
|
|
||||||
// 🔒 Sentinella: conteggio remoti PRIMA
|
|
||||||
final preRem = Sqflite.firstIntValue(
|
|
||||||
await localMediaDb.rawDb.rawQuery('SELECT COUNT(*) FROM entry WHERE origin=$ORIGIN_REMOTE'),
|
|
||||||
) ??
|
|
||||||
0;
|
|
||||||
debugPrint('[cleanup][pre] remoti in DB = $preRem');
|
|
||||||
|
|
||||||
// 🔧 PATCH: cancella SOLO i locali
|
|
||||||
final swClear = Stopwatch()..start();
|
|
||||||
final deletedLocal = await localMediaDb.rawDb
|
|
||||||
.rawDelete('DELETE FROM entry WHERE origin = ?', [EntryOrigins.mediaStoreContent]);
|
|
||||||
swClear.stop();
|
|
||||||
debugPrint('$runtimeType load ${swClear.elapsed} clear local entries deleted $deletedLocal rows');
|
|
||||||
|
|
||||||
// 🔒 Sentinella: conteggio remoti DOPO
|
|
||||||
final postRem = Sqflite.firstIntValue(
|
|
||||||
await localMediaDb.rawDb.rawQuery('SELECT COUNT(*) FROM entry WHERE origin=$ORIGIN_REMOTE'),
|
|
||||||
) ??
|
|
||||||
0;
|
|
||||||
debugPrint('[cleanup][post] remoti in DB = $postRem (Δ=${postRem - preRem})');
|
|
||||||
|
|
||||||
final Set<AvesEntry> topEntries = {};
|
final Set<AvesEntry> topEntries = {};
|
||||||
if (loadTopEntriesFirst) {
|
if (loadTopEntriesFirst) {
|
||||||
final topIds = settings.topEntryIds?.toSet();
|
final topIds = settings.topEntryIds?.toSet();
|
||||||
|
|
@ -424,6 +404,8 @@ class MediaStoreSource extends CollectionSource {
|
||||||
_lastGeneration = await mediaStoreService.getGeneration();
|
_lastGeneration = await mediaStoreService.getGeneration();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// vault
|
||||||
|
|
||||||
Future<void> _loadVaultEntries(String? directory) async {
|
Future<void> _loadVaultEntries(String? directory) async {
|
||||||
addEntries(await localMediaDb.loadEntries(origin: EntryOrigins.vault, directory: directory));
|
addEntries(await localMediaDb.loadEntries(origin: EntryOrigins.vault, directory: directory));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,64 @@
|
||||||
// lib/remote/collection_source_remote_ext.dart
|
// lib/remote/collection_source_remote_ext.dart
|
||||||
|
import 'package:aves/model/entry/entry.dart';
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
import 'package:aves/services/common/services.dart';
|
import 'package:aves/services/common/services.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
extension CollectionSourceRemoteExt on CollectionSource {
|
extension CollectionSourceRemoteExt on CollectionSource {
|
||||||
/// Carica dal DB tutte le entry remote (origin=1) non cestinate
|
/// Carica dal DB tutte le entry remote (origin=1) non cestinate
|
||||||
/// e le aggiunge alla CollectionSource, con log di diagnostica.
|
/// e le aggiunge alla CollectionSource evitando duplicati in memoria.
|
||||||
Future<void> appendRemoteEntriesFromDb() async {
|
Future<void> appendRemoteEntriesFromDb() async {
|
||||||
// 1) carica dal DB
|
// 1) carica dal DB (qui è Set nella tua base di codice)
|
||||||
final remoti = await localMediaDb.loadEntries(origin: 1);
|
final Set<AvesEntry> remoti = await localMediaDb.loadEntries(origin: 1);
|
||||||
debugPrint('[remote-append] candidati=${remoti.length}');
|
debugPrint('[remote-append] candidati=${remoti.length}');
|
||||||
|
|
||||||
// 2) filtra visibili (!!! booleano, NON e.trashed == 0)
|
// 2) filtra visibili
|
||||||
final visibili = remoti.where((e) => !e.trashed).toSet();
|
final Iterable<AvesEntry> visibili = remoti.where((e) => !e.trashed);
|
||||||
debugPrint('[remote-append] visibili=${visibili.length}');
|
final int visCount = visibili.length;
|
||||||
|
debugPrint('[remote-append] visibili=$visCount');
|
||||||
|
|
||||||
|
// 3) chiavi già presenti nella Source (per evitare doppioni in memoria)
|
||||||
|
final Set<String> existingRemoteIds = allEntries
|
||||||
|
.where((e) => e.origin == 1 && !e.trashed)
|
||||||
|
.map((e) => e.remoteId)
|
||||||
|
.whereType<String>()
|
||||||
|
.toSet();
|
||||||
|
|
||||||
|
final Set<String> existingUris = allEntries
|
||||||
|
.where((e) => e.origin == 1 && !e.trashed)
|
||||||
|
.map((e) => e.uri)
|
||||||
|
.whereType<String>()
|
||||||
|
.toSet();
|
||||||
|
|
||||||
|
// 4) dedupe deterministica “dentro il batch” per remoteId/uri
|
||||||
|
final Map<String, AvesEntry> byKey = <String, AvesEntry>{};
|
||||||
|
for (final e in visibili) {
|
||||||
|
final rid = e.remoteId;
|
||||||
|
final key = (rid != null && rid.isNotEmpty) ? 'rid:$rid' : 'uri:${e.uri}';
|
||||||
|
byKey[key] = e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) prendi solo quelli non già presenti in memoria
|
||||||
|
final Set<AvesEntry> toAdd = <AvesEntry>{};
|
||||||
|
for (final e in byKey.values) {
|
||||||
|
final rid = e.remoteId;
|
||||||
|
final u = e.uri;
|
||||||
|
|
||||||
|
final bool alreadyInMemory =
|
||||||
|
(rid != null && rid.isNotEmpty && existingRemoteIds.contains(rid)) ||
|
||||||
|
(u != null && u.isNotEmpty && existingUris.contains(u));
|
||||||
|
|
||||||
|
if (!alreadyInMemory) {
|
||||||
|
toAdd.add(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 3) aggiungi alla source (usa allEntries, non "entries")
|
|
||||||
final prima = allEntries.where((e) => e.origin == 1 && !e.trashed).length;
|
final prima = allEntries.where((e) => e.origin == 1 && !e.trashed).length;
|
||||||
addEntries(visibili);
|
addEntries(toAdd);
|
||||||
final dopo = allEntries.where((e) => e.origin == 1 && !e.trashed).length;
|
final dopo = allEntries.where((e) => e.origin == 1 && !e.trashed).length;
|
||||||
|
|
||||||
debugPrint('[remote-append] appese=${dopo - prima} (prima=$prima -> dopo=$dopo)');
|
debugPrint('[remote-append] appese=${dopo - prima} (prima=$prima -> dopo=$dopo)');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
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
|
// lib/remote/remote_image_tile.dart
|
||||||
import 'package:flutter/material.dart';
|
import 'dart:ui' show FontFeature;
|
||||||
import 'remote_http.dart';
|
|
||||||
import 'package:aves/model/entry/entry.dart';
|
|
||||||
|
|
||||||
class RemoteImageTile extends StatelessWidget {
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:aves/model/entry/entry.dart';
|
||||||
|
import 'package:aves/model/entry/extensions/props.dart'; // entry.isVideo, durationText
|
||||||
|
import 'package:aves/remote/remote_http.dart';
|
||||||
|
|
||||||
|
/// Miniatura per contenuti **remoti** con overlay coerente a quello dei locali:
|
||||||
|
/// - Icona Play in basso-sinistra se è video
|
||||||
|
/// - Chip durata in basso-destra se `entry.durationMillis` è disponibile
|
||||||
|
///
|
||||||
|
/// Fix principali:
|
||||||
|
/// 1) Mai "grigio stuck": retry automatico se la prima richiesta fallisce
|
||||||
|
/// 2) Preload: precache del thumb corrente + (opzionale) prefetch dei prossimi N thumb
|
||||||
|
class RemoteImageTile extends StatefulWidget {
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
||||||
const RemoteImageTile({super.key, required this.entry});
|
final double borderRadius;
|
||||||
|
final BoxFit fit;
|
||||||
|
|
||||||
|
// personalizzazioni overlay
|
||||||
|
final Color? overlayIconBg;
|
||||||
|
final Color? overlayIconFg;
|
||||||
|
final Color? durationBg;
|
||||||
|
final Color? durationFg;
|
||||||
|
|
||||||
|
/// (Opzionale) lista di path relativi (thumb/path) da precaricare
|
||||||
|
/// Es: [nextRel1, nextRel2, ...] in ordine di priorità.
|
||||||
|
final List<String>? prefetchRelPaths;
|
||||||
|
|
||||||
|
/// Quanti prefetch eseguire al massimo (se prefetchRelPaths != null)
|
||||||
|
final int prefetchCount;
|
||||||
|
|
||||||
|
/// Quanti tentativi di retry per la stessa tile (consigliato 1 o 2)
|
||||||
|
final int maxRetry;
|
||||||
|
|
||||||
|
const RemoteImageTile({
|
||||||
|
super.key,
|
||||||
|
required this.entry,
|
||||||
|
this.borderRadius = 12.0,
|
||||||
|
this.fit = BoxFit.cover,
|
||||||
|
this.overlayIconBg,
|
||||||
|
this.overlayIconFg,
|
||||||
|
this.durationBg,
|
||||||
|
this.durationFg,
|
||||||
|
this.prefetchRelPaths,
|
||||||
|
this.prefetchCount = 18,
|
||||||
|
this.maxRetry = 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<RemoteImageTile> createState() => _RemoteImageTileState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RemoteImageTileState extends State<RemoteImageTile> {
|
||||||
|
late Future<Map<String, String>> _headersFuture;
|
||||||
|
int _attempt = 0;
|
||||||
|
bool _selfPrecached = false;
|
||||||
|
bool _neighborsPrecached = false;
|
||||||
|
|
||||||
|
bool get _isRemote => widget.entry.origin == 1;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// headers() dovrebbe essere già “cacheata” nel tuo RemoteHttp, ma la
|
||||||
|
// memorizziamo per non ricreare il Future ad ogni build.
|
||||||
|
_headersFuture = RemoteHttp.headers();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant RemoteImageTile oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
|
||||||
|
// Se cambia entry o cambia url base, resettare stato retry/precache
|
||||||
|
if (oldWidget.entry.id != widget.entry.id ||
|
||||||
|
oldWidget.entry.remoteThumb2 != widget.entry.remoteThumb2 ||
|
||||||
|
oldWidget.entry.remoteThumb1 != widget.entry.remoteThumb1 ||
|
||||||
|
oldWidget.entry.remotePath != widget.entry.remotePath ||
|
||||||
|
oldWidget.entry.path != widget.entry.path) {
|
||||||
|
_attempt = 0;
|
||||||
|
_selfPrecached = false;
|
||||||
|
_neighborsPrecached = false;
|
||||||
|
_headersFuture = RemoteHttp.headers();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se cambia lista prefetch, consentiamo di rifarla
|
||||||
|
if (oldWidget.prefetchRelPaths != widget.prefetchRelPaths) {
|
||||||
|
_neighborsPrecached = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Usa SOLO campi remoti, mai entry.path
|
final entry = widget.entry;
|
||||||
final rel = entry.remoteThumb2 ?? entry.remoteThumb1 ?? entry.remotePath;
|
|
||||||
if (rel == null || rel.isEmpty) {
|
|
||||||
return const ColoredBox(color: Colors.black12);
|
|
||||||
}
|
|
||||||
final url = RemoteHttp.absUrl(rel);
|
|
||||||
|
|
||||||
return FutureBuilder<Map<String, String>>(
|
final rel = entry.remoteThumb2 ?? entry.remoteThumb1 ?? entry.remotePath ?? entry.path;
|
||||||
future: RemoteHttp.headers(),
|
if (!_isRemote || rel == null || rel.isEmpty) {
|
||||||
|
return _frame(context, const ColoredBox(color: Colors.black12));
|
||||||
|
}
|
||||||
|
|
||||||
|
final url = RemoteHttp.absUrl(rel);
|
||||||
|
final ar = (entry.displayAspectRatio > 0) ? entry.displayAspectRatio : 1.0;
|
||||||
|
|
||||||
|
return AspectRatio(
|
||||||
|
aspectRatio: ar,
|
||||||
|
child: FutureBuilder<Map<String, String>>(
|
||||||
|
future: _headersFuture,
|
||||||
builder: (context, snap) {
|
builder: (context, snap) {
|
||||||
if (snap.connectionState != ConnectionState.done) {
|
if (snap.connectionState != ConnectionState.done) {
|
||||||
return const ColoredBox(color: Colors.black12);
|
return _frame(context, const ColoredBox(color: Colors.black12), showProgress: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
final hdrs = snap.data ?? const {};
|
final hdrs = snap.data ?? const {};
|
||||||
return Image.network(
|
|
||||||
url,
|
// ImageProvider “canonico” (serve anche per precache)
|
||||||
fit: BoxFit.cover,
|
final provider = NetworkImage(url, headers: hdrs.isEmpty ? null : hdrs);
|
||||||
headers: hdrs.isEmpty ? null : hdrs,
|
|
||||||
errorBuilder: (_, __, ___) => const Icon(Icons.broken_image),
|
// ✅ Precache del thumb corrente (una volta sola)
|
||||||
);
|
if (!_selfPrecached) {
|
||||||
|
_selfPrecached = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
precacheImage(provider, context);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Prefetch dei prossimi N (se forniti)
|
||||||
|
if (!_neighborsPrecached && widget.prefetchRelPaths != null && widget.prefetchRelPaths!.isNotEmpty) {
|
||||||
|
_neighborsPrecached = true;
|
||||||
|
final next = widget.prefetchRelPaths!
|
||||||
|
.where((p) => p.isNotEmpty)
|
||||||
|
.take(widget.prefetchCount)
|
||||||
|
.map((p) => RemoteHttp.absUrl(p))
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
for (final u in next) {
|
||||||
|
precacheImage(NetworkImage(u, headers: hdrs.isEmpty ? null : hdrs), context);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
final img = Image(
|
||||||
|
image: provider,
|
||||||
|
fit: widget.fit,
|
||||||
|
// ✅ mentre scarica: spinner (così non è "grigio stuck")
|
||||||
|
loadingBuilder: (context, child, progress) {
|
||||||
|
if (progress == null) return child;
|
||||||
|
return _frame(context, const ColoredBox(color: Colors.black12), showProgress: true);
|
||||||
},
|
},
|
||||||
|
// ✅ se fallisce: retry automatico (1 volta di default) poi fallback soft
|
||||||
|
errorBuilder: (_, __, ___) {
|
||||||
|
if (_attempt < widget.maxRetry) {
|
||||||
|
// piccolo delay per evitare loop immediati
|
||||||
|
Future.delayed(const Duration(milliseconds: 150), () {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _attempt++);
|
||||||
|
});
|
||||||
|
// nel frattempo spinner (non grigio fisso)
|
||||||
|
return _frame(context, const ColoredBox(color: Colors.black12), showProgress: true);
|
||||||
|
}
|
||||||
|
// fallback definitivo: box neutro (ma NON rimane bloccato al primo errore)
|
||||||
|
return _frame(context, const ColoredBox(color: Colors.black26));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return _frame(context, img);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _frame(BuildContext context, Widget child, {bool showProgress = false}) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final radius = BorderRadius.circular(widget.borderRadius);
|
||||||
|
|
||||||
|
// Video detection robusta (isVideo + mime + estensione path)
|
||||||
|
final mime = (widget.entry.sourceMimeType ?? widget.entry.mimeType ?? '').toLowerCase();
|
||||||
|
final p = (widget.entry.path ?? widget.entry.remotePath ?? '').toLowerCase();
|
||||||
|
final looksVideo = p.endsWith('.mp4') || p.endsWith('.mov') || p.endsWith('.m4v') || p.endsWith('.mkv') || p.endsWith('.webm');
|
||||||
|
final isVideo = widget.entry.isVideo || mime.startsWith('video/') || looksVideo;
|
||||||
|
|
||||||
|
final showDuration = isVideo && (widget.entry.durationMillis ?? 0) > 0;
|
||||||
|
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: radius,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
Positioned.fill(child: child),
|
||||||
|
|
||||||
|
if (showProgress)
|
||||||
|
const Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 1.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (isVideo) ...[
|
||||||
|
Positioned(
|
||||||
|
left: 6,
|
||||||
|
bottom: 6,
|
||||||
|
child: _PlayBadge(
|
||||||
|
bg: widget.overlayIconBg ?? Colors.black.withOpacity(.55),
|
||||||
|
fg: widget.overlayIconFg ?? Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showDuration)
|
||||||
|
Positioned(
|
||||||
|
right: 6,
|
||||||
|
bottom: 6,
|
||||||
|
child: _DurationChip(
|
||||||
|
text: widget.entry.durationText,
|
||||||
|
bg: widget.durationBg ?? Colors.black.withOpacity(.65),
|
||||||
|
fg: widget.durationFg ?? Colors.white,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
textStyle: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: widget.durationFg ?? Colors.white,
|
||||||
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PlayBadge extends StatelessWidget {
|
||||||
|
final Color bg;
|
||||||
|
final Color fg;
|
||||||
|
|
||||||
|
const _PlayBadge({
|
||||||
|
required this.bg,
|
||||||
|
required this.fg,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return DecoratedBox(
|
||||||
|
decoration: BoxDecoration(color: bg, shape: BoxShape.circle),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
child: Icon(Icons.play_arrow_rounded, color: fg, size: 16),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DurationChip extends StatelessWidget {
|
||||||
|
final String text;
|
||||||
|
final Color bg;
|
||||||
|
final Color fg;
|
||||||
|
final double borderRadius;
|
||||||
|
final EdgeInsets padding;
|
||||||
|
final TextStyle? textStyle;
|
||||||
|
|
||||||
|
const _DurationChip({
|
||||||
|
super.key,
|
||||||
|
required this.text,
|
||||||
|
required this.bg,
|
||||||
|
required this.fg,
|
||||||
|
this.borderRadius = 10,
|
||||||
|
this.padding = const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
this.textStyle,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: bg,
|
||||||
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: padding,
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: textStyle ?? Theme.of(context).textTheme.labelSmall?.copyWith(color: fg),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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)
|
// Costruzione URL assoluto delegata a utility (in base alle impostazioni)
|
||||||
String absoluteUrl(String baseUrl) => buildAbsoluteUri(baseUrl, path).toString();
|
String absoluteUrl(String baseUrl) => buildAbsoluteUri(baseUrl, path).toString();
|
||||||
|
|
||||||
|
|
||||||
static DateTime? _tryParseIsoUtc(dynamic v) {
|
static DateTime? _tryParseIsoUtc(dynamic v) {
|
||||||
if (v == null) return null;
|
if (v == null) return null;
|
||||||
try { return DateTime.parse(v.toString()).toUtc(); } catch (_) { return null; }
|
try {
|
||||||
|
return DateTime.parse(v.toString()).toUtc();
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static double? _toDouble(dynamic v) {
|
static double? _toDouble(dynamic v) {
|
||||||
|
|
@ -55,6 +58,7 @@ class RemotePhotoItem {
|
||||||
return double.tryParse(v.toString());
|
return double.tryParse(v.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Converte secondi→ms se < 1000, altrimenti assume già millisecondi.
|
||||||
static int? _toMillis(dynamic v) {
|
static int? _toMillis(dynamic v) {
|
||||||
if (v == null) return null;
|
if (v == null) return null;
|
||||||
final num? n = (v is num) ? v : num.tryParse(v.toString());
|
final num? n = (v is num) ? v : num.tryParse(v.toString());
|
||||||
|
|
@ -85,7 +89,10 @@ class RemotePhotoItem {
|
||||||
lng: gps != null ? _toDouble(gps['lng']) : null,
|
lng: gps != null ? _toDouble(gps['lng']) : null,
|
||||||
alt: gps != null ? _toDouble(gps['alt']) : null,
|
alt: gps != null ? _toDouble(gps['alt']) : null,
|
||||||
user: j['user']?.toString(),
|
user: j['user']?.toString(),
|
||||||
durationMillis: _toMillis(j['duration']),
|
|
||||||
|
// ⬇️ QUI LA MODIFICA: usiamo duration_ms (ms dal server)
|
||||||
|
durationMillis: _toMillis(j['duration_ms']),
|
||||||
|
|
||||||
location: loc,
|
location: loc,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -120,9 +127,9 @@ class RemoteLocation {
|
||||||
region: j['region']?.toString(),
|
region: j['region']?.toString(),
|
||||||
postcode: j['postcode']?.toString(),
|
postcode: j['postcode']?.toString(),
|
||||||
city: j['city']?.toString(),
|
city: j['city']?.toString(),
|
||||||
countyCode:j['county_code']?.toString(),
|
countyCode: j['county_code']?.toString(),
|
||||||
address: j['address']?.toString(),
|
address: j['address']?.toString(),
|
||||||
timezone: j['timezone']?.toString(),
|
timezone: j['timezone']?.toString(),
|
||||||
timeOffset:j['time']?.toString(),
|
timeOffset: j['time']?.toString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import 'package:flutter/foundation.dart' show debugPrint;
|
||||||
import 'package:sqflite/sqflite.dart';
|
import 'package:sqflite/sqflite.dart';
|
||||||
|
|
||||||
import 'remote_models.dart';
|
import 'remote_models.dart';
|
||||||
import 'remote_db_uris.dart'; // <-- helper per URI fittizi aves-remote://...
|
import 'remote_db_uris.dart'; // helper per URI fittizi aves-remote://...
|
||||||
|
|
||||||
class RemoteRepository {
|
class RemoteRepository {
|
||||||
final Database db;
|
final Database db;
|
||||||
|
|
@ -40,27 +40,28 @@ class RemoteRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Assicura che tutte le entry remote abbiano un uri costruito da remoteId.
|
/// Assicura che tutte le entry remote abbiano un uri costruito da remoteId.
|
||||||
Future<void> ensureRemoteUris(DatabaseExecutor dbExec) async {
|
/// (coerente con aves-remote://rid/...)
|
||||||
|
Future<void> ensureRemoteUris(DatabaseExecutor dbExec) async {
|
||||||
try {
|
try {
|
||||||
await dbExec.execute('''
|
await dbExec.execute('''
|
||||||
UPDATE entry
|
UPDATE entry
|
||||||
SET uri = 'remote://' || remoteId
|
SET uri = 'aves-remote://rid/' || replace(remoteId, ' ', '')
|
||||||
WHERE origin = 1
|
WHERE origin = 1
|
||||||
AND remoteId IS NOT NULL
|
AND remoteId IS NOT NULL
|
||||||
AND remoteId != ''
|
AND trim(remoteId) != ''
|
||||||
AND (uri IS NULL OR uri = '' OR uri NOT LIKE 'remote://%');
|
AND (uri IS NULL OR trim(uri) = '' OR uri NOT LIKE 'aves-remote://%');
|
||||||
''');
|
''');
|
||||||
debugPrint('[RemoteRepository] ensureRemoteUris: migration applied');
|
debugPrint('[RemoteRepository] ensureRemoteUris: migration applied');
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('[RemoteRepository] ensureRemoteUris error: $e\n$st');
|
debugPrint('[RemoteRepository] ensureRemoteUris error: $e\n$st');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Assicura che le colonne GPS e alcune colonne "remote*" esistano nella tabella `entry`.
|
/// Assicura che le colonne necessarie esistano nella tabella `entry`.
|
||||||
Future<void> _ensureEntryColumns(DatabaseExecutor dbExec) async {
|
Future<void> _ensureEntryColumns(DatabaseExecutor dbExec) async {
|
||||||
await _ensureColumns(dbExec, table: 'entry', columnsAndTypes: const {
|
await _ensureColumns(dbExec, table: 'entry', columnsAndTypes: const {
|
||||||
// Core (alcune basi legacy potrebbero non averle ancora)
|
// Core
|
||||||
'uri': 'TEXT',
|
'uri': 'TEXT',
|
||||||
|
|
||||||
// GPS
|
// GPS
|
||||||
|
|
@ -77,13 +78,14 @@ Future<void> ensureRemoteUris(DatabaseExecutor dbExec) async {
|
||||||
'provider': 'TEXT',
|
'provider': 'TEXT',
|
||||||
'trashed': 'INTEGER',
|
'trashed': 'INTEGER',
|
||||||
'remoteRotation': 'INTEGER',
|
'remoteRotation': 'INTEGER',
|
||||||
|
|
||||||
|
// ✅ Durata video (ms)
|
||||||
|
'durationMillis': 'INTEGER',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Indice "normale" per velocizzare il lookup su remoteId
|
// indice lookup
|
||||||
try {
|
try {
|
||||||
await dbExec.execute(
|
await dbExec.execute('CREATE INDEX IF NOT EXISTS idx_entry_remoteId ON entry(remoteId);');
|
||||||
'CREATE INDEX IF NOT EXISTS idx_entry_remoteId ON entry(remoteId);',
|
|
||||||
);
|
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('[RemoteRepository] create index error: $e\n$st');
|
debugPrint('[RemoteRepository] create index error: $e\n$st');
|
||||||
}
|
}
|
||||||
|
|
@ -107,10 +109,9 @@ Future<void> ensureRemoteUris(DatabaseExecutor dbExec) async {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!_isBusy(e) || i == maxAttempts - 1) rethrow;
|
if (!_isBusy(e) || i == maxAttempts - 1) rethrow;
|
||||||
await Future.delayed(delay);
|
await Future.delayed(delay);
|
||||||
delay *= 2; // 250 → 500 → 1000 ms
|
delay *= 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// non dovrebbe arrivare qui
|
|
||||||
return await fn();
|
return await fn();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,8 +126,8 @@ Future<void> ensureRemoteUris(DatabaseExecutor dbExec) async {
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Candidato "canonico" (inserisce '/original/' dopo '/photos/<User>/'
|
/// Candidato canonico: inserisce '/original/' dopo '/photos/<User>/'
|
||||||
/// se manca). Usato per lookup/fallback.
|
/// se manca. Usato per lookup/fallback.
|
||||||
String _canonCandidate(String? rawPath, String fileName) {
|
String _canonCandidate(String? rawPath, String fileName) {
|
||||||
var s = _normPath(rawPath);
|
var s = _normPath(rawPath);
|
||||||
final seg = s.split('/'); // ['', 'photos', '<User>', maybe 'original', ...]
|
final seg = s.split('/'); // ['', 'photos', '<User>', maybe 'original', ...]
|
||||||
|
|
@ -145,7 +146,7 @@ Future<void> ensureRemoteUris(DatabaseExecutor dbExec) async {
|
||||||
|
|
||||||
bool _isVideoItem(RemotePhotoItem it) {
|
bool _isVideoItem(RemotePhotoItem it) {
|
||||||
final mt = (it.mimeType ?? '').toLowerCase();
|
final mt = (it.mimeType ?? '').toLowerCase();
|
||||||
final p = (it.path).toLowerCase();
|
final p = it.path.toLowerCase();
|
||||||
return mt.startsWith('video/') ||
|
return mt.startsWith('video/') ||
|
||||||
p.endsWith('.mp4') ||
|
p.endsWith('.mp4') ||
|
||||||
p.endsWith('.mov') ||
|
p.endsWith('.mov') ||
|
||||||
|
|
@ -154,16 +155,8 @@ Future<void> ensureRemoteUris(DatabaseExecutor dbExec) async {
|
||||||
p.endsWith('.webm');
|
p.endsWith('.webm');
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
|
Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
|
||||||
// ============================================================
|
|
||||||
// REMARK ORIGINALE (da ripristinare quando avrai ImageProvider)
|
|
||||||
final syntheticUri = RemoteDbUris.make(remoteId: it.id, remotePath: it.path);
|
final syntheticUri = RemoteDbUris.make(remoteId: it.id, remotePath: it.path);
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
// TEMPORARY FIX: usare URL HTTP basato su thub2
|
|
||||||
//final syntheticUri = 'https://prova.patachina.it/${it.thub2}';
|
|
||||||
//final syntheticUri = 'https://picsum.photos/400';
|
|
||||||
|
|
||||||
|
|
||||||
int _makeContentId() {
|
int _makeContentId() {
|
||||||
final base = (it.id.isNotEmpty ? it.id : it.path);
|
final base = (it.id.isNotEmpty ? it.id : it.path);
|
||||||
|
|
@ -178,18 +171,15 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
|
||||||
'id': existingId,
|
'id': existingId,
|
||||||
'contentId': _makeContentId(),
|
'contentId': _makeContentId(),
|
||||||
|
|
||||||
// ✔️ URI HTTP temporaneo
|
|
||||||
'uri': syntheticUri,
|
'uri': syntheticUri,
|
||||||
|
|
||||||
// ✔️ MIME sempre valorizzato
|
|
||||||
'path': it.path,
|
'path': it.path,
|
||||||
'sourceMimeType': it.mimeType ?? 'image/jpeg',
|
|
||||||
|
|
||||||
// ✔️ width/height sempre valorizzati
|
// ✅ fallback MIME video se mancante
|
||||||
|
'sourceMimeType': it.mimeType ?? (_isVideoItem(it) ? 'video/mp4' : 'image/jpeg'),
|
||||||
|
|
||||||
'width': it.width ?? 0,
|
'width': it.width ?? 0,
|
||||||
'height': it.height ?? 0,
|
'height': it.height ?? 0,
|
||||||
|
|
||||||
// ✔️ rotation sempre valorizzata
|
|
||||||
'sourceRotationDegrees': it.rotation ?? 0,
|
'sourceRotationDegrees': it.rotation ?? 0,
|
||||||
|
|
||||||
'sizeBytes': it.sizeBytes,
|
'sizeBytes': it.sizeBytes,
|
||||||
|
|
@ -198,6 +188,8 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
|
||||||
'dateAddedSecs': nowMs ~/ 1000,
|
'dateAddedSecs': nowMs ~/ 1000,
|
||||||
'dateModifiedMillis': dateModMs,
|
'dateModifiedMillis': dateModMs,
|
||||||
'sourceDateTakenMillis': it.takenAtUtc?.millisecondsSinceEpoch,
|
'sourceDateTakenMillis': it.takenAtUtc?.millisecondsSinceEpoch,
|
||||||
|
|
||||||
|
// ✅ durata video (ms) (può essere null per foto)
|
||||||
'durationMillis': it.durationMillis,
|
'durationMillis': it.durationMillis,
|
||||||
|
|
||||||
'trashed': 0,
|
'trashed': 0,
|
||||||
|
|
@ -214,11 +206,10 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
|
||||||
'remoteThumb2': it.thub2,
|
'remoteThumb2': it.thub2,
|
||||||
'remoteRotation': it.rotation ?? 0,
|
'remoteRotation': it.rotation ?? 0,
|
||||||
|
|
||||||
// ✔️ remoteWidth/remoteHeight sempre valorizzati
|
|
||||||
'remoteWidth': it.width ?? 0,
|
'remoteWidth': it.width ?? 0,
|
||||||
'remoteHeight': it.height ?? 0,
|
'remoteHeight': it.height ?? 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object?> _buildAddressRow(int newId, RemoteLocation location) {
|
Map<String, Object?> _buildAddressRow(int newId, RemoteLocation location) {
|
||||||
return <String, Object?>{
|
return <String, Object?>{
|
||||||
|
|
@ -262,15 +253,14 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
|
||||||
final batch = txn.batch();
|
final batch = txn.batch();
|
||||||
|
|
||||||
for (final it in chunk) {
|
for (final it in chunk) {
|
||||||
// Log essenziale (puoi silenziare dopo i test)
|
|
||||||
final raw = it.path;
|
final raw = it.path;
|
||||||
final norm = _normPath(raw);
|
final norm = _normPath(raw);
|
||||||
final cand = _canonCandidate(raw, it.name);
|
final cand = _canonCandidate(raw, it.name); // name non-null nel tuo modello
|
||||||
debugPrint('[repo-upsert] in: rid=${it.id.substring(0,8)} name=${it.name} raw="$raw"');
|
debugPrint('[repo-upsert] in: rid=${it.id.substring(0, 8)} name=${it.name} raw="$raw"');
|
||||||
|
|
||||||
// Lookup record esistente:
|
|
||||||
// 1) per remoteId
|
|
||||||
int? existingId;
|
int? existingId;
|
||||||
|
|
||||||
|
// 1) lookup per remoteId
|
||||||
try {
|
try {
|
||||||
final existing = await txn.query(
|
final existing = await txn.query(
|
||||||
'entry',
|
'entry',
|
||||||
|
|
@ -284,7 +274,7 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
|
||||||
debugPrint('[RemoteRepository] lookup by remoteId failed for remoteId=${it.id}: $e\n$st');
|
debugPrint('[RemoteRepository] lookup by remoteId failed for remoteId=${it.id}: $e\n$st');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) fallback per remotePath = candidato canonico (/original/)
|
// 2) fallback per remotePath canonico
|
||||||
if (existingId == null) {
|
if (existingId == null) {
|
||||||
try {
|
try {
|
||||||
final byCanon = await txn.query(
|
final byCanon = await txn.query(
|
||||||
|
|
@ -294,15 +284,13 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
|
||||||
whereArgs: [cand],
|
whereArgs: [cand],
|
||||||
limit: 1,
|
limit: 1,
|
||||||
);
|
);
|
||||||
if (byCanon.isNotEmpty) {
|
if (byCanon.isNotEmpty) existingId = byCanon.first['id'] as int?;
|
||||||
existingId = byCanon.first['id'] as int?;
|
|
||||||
}
|
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('[RemoteRepository] lookup by canonical path failed "$cand": $e\n$st');
|
debugPrint('[RemoteRepository] lookup by canonical path failed "$cand": $e\n$st');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) ultimo fallback per remotePath "raw normalizzato" (solo slash)
|
// 3) fallback per remotePath normalizzato
|
||||||
if (existingId == null) {
|
if (existingId == null) {
|
||||||
try {
|
try {
|
||||||
final byNorm = await txn.query(
|
final byNorm = await txn.query(
|
||||||
|
|
@ -312,15 +300,12 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
|
||||||
whereArgs: [norm],
|
whereArgs: [norm],
|
||||||
limit: 1,
|
limit: 1,
|
||||||
);
|
);
|
||||||
if (byNorm.isNotEmpty) {
|
if (byNorm.isNotEmpty) existingId = byNorm.first['id'] as int?;
|
||||||
existingId = byNorm.first['id'] as int?;
|
|
||||||
}
|
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('[RemoteRepository] lookup by normalized path failed "$norm": $e\n$st');
|
debugPrint('[RemoteRepository] lookup by normalized path failed "$norm": $e\n$st');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Riga completa e REPLACE
|
|
||||||
final row = _buildEntryRow(it, existingId: existingId);
|
final row = _buildEntryRow(it, existingId: existingId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -332,6 +317,7 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
|
||||||
} on DatabaseException catch (e, st) {
|
} on DatabaseException catch (e, st) {
|
||||||
debugPrint('[RemoteRepository] batch insert failed for remoteId=${it.id}: $e\n$st');
|
debugPrint('[RemoteRepository] batch insert failed for remoteId=${it.id}: $e\n$st');
|
||||||
|
|
||||||
|
// fallback: rimuovi gps se causa problemi
|
||||||
final rowNoGps = Map<String, Object?>.from(row)
|
final rowNoGps = Map<String, Object?>.from(row)
|
||||||
..remove('latitude')
|
..remove('latitude')
|
||||||
..remove('longitude')
|
..remove('longitude')
|
||||||
|
|
@ -374,7 +360,7 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('[RemoteRepository] upsert chunk ${offset}..${end - 1} ERROR: $e\n$st');
|
debugPrint('[RemoteRepository] upsert chunk $offset..${end - 1} ERROR: $e\n$st');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -384,7 +370,6 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
|
||||||
// Unicità & deduplica
|
// Unicità & deduplica
|
||||||
// =========================
|
// =========================
|
||||||
|
|
||||||
/// Indice UNICO su `remoteId` limitato alle righe remote (origin=1).
|
|
||||||
Future<void> ensureUniqueRemoteId() async {
|
Future<void> ensureUniqueRemoteId() async {
|
||||||
try {
|
try {
|
||||||
await db.execute(
|
await db.execute(
|
||||||
|
|
@ -397,7 +382,6 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Indice UNICO su `remotePath` (solo remoti) per prevenire doppi.
|
|
||||||
Future<void> ensureUniqueRemotePath() async {
|
Future<void> ensureUniqueRemotePath() async {
|
||||||
try {
|
try {
|
||||||
await db.execute(
|
await db.execute(
|
||||||
|
|
@ -410,7 +394,6 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dedup per `remoteId`, tenendo l’ultima riga.
|
|
||||||
Future<int> deduplicateRemotes() async {
|
Future<int> deduplicateRemotes() async {
|
||||||
try {
|
try {
|
||||||
final deleted = await db.rawDelete(
|
final deleted = await db.rawDelete(
|
||||||
|
|
@ -429,7 +412,6 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dedup per `remotePath` (match esatto), tenendo l’ultima riga.
|
|
||||||
Future<int> deduplicateByRemotePath() async {
|
Future<int> deduplicateByRemotePath() async {
|
||||||
try {
|
try {
|
||||||
final deleted = await db.rawDelete(
|
final deleted = await db.rawDelete(
|
||||||
|
|
@ -448,15 +430,64 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// Bootstrap / prune remoti
|
||||||
|
// =========================
|
||||||
|
|
||||||
|
/// Bootstrap: cancella TUTTI i remoti
|
||||||
|
Future<int> deleteAllRemotes() async {
|
||||||
|
try {
|
||||||
|
final deleted = await db.rawDelete('DELETE FROM entry WHERE origin=1');
|
||||||
|
debugPrint('[RemoteRepository] deleteAllRemotes deleted=$deleted');
|
||||||
|
return deleted;
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('[RemoteRepository] deleteAllRemotes error: $e\n$st');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FULL sync: elimina i remoti NON più presenti nel serverRemoteIds
|
||||||
|
/// (hard-delete)
|
||||||
|
Future<int> pruneMissingRemotes(Set<String> serverRemoteIds) async {
|
||||||
|
if (serverRemoteIds.isEmpty) return 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final deleted = await db.transaction((txn) async {
|
||||||
|
await txn.execute('CREATE TEMP TABLE IF NOT EXISTS tmp_remote_ids(remoteId TEXT PRIMARY KEY);');
|
||||||
|
await txn.execute('DELETE FROM tmp_remote_ids;');
|
||||||
|
|
||||||
|
final batch = txn.batch();
|
||||||
|
for (final id in serverRemoteIds) {
|
||||||
|
batch.insert(
|
||||||
|
'tmp_remote_ids',
|
||||||
|
{'remoteId': id},
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.ignore,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await batch.commit(noResult: true);
|
||||||
|
|
||||||
|
final deleted = await txn.rawDelete('''
|
||||||
|
DELETE FROM entry
|
||||||
|
WHERE origin=1
|
||||||
|
AND remoteId IS NOT NULL
|
||||||
|
AND remoteId NOT IN (SELECT remoteId FROM tmp_remote_ids)
|
||||||
|
''');
|
||||||
|
return deleted;
|
||||||
|
});
|
||||||
|
|
||||||
|
debugPrint('[RemoteRepository] pruneMissingRemotes deleted=$deleted');
|
||||||
|
return deleted;
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('[RemoteRepository] pruneMissingRemotes error: $e\n$st');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// =========================
|
// =========================
|
||||||
// Backfill URI fittizi per remoti legacy
|
// Backfill URI fittizi per remoti legacy
|
||||||
// =========================
|
// =========================
|
||||||
|
|
||||||
/// Imposta un URI fittizio `aves-remote://...` per tutte le righe remote
|
|
||||||
/// con `uri` NULL/vuoto. Prima prova a usare `remoteId` (SQL puro),
|
|
||||||
/// poi completa i rimanenti (senza remoteId) in un loop Dart usando `remotePath`.
|
|
||||||
Future<void> backfillRemoteUris() async {
|
Future<void> backfillRemoteUris() async {
|
||||||
// 1) Backfill via SQL per chi ha remoteId (più veloce)
|
|
||||||
try {
|
try {
|
||||||
final updated = await db.rawUpdate(
|
final updated = await db.rawUpdate(
|
||||||
"UPDATE entry "
|
"UPDATE entry "
|
||||||
|
|
@ -468,13 +499,12 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
|
||||||
debugPrint('[RemoteRepository] backfill URIs (SQL) error: $e\n$st');
|
debugPrint('[RemoteRepository] backfill URIs (SQL) error: $e\n$st');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Loop Dart per i (pochi) rimanenti senza remoteId ma con remotePath
|
|
||||||
try {
|
try {
|
||||||
final rows = await db.rawQuery(
|
final rows = await db.rawQuery(
|
||||||
"SELECT id, remotePath FROM entry "
|
"SELECT id, remotePath FROM entry "
|
||||||
"WHERE origin=1 AND (uri IS NULL OR trim(uri)='') "
|
"WHERE origin=1 AND (uri IS NULL OR trim(uri)='') "
|
||||||
"AND (remoteId IS NULL OR trim(remoteId)='') "
|
"AND (remoteId IS NULL OR trim(remoteId)='') "
|
||||||
"AND remotePath IS NOT NULL"
|
"AND remotePath IS NOT NULL",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (rows.isNotEmpty) {
|
if (rows.isNotEmpty) {
|
||||||
|
|
@ -498,15 +528,14 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================
|
// =========================
|
||||||
// Backfill ESSENZIALI (contentId, dateModifiedMillis) per remoti legacy
|
// Backfill essentials per remoti legacy
|
||||||
// =========================
|
// =========================
|
||||||
|
|
||||||
Future<void> backfillRemoteEssentials() async {
|
Future<void> backfillRemoteEssentials() async {
|
||||||
// 1) backfill contentId sintetico per remoti con contentId NULL/<=0
|
|
||||||
try {
|
try {
|
||||||
final rows = await db.rawQuery(
|
final rows = await db.rawQuery(
|
||||||
"SELECT id, remoteId, remotePath FROM entry "
|
"SELECT id, remoteId, remotePath FROM entry "
|
||||||
"WHERE origin=1 AND (contentId IS NULL OR contentId<=0)"
|
"WHERE origin=1 AND (contentId IS NULL OR contentId<=0)",
|
||||||
);
|
);
|
||||||
if (rows.isNotEmpty) {
|
if (rows.isNotEmpty) {
|
||||||
for (final r in rows) {
|
for (final r in rows) {
|
||||||
|
|
@ -514,7 +543,7 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
|
||||||
final rid = (r['remoteId'] as String?) ?? '';
|
final rid = (r['remoteId'] as String?) ?? '';
|
||||||
final rpath = (r['remotePath'] as String?) ?? '';
|
final rpath = (r['remotePath'] as String?) ?? '';
|
||||||
final base = rid.isNotEmpty ? rid : rpath;
|
final base = rid.isNotEmpty ? rid : rpath;
|
||||||
final h = base.hashCode & 0x7fffffff; // positivo
|
final h = base.hashCode & 0x7fffffff;
|
||||||
final cid = 1_000_000_000 + (h % 900_000_000);
|
final cid = 1_000_000_000 + (h % 900_000_000);
|
||||||
await db.update('entry', {'contentId': cid}, where: 'id=?', whereArgs: [id]);
|
await db.update('entry', {'contentId': cid}, where: 'id=?', whereArgs: [id]);
|
||||||
}
|
}
|
||||||
|
|
@ -524,7 +553,6 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
|
||||||
debugPrint('[RemoteRepository] backfill contentId error: $e\n$st');
|
debugPrint('[RemoteRepository] backfill contentId error: $e\n$st');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) backfill dateModifiedMillis (usa sourceDateTakenMillis se presente, altrimenti now)
|
|
||||||
try {
|
try {
|
||||||
final nowMs = DateTime.now().millisecondsSinceEpoch;
|
final nowMs = DateTime.now().millisecondsSinceEpoch;
|
||||||
final updated = await db.rawUpdate(
|
final updated = await db.rawUpdate(
|
||||||
|
|
@ -538,27 +566,15 @@ Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================
|
|
||||||
// Helper combinato: pulizia + indici + backfill URI/ESSENZIALI
|
|
||||||
// =========================
|
|
||||||
|
|
||||||
Future<void> sanitizeRemotes() async {
|
Future<void> sanitizeRemotes() async {
|
||||||
await deduplicateRemotes();
|
await deduplicateRemotes();
|
||||||
await deduplicateByRemotePath(); // opzionale ma utile
|
await deduplicateByRemotePath();
|
||||||
await ensureUniqueRemoteId();
|
await ensureUniqueRemoteId();
|
||||||
await ensureUniqueRemotePath();
|
await ensureUniqueRemotePath();
|
||||||
|
|
||||||
// Assicura che ogni remoto abbia un uri fittizio valorizzato
|
|
||||||
await backfillRemoteUris();
|
await backfillRemoteUris();
|
||||||
|
|
||||||
// Assicura che i remoti abbiano contentId/dateModifiedMillis validi
|
|
||||||
await backfillRemoteEssentials();
|
await backfillRemoteEssentials();
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================
|
|
||||||
// Utils
|
|
||||||
// =========================
|
|
||||||
|
|
||||||
Future<int> countRemote() async {
|
Future<int> countRemote() async {
|
||||||
final rows = await db.rawQuery('SELECT COUNT(1) AS c FROM entry WHERE origin=1');
|
final rows = await db.rawQuery('SELECT COUNT(1) AS c FROM entry WHERE origin=1');
|
||||||
return (rows.first['c'] as int?) ?? 0;
|
return (rows.first['c'] as int?) ?? 0;
|
||||||
|
|
|
||||||
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(
|
static const _storage = FlutterSecureStorage(
|
||||||
aOptions: AndroidOptions(
|
aOptions: AndroidOptions(
|
||||||
encryptedSharedPreferences: true,
|
encryptedSharedPreferences: true,
|
||||||
resetOnError: true, // auto-reset della singola voce cifrata se fallisce la decrittazione
|
resetOnError: true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -17,7 +17,10 @@ class RemoteSettings {
|
||||||
static const _kEmail = 'remote_email';
|
static const _kEmail = 'remote_email';
|
||||||
static const _kPassword = 'remote_password';
|
static const _kPassword = 'remote_password';
|
||||||
|
|
||||||
static final bool defaultEnabled = kDebugMode ? true : false;
|
// ✅ remote OFF by default ALWAYS
|
||||||
|
static const bool defaultEnabled = false;
|
||||||
|
|
||||||
|
// ✅ in debug puoi precompilare credenziali/URL, ma NON attivare automaticamente
|
||||||
static final String defaultBaseUrl = kDebugMode ? 'https://prova.patachina.it' : '';
|
static final String defaultBaseUrl = kDebugMode ? 'https://prova.patachina.it' : '';
|
||||||
static final String defaultIndexPath = kDebugMode ? 'photos/' : '';
|
static final String defaultIndexPath = kDebugMode ? 'photos/' : '';
|
||||||
static final String defaultEmail = kDebugMode ? 'fabio@gmail.com' : '';
|
static final String defaultEmail = kDebugMode ? 'fabio@gmail.com' : '';
|
||||||
|
|
@ -37,36 +40,30 @@ class RemoteSettings {
|
||||||
required this.password,
|
required this.password,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🔎 helper: leggi una chiave in modo “safe” e, se fallisce, cancella solo quella
|
|
||||||
static Future<String?> _readKeySafe(String key) async {
|
static Future<String?> _readKeySafe(String key) async {
|
||||||
try {
|
try {
|
||||||
return await _storage.read(key: key);
|
return await _storage.read(key: key);
|
||||||
} on PlatformException {
|
} on PlatformException {
|
||||||
// solo questa chiave è corrotta → la pulisco
|
|
||||||
await _storage.delete(key: key);
|
await _storage.delete(key: key);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🧼 helper: rimuove caratteri invisibili/di controllo tipici che “sporcano” gli URL
|
|
||||||
static String _sanitizeUrl(String s) {
|
static String _sanitizeUrl(String s) {
|
||||||
// rimuove BOM, LRM/RLM e altri ‘format characters’ comuni negli incolla
|
|
||||||
const _invisibles = r'[\u200B-\u200F\u202A-\u202E\u2060-\u2064\uFEFF]';
|
const _invisibles = r'[\u200B-\u200F\u202A-\u202E\u2060-\u2064\uFEFF]';
|
||||||
final cleaned = s.replaceAll(RegExp(_invisibles), '');
|
final cleaned = s.replaceAll(RegExp(_invisibles), '');
|
||||||
return cleaned.trim();
|
return cleaned.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<RemoteSettings> load() async {
|
static Future<RemoteSettings> load() async {
|
||||||
// legge *per singola chiave* con fallback ai default
|
|
||||||
final enabledStr = await _readKeySafe(_kEnabled);
|
final enabledStr = await _readKeySafe(_kEnabled);
|
||||||
final rawBase = await _readKeySafe(_kBaseUrl);
|
final rawBase = await _readKeySafe(_kBaseUrl);
|
||||||
final indexPath = await _readKeySafe(_kIndexPath) ?? defaultIndexPath;
|
final indexPath = await _readKeySafe(_kIndexPath) ?? defaultIndexPath;
|
||||||
final email = await _readKeySafe(_kEmail) ?? defaultEmail;
|
final email = await _readKeySafe(_kEmail) ?? defaultEmail;
|
||||||
final password = await _readKeySafe(_kPassword) ?? defaultPassword;
|
final password = await _readKeySafe(_kPassword) ?? defaultPassword;
|
||||||
|
|
||||||
|
// ✅ defaultEnabled è false sempre
|
||||||
final enabled = (enabledStr ?? (defaultEnabled ? 'true' : 'false')) == 'true';
|
final enabled = (enabledStr ?? (defaultEnabled ? 'true' : 'false')) == 'true';
|
||||||
|
|
||||||
// sanitize della base URL (toglie caratteri non alfabetici “invisibili”)
|
|
||||||
final baseUrl = _sanitizeUrl(rawBase ?? defaultBaseUrl);
|
final baseUrl = _sanitizeUrl(rawBase ?? defaultBaseUrl);
|
||||||
|
|
||||||
return RemoteSettings(
|
return RemoteSettings(
|
||||||
|
|
@ -79,7 +76,6 @@ class RemoteSettings {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> save() async {
|
Future<void> save() async {
|
||||||
// Sanitize prima di salvare, così evitiamo che restino in storage caratteri strani
|
|
||||||
await _storage.write(key: _kEnabled, value: enabled ? 'true' : 'false');
|
await _storage.write(key: _kEnabled, value: enabled ? 'true' : 'false');
|
||||||
await _storage.write(key: _kBaseUrl, value: _sanitizeUrl(baseUrl));
|
await _storage.write(key: _kBaseUrl, value: _sanitizeUrl(baseUrl));
|
||||||
await _storage.write(key: _kIndexPath, value: indexPath.trim());
|
await _storage.write(key: _kIndexPath, value: indexPath.trim());
|
||||||
|
|
@ -97,13 +93,15 @@ class RemoteSettings {
|
||||||
await _storage.write(key: key, value: value);
|
await _storage.write(key: key, value: value);
|
||||||
}
|
}
|
||||||
} on PlatformException {
|
} on PlatformException {
|
||||||
// chiave “sporca” → reset di quella sola chiave e poi scrittura
|
|
||||||
await _storage.delete(key: key);
|
await _storage.delete(key: key);
|
||||||
await _storage.write(key: key, value: value);
|
await _storage.write(key: key, value: value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await _seed(_kEnabled, defaultEnabled ? 'true' : 'false');
|
// ✅ anche in debug: remote parte SPENTO
|
||||||
|
await _seed(_kEnabled, 'false');
|
||||||
|
|
||||||
|
// ✅ ma precompila gli altri campi per comodità
|
||||||
await _seed(_kBaseUrl, defaultBaseUrl);
|
await _seed(_kBaseUrl, defaultBaseUrl);
|
||||||
await _seed(_kIndexPath, defaultIndexPath);
|
await _seed(_kIndexPath, defaultIndexPath);
|
||||||
await _seed(_kEmail, defaultEmail);
|
await _seed(_kEmail, defaultEmail);
|
||||||
|
|
|
||||||
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:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
|
import 'package:aves/remote/collection_source_remote_ext.dart'; // per appendRemoteEntriesFromDb()
|
||||||
|
import 'package:aves/remote/remote_controller.dart';
|
||||||
|
import 'package:aves/remote/remote_sync_bus.dart';
|
||||||
|
|
||||||
import 'remote_settings.dart';
|
import 'remote_settings.dart';
|
||||||
import 'remote_http.dart';
|
import 'remote_http.dart';
|
||||||
|
|
||||||
|
|
@ -49,7 +56,6 @@ class _RemoteSettingsPageState extends State<RemoteSettingsPage> {
|
||||||
_loaded = true;
|
_loaded = true;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Fail-open: apri comunque con default/blank e notifica
|
|
||||||
_showSnack('Impossibile leggere le impostazioni sicure: $e');
|
_showSnack('Impossibile leggere le impostazioni sicure: $e');
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -70,7 +76,6 @@ class _RemoteSettingsPageState extends State<RemoteSettingsPage> {
|
||||||
if (uri == null || !(uri.hasScheme && (uri.scheme == 'http' || uri.scheme == 'https'))) {
|
if (uri == null || !(uri.hasScheme && (uri.scheme == 'http' || uri.scheme == 'https'))) {
|
||||||
return 'URL non valida (deve iniziare con http/https)';
|
return 'URL non valida (deve iniziare con http/https)';
|
||||||
}
|
}
|
||||||
// opzionale: blocca spazi/controlli interni
|
|
||||||
if (RegExp(r'[\u200B-\u200F\u202A-\u202E\u2060-\u2064\uFEFF]').hasMatch(s)) {
|
if (RegExp(r'[\u200B-\u200F\u202A-\u202E\u2060-\u2064\uFEFF]').hasMatch(s)) {
|
||||||
return 'URL contiene caratteri non validi (invisibili)';
|
return 'URL contiene caratteri non validi (invisibili)';
|
||||||
}
|
}
|
||||||
|
|
@ -83,6 +88,28 @@ class _RemoteSettingsPageState extends State<RemoteSettingsPage> {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _applyRuntimeEffects() async {
|
||||||
|
// Applica subito l'effetto ON/OFF in UI
|
||||||
|
try {
|
||||||
|
final source = context.read<CollectionSource>();
|
||||||
|
|
||||||
|
if (!_enabled) {
|
||||||
|
// OFF: nascondi remoti dalla UI e imposta icona grigia
|
||||||
|
final remotesInMemory = source.allEntries.where((e) => e.origin == 1).toSet();
|
||||||
|
if (remotesInMemory.isNotEmpty) {
|
||||||
|
source.removeEntriesFromMemory(remotesInMemory);
|
||||||
|
}
|
||||||
|
RemoteSyncBus.instance.setDisabled();
|
||||||
|
} else {
|
||||||
|
// ON: mostra subito remoti da DB (senza full sync qui)
|
||||||
|
await source.appendRemoteEntriesFromDb();
|
||||||
|
await RemoteController.instance.initBusFromSettings();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// se la pagina non è nel contesto con Provider(CollectionSource), non facciamo crash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
if (!(_form.currentState?.validate() ?? false)) return;
|
if (!(_form.currentState?.validate() ?? false)) return;
|
||||||
|
|
||||||
|
|
@ -98,9 +125,12 @@ class _RemoteSettingsPageState extends State<RemoteSettingsPage> {
|
||||||
|
|
||||||
await s.save();
|
await s.save();
|
||||||
|
|
||||||
// ✅ forza Aves a usare SUBITO base URL & token aggiornati
|
// aggiorna headers/token
|
||||||
await RemoteHttp.refreshFromSettings();
|
await RemoteHttp.refreshFromSettings();
|
||||||
await RemoteHttp.warmUp(); // non bloccante: utile per loggare stato token/base
|
await RemoteHttp.warmUp();
|
||||||
|
|
||||||
|
// ✅ applica subito ON/OFF live
|
||||||
|
await _applyRuntimeEffects();
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_showSnack('Impostazioni remote salvate');
|
_showSnack('Impostazioni remote salvate');
|
||||||
|
|
@ -116,7 +146,7 @@ class _RemoteSettingsPageState extends State<RemoteSettingsPage> {
|
||||||
void _showSnack(String msg) {
|
void _showSnack(String msg) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
behavior: SnackBarBehavior.fixed, // evita "floating off screen"
|
behavior: SnackBarBehavior.fixed,
|
||||||
content: Text(msg),
|
content: Text(msg),
|
||||||
duration: const Duration(seconds: 3),
|
duration: const Duration(seconds: 3),
|
||||||
),
|
),
|
||||||
|
|
@ -208,4 +238,3 @@ class _RemoteSettingsPageState extends State<RemoteSettingsPage> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -46,6 +46,9 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
// ✅ Remote icon/button (tap toggle + long-press settings)
|
||||||
|
import 'package:aves/widgets/collection/remote_status_button.dart';
|
||||||
|
|
||||||
class CollectionAppBar extends StatefulWidget {
|
class CollectionAppBar extends StatefulWidget {
|
||||||
final ValueNotifier<double> appBarHeightNotifier;
|
final ValueNotifier<double> appBarHeightNotifier;
|
||||||
final ScrollController scrollController;
|
final ScrollController scrollController;
|
||||||
|
|
@ -62,7 +65,8 @@ class CollectionAppBar extends StatefulWidget {
|
||||||
State<CollectionAppBar> createState() => _CollectionAppBarState();
|
State<CollectionAppBar> createState() => _CollectionAppBarState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, SingleTickerProviderStateMixin, WidgetsBindingObserver {
|
class _CollectionAppBarState extends State<CollectionAppBar>
|
||||||
|
with RouteAware, SingleTickerProviderStateMixin, WidgetsBindingObserver {
|
||||||
final Set<StreamSubscription> _subscriptions = {};
|
final Set<StreamSubscription> _subscriptions = {};
|
||||||
final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate();
|
final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate();
|
||||||
late AnimationController _browseToSelectAnimation;
|
late AnimationController _browseToSelectAnimation;
|
||||||
|
|
@ -77,7 +81,8 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
|
||||||
|
|
||||||
CollectionSource get source => collection.source;
|
CollectionSource get source => collection.source;
|
||||||
|
|
||||||
Set<CollectionFilter> get visibleFilters => collection.filters.where((v) => !(v is QueryFilter && v.live) && v is! TrashFilter).toSet();
|
Set<CollectionFilter> get visibleFilters =>
|
||||||
|
collection.filters.where((v) => !(v is QueryFilter && v.live) && v is! TrashFilter).toSet();
|
||||||
|
|
||||||
bool get showFilterBar => visibleFilters.isNotEmpty;
|
bool get showFilterBar => visibleFilters.isNotEmpty;
|
||||||
|
|
||||||
|
|
@ -171,14 +176,11 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didPushNext() {
|
void didPushNext() {
|
||||||
// unfocus when navigating away, so that when navigating back,
|
|
||||||
// the query bar does not get back focus and bring the keyboard
|
|
||||||
_queryBarFocusNode.unfocus();
|
_queryBarFocusNode.unfocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeMetrics() {
|
void didChangeMetrics() {
|
||||||
// when top padding or text scale factor change
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _updateStatusBarHeight());
|
WidgetsBinding.instance.addPostFrameCallback((_) => _updateStatusBarHeight());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -188,9 +190,8 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
|
||||||
final selection = context.watch<Selection<AvesEntry>>();
|
final selection = context.watch<Selection<AvesEntry>>();
|
||||||
final isSelecting = selection.isSelecting;
|
final isSelecting = selection.isSelecting;
|
||||||
_isSelectingNotifier.value = isSelecting;
|
_isSelectingNotifier.value = isSelecting;
|
||||||
|
|
||||||
return NotificationListener<ScrollNotification>(
|
return NotificationListener<ScrollNotification>(
|
||||||
// cancel notification bubbling so that the draggable scroll bar
|
|
||||||
// does not misinterpret filter bar scrolling for collection scrolling
|
|
||||||
onNotification: (notification) => true,
|
onNotification: (notification) => true,
|
||||||
child: AnimatedBuilder(
|
child: AnimatedBuilder(
|
||||||
animation: collection.filterChangeNotifier,
|
animation: collection.filterChangeNotifier,
|
||||||
|
|
@ -204,6 +205,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
|
||||||
builder: (context, _, child) {
|
builder: (context, _, child) {
|
||||||
final useTvLayout = settings.useTvLayout;
|
final useTvLayout = settings.useTvLayout;
|
||||||
final onFilterTap = canRemoveFilters ? collection.removeFilter : null;
|
final onFilterTap = canRemoveFilters ? collection.removeFilter : null;
|
||||||
|
|
||||||
return AvesAppBar(
|
return AvesAppBar(
|
||||||
contentHeight: appBarContentHeight,
|
contentHeight: appBarContentHeight,
|
||||||
pinned: context.select<Selection<AvesEntry>, bool>((selection) => selection.isSelecting),
|
pinned: context.select<Selection<AvesEntry>, bool>((selection) => selection.isSelecting),
|
||||||
|
|
@ -212,7 +214,15 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
|
||||||
isSelecting: isSelecting,
|
isSelecting: isSelecting,
|
||||||
),
|
),
|
||||||
title: _buildAppBarTitle(isSelecting),
|
title: _buildAppBarTitle(isSelecting),
|
||||||
actions: (context, maxWidth) => useTvLayout ? [] : _buildActions(context, selection, maxWidth),
|
|
||||||
|
// ✅ actions: parabola + azioni originali (mobile); solo parabola (tv)
|
||||||
|
actions: (context, maxWidth) => _buildAppBarActions(
|
||||||
|
context: context,
|
||||||
|
selection: selection,
|
||||||
|
maxWidth: maxWidth,
|
||||||
|
useTvLayout: useTvLayout,
|
||||||
|
),
|
||||||
|
|
||||||
bottom: Column(
|
bottom: Column(
|
||||||
children: [
|
children: [
|
||||||
if (useTvLayout)
|
if (useTvLayout)
|
||||||
|
|
@ -266,6 +276,29 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ✅ NEW: actions con RemoteStatusButton sempre visibile
|
||||||
|
List<Widget> _buildAppBarActions({
|
||||||
|
required BuildContext context,
|
||||||
|
required Selection<AvesEntry> selection,
|
||||||
|
required double maxWidth,
|
||||||
|
required bool useTvLayout,
|
||||||
|
}) {
|
||||||
|
final statusButton = Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||||
|
child: RemoteStatusButton(source: source),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (useTvLayout) {
|
||||||
|
// in TV layout le azioni sono nella barra sotto, qui solo lo stato remoto
|
||||||
|
return [statusButton];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
statusButton,
|
||||||
|
..._buildActions(context, selection, maxWidth),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
double get appBarContentHeight {
|
double get appBarContentHeight {
|
||||||
final textScaler = MediaQuery.textScalerOf(context);
|
final textScaler = MediaQuery.textScalerOf(context);
|
||||||
double height = textScaler.scale(kToolbarHeight);
|
double height = textScaler.scale(kToolbarHeight);
|
||||||
|
|
@ -298,7 +331,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
|
||||||
tooltip = MaterialLocalizations.of(context).openAppDrawerTooltip;
|
tooltip = MaterialLocalizations.of(context).openAppDrawerTooltip;
|
||||||
}
|
}
|
||||||
return IconButton(
|
return IconButton(
|
||||||
// key is expected by test driver
|
|
||||||
key: const Key('appbar-leading-button'),
|
key: const Key('appbar-leading-button'),
|
||||||
icon: AnimatedIcon(
|
icon: AnimatedIcon(
|
||||||
icon: AnimatedIcons.menu_arrow,
|
icon: AnimatedIcons.menu_arrow,
|
||||||
|
|
@ -313,7 +345,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
|
|
||||||
if (isSelecting) {
|
if (isSelecting) {
|
||||||
// `Selection` may not be available during hero
|
|
||||||
return Selector<Selection<AvesEntry>?, int>(
|
return Selector<Selection<AvesEntry>?, int>(
|
||||||
selector: (context, selection) => selection?.selectedItems.length ?? 0,
|
selector: (context, selection) => selection?.selectedItems.length ?? 0,
|
||||||
builder: (context, count, child) => Text(
|
builder: (context, count, child) => Text(
|
||||||
|
|
@ -435,26 +466,21 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
|
||||||
final quickActions = (isSelecting ? selectionQuickActions : browsingQuickActions).take(max(0, availableCount - 1)).toList();
|
final quickActions = (isSelecting ? selectionQuickActions : browsingQuickActions).take(max(0, availableCount - 1)).toList();
|
||||||
final quickActionButtons = quickActions
|
final quickActionButtons = quickActions
|
||||||
.where(isVisible)
|
.where(isVisible)
|
||||||
.map(
|
.map((action) => _buildButtonIcon(context, action, enabled: canApply(action), selection: selection));
|
||||||
(action) => _buildButtonIcon(context, action, enabled: canApply(action), selection: selection),
|
|
||||||
);
|
|
||||||
|
|
||||||
final animations = context.select<Settings, AccessibilityAnimations>((v) => v.accessibilityAnimations);
|
final animations = context.select<Settings, AccessibilityAnimations>((v) => v.accessibilityAnimations);
|
||||||
return [
|
return [
|
||||||
...quickActionButtons,
|
...quickActionButtons,
|
||||||
PopupMenuButton<EntrySetAction>(
|
PopupMenuButton<EntrySetAction>(
|
||||||
// key is expected by test driver
|
|
||||||
key: const Key('appbar-menu-button'),
|
key: const Key('appbar-menu-button'),
|
||||||
itemBuilder: (context) {
|
itemBuilder: (context) {
|
||||||
bool _isValidForMenu(EntrySetAction? v) => v == null || (!quickActions.contains(v) && isVisible(v));
|
bool _isValidForMenu(EntrySetAction? v) => v == null || (!quickActions.contains(v) && isVisible(v));
|
||||||
final generalMenuItems = EntrySetActions.general
|
final generalMenuItems = EntrySetActions.general
|
||||||
.where(_isValidForMenu)
|
.where(_isValidForMenu)
|
||||||
.map(
|
.map((action) => _toMenuItem(action, enabled: canApply(action), selection: selection));
|
||||||
(action) => _toMenuItem(action, enabled: canApply(action), selection: selection),
|
|
||||||
);
|
|
||||||
|
|
||||||
final allContextualActions = isSelecting ? EntrySetActions.pageSelection : EntrySetActions.pageBrowsing;
|
final allContextualActions = isSelecting ? EntrySetActions.pageSelection : EntrySetActions.pageBrowsing;
|
||||||
final contextualMenuActions = allContextualActions.where(_isValidForMenu).fold(<EntrySetAction?>[], (prev, v) {
|
final contextualMenuActions = allContextualActions.where(_isValidForMenu).fold<List<EntrySetAction?>>([], (prev, v) {
|
||||||
if (v == null && (prev.isEmpty || prev.last == null)) return prev;
|
if (v == null && (prev.isEmpty || prev.last == null)) return prev;
|
||||||
return [...prev, v];
|
return [...prev, v];
|
||||||
});
|
});
|
||||||
|
|
@ -463,12 +489,10 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
|
||||||
}
|
}
|
||||||
|
|
||||||
final contextualMenuItems = <PopupMenuEntry<EntrySetAction>>[
|
final contextualMenuItems = <PopupMenuEntry<EntrySetAction>>[
|
||||||
...contextualMenuActions.map(
|
...contextualMenuActions.map((action) {
|
||||||
(action) {
|
|
||||||
if (action == null) return const PopupMenuDivider();
|
if (action == null) return const PopupMenuDivider();
|
||||||
return _toMenuItem(action, enabled: canApply(action), selection: selection);
|
return _toMenuItem(action, enabled: canApply(action), selection: selection);
|
||||||
},
|
}),
|
||||||
),
|
|
||||||
if (isSelecting && !settings.isReadOnly && appMode == AppMode.main && !isTrash)
|
if (isSelecting && !settings.isReadOnly && appMode == AppMode.main && !isTrash)
|
||||||
PopupMenuExpansionPanel<EntrySetAction>(
|
PopupMenuExpansionPanel<EntrySetAction>(
|
||||||
enabled: hasSelection,
|
enabled: hasSelection,
|
||||||
|
|
@ -477,7 +501,9 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
|
||||||
title: context.l10n.collectionActionEdit,
|
title: context.l10n.collectionActionEdit,
|
||||||
items: [
|
items: [
|
||||||
_buildRotateAndFlipMenuItems(context, canApply: canApply),
|
_buildRotateAndFlipMenuItems(context, canApply: canApply),
|
||||||
...EntrySetActions.edit.where((v) => isVisible(v) && !quickActions.contains(v)).map((action) => _toMenuItem(action, enabled: canApply(action), selection: selection)),
|
...EntrySetActions.edit
|
||||||
|
.where((v) => isVisible(v) && !quickActions.contains(v))
|
||||||
|
.map((action) => _toMenuItem(action, enabled: canApply(action), selection: selection)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
@ -491,7 +517,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
onSelected: (action) async {
|
onSelected: (action) async {
|
||||||
// wait for the popup menu to hide before proceeding with the action
|
|
||||||
await Future.delayed(animations.popUpAnimationDelay * timeDilation);
|
await Future.delayed(animations.popUpAnimationDelay * timeDilation);
|
||||||
await _onActionSelected(action);
|
await _onActionSelected(action);
|
||||||
},
|
},
|
||||||
|
|
@ -504,7 +529,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
|
||||||
return selection.selectedItems.expand((entry) => entry.stackedEntries ?? {entry}).toSet();
|
return selection.selectedItems.expand((entry) => entry.stackedEntries ?? {entry}).toSet();
|
||||||
}
|
}
|
||||||
|
|
||||||
// key is expected by test driver
|
|
||||||
Key _getActionKey(EntrySetAction action) => Key('menu-${action.name}');
|
Key _getActionKey(EntrySetAction action) => Key('menu-${action.name}');
|
||||||
|
|
||||||
Widget _buildButtonIcon(
|
Widget _buildButtonIcon(
|
||||||
|
|
@ -518,7 +542,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
|
||||||
final onPressed = enabled ? () => _onActionSelected(action) : null;
|
final onPressed = enabled ? () => _onActionSelected(action) : null;
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case EntrySetAction.toggleTitleSearch:
|
case EntrySetAction.toggleTitleSearch:
|
||||||
// `Query` may not be available during hero
|
|
||||||
return Selector<Query?, bool>(
|
return Selector<Query?, bool>(
|
||||||
selector: (context, query) => query?.enabled ?? false,
|
selector: (context, query) => query?.enabled ?? false,
|
||||||
builder: (context, queryEnabled, child) {
|
builder: (context, queryEnabled, child) {
|
||||||
|
|
@ -581,14 +604,9 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
|
||||||
}) {
|
}) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case EntrySetAction.toggleTitleSearch:
|
case EntrySetAction.toggleTitleSearch:
|
||||||
return TitleSearchTogglerCaption(
|
return TitleSearchTogglerCaption(enabled: enabled);
|
||||||
enabled: enabled,
|
|
||||||
);
|
|
||||||
default:
|
default:
|
||||||
return CaptionedButtonText(
|
return CaptionedButtonText(text: action.getText(context), enabled: enabled);
|
||||||
text: action.getText(context),
|
|
||||||
enabled: enabled,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -622,26 +640,18 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
|
||||||
}) {
|
}) {
|
||||||
Widget buildDivider() => const SizedBox(
|
Widget buildDivider() => const SizedBox(
|
||||||
height: 16,
|
height: 16,
|
||||||
child: VerticalDivider(
|
child: VerticalDivider(width: 1, thickness: 1),
|
||||||
width: 1,
|
|
||||||
thickness: 1,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget buildItem(EntrySetAction action) => Expanded(
|
Widget buildItem(EntrySetAction action) => Expanded(
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(8))),
|
||||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
|
||||||
),
|
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: PopupMenuItem(
|
child: PopupMenuItem(
|
||||||
value: action,
|
value: action,
|
||||||
enabled: canApply(action),
|
enabled: canApply(action),
|
||||||
child: Tooltip(
|
child: Tooltip(message: action.getText(context), child: Center(child: action.getIcon())),
|
||||||
message: action.getText(context),
|
|
||||||
child: Center(child: action.getIcon()),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -686,10 +696,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
|
||||||
|
|
||||||
void _onQueryBarFocusChanged() {
|
void _onQueryBarFocusChanged() {
|
||||||
if (_queryBarFocusNode.hasFocus) {
|
if (_queryBarFocusNode.hasFocus) {
|
||||||
// the query bar is in the top sliver of the page scrollable,
|
|
||||||
// so when the bar text field gets focus and requests to be on screen,
|
|
||||||
// it will scroll to show it by default, but it may not end at the very top,
|
|
||||||
// so we do it manually for a more predicable end position
|
|
||||||
_scrollToTop();
|
_scrollToTop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -697,9 +703,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
|
||||||
void _scrollToTop() => widget.scrollController.jumpTo(0);
|
void _scrollToTop() => widget.scrollController.jumpTo(0);
|
||||||
|
|
||||||
void _updateStatusBarHeight() {
|
void _updateStatusBarHeight() {
|
||||||
if (!mounted) {
|
if (!mounted) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
_statusBarHeight = MediaQuery.paddingOf(context).top;
|
_statusBarHeight = MediaQuery.paddingOf(context).top;
|
||||||
_updateAppBarHeight();
|
_updateAppBarHeight();
|
||||||
}
|
}
|
||||||
|
|
@ -710,7 +714,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
|
||||||
|
|
||||||
Future<void> _onActionSelected(EntrySetAction action) async {
|
Future<void> _onActionSelected(EntrySetAction action) async {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
// general
|
|
||||||
case EntrySetAction.configureView:
|
case EntrySetAction.configureView:
|
||||||
await _configureView();
|
await _configureView();
|
||||||
case EntrySetAction.select:
|
case EntrySetAction.select:
|
||||||
|
|
@ -719,36 +722,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
|
||||||
context.read<Selection<AvesEntry>>().addToSelection(collection.sortedEntries);
|
context.read<Selection<AvesEntry>>().addToSelection(collection.sortedEntries);
|
||||||
case EntrySetAction.selectNone:
|
case EntrySetAction.selectNone:
|
||||||
context.read<Selection<AvesEntry>>().clearSelection();
|
context.read<Selection<AvesEntry>>().clearSelection();
|
||||||
// browsing
|
default:
|
||||||
case EntrySetAction.searchCollection:
|
|
||||||
case EntrySetAction.toggleTitleSearch:
|
|
||||||
case EntrySetAction.addDynamicAlbum:
|
|
||||||
case EntrySetAction.addShortcut:
|
|
||||||
case EntrySetAction.setHome:
|
|
||||||
// browsing or selecting
|
|
||||||
case EntrySetAction.map:
|
|
||||||
case EntrySetAction.slideshow:
|
|
||||||
case EntrySetAction.stats:
|
|
||||||
case EntrySetAction.rescan:
|
|
||||||
case EntrySetAction.emptyBin:
|
|
||||||
// selecting
|
|
||||||
case EntrySetAction.share:
|
|
||||||
case EntrySetAction.delete:
|
|
||||||
case EntrySetAction.restore:
|
|
||||||
case EntrySetAction.copy:
|
|
||||||
case EntrySetAction.move:
|
|
||||||
case EntrySetAction.rename:
|
|
||||||
case EntrySetAction.convert:
|
|
||||||
case EntrySetAction.toggleFavourite:
|
|
||||||
case EntrySetAction.rotateCCW:
|
|
||||||
case EntrySetAction.rotateCW:
|
|
||||||
case EntrySetAction.flip:
|
|
||||||
case EntrySetAction.editDate:
|
|
||||||
case EntrySetAction.editLocation:
|
|
||||||
case EntrySetAction.editTitleDescription:
|
|
||||||
case EntrySetAction.editRating:
|
|
||||||
case EntrySetAction.editTags:
|
|
||||||
case EntrySetAction.removeMetadata:
|
|
||||||
_actionDelegate.onActionSelected(context, action);
|
_actionDelegate.onActionSelected(context, action);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -776,7 +750,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with RouteAware, Si
|
||||||
},
|
},
|
||||||
routeSettings: const RouteSettings(name: TileViewDialog.routeName),
|
routeSettings: const RouteSettings(name: TileViewDialog.routeName),
|
||||||
);
|
);
|
||||||
// wait for the dialog to hide
|
|
||||||
await Future.delayed(ADurations.dialogTransitionLoose * timeDilation);
|
await Future.delayed(ADurations.dialogTransitionLoose * timeDilation);
|
||||||
if (value != null && initialValue != value) {
|
if (value != null && initialValue != value) {
|
||||||
settings.collectionSortFactor = value.$1!;
|
settings.collectionSortFactor = value.$1!;
|
||||||
|
|
|
||||||
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:permission_handler/permission_handler.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
// ✅ NEW: accesso al DB per capire se esiste cache (evita "scanner" alle riaperture)
|
||||||
|
import 'package:aves/services/common/services.dart';
|
||||||
|
|
||||||
// REMOTE: import per le thumb di rete
|
// REMOTE: import per le thumb di rete
|
||||||
import 'package:aves/remote/remote_image_tile.dart';
|
import 'package:aves/remote/remote_image_tile.dart';
|
||||||
|
|
||||||
|
|
@ -91,7 +94,11 @@ class _CollectionGridState extends State<CollectionGrid> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final spacing = context.select<Settings, double>((v) => v.getTileLayout(settingsRouteKey) == TileLayout.mosaic ? CollectionGrid.mosaicLayoutSpacing : CollectionGrid.fixedExtentLayoutSpacing);
|
final spacing = context.select<Settings, double>((v) =>
|
||||||
|
v.getTileLayout(settingsRouteKey) == TileLayout.mosaic
|
||||||
|
? CollectionGrid.mosaicLayoutSpacing
|
||||||
|
: CollectionGrid.fixedExtentLayoutSpacing);
|
||||||
|
|
||||||
if (_tileExtentController?.spacing != spacing) {
|
if (_tileExtentController?.spacing != spacing) {
|
||||||
_tileExtentController = TileExtentController(
|
_tileExtentController = TileExtentController(
|
||||||
settingsRouteKey: settingsRouteKey,
|
settingsRouteKey: settingsRouteKey,
|
||||||
|
|
@ -102,6 +109,7 @@ class _CollectionGridState extends State<CollectionGrid> {
|
||||||
horizontalPadding: 2,
|
horizontalPadding: 2,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return TileExtentControllerProvider(
|
return TileExtentControllerProvider(
|
||||||
controller: _tileExtentController!,
|
controller: _tileExtentController!,
|
||||||
child: const _CollectionGridContent(),
|
child: const _CollectionGridContent(),
|
||||||
|
|
@ -119,12 +127,14 @@ class _CollectionGridContent extends StatefulWidget {
|
||||||
class _CollectionGridContentState extends State<_CollectionGridContent> {
|
class _CollectionGridContentState extends State<_CollectionGridContent> {
|
||||||
final ValueNotifier<AvesEntry?> _focusedItemNotifier = ValueNotifier(null);
|
final ValueNotifier<AvesEntry?> _focusedItemNotifier = ValueNotifier(null);
|
||||||
final ValueNotifier<bool> _isScrollingNotifier = ValueNotifier(false);
|
final ValueNotifier<bool> _isScrollingNotifier = ValueNotifier(false);
|
||||||
final ValueNotifier<AppMode> _selectingAppModeNotifier = ValueNotifier(AppMode.pickFilteredMediaInternal);
|
final ValueNotifier<AppMode> _selectingAppModeNotifier =
|
||||||
|
ValueNotifier(AppMode.pickFilteredMediaInternal);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => context.read<ViewerEntryNotifier>().value = null);
|
WidgetsBinding.instance
|
||||||
|
.addPostFrameCallback((_) => context.read<ViewerEntryNotifier>().value = null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -137,20 +147,26 @@ class _CollectionGridContentState extends State<_CollectionGridContent> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final selectable = context.select<ValueNotifier<AppMode>, bool>((v) => v.value.canSelectMedia);
|
final selectable =
|
||||||
|
context.select<ValueNotifier<AppMode>, bool>((v) => v.value.canSelectMedia);
|
||||||
final settingsRouteKey = context.read<TileExtentController>().settingsRouteKey;
|
final settingsRouteKey = context.read<TileExtentController>().settingsRouteKey;
|
||||||
final tileLayout = context.select<Settings, TileLayout>((v) => v.getTileLayout(settingsRouteKey));
|
final tileLayout =
|
||||||
|
context.select<Settings, TileLayout>((v) => v.getTileLayout(settingsRouteKey));
|
||||||
|
|
||||||
return Consumer<CollectionLens>(
|
return Consumer<CollectionLens>(
|
||||||
builder: (context, collection, child) {
|
builder: (context, collection, child) {
|
||||||
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
|
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
|
||||||
valueListenable: context.select<TileExtentController, ValueNotifier<double>>((controller) => controller.extentNotifier),
|
valueListenable: context.select<TileExtentController, ValueNotifier<double>>(
|
||||||
|
(controller) => controller.extentNotifier),
|
||||||
builder: (context, thumbnailExtent, child) {
|
builder: (context, thumbnailExtent, child) {
|
||||||
assert(thumbnailExtent > 0);
|
assert(thumbnailExtent > 0);
|
||||||
return Selector<TileExtentController, (double, int, double, double)>(
|
return Selector<TileExtentController, (double, int, double, double)>(
|
||||||
selector: (context, c) => (c.viewportSize.width, c.columnCount, c.spacing, c.horizontalPadding),
|
selector: (context, c) =>
|
||||||
|
(c.viewportSize.width, c.columnCount, c.spacing, c.horizontalPadding),
|
||||||
builder: (context, c, child) {
|
builder: (context, c, child) {
|
||||||
final (scrollableWidth, columnCount, tileSpacing, horizontalPadding) = c;
|
final (scrollableWidth, columnCount, tileSpacing, horizontalPadding) = c;
|
||||||
final source = collection.source;
|
final source = collection.source;
|
||||||
|
|
||||||
return GridTheme(
|
return GridTheme(
|
||||||
extent: thumbnailExtent,
|
extent: thumbnailExtent,
|
||||||
child: EntryListDetailsTheme(
|
child: EntryListDetailsTheme(
|
||||||
|
|
@ -160,9 +176,10 @@ class _CollectionGridContentState extends State<_CollectionGridContent> {
|
||||||
builder: (context, sourceState, child) {
|
builder: (context, sourceState, child) {
|
||||||
late final Duration tileAnimationDelay;
|
late final Duration tileAnimationDelay;
|
||||||
if (sourceState == SourceState.ready) {
|
if (sourceState == SourceState.ready) {
|
||||||
// do not listen for animation delay change
|
|
||||||
final target = context.read<DurationsData>().staggeredAnimationPageTarget;
|
final target = context.read<DurationsData>().staggeredAnimationPageTarget;
|
||||||
tileAnimationDelay = context.read<TileExtentController>().getTileAnimationDelay(target);
|
tileAnimationDelay = context
|
||||||
|
.read<TileExtentController>()
|
||||||
|
.getTileAnimationDelay(target);
|
||||||
} else {
|
} else {
|
||||||
tileAnimationDelay = Duration.zero;
|
tileAnimationDelay = Duration.zero;
|
||||||
}
|
}
|
||||||
|
|
@ -223,7 +240,8 @@ class _CollectionGridContentState extends State<_CollectionGridContent> {
|
||||||
return AnimatedScale(
|
return AnimatedScale(
|
||||||
scale: focusedItem == entry ? 1 : .9,
|
scale: focusedItem == entry ? 1 : .9,
|
||||||
curve: Curves.fastOutSlowIn,
|
curve: Curves.fastOutSlowIn,
|
||||||
duration: context.select<DurationsData, Duration>((v) => v.tvImageFocusAnimation),
|
duration: context.select<DurationsData, Duration>(
|
||||||
|
(v) => v.tvImageFocusAnimation),
|
||||||
child: child!,
|
child: child!,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -261,12 +279,8 @@ class _CollectionGridContentState extends State<_CollectionGridContent> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _goToViewer(CollectionLens collection, AvesEntry entry) async {
|
Future<void> _goToViewer(CollectionLens collection, AvesEntry entry) async {
|
||||||
// track viewer entry for dynamic hero placeholder
|
|
||||||
final viewerEntryNotifier = context.read<ViewerEntryNotifier>();
|
final viewerEntryNotifier = context.read<ViewerEntryNotifier>();
|
||||||
|
|
||||||
// prevent navigating again to the same entry until fully back,
|
|
||||||
// as a workaround for the hero pop/push diversion animation issue
|
|
||||||
// (cf `ThumbnailImage` `Hero` usage)
|
|
||||||
if (viewerEntryNotifier.value == entry) return;
|
if (viewerEntryNotifier.value == entry) return;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => viewerEntryNotifier.value = entry);
|
WidgetsBinding.instance.addPostFrameCallback((_) => viewerEntryNotifier.value = entry);
|
||||||
|
|
||||||
|
|
@ -298,10 +312,8 @@ class _CollectionGridContentState extends State<_CollectionGridContent> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// reset track viewer entry
|
|
||||||
final animate = context.read<Settings>().animate;
|
final animate = context.read<Settings>().animate;
|
||||||
if (animate) {
|
if (animate) {
|
||||||
// TODO TLAD fix timing when transition is incomplete, e.g. when going back while going to the viewer
|
|
||||||
await Future.delayed(ADurations.pageTransitionExact * timeDilation);
|
await Future.delayed(ADurations.pageTransitionExact * timeDilation);
|
||||||
}
|
}
|
||||||
viewerEntryNotifier.value = null;
|
viewerEntryNotifier.value = null;
|
||||||
|
|
@ -409,7 +421,9 @@ class _CollectionScaler extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final (tileSpacing, horizontalPadding) = context.select<TileExtentController, (double, double)>((v) => (v.spacing, v.horizontalPadding));
|
final (tileSpacing, horizontalPadding) =
|
||||||
|
context.select<TileExtentController, (double, double)>(
|
||||||
|
(v) => (v.spacing, v.horizontalPadding));
|
||||||
final brightness = Theme.of(context).brightness;
|
final brightness = Theme.of(context).brightness;
|
||||||
final borderColor = DecoratedThumbnail.borderColor(context);
|
final borderColor = DecoratedThumbnail.borderColor(context);
|
||||||
final borderWidth = DecoratedThumbnail.borderWidth(context);
|
final borderWidth = DecoratedThumbnail.borderWidth(context);
|
||||||
|
|
@ -435,7 +449,6 @@ class _CollectionScaler extends StatelessWidget {
|
||||||
extent: tileSize.height,
|
extent: tileSize.height,
|
||||||
child: Builder(
|
child: Builder(
|
||||||
builder: (_) {
|
builder: (_) {
|
||||||
// REMOTE: ramo dedicato in layout "fixed scale"
|
|
||||||
if (entry.origin == 1) {
|
if (entry.origin == 1) {
|
||||||
return RemoteInteractiveTile(
|
return RemoteInteractiveTile(
|
||||||
key: ValueKey('remote_scaled_${entry.id}'),
|
key: ValueKey('remote_scaled_${entry.id}'),
|
||||||
|
|
@ -443,7 +456,6 @@ class _CollectionScaler extends StatelessWidget {
|
||||||
thumbnailExtent: context.read<TileExtentController>().effectiveExtentMax,
|
thumbnailExtent: context.read<TileExtentController>().effectiveExtentMax,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Locale: flusso preesistente
|
|
||||||
return Tile(
|
return Tile(
|
||||||
entry: entry,
|
entry: entry,
|
||||||
thumbnailExtent: context.read<TileExtentController>().effectiveExtentMax,
|
thumbnailExtent: context.read<TileExtentController>().effectiveExtentMax,
|
||||||
|
|
@ -454,7 +466,8 @@ class _CollectionScaler extends StatelessWidget {
|
||||||
),
|
),
|
||||||
mosaicItemBuilder: (index, targetExtent) => DecoratedBox(
|
mosaicItemBuilder: (index, targetExtent) => DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: ThumbnailImage.computeLoadingBackgroundColor(index * 10, brightness).withValues(alpha: .9),
|
color: ThumbnailImage.computeLoadingBackgroundColor(index * 10, brightness)
|
||||||
|
.withValues(alpha: .9),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: borderColor,
|
color: borderColor,
|
||||||
width: borderWidth,
|
width: borderWidth,
|
||||||
|
|
@ -491,13 +504,28 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge
|
||||||
Timer? _scrollMonitoringTimer;
|
Timer? _scrollMonitoringTimer;
|
||||||
bool _checkingStoragePermission = false;
|
bool _checkingStoragePermission = false;
|
||||||
|
|
||||||
|
// ✅ NEW: memoizza se esiste cache DB (evita lo "scanner" alle riaperture)
|
||||||
|
late final Future<bool> _hasAnyDbCacheFuture;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_hasAnyDbCacheFuture = _hasAnyDbCache();
|
||||||
_registerWidget(widget);
|
_registerWidget(widget);
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> _hasAnyDbCache() async {
|
||||||
|
try {
|
||||||
|
final rows = await localMediaDb.rawDb.rawQuery(
|
||||||
|
'SELECT 1 FROM entry WHERE trashed=0 LIMIT 1',
|
||||||
|
);
|
||||||
|
return rows.isNotEmpty;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant _CollectionScrollView oldWidget) {
|
void didUpdateWidget(covariant _CollectionScrollView oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
|
|
@ -553,14 +581,16 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge
|
||||||
return Selector<Settings, bool>(
|
return Selector<Settings, bool>(
|
||||||
selector: (context, s) => s.enableBottomNavigationBar,
|
selector: (context, s) => s.enableBottomNavigationBar,
|
||||||
builder: (context, enableBottomNavigationBar, child) {
|
builder: (context, enableBottomNavigationBar, child) {
|
||||||
final canNavigate = context.select<ValueNotifier<AppMode>, bool>((v) => v.value.canNavigate);
|
final canNavigate =
|
||||||
|
context.select<ValueNotifier<AppMode>, bool>((v) => v.value.canNavigate);
|
||||||
final showBottomNavigationBar = canNavigate && enableBottomNavigationBar;
|
final showBottomNavigationBar = canNavigate && enableBottomNavigationBar;
|
||||||
final navBarHeight = showBottomNavigationBar ? AppBottomNavBar.height : 0;
|
final navBarHeight = showBottomNavigationBar ? AppBottomNavBar.height : 0;
|
||||||
return Selector<SectionedListLayout<AvesEntry>, List<SectionLayout>>(
|
return Selector<SectionedListLayout<AvesEntry>, List<SectionLayout>>(
|
||||||
selector: (context, layout) => layout.sectionLayouts,
|
selector: (context, layout) => layout.sectionLayouts,
|
||||||
builder: (context, sectionLayouts, child) {
|
builder: (context, sectionLayouts, child) {
|
||||||
final scrollController = widget.scrollController;
|
final scrollController = widget.scrollController;
|
||||||
final offsetIncrementSnapThreshold = context.select<TileExtentController, double>((v) => (v.extentNotifier.value + v.spacing) / 4);
|
final offsetIncrementSnapThreshold =
|
||||||
|
context.select<TileExtentController, double>((v) => (v.extentNotifier.value + v.spacing) / 4);
|
||||||
return DraggableScrollbar(
|
return DraggableScrollbar(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
scrollThumbSize: Size(avesScrollThumbWidth, avesScrollThumbHeight),
|
scrollThumbSize: Size(avesScrollThumbWidth, avesScrollThumbHeight),
|
||||||
|
|
@ -570,14 +600,15 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge
|
||||||
),
|
),
|
||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
dragOffsetSnapper: (scrollOffset, offsetIncrement) {
|
dragOffsetSnapper: (scrollOffset, offsetIncrement) {
|
||||||
if (offsetIncrement > offsetIncrementSnapThreshold && scrollOffset < scrollController.position.maxScrollExtent) {
|
if (offsetIncrement > offsetIncrementSnapThreshold &&
|
||||||
final section = sectionLayouts.firstWhereOrNull((section) => section.hasChildAtOffset(scrollOffset));
|
scrollOffset < scrollController.position.maxScrollExtent) {
|
||||||
|
final section =
|
||||||
|
sectionLayouts.firstWhereOrNull((section) => section.hasChildAtOffset(scrollOffset));
|
||||||
if (section != null) {
|
if (section != null) {
|
||||||
if (section.maxOffset - section.minOffset < scrollController.position.viewportDimension) {
|
if (section.maxOffset - section.minOffset <
|
||||||
// snap to section header
|
scrollController.position.viewportDimension) {
|
||||||
return section.minOffset;
|
return section.minOffset;
|
||||||
} else {
|
} else {
|
||||||
// snap to content row
|
|
||||||
final index = section.getMinChildIndexForScrollOffset(scrollOffset);
|
final index = section.getMinChildIndexForScrollOffset(scrollOffset);
|
||||||
return section.indexToLayoutOffset(index);
|
return section.indexToLayoutOffset(index);
|
||||||
}
|
}
|
||||||
|
|
@ -587,7 +618,6 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge
|
||||||
},
|
},
|
||||||
crumbsBuilder: () => _getCrumbs(sectionLayouts),
|
crumbsBuilder: () => _getCrumbs(sectionLayouts),
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
// padding to keep scroll thumb between app bar above and nav bar below
|
|
||||||
top: appBarHeight,
|
top: appBarHeight,
|
||||||
bottom: navBarHeight + mqPaddingBottom,
|
bottom: navBarHeight + mqPaddingBottom,
|
||||||
),
|
),
|
||||||
|
|
@ -612,15 +642,19 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
key: widget.scrollableKey,
|
key: widget.scrollableKey,
|
||||||
primary: true,
|
primary: true,
|
||||||
// workaround to prevent scrolling the app bar away
|
|
||||||
// when there is no content and we use `SliverFillRemaining`
|
|
||||||
physics: collection.isEmpty
|
physics: collection.isEmpty
|
||||||
? const NeverScrollableScrollPhysics()
|
? const NeverScrollableScrollPhysics()
|
||||||
: SloppyScrollPhysics(
|
: SloppyScrollPhysics(
|
||||||
gestureSettings: MediaQuery.gestureSettingsOf(context),
|
gestureSettings: MediaQuery.gestureSettingsOf(context),
|
||||||
parent: const AlwaysScrollableScrollPhysics(),
|
parent: const AlwaysScrollableScrollPhysics(),
|
||||||
),
|
),
|
||||||
cacheExtent: context.select<TileExtentController, double>((controller) => controller.effectiveExtentMax),
|
// ✅ MOD: preload viewport + ahead (senza prefetchRelPaths)
|
||||||
|
cacheExtent: (() {
|
||||||
|
final base = context.select<TileExtentController, double>((c) => c.effectiveExtentMax);
|
||||||
|
final h = MediaQuery.of(context).size.height;
|
||||||
|
final target = h * 2; // ~2 schermate avanti
|
||||||
|
return target < base ? base : target;
|
||||||
|
})(),
|
||||||
slivers: [
|
slivers: [
|
||||||
appBar,
|
appBar,
|
||||||
collection.isEmpty
|
collection.isEmpty
|
||||||
|
|
@ -642,7 +676,23 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge
|
||||||
valueListenable: source.stateNotifier,
|
valueListenable: source.stateNotifier,
|
||||||
builder: (context, sourceState, child) {
|
builder: (context, sourceState, child) {
|
||||||
if (sourceState == SourceState.loading) {
|
if (sourceState == SourceState.loading) {
|
||||||
|
// ✅ MOD: se DB ha cache, non mostrare "scanner" ma solo spinner piccolo
|
||||||
|
return FutureBuilder<bool>(
|
||||||
|
future: _hasAnyDbCacheFuture,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final hasCache = snapshot.data ?? false;
|
||||||
|
if (hasCache) {
|
||||||
|
return const Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 1.6),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
return LoadingEmptyContent(source: source);
|
return LoadingEmptyContent(source: source);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return FutureBuilder<bool>(
|
return FutureBuilder<bool>(
|
||||||
|
|
@ -670,7 +720,8 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge
|
||||||
bottom: bottom,
|
bottom: bottom,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (collection.filters.any((filter) => filter is MimeFilter && filter.mime == MimeTypes.anyVideo)) {
|
if (collection.filters.any((filter) =>
|
||||||
|
filter is MimeFilter && filter.mime == MimeTypes.anyVideo)) {
|
||||||
return EmptyContent(
|
return EmptyContent(
|
||||||
icon: AIcons.video,
|
icon: AIcons.video,
|
||||||
text: context.l10n.collectionEmptyVideos,
|
text: context.l10n.collectionEmptyVideos,
|
||||||
|
|
@ -705,7 +756,8 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge
|
||||||
if (sectionLayouts.length <= 1) return crumbs;
|
if (sectionLayouts.length <= 1) return crumbs;
|
||||||
|
|
||||||
final maxOffset = sectionLayouts.last.maxOffset;
|
final maxOffset = sectionLayouts.last.maxOffset;
|
||||||
void addAlbums(CollectionLens collection, List<SectionLayout> sectionLayouts, Map<double, String> crumbs) {
|
void addAlbums(CollectionLens collection, List<SectionLayout> sectionLayouts,
|
||||||
|
Map<double, String> crumbs) {
|
||||||
final source = collection.source;
|
final source = collection.source;
|
||||||
sectionLayouts.forEach((section) {
|
sectionLayouts.forEach((section) {
|
||||||
final directory = (section.sectionKey as EntryAlbumSectionKey).directory;
|
final directory = (section.sectionKey as EntryAlbumSectionKey).directory;
|
||||||
|
|
@ -731,7 +783,9 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge
|
||||||
final oldest = lastKey.date;
|
final oldest = lastKey.date;
|
||||||
if (newest != null && oldest != null) {
|
if (newest != null && oldest != null) {
|
||||||
final locale = context.locale;
|
final locale = context.locale;
|
||||||
final dateFormat = (newest.difference(oldest).inHumanDays).abs() > 365 ? DateFormat.y(locale) : DateFormat.MMM(locale);
|
final dateFormat = (newest.difference(oldest).inHumanDays).abs() > 365
|
||||||
|
? DateFormat.y(locale)
|
||||||
|
: DateFormat.MMM(locale);
|
||||||
String? lastLabel;
|
String? lastLabel;
|
||||||
sectionLayouts.forEach((section) {
|
sectionLayouts.forEach((section) {
|
||||||
final date = (section.sectionKey as EntryDateSectionKey).date;
|
final date = (section.sectionKey as EntryDateSectionKey).date;
|
||||||
|
|
@ -759,7 +813,9 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge
|
||||||
return crumbs;
|
return crumbs;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> get _isStoragePermissionGranted => Future.wait(Permissions.storage.map((v) => v.status)).then((v) => v.any((status) => status.isGranted));
|
Future<bool> get _isStoragePermissionGranted =>
|
||||||
|
Future.wait(Permissions.storage.map((v) => v.status))
|
||||||
|
.then((v) => v.any((status) => status.isGranted));
|
||||||
}
|
}
|
||||||
|
|
||||||
// REMOTE: mini-tile che mostra la thumb remota e apre il viewer con OpenViewerNotification
|
// REMOTE: mini-tile che mostra la thumb remota e apre il viewer con OpenViewerNotification
|
||||||
|
|
@ -775,8 +831,6 @@ class RemoteInteractiveTile extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Nota: usiamo OpenViewerNotification perché la Collection già la intercetta
|
|
||||||
// e apre il viewer col lens corretto (stesso comportamento dei locali).
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => OpenViewerNotification(entry).dispatch(context),
|
onTap: () => OpenViewerNotification(entry).dispatch(context),
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,9 @@ import 'package:intl/intl.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
// REMOTE: import per le thumb di rete
|
||||||
|
import 'package:aves/remote/remote_image_tile.dart';
|
||||||
|
|
||||||
class CollectionGrid extends StatefulWidget {
|
class CollectionGrid extends StatefulWidget {
|
||||||
final String settingsRouteKey;
|
final String settingsRouteKey;
|
||||||
|
|
||||||
|
|
@ -182,6 +185,17 @@ class _CollectionGridContentState extends State<_CollectionGridContent> {
|
||||||
tileExtent: thumbnailExtent,
|
tileExtent: thumbnailExtent,
|
||||||
tileBuilder: (entry, tileSize) {
|
tileBuilder: (entry, tileSize) {
|
||||||
final extent = tileSize.shortestSide;
|
final extent = tileSize.shortestSide;
|
||||||
|
|
||||||
|
// REMOTE: ramo dedicato per le entry remote (origin=1)
|
||||||
|
if (entry.origin == 1) {
|
||||||
|
return RemoteInteractiveTile(
|
||||||
|
key: ValueKey('remote_${entry.id}'),
|
||||||
|
entry: entry,
|
||||||
|
thumbnailExtent: extent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locale: flusso preesistente
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: favourites,
|
animation: favourites,
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
|
|
@ -419,10 +433,23 @@ class _CollectionScaler extends StatelessWidget {
|
||||||
),
|
),
|
||||||
scaledItemBuilder: (entry, tileSize) => EntryListDetailsTheme(
|
scaledItemBuilder: (entry, tileSize) => EntryListDetailsTheme(
|
||||||
extent: tileSize.height,
|
extent: tileSize.height,
|
||||||
child: Tile(
|
child: Builder(
|
||||||
|
builder: (_) {
|
||||||
|
// REMOTE: ramo dedicato in layout "fixed scale"
|
||||||
|
if (entry.origin == 1) {
|
||||||
|
return RemoteInteractiveTile(
|
||||||
|
key: ValueKey('remote_scaled_${entry.id}'),
|
||||||
|
entry: entry,
|
||||||
|
thumbnailExtent: context.read<TileExtentController>().effectiveExtentMax,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Locale: flusso preesistente
|
||||||
|
return Tile(
|
||||||
entry: entry,
|
entry: entry,
|
||||||
thumbnailExtent: context.read<TileExtentController>().effectiveExtentMax,
|
thumbnailExtent: context.read<TileExtentController>().effectiveExtentMax,
|
||||||
tileLayout: tileLayout,
|
tileLayout: tileLayout,
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
mosaicItemBuilder: (index, targetExtent) => DecoratedBox(
|
mosaicItemBuilder: (index, targetExtent) => DecoratedBox(
|
||||||
|
|
@ -734,3 +761,32 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge
|
||||||
|
|
||||||
Future<bool> get _isStoragePermissionGranted => Future.wait(Permissions.storage.map((v) => v.status)).then((v) => v.any((status) => status.isGranted));
|
Future<bool> get _isStoragePermissionGranted => Future.wait(Permissions.storage.map((v) => v.status)).then((v) => v.any((status) => status.isGranted));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// REMOTE: mini-tile che mostra la thumb remota e apre il viewer con OpenViewerNotification
|
||||||
|
class RemoteInteractiveTile extends StatelessWidget {
|
||||||
|
final AvesEntry entry;
|
||||||
|
final double thumbnailExtent;
|
||||||
|
|
||||||
|
const RemoteInteractiveTile({
|
||||||
|
super.key,
|
||||||
|
required this.entry,
|
||||||
|
required this.thumbnailExtent,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Nota: usiamo OpenViewerNotification perché la Collection già la intercetta
|
||||||
|
// e apre il viewer col lens corretto (stesso comportamento dei locali).
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => OpenViewerNotification(entry).dispatch(context),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.zero,
|
||||||
|
child: SizedBox(
|
||||||
|
width: thumbnailExtent,
|
||||||
|
height: thumbnailExtent,
|
||||||
|
child: RemoteImageTile(entry: entry),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,9 @@ import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
// ✅ STEP 2: banner progresso remoti (overlay sopra la griglia)
|
||||||
|
import 'package:aves/widgets/collection/remote_progress_banner.dart';
|
||||||
|
|
||||||
class CollectionPage extends StatefulWidget {
|
class CollectionPage extends StatefulWidget {
|
||||||
static const routeName = '/collection';
|
static const routeName = '/collection';
|
||||||
|
|
||||||
|
|
@ -54,7 +57,8 @@ class CollectionPage extends StatefulWidget {
|
||||||
class _CollectionPageState extends State<CollectionPage> {
|
class _CollectionPageState extends State<CollectionPage> {
|
||||||
final Set<StreamSubscription> _subscriptions = {};
|
final Set<StreamSubscription> _subscriptions = {};
|
||||||
late CollectionLens _collection;
|
late CollectionLens _collection;
|
||||||
final StreamController<DraggableScrollbarEvent> _draggableScrollBarEventStreamController = StreamController.broadcast();
|
final StreamController<DraggableScrollbarEvent> _draggableScrollBarEventStreamController =
|
||||||
|
StreamController.broadcast();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -66,7 +70,9 @@ class _CollectionPageState extends State<CollectionPage> {
|
||||||
);
|
);
|
||||||
super.initState();
|
super.initState();
|
||||||
_subscriptions.add(
|
_subscriptions.add(
|
||||||
settings.updateStream.where((event) => event.key == SettingKeys.enableBinKey).listen((_) {
|
settings.updateStream
|
||||||
|
.where((event) => event.key == SettingKeys.enableBinKey)
|
||||||
|
.listen((_) {
|
||||||
if (!settings.enableBin) {
|
if (!settings.enableBin) {
|
||||||
_collection.removeFilter(TrashFilter.instance);
|
_collection.removeFilter(TrashFilter.instance);
|
||||||
}
|
}
|
||||||
|
|
@ -87,7 +93,8 @@ class _CollectionPageState extends State<CollectionPage> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final useTvLayout = settings.useTvLayout;
|
final useTvLayout = settings.useTvLayout;
|
||||||
final liveFilter = _collection.filters.firstWhereOrNull((v) => v is QueryFilter && v.live) as QueryFilter?;
|
final liveFilter =
|
||||||
|
_collection.filters.firstWhereOrNull((v) => v is QueryFilter && v.live) as QueryFilter?;
|
||||||
return SelectionProvider<AvesEntry>(
|
return SelectionProvider<AvesEntry>(
|
||||||
child: Selector<Selection<AvesEntry>, bool>(
|
child: Selector<Selection<AvesEntry>, bool>(
|
||||||
selector: (context, selection) => selection.selectedItems.isNotEmpty,
|
selector: (context, selection) => selection.selectedItems.isNotEmpty,
|
||||||
|
|
@ -107,7 +114,10 @@ class _CollectionPageState extends State<CollectionPage> {
|
||||||
doubleBackPopHandler,
|
doubleBackPopHandler,
|
||||||
],
|
],
|
||||||
child: GestureAreaProtectorStack(
|
child: GestureAreaProtectorStack(
|
||||||
child: DirectionalSafeArea(
|
// ✅ STEP 2: overlay in Stack (griglia + banner progresso remoti)
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
DirectionalSafeArea(
|
||||||
start: !useTvLayout,
|
start: !useTvLayout,
|
||||||
top: false,
|
top: false,
|
||||||
bottom: false,
|
bottom: false,
|
||||||
|
|
@ -117,6 +127,9 @@ class _CollectionPageState extends State<CollectionPage> {
|
||||||
settingsRouteKey: CollectionPage.routeName,
|
settingsRouteKey: CollectionPage.routeName,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const RemoteProgressBanner(),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -142,7 +155,8 @@ class _CollectionPageState extends State<CollectionPage> {
|
||||||
page = Selector<Settings, bool>(
|
page = Selector<Settings, bool>(
|
||||||
selector: (context, s) => s.enableBottomNavigationBar,
|
selector: (context, s) => s.enableBottomNavigationBar,
|
||||||
builder: (context, enableBottomNavigationBar, child) {
|
builder: (context, enableBottomNavigationBar, child) {
|
||||||
final canNavigate = context.select<ValueNotifier<AppMode>, bool>((v) => v.value.canNavigate);
|
final canNavigate =
|
||||||
|
context.select<ValueNotifier<AppMode>, bool>((v) => v.value.canNavigate);
|
||||||
final showBottomNavigationBar = canNavigate && enableBottomNavigationBar;
|
final showBottomNavigationBar = canNavigate && enableBottomNavigationBar;
|
||||||
|
|
||||||
return NotificationListener<DraggableScrollbarNotification>(
|
return NotificationListener<DraggableScrollbarNotification>(
|
||||||
|
|
@ -167,6 +181,7 @@ class _CollectionPageState extends State<CollectionPage> {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// this provider should be above `TvRail`
|
// this provider should be above `TvRail`
|
||||||
return ChangeNotifierProvider<CollectionLens>.value(
|
return ChangeNotifierProvider<CollectionLens>.value(
|
||||||
value: _collection,
|
value: _collection,
|
||||||
|
|
|
||||||
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/collection_source.dart';
|
||||||
import 'package:aves/model/source/events.dart';
|
import 'package:aves/model/source/events.dart';
|
||||||
|
import 'package:aves/remote/remote_sync_bus.dart';
|
||||||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/common/identity/empty.dart';
|
import 'package:aves/widgets/common/identity/empty.dart';
|
||||||
|
|
@ -26,7 +27,11 @@ class LoadingEmptyContent extends StatelessWidget {
|
||||||
text: context.l10n.sourceStateLoading,
|
text: context.l10n.sourceStateLoading,
|
||||||
bottom: Padding(
|
bottom: Padding(
|
||||||
padding: const EdgeInsets.only(top: 16),
|
padding: const EdgeInsets.only(top: 16),
|
||||||
child: Stack(
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// === PROGRESS LOCALE (Aves originale) ===
|
||||||
|
Stack(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
const ReportProgressIndicator(),
|
const ReportProgressIndicator(),
|
||||||
|
|
@ -44,7 +49,35 @@ class LoadingEmptyContent extends StatelessWidget {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// === PROGRESS REMOTO (solo bootstrap, stile "Aves") ===
|
||||||
|
ValueListenableBuilder<RemoteSyncProgress?>(
|
||||||
|
valueListenable: RemoteSyncBus.instance.progressNotifier,
|
||||||
|
builder: (context, prog, _) {
|
||||||
|
if (prog == null || !prog.showOverlay) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final done = prog.done;
|
||||||
|
final total = prog.total;
|
||||||
|
|
||||||
|
// stesso stile "numerone", ma per remoti preferiamo X/Y
|
||||||
|
final text = total > 0
|
||||||
|
? 'Agg remoti ${countFormatter.format(done)}/${countFormatter.format(total)}'
|
||||||
|
: 'Agg remoti ${countFormatter.format(done)}';
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 12),
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: progressTextStyle,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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 'dart:async';
|
||||||
|
|
||||||
import 'package:aves/remote/collection_source_remote_ext.dart';
|
|
||||||
import 'package:aves/app_mode.dart';
|
import 'package:aves/app_mode.dart';
|
||||||
import 'package:aves/geo/uri.dart';
|
import 'package:aves/geo/uri.dart';
|
||||||
import 'package:aves/model/app/intent.dart';
|
import 'package:aves/model/app/intent.dart';
|
||||||
|
|
@ -47,26 +45,14 @@ import 'package:latlong2/latlong.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
// --- IMPORT aggiunti per integrazione remota / telemetria ---
|
// ✅ Remote
|
||||||
import 'package:flutter/foundation.dart' show kDebugMode;
|
import 'package:aves/remote/remote_controller.dart';
|
||||||
import 'package:sqflite/sqflite.dart';
|
|
||||||
import 'package:path/path.dart' as p;
|
|
||||||
import 'package:aves/remote/remote_test_page.dart' as rtp;
|
|
||||||
import 'package:aves/remote/run_remote_sync.dart' as rrs;
|
|
||||||
import 'package:aves/remote/remote_settings.dart';
|
import 'package:aves/remote/remote_settings.dart';
|
||||||
import 'package:aves/remote/remote_http.dart'; // PERF/REMOTE: warm-up headers
|
|
||||||
import 'package:aves/remote/remote_models.dart'; // RemotePhotoItem
|
|
||||||
|
|
||||||
// --- IMPORT per client reale ---
|
|
||||||
import 'package:aves/remote/remote_client.dart';
|
|
||||||
import 'package:aves/remote/auth_client.dart';
|
|
||||||
|
|
||||||
// secure storage import (used only in debug helper)
|
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
||||||
|
|
||||||
class HomePage extends StatefulWidget {
|
class HomePage extends StatefulWidget {
|
||||||
static const routeName = '/';
|
static const routeName = '/';
|
||||||
// untyped map as it is coming from the platform
|
|
||||||
|
// compatibile con aves_app.dart
|
||||||
final Map? intentData;
|
final Map? intentData;
|
||||||
|
|
||||||
const HomePage({
|
const HomePage({
|
||||||
|
|
@ -88,14 +74,6 @@ class _HomePageState extends State<HomePage> {
|
||||||
List<String>? _secureUris;
|
List<String>? _secureUris;
|
||||||
(Object, StackTrace)? _setupError;
|
(Object, StackTrace)? _setupError;
|
||||||
|
|
||||||
// guard UI per schedulare UNA sola run del sync da Home
|
|
||||||
bool _remoteSyncScheduled = false;
|
|
||||||
// indica se il sync è effettivamente in corso
|
|
||||||
bool _remoteSyncActive = false;
|
|
||||||
|
|
||||||
// guard per evitare doppi push della pagina di test remota
|
|
||||||
bool _remoteTestOpen = false;
|
|
||||||
|
|
||||||
static const allowedShortcutRoutes = [
|
static const allowedShortcutRoutes = [
|
||||||
AlbumListPage.routeName,
|
AlbumListPage.routeName,
|
||||||
CollectionPage.routeName,
|
CollectionPage.routeName,
|
||||||
|
|
@ -121,20 +99,32 @@ class _HomePageState extends State<HomePage> {
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Map<String, Object?> _safeCastIntentMap(Object? raw) {
|
||||||
|
if (raw is Map) {
|
||||||
|
final out = <String, Object?>{};
|
||||||
|
for (final entry in raw.entries) {
|
||||||
|
final k = entry.key;
|
||||||
|
if (k is String) out[k] = entry.value as Object?;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return <String, Object?>{};
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _setup() async {
|
Future<void> _setup() async {
|
||||||
try {
|
try {
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
|
|
||||||
if (await windowService.isActivity()) {
|
if (await windowService.isActivity()) {
|
||||||
// do not check whether permission was granted, because some app stores
|
// ✅ Permessi Aves originali
|
||||||
// hide in some countries apps that force quit on permission denial
|
|
||||||
await Permissions.mediaAccess.request();
|
await Permissions.mediaAccess.request();
|
||||||
}
|
}
|
||||||
|
|
||||||
var appMode = AppMode.main;
|
var appMode = AppMode.main;
|
||||||
var error = false;
|
var error = false;
|
||||||
|
|
||||||
final intentData = widget.intentData ?? await IntentService.getIntentData();
|
final rawIntentData = widget.intentData ?? await IntentService.getIntentData();
|
||||||
|
final intentData = _safeCastIntentMap(rawIntentData);
|
||||||
final intentAction = intentData[IntentDataKeys.action] as String?;
|
final intentAction = intentData[IntentDataKeys.action] as String?;
|
||||||
|
|
||||||
_initialFilters = null;
|
_initialFilters = null;
|
||||||
|
|
@ -144,19 +134,6 @@ class _HomePageState extends State<HomePage> {
|
||||||
await availability.onNewIntent();
|
await availability.onNewIntent();
|
||||||
await androidFileUtils.init();
|
await androidFileUtils.init();
|
||||||
|
|
||||||
// PERF/REMOTE: warm-up headers (Bearer) in background — safe version
|
|
||||||
unawaited(Future(() async {
|
|
||||||
try {
|
|
||||||
final s = await _safeLoadRemoteSettings();
|
|
||||||
if (s.enabled && s.baseUrl.trim().isNotEmpty) {
|
|
||||||
await _safeHeaders(); // popola la cache per peekHeaders() in modo sicuro
|
|
||||||
debugPrint('[startup] remote headers warm-up done (safe)');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('[startup] remote headers warm-up skipped: $e');
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (!{
|
if (!{
|
||||||
IntentActions.edit,
|
IntentActions.edit,
|
||||||
IntentActions.screenSaver,
|
IntentActions.screenSaver,
|
||||||
|
|
@ -187,34 +164,40 @@ class _HomePageState extends State<HomePage> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case IntentActions.edit:
|
case IntentActions.edit:
|
||||||
appMode = AppMode.edit;
|
appMode = AppMode.edit;
|
||||||
case IntentActions.setWallpaper:
|
case IntentActions.setWallpaper:
|
||||||
appMode = AppMode.setWallpaper;
|
appMode = AppMode.setWallpaper;
|
||||||
|
|
||||||
case IntentActions.pickItems:
|
case IntentActions.pickItems:
|
||||||
// some apps define multiple types, separated by a space
|
|
||||||
final multiple = (intentData[IntentDataKeys.allowMultiple] as bool?) ?? false;
|
final multiple = (intentData[IntentDataKeys.allowMultiple] as bool?) ?? false;
|
||||||
debugPrint('pick mimeType=$intentMimeType multiple=$multiple');
|
debugPrint('pick mimeType=$intentMimeType multiple=$multiple');
|
||||||
appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal;
|
appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal;
|
||||||
|
|
||||||
case IntentActions.pickCollectionFilters:
|
case IntentActions.pickCollectionFilters:
|
||||||
appMode = AppMode.pickCollectionFiltersExternal;
|
appMode = AppMode.pickCollectionFiltersExternal;
|
||||||
|
|
||||||
case IntentActions.screenSaver:
|
case IntentActions.screenSaver:
|
||||||
appMode = AppMode.screenSaver;
|
appMode = AppMode.screenSaver;
|
||||||
_initialRouteName = ScreenSaverPage.routeName;
|
_initialRouteName = ScreenSaverPage.routeName;
|
||||||
|
|
||||||
case IntentActions.screenSaverSettings:
|
case IntentActions.screenSaverSettings:
|
||||||
_initialRouteName = ScreenSaverSettingsPage.routeName;
|
_initialRouteName = ScreenSaverSettingsPage.routeName;
|
||||||
|
|
||||||
case IntentActions.search:
|
case IntentActions.search:
|
||||||
_initialRouteName = SearchPage.routeName;
|
_initialRouteName = SearchPage.routeName;
|
||||||
_initialSearchQuery = intentData[IntentDataKeys.query] as String?;
|
_initialSearchQuery = intentData[IntentDataKeys.query] as String?;
|
||||||
|
|
||||||
case IntentActions.widgetSettings:
|
case IntentActions.widgetSettings:
|
||||||
_initialRouteName = HomeWidgetSettingsPage.routeName;
|
_initialRouteName = HomeWidgetSettingsPage.routeName;
|
||||||
_widgetId = (intentData[IntentDataKeys.widgetId] as int?) ?? 0;
|
_widgetId = (intentData[IntentDataKeys.widgetId] as int?) ?? 0;
|
||||||
|
|
||||||
case IntentActions.widgetOpen:
|
case IntentActions.widgetOpen:
|
||||||
final widgetId = intentData[IntentDataKeys.widgetId] as int?;
|
final widgetId = intentData[IntentDataKeys.widgetId] as int?;
|
||||||
if (widgetId == null) {
|
if (widgetId == null) {
|
||||||
error = true;
|
error = true;
|
||||||
} else {
|
} else {
|
||||||
// widget settings may be modified in a different process after channel setup
|
|
||||||
await settings.reload();
|
await settings.reload();
|
||||||
final page = settings.getWidgetOpenPage(widgetId);
|
final page = settings.getWidgetOpenPage(widgetId);
|
||||||
switch (page) {
|
switch (page) {
|
||||||
|
|
@ -229,6 +212,7 @@ class _HomePageState extends State<HomePage> {
|
||||||
}
|
}
|
||||||
unawaited(WidgetService.update(widgetId));
|
unawaited(WidgetService.update(widgetId));
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
final extraRoute = intentData[IntentDataKeys.page] as String?;
|
final extraRoute = intentData[IntentDataKeys.page] as String?;
|
||||||
if (allowedShortcutRoutes.contains(extraRoute)) {
|
if (allowedShortcutRoutes.contains(extraRoute)) {
|
||||||
|
|
@ -240,7 +224,6 @@ class _HomePageState extends State<HomePage> {
|
||||||
final extraFilters = (intentData[IntentDataKeys.filters] as List?)?.cast<String>();
|
final extraFilters = (intentData[IntentDataKeys.filters] as List?)?.cast<String>();
|
||||||
_initialFilters = extraFilters?.map(CollectionFilter.fromJson).nonNulls.toSet();
|
_initialFilters = extraFilters?.map(CollectionFilter.fromJson).nonNulls.toSet();
|
||||||
}
|
}
|
||||||
|
|
||||||
_initialExplorerPath = intentData[IntentDataKeys.explorerPath] as String?;
|
_initialExplorerPath = intentData[IntentDataKeys.explorerPath] as String?;
|
||||||
|
|
||||||
switch (appMode) {
|
switch (appMode) {
|
||||||
|
|
@ -248,10 +231,7 @@ class _HomePageState extends State<HomePage> {
|
||||||
case AppMode.edit:
|
case AppMode.edit:
|
||||||
case AppMode.setWallpaper:
|
case AppMode.setWallpaper:
|
||||||
if (intentUri != null) {
|
if (intentUri != null) {
|
||||||
_viewerEntry = await _initViewerEntry(
|
_viewerEntry = await _initViewerEntry(uri: intentUri, mimeType: intentMimeType);
|
||||||
uri: intentUri,
|
|
||||||
mimeType: intentMimeType,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
error = _viewerEntry == null;
|
error = _viewerEntry == null;
|
||||||
default:
|
default:
|
||||||
|
|
@ -267,6 +247,10 @@ class _HomePageState extends State<HomePage> {
|
||||||
context.read<ValueNotifier<AppMode>>().value = appMode;
|
context.read<ValueNotifier<AppMode>>().value = appMode;
|
||||||
unawaited(reportService.setCustomKey('app_mode', appMode.toString()));
|
unawaited(reportService.setCustomKey('app_mode', appMode.toString()));
|
||||||
|
|
||||||
|
// ✅ Remote: seed debug + icona coerente
|
||||||
|
unawaited(RemoteSettings.debugSeedIfEmpty());
|
||||||
|
unawaited(RemoteController.instance.initBusFromSettings());
|
||||||
|
|
||||||
switch (appMode) {
|
switch (appMode) {
|
||||||
case AppMode.main:
|
case AppMode.main:
|
||||||
case AppMode.pickCollectionFiltersExternal:
|
case AppMode.pickCollectionFiltersExternal:
|
||||||
|
|
@ -276,127 +260,30 @@ class _HomePageState extends State<HomePage> {
|
||||||
unawaited(AnalysisService.registerCallback());
|
unawaited(AnalysisService.registerCallback());
|
||||||
|
|
||||||
final source = context.read<CollectionSource>();
|
final source = context.read<CollectionSource>();
|
||||||
|
|
||||||
|
// ✅ Aves originale: init SOLO se non già full scope
|
||||||
if (source.loadedScope != CollectionSource.fullScope) {
|
if (source.loadedScope != CollectionSource.fullScope) {
|
||||||
await reportService.log(
|
await reportService.log(
|
||||||
'Initialize source to start app with mode=$appMode, loaded scope=${source.loadedScope}',
|
'Initialize source to start app with mode=$appMode, loaded scope=${source.loadedScope}',
|
||||||
);
|
);
|
||||||
final loadTopEntriesFirst =
|
final loadTopEntriesFirst =
|
||||||
settings.homeNavItem.route == CollectionPage.routeName && settings.homeCustomCollection.isEmpty;
|
settings.homeNavItem.route == CollectionPage.routeName &&
|
||||||
|
settings.homeCustomCollection.isEmpty;
|
||||||
// PERF: UI-first → niente analisi prima della prima paint
|
|
||||||
source.canAnalyze = false;
|
|
||||||
final swInit = Stopwatch()..start();
|
|
||||||
await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst);
|
|
||||||
swInit.stop();
|
|
||||||
debugPrint('[startup] source.init done in ${swInit.elapsedMilliseconds}ms');
|
|
||||||
}
|
|
||||||
|
|
||||||
// REMOTE: aggiungi remoti visibili (origin=1, trashed=0)
|
|
||||||
final swAppend1 = Stopwatch()..start();
|
|
||||||
await source.appendRemoteEntriesFromDb();
|
|
||||||
swAppend1.stop();
|
|
||||||
debugPrint('[startup] appendRemoteEntries (pre-sync) in ${swAppend1.elapsedMilliseconds}ms');
|
|
||||||
|
|
||||||
// === DIAGNOSTICA PRE- SYNC ===
|
|
||||||
await _printRemoteDiag(source, when: ' PRE');
|
|
||||||
|
|
||||||
// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
|
|
||||||
// PATCH A: se ci sono remoti in DB, forza la Collection "All items"
|
|
||||||
try {
|
|
||||||
final remCount = (await localMediaDb.rawDb
|
|
||||||
.rawQuery('SELECT COUNT(*) AS c FROM entry WHERE origin=1 AND trashed=0'))
|
|
||||||
.first['c'] as int? ?? 0;
|
|
||||||
if (remCount > 0) {
|
|
||||||
_initialRouteName = CollectionPage.routeName;
|
|
||||||
_initialFilters = <CollectionFilter>{}; // All items (nessun filtro)
|
|
||||||
debugPrint('[startup] forcing CollectionPage All-items (remoti=$remCount)');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('[startup] unable to count remotes: $e');
|
|
||||||
}
|
|
||||||
// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
|
|
||||||
|
|
||||||
// PERF: riattiva l’analisi in background appena la UI è pronta
|
|
||||||
unawaited(Future.delayed(const Duration(milliseconds: 300)).then((_) {
|
|
||||||
source.canAnalyze = true;
|
source.canAnalyze = true;
|
||||||
debugPrint('[startup] analysis re-enabled in background');
|
await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst);
|
||||||
}));
|
|
||||||
|
|
||||||
// === SYNC REMOTO post-init (non blocca la UI) ===
|
|
||||||
if (!_remoteSyncScheduled) {
|
|
||||||
_remoteSyncScheduled = true; // una sola schedulazione per avvio
|
|
||||||
unawaited(Future(() async {
|
|
||||||
try {
|
|
||||||
await RemoteSettings.debugSeedIfEmpty();
|
|
||||||
final rs = await _safeLoadRemoteSettings();
|
|
||||||
if (!rs.enabled) return;
|
|
||||||
|
|
||||||
// attesa fine loading
|
|
||||||
final notifier = source.stateNotifier;
|
|
||||||
if (notifier.value == SourceState.loading) {
|
|
||||||
final completer = Completer<void>();
|
|
||||||
void onState() {
|
|
||||||
if (notifier.value != SourceState.loading) {
|
|
||||||
notifier.removeListener(onState);
|
|
||||||
completer.complete();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
notifier.addListener(onState);
|
// ✅ Remote: dopo init locale, ma non blocca
|
||||||
// nel caso non sia già loading:
|
unawaited(RemoteController.instance.onAppStart(
|
||||||
onState();
|
source: source,
|
||||||
await completer.future;
|
resumeBootstrapIfEnabled: true,
|
||||||
}
|
));
|
||||||
|
|
||||||
// piccolo margine per step secondari (tag, ecc.)
|
|
||||||
await Future.delayed(const Duration(milliseconds: 400));
|
|
||||||
|
|
||||||
// ⬇️ SYNC su **stessa connessione** + FETCH (obbligatorio)
|
|
||||||
debugPrint('[remote-sync] START (scheduled=$_remoteSyncScheduled)');
|
|
||||||
_remoteSyncActive = true;
|
|
||||||
try {
|
|
||||||
final swSync = Stopwatch()..start();
|
|
||||||
final imported = await rrs.runRemoteSyncOnceManaged(
|
|
||||||
fetch: _fetchAllRemoteItems, // 👈 PASSIAMO IL FETCH
|
|
||||||
).timeout(const Duration(seconds: 60)); // timeout regolabile
|
|
||||||
swSync.stop();
|
|
||||||
debugPrint('[remote-sync] completed in ${swSync.elapsedMilliseconds}ms, imported=$imported');
|
|
||||||
} on TimeoutException catch (e) {
|
|
||||||
debugPrint('[remote-sync] TIMEOUT after 60s: $e');
|
|
||||||
} catch (e, st) {
|
|
||||||
debugPrint('[remote-sync] error: $e\n$st');
|
|
||||||
} finally {
|
|
||||||
_remoteSyncActive = false;
|
|
||||||
debugPrint('[remote-sync] END (active=$_remoteSyncActive)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// REMOTE: dopo il sync, append di eventuali nuovi remoti
|
|
||||||
if (mounted) {
|
|
||||||
final swAppend2 = Stopwatch()..start();
|
|
||||||
await source.appendRemoteEntriesFromDb();
|
|
||||||
swAppend2.stop();
|
|
||||||
debugPrint('[remote-sync] appendRemoteEntries (post-sync) in ${swAppend2.elapsedMilliseconds}ms');
|
|
||||||
|
|
||||||
// 🔎 Conteggio di debug usando una CollectionLens temporanea
|
|
||||||
final c = _countRemotesInSource(source);
|
|
||||||
debugPrint('[check] remoti in CollectionSource = $c');
|
|
||||||
|
|
||||||
// === DIAGNOSTICA POST- SYNC ===
|
|
||||||
await _printRemoteDiag(source, when: ' POST');
|
|
||||||
}
|
|
||||||
} catch (e, st) {
|
|
||||||
debugPrint('[remote-sync] outer error: $e\n$st');
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case AppMode.screenSaver:
|
case AppMode.screenSaver:
|
||||||
await reportService.log('Initialize source to start screen saver');
|
await reportService.log('Initialize source to start screen saver');
|
||||||
final source2 = context.read<CollectionSource>();
|
final source = context.read<CollectionSource>();
|
||||||
source2.canAnalyze = false;
|
source.canAnalyze = false;
|
||||||
await source2.init(scope: settings.screenSaverCollectionFilters);
|
await source.init(scope: settings.screenSaverCollectionFilters);
|
||||||
break;
|
|
||||||
|
|
||||||
case AppMode.view:
|
case AppMode.view:
|
||||||
if (_isViewerSourceable(_viewerEntry) && _secureUris == null) {
|
if (_isViewerSourceable(_viewerEntry) && _secureUris == null) {
|
||||||
|
|
@ -405,19 +292,16 @@ class _HomePageState extends State<HomePage> {
|
||||||
unawaited(AnalysisService.registerCallback());
|
unawaited(AnalysisService.registerCallback());
|
||||||
await reportService.log('Initialize source to view item in directory $directory');
|
await reportService.log('Initialize source to view item in directory $directory');
|
||||||
final source = context.read<CollectionSource>();
|
final source = context.read<CollectionSource>();
|
||||||
// analysis is necessary to display neighbour items when the initial item is a new one
|
|
||||||
source.canAnalyze = true;
|
source.canAnalyze = true;
|
||||||
await source.init(scope: {StoredAlbumFilter(directory, null)});
|
await source.init(scope: {StoredAlbumFilter(directory, null)});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await _initViewerEssentials();
|
await _initViewerEssentials();
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
|
|
||||||
case AppMode.edit:
|
case AppMode.edit:
|
||||||
case AppMode.setWallpaper:
|
case AppMode.setWallpaper:
|
||||||
await _initViewerEssentials();
|
await _initViewerEssentials();
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
|
|
@ -425,8 +309,6 @@ class _HomePageState extends State<HomePage> {
|
||||||
|
|
||||||
debugPrint('Home setup complete in ${stopwatch.elapsed.inMilliseconds}ms');
|
debugPrint('Home setup complete in ${stopwatch.elapsed.inMilliseconds}ms');
|
||||||
|
|
||||||
// `pushReplacement` is not enough in some edge cases
|
|
||||||
// e.g. when opening the viewer in `view` mode should replace a viewer in `main` mode
|
|
||||||
unawaited(
|
unawaited(
|
||||||
Navigator.maybeOf(context)?.pushAndRemoveUntil(
|
Navigator.maybeOf(context)?.pushAndRemoveUntil(
|
||||||
await _getRedirectRoute(appMode),
|
await _getRedirectRoute(appMode),
|
||||||
|
|
@ -439,65 +321,7 @@ class _HomePageState extends State<HomePage> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// === FETCH per il sync (implementazione reale usando RemoteJsonClient) ===
|
|
||||||
Future<List<RemotePhotoItem>> _fetchAllRemoteItems() async {
|
|
||||||
try {
|
|
||||||
final rs = await _safeLoadRemoteSettings();
|
|
||||||
if (!rs.enabled || rs.baseUrl.trim().isEmpty) {
|
|
||||||
debugPrint('[remote-sync][fetch] disabled or baseUrl empty');
|
|
||||||
return <RemotePhotoItem>[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Costruisci l'auth solo se sono presenti credenziali
|
|
||||||
RemoteAuth? auth;
|
|
||||||
if (rs.email.isNotEmpty && rs.password.isNotEmpty) {
|
|
||||||
auth = RemoteAuth(baseUrl: rs.baseUrl, email: rs.email, password: rs.password);
|
|
||||||
}
|
|
||||||
|
|
||||||
final client = RemoteJsonClient(rs.baseUrl, rs.indexPath, auth: auth);
|
|
||||||
|
|
||||||
try {
|
|
||||||
final items = await client.fetchAll();
|
|
||||||
debugPrint('[remote-sync][fetch] fetched ${items.length} items from ${rs.baseUrl}');
|
|
||||||
return items;
|
|
||||||
} catch (e, st) {
|
|
||||||
debugPrint('[remote-sync][fetch] client.fetchAll ERROR: $e\n$st');
|
|
||||||
return <RemotePhotoItem>[];
|
|
||||||
}
|
|
||||||
} catch (e, st) {
|
|
||||||
debugPrint('[remote-sync][fetch] ERROR: $e\n$st');
|
|
||||||
return <RemotePhotoItem>[];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Helper di debug: crea una lens temporanea, conta i remoti, poi dispose
|
|
||||||
int _countRemotesInSource(CollectionSource source) {
|
|
||||||
final lens = CollectionLens(source: source, filters: {});
|
|
||||||
try {
|
|
||||||
return lens.sortedEntries.where((e) => e.origin == 1 && e.trashed == 0).length;
|
|
||||||
} finally {
|
|
||||||
lens.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === DIAG: stampa conteggi remoti DB/Source/visibleEntries ===
|
|
||||||
Future<void> _printRemoteDiag(CollectionSource source, {String when = ''}) async {
|
|
||||||
try {
|
|
||||||
final dbRem = await localMediaDb.loadEntries(origin: 1);
|
|
||||||
final dbCount = dbRem.length;
|
|
||||||
final displayable = dbRem.where((e) => !e.trashed && e.isDisplayable).length;
|
|
||||||
final inSource = source.allEntries.where((e) => e.origin == 1 && !e.trashed).length;
|
|
||||||
final inVisible = source.visibleEntries.where((e) => e.origin == 1 && !e.trashed).length;
|
|
||||||
|
|
||||||
debugPrint('[diag$when] DB remoti=$dbCount, displayable=$displayable, '
|
|
||||||
'inSource=$inSource, inVisible=$inVisible');
|
|
||||||
} catch (e, st) {
|
|
||||||
debugPrint('[diag$when] ERROR: $e\n$st');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _initViewerEssentials() async {
|
Future<void> _initViewerEssentials() async {
|
||||||
// for video playback storage
|
|
||||||
await localMediaDb.init();
|
await localMediaDb.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -509,204 +333,15 @@ class _HomePageState extends State<HomePage> {
|
||||||
|
|
||||||
Future<AvesEntry?> _initViewerEntry({required String uri, required String? mimeType}) async {
|
Future<AvesEntry?> _initViewerEntry({required String uri, required String? mimeType}) async {
|
||||||
if (uri.startsWith('/')) {
|
if (uri.startsWith('/')) {
|
||||||
// convert this file path to a proper URI
|
|
||||||
uri = Uri.file(uri).toString();
|
uri = Uri.file(uri).toString();
|
||||||
}
|
}
|
||||||
final entry = await mediaFetchService.getEntry(uri, mimeType);
|
final entry = await mediaFetchService.getEntry(uri, mimeType);
|
||||||
if (entry != null) {
|
if (entry != null) {
|
||||||
// cataloguing is essential for coordinates and video rotation
|
|
||||||
await entry.catalog(background: false, force: false, persist: false);
|
await entry.catalog(background: false, force: false, persist: false);
|
||||||
}
|
}
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === DEBUG: apre la pagina di test remota con una seconda connessione al DB ===
|
|
||||||
Future<void> _openRemoteTestPage(BuildContext context) async {
|
|
||||||
if (_remoteTestOpen) return; // evita doppi push/sovrapposizioni
|
|
||||||
// blocca solo se il sync è effettivamente in corso
|
|
||||||
if (_remoteSyncActive) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Attendi fine sync remoto in corso prima di aprire RemoteTest')),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_remoteTestOpen = true;
|
|
||||||
|
|
||||||
Database? debugDb;
|
|
||||||
try {
|
|
||||||
final dbDir = await getDatabasesPath();
|
|
||||||
final dbPath = p.join(dbDir, 'metadata.db');
|
|
||||||
|
|
||||||
// Apri il DB in R/W (istanza indipendente) → niente "read only database"
|
|
||||||
debugDb = await openDatabase(
|
|
||||||
dbPath,
|
|
||||||
singleInstance: false,
|
|
||||||
onConfigure: (db) async {
|
|
||||||
await db.rawQuery('PRAGMA journal_mode=WAL');
|
|
||||||
await db.rawQuery('PRAGMA foreign_keys=ON');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!context.mounted) return;
|
|
||||||
|
|
||||||
final rs = await _safeLoadRemoteSettings();
|
|
||||||
final baseUrl = rs.baseUrl.isNotEmpty ? rs.baseUrl : RemoteSettings.defaultBaseUrl;
|
|
||||||
|
|
||||||
await Navigator.of(context).push(MaterialPageRoute(
|
|
||||||
builder: (_) => rtp.RemoteTestPage(
|
|
||||||
db: debugDb!,
|
|
||||||
baseUrl: baseUrl,
|
|
||||||
),
|
|
||||||
));
|
|
||||||
} catch (e, st) {
|
|
||||||
// ignore: avoid_print
|
|
||||||
print('[RemoteTest] errore apertura DB/pagina: $e\n$st');
|
|
||||||
if (!context.mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Errore RemoteTest: $e')),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
await debugDb?.close();
|
|
||||||
} catch (_) {}
|
|
||||||
_remoteTestOpen = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === DEBUG: dialog impostazioni remote (semplice) ===
|
|
||||||
Future<void> _openRemoteSettingsDialog(BuildContext context) async {
|
|
||||||
final s = await _safeLoadRemoteSettings();
|
|
||||||
final formKey = GlobalKey<FormState>();
|
|
||||||
bool enabled = s.enabled;
|
|
||||||
final baseUrlC = TextEditingController(text: s.baseUrl);
|
|
||||||
final indexC = TextEditingController(text: s.indexPath);
|
|
||||||
final emailC = TextEditingController(text: s.email);
|
|
||||||
final pwC = TextEditingController(text: s.password);
|
|
||||||
|
|
||||||
await showDialog<void>(
|
|
||||||
context: context,
|
|
||||||
builder: (_) => AlertDialog(
|
|
||||||
title: const Text('Remote Settings'),
|
|
||||||
content: Form(
|
|
||||||
key: formKey,
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
SwitchListTile(
|
|
||||||
title: const Text('Abilita sync remoto'),
|
|
||||||
value: enabled,
|
|
||||||
onChanged: (v) {
|
|
||||||
enabled = v;
|
|
||||||
},
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
TextFormField(
|
|
||||||
controller: baseUrlC,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Base URL',
|
|
||||||
hintText: 'https://prova.patachina.it',
|
|
||||||
),
|
|
||||||
validator: (v) => (v == null || v.isEmpty) ? 'Obbligatorio' : null,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
TextFormField(
|
|
||||||
controller: indexC,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Index path',
|
|
||||||
hintText: 'photos/',
|
|
||||||
),
|
|
||||||
validator: (v) => (v == null || v.isEmpty) ? 'Obbligatorio' : null,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
TextFormField(
|
|
||||||
controller: emailC,
|
|
||||||
decoration: const InputDecoration(labelText: 'User/Email'),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
TextFormField(
|
|
||||||
controller: pwC,
|
|
||||||
obscureText: true,
|
|
||||||
decoration: const InputDecoration(labelText: 'Password'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(context).maybePop(),
|
|
||||||
child: const Text('Annulla'),
|
|
||||||
),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: () async {
|
|
||||||
if (!formKey.currentState!.validate()) return;
|
|
||||||
final upd = RemoteSettings(
|
|
||||||
enabled: enabled,
|
|
||||||
baseUrl: baseUrlC.text.trim(),
|
|
||||||
indexPath: indexC.text.trim(),
|
|
||||||
email: emailC.text.trim(),
|
|
||||||
password: pwC.text,
|
|
||||||
);
|
|
||||||
await upd.save();
|
|
||||||
|
|
||||||
// forza refresh immediato delle impostazioni e headers
|
|
||||||
await RemoteHttp.refreshFromSettings();
|
|
||||||
unawaited(RemoteHttp.warmUp());
|
|
||||||
|
|
||||||
if (context.mounted) Navigator.of(context).pop();
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context)
|
|
||||||
.showSnackBar(const SnackBar(content: Text('Impostazioni salvate')));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.save),
|
|
||||||
label: const Text('Salva'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
baseUrlC.dispose();
|
|
||||||
indexC.dispose();
|
|
||||||
emailC.dispose();
|
|
||||||
pwC.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- DEBUG: wrapper che aggiunge 2 FAB (Settings + Remote Test) ---
|
|
||||||
Widget _wrapWithRemoteDebug(BuildContext context, Widget child) {
|
|
||||||
if (!kDebugMode) return child;
|
|
||||||
return Stack(
|
|
||||||
children: [
|
|
||||||
child,
|
|
||||||
Positioned(
|
|
||||||
right: 16,
|
|
||||||
bottom: 16,
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
FloatingActionButton(
|
|
||||||
heroTag: 'remote_debug_settings_fab',
|
|
||||||
mini: true,
|
|
||||||
onPressed: () => _openRemoteSettingsDialog(context),
|
|
||||||
tooltip: 'Remote Settings',
|
|
||||||
child: const Icon(Icons.settings),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
FloatingActionButton(
|
|
||||||
heroTag: 'remote_debug_test_fab',
|
|
||||||
onPressed: () => _openRemoteTestPage(context),
|
|
||||||
tooltip: 'Remote Test',
|
|
||||||
child: const Icon(Icons.image_search),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Route> _getRedirectRoute(AppMode appMode) async {
|
Future<Route> _getRedirectRoute(AppMode appMode) async {
|
||||||
String routeName;
|
String routeName;
|
||||||
Set<CollectionFilter?>? filters;
|
Set<CollectionFilter?>? filters;
|
||||||
|
|
@ -715,19 +350,16 @@ class _HomePageState extends State<HomePage> {
|
||||||
case AppMode.setWallpaper:
|
case AppMode.setWallpaper:
|
||||||
return DirectMaterialPageRoute(
|
return DirectMaterialPageRoute(
|
||||||
settings: const RouteSettings(name: WallpaperPage.routeName),
|
settings: const RouteSettings(name: WallpaperPage.routeName),
|
||||||
builder: (_) {
|
builder: (_) => WallpaperPage(entry: _viewerEntry),
|
||||||
return WallpaperPage(
|
|
||||||
entry: _viewerEntry,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
case AppMode.view:
|
case AppMode.view:
|
||||||
AvesEntry viewerEntry = _viewerEntry!;
|
AvesEntry viewerEntry = _viewerEntry!;
|
||||||
CollectionLens? collection;
|
CollectionLens? collection;
|
||||||
|
|
||||||
final source = context.read<CollectionSource>();
|
final source = context.read<CollectionSource>();
|
||||||
final album = viewerEntry.directory;
|
final album = viewerEntry.directory;
|
||||||
if (album != null) {
|
if (album != null) {
|
||||||
// wait for collection to pass the `loading` state
|
|
||||||
final loadingCompleter = Completer();
|
final loadingCompleter = Completer();
|
||||||
final stateNotifier = source.stateNotifier;
|
final stateNotifier = source.stateNotifier;
|
||||||
void _onSourceStateChanged() {
|
void _onSourceStateChanged() {
|
||||||
|
|
@ -741,16 +373,10 @@ class _HomePageState extends State<HomePage> {
|
||||||
_onSourceStateChanged();
|
_onSourceStateChanged();
|
||||||
await loadingCompleter.future;
|
await loadingCompleter.future;
|
||||||
|
|
||||||
// ⚠️ NON lanciamo più il sync qui (evita contese durante il viewer)
|
|
||||||
// unawaited(rrs.runRemoteSyncOnceManaged());
|
|
||||||
|
|
||||||
collection = CollectionLens(
|
collection = CollectionLens(
|
||||||
source: source,
|
source: source,
|
||||||
filters: {StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))},
|
filters: {StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))},
|
||||||
listenToSource: false,
|
listenToSource: false,
|
||||||
// if we group bursts, opening a burst sub-entry should:
|
|
||||||
// - identify and select the containing main entry,
|
|
||||||
// - select the sub-entry in the Viewer page.
|
|
||||||
stackBursts: false,
|
stackBursts: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -760,39 +386,22 @@ class _HomePageState extends State<HomePage> {
|
||||||
if (collectionEntry != null) {
|
if (collectionEntry != null) {
|
||||||
viewerEntry = collectionEntry;
|
viewerEntry = collectionEntry;
|
||||||
} else {
|
} else {
|
||||||
debugPrint('collection does not contain viewerEntry=$viewerEntry');
|
|
||||||
collection = null;
|
collection = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return DirectMaterialPageRoute(
|
return DirectMaterialPageRoute(
|
||||||
settings: const RouteSettings(name: EntryViewerPage.routeName),
|
settings: const RouteSettings(name: EntryViewerPage.routeName),
|
||||||
builder: (_) {
|
builder: (_) => EntryViewerPage(collection: collection, initialEntry: viewerEntry),
|
||||||
return EntryViewerPage(
|
|
||||||
collection: collection,
|
|
||||||
initialEntry: viewerEntry,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
case AppMode.edit:
|
case AppMode.edit:
|
||||||
return DirectMaterialPageRoute(
|
return DirectMaterialPageRoute(
|
||||||
settings: const RouteSettings(name: EntryViewerPage.routeName),
|
settings: const RouteSettings(name: EntryViewerPage.routeName),
|
||||||
builder: (_) {
|
builder: (_) => ImageEditorPage(entry: _viewerEntry!),
|
||||||
return ImageEditorPage(
|
|
||||||
entry: _viewerEntry!,
|
|
||||||
);
|
);
|
||||||
},
|
|
||||||
);
|
default:
|
||||||
case AppMode.initialization:
|
|
||||||
case AppMode.main:
|
|
||||||
case AppMode.pickCollectionFiltersExternal:
|
|
||||||
case AppMode.pickSingleMediaExternal:
|
|
||||||
case AppMode.pickMultipleMediaExternal:
|
|
||||||
case AppMode.pickFilteredMediaInternal:
|
|
||||||
case AppMode.pickUnfilteredMediaInternal:
|
|
||||||
case AppMode.pickFilterInternal:
|
|
||||||
case AppMode.previewMap:
|
|
||||||
case AppMode.screenSaver:
|
|
||||||
case AppMode.slideshow:
|
|
||||||
routeName = _initialRouteName ?? settings.homeNavItem.route;
|
routeName = _initialRouteName ?? settings.homeNavItem.route;
|
||||||
filters = _initialFilters ??
|
filters = _initialFilters ??
|
||||||
(settings.homeNavItem.route == CollectionPage.routeName ? settings.homeCustomCollection : {});
|
(settings.homeNavItem.route == CollectionPage.routeName ? settings.homeCustomCollection : {});
|
||||||
|
|
@ -804,7 +413,6 @@ class _HomePageState extends State<HomePage> {
|
||||||
);
|
);
|
||||||
|
|
||||||
final source = context.read<CollectionSource>();
|
final source = context.read<CollectionSource>();
|
||||||
|
|
||||||
switch (routeName) {
|
switch (routeName) {
|
||||||
case AlbumListPage.routeName:
|
case AlbumListPage.routeName:
|
||||||
return buildRoute((context) => const AlbumListPage(initialGroup: null));
|
return buildRoute((context) => const AlbumListPage(initialGroup: null));
|
||||||
|
|
@ -846,60 +454,8 @@ class _HomePageState extends State<HomePage> {
|
||||||
);
|
);
|
||||||
case CollectionPage.routeName:
|
case CollectionPage.routeName:
|
||||||
default:
|
default:
|
||||||
// Wrapper di debug che aggiunge i due FAB (solo in debug)
|
return buildRoute((context) => CollectionPage(source: source, filters: filters));
|
||||||
return buildRoute(
|
|
||||||
(context) => _wrapWithRemoteDebug(
|
|
||||||
context,
|
|
||||||
CollectionPage(source: source, filters: filters),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------
|
|
||||||
// Utility sicure per remote
|
|
||||||
// -------------------------
|
|
||||||
|
|
||||||
// safe load of RemoteSettings with timeout and fallback
|
|
||||||
Future<RemoteSettings> _safeLoadRemoteSettings({Duration timeout = const Duration(seconds: 5)}) async {
|
|
||||||
try {
|
|
||||||
return await RemoteSettings.load().timeout(timeout);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('[remote] RemoteSettings.load failed: $e — using defaults');
|
|
||||||
return RemoteSettings(
|
|
||||||
enabled: RemoteSettings.defaultEnabled,
|
|
||||||
baseUrl: RemoteSettings.defaultBaseUrl,
|
|
||||||
indexPath: RemoteSettings.defaultIndexPath,
|
|
||||||
email: RemoteSettings.defaultEmail,
|
|
||||||
password: RemoteSettings.defaultPassword,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// safe headers retrieval with timeout and empty fallback
|
|
||||||
Future<Map<String, String>> _safeHeaders({Duration timeout = const Duration(seconds: 6)}) async {
|
|
||||||
try {
|
|
||||||
return await RemoteHttp.headers().timeout(timeout);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('[remote] RemoteHttp.headers failed: $e — returning empty headers');
|
|
||||||
return const {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// debug helper: clear remote keys from secure storage (debug only)
|
|
||||||
Future<void> _debugClearRemoteKeys() async {
|
|
||||||
if (!kDebugMode) return;
|
|
||||||
try {
|
|
||||||
// FlutterSecureStorage non è const
|
|
||||||
final storage = FlutterSecureStorage();
|
|
||||||
await storage.delete(key: 'remote_base_url');
|
|
||||||
await storage.delete(key: 'remote_index_path');
|
|
||||||
await storage.delete(key: 'remote_email');
|
|
||||||
await storage.delete(key: 'remote_password');
|
|
||||||
await storage.delete(key: 'remote_enabled');
|
|
||||||
debugPrint('[remote] debugClearRemoteKeys executed');
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('[remote] debugClearRemoteKeys failed: $e');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,22 +47,20 @@ import 'package:latlong2/latlong.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
// --- IMPORT aggiunti per integrazione remota / telemetria ---
|
// --- REMOTO / DEBUG ---
|
||||||
import 'package:flutter/foundation.dart' show kDebugMode;
|
import 'package:flutter/foundation.dart' show kDebugMode;
|
||||||
import 'package:sqflite/sqflite.dart';
|
import 'package:sqflite/sqflite.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:aves/remote/remote_test_page.dart' as rtp;
|
import 'package:aves/remote/remote_test_page.dart' as rtp;
|
||||||
import 'package:aves/remote/run_remote_sync.dart' as rrs;
|
import 'package:aves/remote/run_remote_sync.dart' as rrs;
|
||||||
import 'package:aves/remote/remote_settings.dart';
|
import 'package:aves/remote/remote_settings.dart';
|
||||||
import 'package:aves/remote/remote_http.dart'; // PERF/REMOTE: warm-up headers
|
import 'package:aves/remote/remote_http.dart';
|
||||||
import 'package:aves/remote/remote_models.dart'; // RemotePhotoItem
|
import 'package:aves/remote/remote_models.dart';
|
||||||
|
|
||||||
// --- IMPORT per client reale ---
|
|
||||||
import 'package:aves/remote/remote_client.dart';
|
import 'package:aves/remote/remote_client.dart';
|
||||||
import 'package:aves/remote/auth_client.dart';
|
import 'package:aves/remote/auth_client.dart';
|
||||||
|
|
||||||
// secure storage import (used only in debug helper)
|
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:aves/remote/remote_sync_bus.dart';
|
||||||
|
import 'package:aves/remote/remote_repository.dart';
|
||||||
|
|
||||||
class HomePage extends StatefulWidget {
|
class HomePage extends StatefulWidget {
|
||||||
static const routeName = '/';
|
static const routeName = '/';
|
||||||
|
|
@ -88,12 +86,11 @@ class _HomePageState extends State<HomePage> {
|
||||||
List<String>? _secureUris;
|
List<String>? _secureUris;
|
||||||
(Object, StackTrace)? _setupError;
|
(Object, StackTrace)? _setupError;
|
||||||
|
|
||||||
// guard UI per schedulare UNA sola run del sync da Home
|
// sync remoto: singola esecuzione
|
||||||
bool _remoteSyncScheduled = false;
|
bool _remoteSyncScheduled = false;
|
||||||
// indica se il sync è effettivamente in corso
|
|
||||||
bool _remoteSyncActive = false;
|
bool _remoteSyncActive = false;
|
||||||
|
|
||||||
// guard per evitare doppi push della pagina di test remota
|
// pagina test remoto (FAB debug)
|
||||||
bool _remoteTestOpen = false;
|
bool _remoteTestOpen = false;
|
||||||
|
|
||||||
static const allowedShortcutRoutes = [
|
static const allowedShortcutRoutes = [
|
||||||
|
|
@ -126,8 +123,6 @@ class _HomePageState extends State<HomePage> {
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
|
|
||||||
if (await windowService.isActivity()) {
|
if (await windowService.isActivity()) {
|
||||||
// do not check whether permission was granted, because some app stores
|
|
||||||
// hide in some countries apps that force quit on permission denial
|
|
||||||
await Permissions.mediaAccess.request();
|
await Permissions.mediaAccess.request();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,17 +139,14 @@ class _HomePageState extends State<HomePage> {
|
||||||
await availability.onNewIntent();
|
await availability.onNewIntent();
|
||||||
await androidFileUtils.init();
|
await androidFileUtils.init();
|
||||||
|
|
||||||
// PERF/REMOTE: warm-up headers (Bearer) in background — safe version
|
// Warm-up header remoti (non blocca UI)
|
||||||
unawaited(Future(() async {
|
unawaited(Future(() async {
|
||||||
try {
|
try {
|
||||||
final s = await _safeLoadRemoteSettings();
|
final s = await _safeLoadRemoteSettings();
|
||||||
if (s.enabled && s.baseUrl.trim().isNotEmpty) {
|
if (s.enabled && s.baseUrl.trim().isNotEmpty) {
|
||||||
await _safeHeaders(); // popola la cache per peekHeaders() in modo sicuro
|
await _safeHeaders();
|
||||||
debugPrint('[startup] remote headers warm-up done (safe)');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('[startup] remote headers warm-up skipped: $e');
|
|
||||||
}
|
}
|
||||||
|
} catch (_) {}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (!{
|
if (!{
|
||||||
|
|
@ -192,7 +184,6 @@ class _HomePageState extends State<HomePage> {
|
||||||
case IntentActions.setWallpaper:
|
case IntentActions.setWallpaper:
|
||||||
appMode = AppMode.setWallpaper;
|
appMode = AppMode.setWallpaper;
|
||||||
case IntentActions.pickItems:
|
case IntentActions.pickItems:
|
||||||
// some apps define multiple types, separated by a space
|
|
||||||
final multiple = (intentData[IntentDataKeys.allowMultiple] as bool?) ?? false;
|
final multiple = (intentData[IntentDataKeys.allowMultiple] as bool?) ?? false;
|
||||||
debugPrint('pick mimeType=$intentMimeType multiple=$multiple');
|
debugPrint('pick mimeType=$intentMimeType multiple=$multiple');
|
||||||
appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal;
|
appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal;
|
||||||
|
|
@ -214,7 +205,6 @@ class _HomePageState extends State<HomePage> {
|
||||||
if (widgetId == null) {
|
if (widgetId == null) {
|
||||||
error = true;
|
error = true;
|
||||||
} else {
|
} else {
|
||||||
// widget settings may be modified in a different process after channel setup
|
|
||||||
await settings.reload();
|
await settings.reload();
|
||||||
final page = settings.getWidgetOpenPage(widgetId);
|
final page = settings.getWidgetOpenPage(widgetId);
|
||||||
switch (page) {
|
switch (page) {
|
||||||
|
|
@ -276,97 +266,97 @@ class _HomePageState extends State<HomePage> {
|
||||||
unawaited(AnalysisService.registerCallback());
|
unawaited(AnalysisService.registerCallback());
|
||||||
|
|
||||||
final source = context.read<CollectionSource>();
|
final source = context.read<CollectionSource>();
|
||||||
|
|
||||||
|
//////////////////////////////////////////////////////////////////////////////
|
||||||
|
//////////////////////////////////////////////////////////////////////////////
|
||||||
|
//////////////////////////////////////////////////////////////////////////////
|
||||||
|
// STEP 1 STARTUP VELOCE (MARKER 1/6) - NON MODIFICARE FUORI DA QUESTI MARKER
|
||||||
|
// STEP 1 STARTUP VELOCE (MARKER 2/6) - DB cache -> init in background
|
||||||
|
// STEP 1 STARTUP VELOCE (MARKER 3/6) - DB vuoto -> init standard con progress
|
||||||
|
//////////////////////////////////////////////////////////////////////////////
|
||||||
|
//////////////////////////////////////////////////////////////////////////////
|
||||||
|
//////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// Capisco se c'è cache nel DB (locali e/o remoti)
|
||||||
|
bool hasAnyCache = false;
|
||||||
|
try {
|
||||||
|
await localMediaDb.init(); // assicura DB pronto
|
||||||
|
final rows = await localMediaDb.rawDb.rawQuery(
|
||||||
|
'SELECT 1 FROM entry WHERE trashed=0 LIMIT 1',
|
||||||
|
);
|
||||||
|
hasAnyCache = rows.isNotEmpty;
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
final loadTopEntriesFirst =
|
||||||
|
settings.homeNavItem.route == CollectionPage.routeName &&
|
||||||
|
settings.homeCustomCollection.isEmpty;
|
||||||
|
|
||||||
|
// Se la source non è full scope, dobbiamo comunque fare init almeno una volta
|
||||||
if (source.loadedScope != CollectionSource.fullScope) {
|
if (source.loadedScope != CollectionSource.fullScope) {
|
||||||
await reportService.log(
|
await reportService.log(
|
||||||
'Initialize source to start app with mode=$appMode, loaded scope=${source.loadedScope}',
|
'Initialize source to start app with mode=$appMode, loaded scope=${source.loadedScope}',
|
||||||
);
|
);
|
||||||
final loadTopEntriesFirst =
|
|
||||||
settings.homeNavItem.route == CollectionPage.routeName && settings.homeCustomCollection.isEmpty;
|
|
||||||
|
|
||||||
// PERF: UI-first → niente analisi prima della prima paint
|
if (hasAnyCache) {
|
||||||
source.canAnalyze = false;
|
// ✅ DB ha dati: avvio veloce -> init in background (non blocca UI)
|
||||||
final swInit = Stopwatch()..start();
|
debugPrint('[startup] DB cache present -> init in background (fast start)');
|
||||||
await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst);
|
|
||||||
swInit.stop();
|
|
||||||
debugPrint('[startup] source.init done in ${swInit.elapsedMilliseconds}ms');
|
|
||||||
}
|
|
||||||
|
|
||||||
// REMOTE: aggiungi remoti visibili (origin=1, trashed=0)
|
|
||||||
final swAppend1 = Stopwatch()..start();
|
|
||||||
await source.appendRemoteEntriesFromDb();
|
|
||||||
swAppend1.stop();
|
|
||||||
debugPrint('[startup] appendRemoteEntries (pre-sync) in ${swAppend1.elapsedMilliseconds}ms');
|
|
||||||
|
|
||||||
// PERF: riattiva l’analisi in background appena la UI è pronta
|
|
||||||
unawaited(Future.delayed(const Duration(milliseconds: 300)).then((_) {
|
|
||||||
source.canAnalyze = true;
|
source.canAnalyze = true;
|
||||||
debugPrint('[startup] analysis re-enabled in background');
|
|
||||||
}));
|
|
||||||
|
|
||||||
// === SYNC REMOTO post-init (non blocca la UI) ===
|
unawaited(
|
||||||
if (!_remoteSyncScheduled) {
|
source
|
||||||
_remoteSyncScheduled = true; // una sola schedulazione per avvio
|
.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst)
|
||||||
unawaited(Future(() async {
|
.then((_) async {
|
||||||
try {
|
// ✅ SOLO DOPO init: possiamo usare addEntries/append remoti senza crash
|
||||||
await RemoteSettings.debugSeedIfEmpty();
|
|
||||||
final rs = await _safeLoadRemoteSettings();
|
|
||||||
if (!rs.enabled) return;
|
|
||||||
|
|
||||||
// attesa fine loading
|
|
||||||
final notifier = source.stateNotifier;
|
|
||||||
if (notifier.value == SourceState.loading) {
|
|
||||||
final completer = Completer<void>();
|
|
||||||
void onState() {
|
|
||||||
if (notifier.value != SourceState.loading) {
|
|
||||||
notifier.removeListener(onState);
|
|
||||||
completer.complete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
notifier.addListener(onState);
|
|
||||||
// nel caso non sia già loading:
|
|
||||||
onState();
|
|
||||||
await completer.future;
|
|
||||||
}
|
|
||||||
|
|
||||||
// piccolo margine per step secondari (tag, ecc.)
|
|
||||||
await Future.delayed(const Duration(milliseconds: 400));
|
|
||||||
|
|
||||||
// ⬇️ SYNC su **stessa connessione** + FETCH (obbligatorio)
|
|
||||||
debugPrint('[remote-sync] START (scheduled=$_remoteSyncScheduled)');
|
|
||||||
_remoteSyncActive = true;
|
|
||||||
try {
|
|
||||||
final swSync = Stopwatch()..start();
|
|
||||||
final imported = await rrs.runRemoteSyncOnceManaged(
|
|
||||||
fetch: _fetchAllRemoteItems, // 👈 PASSIAMO IL FETCH
|
|
||||||
).timeout(const Duration(seconds: 60)); // timeout regolabile
|
|
||||||
swSync.stop();
|
|
||||||
debugPrint('[remote-sync] completed in ${swSync.elapsedMilliseconds}ms, imported=$imported');
|
|
||||||
} on TimeoutException catch (e) {
|
|
||||||
debugPrint('[remote-sync] TIMEOUT after 60s: $e');
|
|
||||||
} catch (e, st) {
|
|
||||||
debugPrint('[remote-sync] error: $e\n$st');
|
|
||||||
} finally {
|
|
||||||
_remoteSyncActive = false;
|
|
||||||
debugPrint('[remote-sync] END (active=$_remoteSyncActive)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// REMOTE: dopo il sync, append di eventuali nuovi remoti
|
|
||||||
if (mounted) {
|
|
||||||
final swAppend2 = Stopwatch()..start();
|
|
||||||
await source.appendRemoteEntriesFromDb();
|
await source.appendRemoteEntriesFromDb();
|
||||||
swAppend2.stop();
|
debugPrint('[startup][bg] remote append after init done');
|
||||||
debugPrint('[remote-sync] appendRemoteEntries (post-sync) in ${swAppend2.elapsedMilliseconds}ms');
|
|
||||||
|
|
||||||
// 🔎 Conteggio di debug usando una CollectionLens temporanea
|
if (!_remoteSyncScheduled) {
|
||||||
final c = _countRemotesInSource(source);
|
_remoteSyncScheduled = true;
|
||||||
debugPrint('[check] remoti in CollectionSource = $c');
|
final sourceRef = source;
|
||||||
|
unawaited(Future.microtask(() => _runRemoteSync(sourceRef)));
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
}),
|
||||||
debugPrint('[remote-sync] outer error: $e\n$st');
|
);
|
||||||
|
} else {
|
||||||
|
// ✅ DB vuoto: comportamento Aves standard -> await init (progress locale)
|
||||||
|
debugPrint('[startup] DB empty -> await init (Aves standard)');
|
||||||
|
source.canAnalyze = true;
|
||||||
|
await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst);
|
||||||
|
|
||||||
|
// ora init è avvenuta -> safe append remoti da DB (se presenti)
|
||||||
|
await source.appendRemoteEntriesFromDb();
|
||||||
|
|
||||||
|
if (!_remoteSyncScheduled) {
|
||||||
|
_remoteSyncScheduled = true;
|
||||||
|
final sourceRef = source;
|
||||||
|
unawaited(Future.microtask(() => _runRemoteSync(sourceRef)));
|
||||||
}
|
}
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Source già full scope (hot state): safe append remoti + sync
|
||||||
|
debugPrint('[startup] source already fullScope');
|
||||||
|
await source.appendRemoteEntriesFromDb();
|
||||||
|
|
||||||
|
if (!_remoteSyncScheduled) {
|
||||||
|
_remoteSyncScheduled = true;
|
||||||
|
final sourceRef = source;
|
||||||
|
unawaited(Future.microtask(() => _runRemoteSync(sourceRef)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DIAG: stato prima/dopo (facoltativo)
|
||||||
|
unawaited(_printRemoteDiag(source, when: ' PRE'));
|
||||||
|
|
||||||
|
//////////////////////////////////////////////////////////////////////////////
|
||||||
|
//////////////////////////////////////////////////////////////////////////////
|
||||||
|
//////////////////////////////////////////////////////////////////////////////
|
||||||
|
// STEP 1 STARTUP VELOCE (MARKER 4/6) - FINE BLOCCO
|
||||||
|
// STEP 1 STARTUP VELOCE (MARKER 5/6) - QUI PUOI AGGIUNGERE LOG/DIAG
|
||||||
|
// STEP 1 STARTUP VELOCE (MARKER 6/6) - NON MODIFICARE FUORI DA QUESTI MARKER
|
||||||
|
//////////////////////////////////////////////////////////////////////////////
|
||||||
|
//////////////////////////////////////////////////////////////////////////////
|
||||||
|
//////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case AppMode.screenSaver:
|
case AppMode.screenSaver:
|
||||||
|
|
@ -383,7 +373,6 @@ class _HomePageState extends State<HomePage> {
|
||||||
unawaited(AnalysisService.registerCallback());
|
unawaited(AnalysisService.registerCallback());
|
||||||
await reportService.log('Initialize source to view item in directory $directory');
|
await reportService.log('Initialize source to view item in directory $directory');
|
||||||
final source = context.read<CollectionSource>();
|
final source = context.read<CollectionSource>();
|
||||||
// analysis is necessary to display neighbour items when the initial item is a new one
|
|
||||||
source.canAnalyze = true;
|
source.canAnalyze = true;
|
||||||
await source.init(scope: {StoredAlbumFilter(directory, null)});
|
await source.init(scope: {StoredAlbumFilter(directory, null)});
|
||||||
}
|
}
|
||||||
|
|
@ -403,8 +392,7 @@ class _HomePageState extends State<HomePage> {
|
||||||
|
|
||||||
debugPrint('Home setup complete in ${stopwatch.elapsed.inMilliseconds}ms');
|
debugPrint('Home setup complete in ${stopwatch.elapsed.inMilliseconds}ms');
|
||||||
|
|
||||||
// `pushReplacement` is not enough in some edge cases
|
// navigazione finale
|
||||||
// e.g. when opening the viewer in `view` mode should replace a viewer in `main` mode
|
|
||||||
unawaited(
|
unawaited(
|
||||||
Navigator.maybeOf(context)?.pushAndRemoveUntil(
|
Navigator.maybeOf(context)?.pushAndRemoveUntil(
|
||||||
await _getRedirectRoute(appMode),
|
await _getRedirectRoute(appMode),
|
||||||
|
|
@ -417,26 +405,102 @@ class _HomePageState extends State<HomePage> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// === FETCH per il sync (implementazione reale usando RemoteJsonClient) ===
|
// === SYNC REMOTO (indipendente dal context della Home) ===
|
||||||
|
Future<void> _runRemoteSync(CollectionSource source) async {
|
||||||
|
try {
|
||||||
|
final rs = await _safeLoadRemoteSettings();
|
||||||
|
if (!rs.enabled) {
|
||||||
|
debugPrint('[remote-sync] disabled → skip');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se locali ancora in loading, attendi
|
||||||
|
try {
|
||||||
|
if (source.stateNotifier.value == SourceState.loading) {
|
||||||
|
final c = Completer<void>();
|
||||||
|
void onState() {
|
||||||
|
if (source.stateNotifier.value != SourceState.loading) {
|
||||||
|
source.stateNotifier.removeListener(onState);
|
||||||
|
c.complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
source.stateNotifier.addListener(onState);
|
||||||
|
onState();
|
||||||
|
await c.future;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
_remoteSyncActive = true;
|
||||||
|
|
||||||
|
// FULL fetch dal server
|
||||||
|
final items = await _fetchAllRemoteItems();
|
||||||
|
final total = items.length;
|
||||||
|
final serverIds = items.map((e) => e.id).where((v) => v.isNotEmpty).toSet();
|
||||||
|
|
||||||
|
// progress start
|
||||||
|
RemoteSyncBus.instance.start(phase: 'Sync remoto…', total: total);
|
||||||
|
|
||||||
|
// upsert chunked (progresso reale X/Y)
|
||||||
|
final repo = RemoteRepository(localMediaDb.rawDb);
|
||||||
|
const chunkSize = 200;
|
||||||
|
int done = 0;
|
||||||
|
|
||||||
|
final sw = Stopwatch()..start();
|
||||||
|
for (var offset = 0; offset < total; offset += chunkSize) {
|
||||||
|
final end = (offset + chunkSize < total) ? offset + chunkSize : total;
|
||||||
|
final chunk = items.sublist(offset, end);
|
||||||
|
|
||||||
|
// re-usa il tuo upsertAll esistente
|
||||||
|
await repo.upsertAll(chunk, chunkSize: chunkSize);
|
||||||
|
|
||||||
|
done = end;
|
||||||
|
RemoteSyncBus.instance.update(
|
||||||
|
phase: 'Sync remoto…',
|
||||||
|
done: done,
|
||||||
|
total: total,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// prune hard-delete (solo perché questa è una FULL LIST)
|
||||||
|
final pruned = await repo.pruneMissingRemotes(serverIds);
|
||||||
|
debugPrint('[remote-sync] prune deleted=$pruned');
|
||||||
|
|
||||||
|
sw.stop();
|
||||||
|
debugPrint('[remote-sync] completed in ${sw.elapsedMilliseconds}ms, total=$total');
|
||||||
|
|
||||||
|
// append remoti alla source
|
||||||
|
await source.appendRemoteEntriesFromDb();
|
||||||
|
|
||||||
|
RemoteSyncBus.instance.finish();
|
||||||
|
unawaited(_printRemoteDiag(source, when: ' POST'));
|
||||||
|
} on TimeoutException catch (e) {
|
||||||
|
debugPrint('[remote-sync] TIMEOUT: $e');
|
||||||
|
RemoteSyncBus.instance.clear();
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('[remote-sync] error: $e\n$st');
|
||||||
|
RemoteSyncBus.instance.clear();
|
||||||
|
} finally {
|
||||||
|
_remoteSyncActive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === FETCH remoto reale ===
|
||||||
Future<List<RemotePhotoItem>> _fetchAllRemoteItems() async {
|
Future<List<RemotePhotoItem>> _fetchAllRemoteItems() async {
|
||||||
try {
|
try {
|
||||||
final rs = await _safeLoadRemoteSettings();
|
final rs = await _safeLoadRemoteSettings();
|
||||||
if (!rs.enabled || rs.baseUrl.trim().isEmpty) {
|
if (!rs.enabled || rs.baseUrl.trim().isEmpty) {
|
||||||
debugPrint('[remote-sync][fetch] disabled or baseUrl empty');
|
|
||||||
return <RemotePhotoItem>[];
|
return <RemotePhotoItem>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Costruisci l'auth solo se sono presenti credenziali
|
|
||||||
RemoteAuth? auth;
|
RemoteAuth? auth;
|
||||||
if (rs.email.isNotEmpty && rs.password.isNotEmpty) {
|
if (rs.email.isNotEmpty && rs.password.isNotEmpty) {
|
||||||
auth = RemoteAuth(baseUrl: rs.baseUrl, email: rs.email, password: rs.password);
|
auth = RemoteAuth(baseUrl: rs.baseUrl, email: rs.email, password: rs.password);
|
||||||
}
|
}
|
||||||
|
|
||||||
final client = RemoteJsonClient(rs.baseUrl, rs.indexPath, auth: auth);
|
final client = RemoteJsonClient(rs.baseUrl, rs.indexPath, auth: auth);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final items = await client.fetchAll();
|
final items = await client.fetchAll();
|
||||||
debugPrint('[remote-sync][fetch] fetched ${items.length} items from ${rs.baseUrl}');
|
debugPrint('[remote-sync][fetch] fetched ${items.length} items');
|
||||||
return items;
|
return items;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('[remote-sync][fetch] client.fetchAll ERROR: $e\n$st');
|
debugPrint('[remote-sync][fetch] client.fetchAll ERROR: $e\n$st');
|
||||||
|
|
@ -448,18 +512,23 @@ class _HomePageState extends State<HomePage> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Helper di debug: crea una lens temporanea, conta i remoti, poi dispose
|
// --- DIAGNOSTICA ---
|
||||||
int _countRemotesInSource(CollectionSource source) {
|
Future<void> _printRemoteDiag(CollectionSource source, {String when = ''}) async {
|
||||||
final lens = CollectionLens(source: source, filters: {});
|
|
||||||
try {
|
try {
|
||||||
return lens.sortedEntries.where((e) => e.origin == 1 && e.trashed == 0).length;
|
final dbRem = await localMediaDb.loadEntries(origin: 1);
|
||||||
} finally {
|
final dbCount = dbRem.length;
|
||||||
lens.dispose();
|
final displayable = dbRem.where((e) => !e.trashed && e.isDisplayable).length;
|
||||||
|
final inSource = source.allEntries.where((e) => e.origin == 1 && !e.trashed).length;
|
||||||
|
final inVisible = source.visibleEntries.where((e) => e.origin == 1 && !e.trashed).length;
|
||||||
|
|
||||||
|
debugPrint('[diag$when] DB remoti=$dbCount, displayable=$displayable, '
|
||||||
|
'inSource=$inSource, inVisible=$inVisible');
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('[diag$when] ERROR: $e\n$st');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initViewerEssentials() async {
|
Future<void> _initViewerEssentials() async {
|
||||||
// for video playback storage
|
|
||||||
await localMediaDb.init();
|
await localMediaDb.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -471,21 +540,18 @@ class _HomePageState extends State<HomePage> {
|
||||||
|
|
||||||
Future<AvesEntry?> _initViewerEntry({required String uri, required String? mimeType}) async {
|
Future<AvesEntry?> _initViewerEntry({required String uri, required String? mimeType}) async {
|
||||||
if (uri.startsWith('/')) {
|
if (uri.startsWith('/')) {
|
||||||
// convert this file path to a proper URI
|
|
||||||
uri = Uri.file(uri).toString();
|
uri = Uri.file(uri).toString();
|
||||||
}
|
}
|
||||||
final entry = await mediaFetchService.getEntry(uri, mimeType);
|
final entry = await mediaFetchService.getEntry(uri, mimeType);
|
||||||
if (entry != null) {
|
if (entry != null) {
|
||||||
// cataloguing is essential for coordinates and video rotation
|
|
||||||
await entry.catalog(background: false, force: false, persist: false);
|
await entry.catalog(background: false, force: false, persist: false);
|
||||||
}
|
}
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === DEBUG: apre la pagina di test remota con una seconda connessione al DB ===
|
// === DEBUG: pagina test remoto con DB indipendente ===
|
||||||
Future<void> _openRemoteTestPage(BuildContext context) async {
|
Future<void> _openRemoteTestPage(BuildContext context) async {
|
||||||
if (_remoteTestOpen) return; // evita doppi push/sovrapposizioni
|
if (_remoteTestOpen) return;
|
||||||
// blocca solo se il sync è effettivamente in corso
|
|
||||||
if (_remoteSyncActive) {
|
if (_remoteSyncActive) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Attendi fine sync remoto in corso prima di aprire RemoteTest')),
|
const SnackBar(content: Text('Attendi fine sync remoto in corso prima di aprire RemoteTest')),
|
||||||
|
|
@ -500,7 +566,6 @@ class _HomePageState extends State<HomePage> {
|
||||||
final dbDir = await getDatabasesPath();
|
final dbDir = await getDatabasesPath();
|
||||||
final dbPath = p.join(dbDir, 'metadata.db');
|
final dbPath = p.join(dbDir, 'metadata.db');
|
||||||
|
|
||||||
// Apri il DB in R/W (istanza indipendente) → niente "read only database"
|
|
||||||
debugDb = await openDatabase(
|
debugDb = await openDatabase(
|
||||||
dbPath,
|
dbPath,
|
||||||
singleInstance: false,
|
singleInstance: false,
|
||||||
|
|
@ -613,7 +678,6 @@ class _HomePageState extends State<HomePage> {
|
||||||
);
|
);
|
||||||
await upd.save();
|
await upd.save();
|
||||||
|
|
||||||
// forza refresh immediato delle impostazioni e headers
|
|
||||||
await RemoteHttp.refreshFromSettings();
|
await RemoteHttp.refreshFromSettings();
|
||||||
unawaited(RemoteHttp.warmUp());
|
unawaited(RemoteHttp.warmUp());
|
||||||
|
|
||||||
|
|
@ -689,7 +753,6 @@ class _HomePageState extends State<HomePage> {
|
||||||
final source = context.read<CollectionSource>();
|
final source = context.read<CollectionSource>();
|
||||||
final album = viewerEntry.directory;
|
final album = viewerEntry.directory;
|
||||||
if (album != null) {
|
if (album != null) {
|
||||||
// wait for collection to pass the `loading` state
|
|
||||||
final loadingCompleter = Completer();
|
final loadingCompleter = Completer();
|
||||||
final stateNotifier = source.stateNotifier;
|
final stateNotifier = source.stateNotifier;
|
||||||
void _onSourceStateChanged() {
|
void _onSourceStateChanged() {
|
||||||
|
|
@ -703,16 +766,10 @@ class _HomePageState extends State<HomePage> {
|
||||||
_onSourceStateChanged();
|
_onSourceStateChanged();
|
||||||
await loadingCompleter.future;
|
await loadingCompleter.future;
|
||||||
|
|
||||||
// ⚠️ NON lanciamo più il sync qui (evita contese durante il viewer)
|
|
||||||
// unawaited(rrs.runRemoteSyncOnceManaged());
|
|
||||||
|
|
||||||
collection = CollectionLens(
|
collection = CollectionLens(
|
||||||
source: source,
|
source: source,
|
||||||
filters: {StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))},
|
filters: {StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))},
|
||||||
listenToSource: false,
|
listenToSource: false,
|
||||||
// if we group bursts, opening a burst sub-entry should:
|
|
||||||
// - identify and select the containing main entry,
|
|
||||||
// - select the sub-entry in the Viewer page.
|
|
||||||
stackBursts: false,
|
stackBursts: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -726,6 +783,7 @@ class _HomePageState extends State<HomePage> {
|
||||||
collection = null;
|
collection = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return DirectMaterialPageRoute(
|
return DirectMaterialPageRoute(
|
||||||
settings: const RouteSettings(name: EntryViewerPage.routeName),
|
settings: const RouteSettings(name: EntryViewerPage.routeName),
|
||||||
builder: (_) {
|
builder: (_) {
|
||||||
|
|
@ -808,7 +866,6 @@ class _HomePageState extends State<HomePage> {
|
||||||
);
|
);
|
||||||
case CollectionPage.routeName:
|
case CollectionPage.routeName:
|
||||||
default:
|
default:
|
||||||
// Wrapper di debug che aggiunge i due FAB (solo in debug)
|
|
||||||
return buildRoute(
|
return buildRoute(
|
||||||
(context) => _wrapWithRemoteDebug(
|
(context) => _wrapWithRemoteDebug(
|
||||||
context,
|
context,
|
||||||
|
|
@ -822,7 +879,6 @@ class _HomePageState extends State<HomePage> {
|
||||||
// Utility sicure per remote
|
// Utility sicure per remote
|
||||||
// -------------------------
|
// -------------------------
|
||||||
|
|
||||||
// safe load of RemoteSettings with timeout and fallback
|
|
||||||
Future<RemoteSettings> _safeLoadRemoteSettings({Duration timeout = const Duration(seconds: 5)}) async {
|
Future<RemoteSettings> _safeLoadRemoteSettings({Duration timeout = const Duration(seconds: 5)}) async {
|
||||||
try {
|
try {
|
||||||
return await RemoteSettings.load().timeout(timeout);
|
return await RemoteSettings.load().timeout(timeout);
|
||||||
|
|
@ -838,7 +894,6 @@ class _HomePageState extends State<HomePage> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// safe headers retrieval with timeout and empty fallback
|
|
||||||
Future<Map<String, String>> _safeHeaders({Duration timeout = const Duration(seconds: 6)}) async {
|
Future<Map<String, String>> _safeHeaders({Duration timeout = const Duration(seconds: 6)}) async {
|
||||||
try {
|
try {
|
||||||
return await RemoteHttp.headers().timeout(timeout);
|
return await RemoteHttp.headers().timeout(timeout);
|
||||||
|
|
@ -848,11 +903,9 @@ class _HomePageState extends State<HomePage> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// debug helper: clear remote keys from secure storage (debug only)
|
|
||||||
Future<void> _debugClearRemoteKeys() async {
|
Future<void> _debugClearRemoteKeys() async {
|
||||||
if (!kDebugMode) return;
|
if (!kDebugMode) return;
|
||||||
try {
|
try {
|
||||||
// FlutterSecureStorage non è const
|
|
||||||
final storage = FlutterSecureStorage();
|
final storage = FlutterSecureStorage();
|
||||||
await storage.delete(key: 'remote_base_url');
|
await storage.delete(key: 'remote_base_url');
|
||||||
await storage.delete(key: 'remote_index_path');
|
await storage.delete(key: 'remote_index_path');
|
||||||
|
|
|
||||||
|
|
@ -43,19 +43,18 @@ import 'package:aves_model/aves_model.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
// --- IMPORT per debug page remota ---
|
// ✅ Permissions (platform interface) perché nel tuo branch Permissions.mediaAccess è List<Permission> platform_interface
|
||||||
import 'package:flutter/foundation.dart' show kDebugMode;
|
import 'package:permission_handler_platform_interface/permission_handler_platform_interface.dart';
|
||||||
import 'package:sqflite/sqflite.dart';
|
|
||||||
import 'package:path/path.dart' as p;
|
// ✅ Remote controller
|
||||||
import 'package:aves/remote/remote_test_page.dart';
|
import 'package:aves/remote/remote_controller.dart';
|
||||||
|
|
||||||
class HomePage extends StatefulWidget {
|
class HomePage extends StatefulWidget {
|
||||||
static const routeName = '/';
|
static const routeName = '/';
|
||||||
|
|
||||||
// untyped map as it is coming from the platform
|
// ✅ torna a Map? (compatibile con aves_app.dart che passa Map<dynamic,dynamic>?)
|
||||||
final Map? intentData;
|
final Map? intentData;
|
||||||
|
|
||||||
const HomePage({
|
const HomePage({
|
||||||
|
|
@ -105,22 +104,27 @@ class _HomePageState extends State<HomePage> {
|
||||||
Future<void> _setup() async {
|
Future<void> _setup() async {
|
||||||
try {
|
try {
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
|
|
||||||
if (await windowService.isActivity()) {
|
if (await windowService.isActivity()) {
|
||||||
// do not check whether permission was granted, because some app stores
|
// ✅ come Aves: non forzare quit se utente nega permesso
|
||||||
// hide in some countries apps that force quit on permission denial
|
// ma nel tuo branch serve la platform-interface API
|
||||||
await Permissions.mediaAccess.request();
|
await PermissionHandlerPlatform.instance.requestPermissions(Permissions.mediaAccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
var appMode = AppMode.main;
|
var appMode = AppMode.main;
|
||||||
var error = false;
|
var error = false;
|
||||||
final intentData = widget.intentData ?? await IntentService.getIntentData();
|
|
||||||
|
// ✅ torna a Map (come Aves originale)
|
||||||
|
final Map intentData = widget.intentData ?? await IntentService.getIntentData();
|
||||||
final intentAction = intentData[IntentDataKeys.action] as String?;
|
final intentAction = intentData[IntentDataKeys.action] as String?;
|
||||||
|
|
||||||
_initialFilters = null;
|
_initialFilters = null;
|
||||||
_initialExplorerPath = null;
|
_initialExplorerPath = null;
|
||||||
_secureUris = null;
|
_secureUris = null;
|
||||||
|
|
||||||
await availability.onNewIntent();
|
await availability.onNewIntent();
|
||||||
await androidFileUtils.init();
|
await androidFileUtils.init();
|
||||||
|
|
||||||
if (!{
|
if (!{
|
||||||
IntentActions.edit,
|
IntentActions.edit,
|
||||||
IntentActions.screenSaver,
|
IntentActions.screenSaver,
|
||||||
|
|
@ -132,6 +136,7 @@ class _HomePageState extends State<HomePage> {
|
||||||
|
|
||||||
if (intentData.values.nonNulls.isNotEmpty) {
|
if (intentData.values.nonNulls.isNotEmpty) {
|
||||||
await reportService.log('Intent data=$intentData');
|
await reportService.log('Intent data=$intentData');
|
||||||
|
|
||||||
var intentUri = intentData[IntentDataKeys.uri] as String?;
|
var intentUri = intentData[IntentDataKeys.uri] as String?;
|
||||||
final intentMimeType = intentData[IntentDataKeys.mimeType] as String?;
|
final intentMimeType = intentData[IntentDataKeys.mimeType] as String?;
|
||||||
|
|
||||||
|
|
@ -150,35 +155,40 @@ class _HomePageState extends State<HomePage> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case IntentActions.edit:
|
case IntentActions.edit:
|
||||||
appMode = AppMode.edit;
|
appMode = AppMode.edit;
|
||||||
case IntentActions.setWallpaper:
|
case IntentActions.setWallpaper:
|
||||||
appMode = AppMode.setWallpaper;
|
appMode = AppMode.setWallpaper;
|
||||||
|
|
||||||
case IntentActions.pickItems:
|
case IntentActions.pickItems:
|
||||||
// TODO TLAD apply pick mimetype(s)
|
|
||||||
// some apps define multiple types, separated by a space (maybe other signs too, like `,` `;`?)
|
|
||||||
final multiple = (intentData[IntentDataKeys.allowMultiple] as bool?) ?? false;
|
final multiple = (intentData[IntentDataKeys.allowMultiple] as bool?) ?? false;
|
||||||
debugPrint('pick mimeType=$intentMimeType multiple=$multiple');
|
debugPrint('pick mimeType=$intentMimeType multiple=$multiple');
|
||||||
appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal;
|
appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal;
|
||||||
|
|
||||||
case IntentActions.pickCollectionFilters:
|
case IntentActions.pickCollectionFilters:
|
||||||
appMode = AppMode.pickCollectionFiltersExternal;
|
appMode = AppMode.pickCollectionFiltersExternal;
|
||||||
|
|
||||||
case IntentActions.screenSaver:
|
case IntentActions.screenSaver:
|
||||||
appMode = AppMode.screenSaver;
|
appMode = AppMode.screenSaver;
|
||||||
_initialRouteName = ScreenSaverPage.routeName;
|
_initialRouteName = ScreenSaverPage.routeName;
|
||||||
|
|
||||||
case IntentActions.screenSaverSettings:
|
case IntentActions.screenSaverSettings:
|
||||||
_initialRouteName = ScreenSaverSettingsPage.routeName;
|
_initialRouteName = ScreenSaverSettingsPage.routeName;
|
||||||
|
|
||||||
case IntentActions.search:
|
case IntentActions.search:
|
||||||
_initialRouteName = SearchPage.routeName;
|
_initialRouteName = SearchPage.routeName;
|
||||||
_initialSearchQuery = intentData[IntentDataKeys.query] as String?;
|
_initialSearchQuery = intentData[IntentDataKeys.query] as String?;
|
||||||
|
|
||||||
case IntentActions.widgetSettings:
|
case IntentActions.widgetSettings:
|
||||||
_initialRouteName = HomeWidgetSettingsPage.routeName;
|
_initialRouteName = HomeWidgetSettingsPage.routeName;
|
||||||
_widgetId = (intentData[IntentDataKeys.widgetId] as int?) ?? 0;
|
_widgetId = (intentData[IntentDataKeys.widgetId] as int?) ?? 0;
|
||||||
|
|
||||||
case IntentActions.widgetOpen:
|
case IntentActions.widgetOpen:
|
||||||
final widgetId = intentData[IntentDataKeys.widgetId] as int?;
|
final widgetId = intentData[IntentDataKeys.widgetId] as int?;
|
||||||
if (widgetId == null) {
|
if (widgetId == null) {
|
||||||
error = true;
|
error = true;
|
||||||
} else {
|
} else {
|
||||||
// widget settings may be modified in a different process after channel setup
|
|
||||||
await settings.reload();
|
await settings.reload();
|
||||||
final page = settings.getWidgetOpenPage(widgetId);
|
final page = settings.getWidgetOpenPage(widgetId);
|
||||||
switch (page) {
|
switch (page) {
|
||||||
|
|
@ -193,17 +203,19 @@ class _HomePageState extends State<HomePage> {
|
||||||
}
|
}
|
||||||
unawaited(WidgetService.update(widgetId));
|
unawaited(WidgetService.update(widgetId));
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// do not use 'route' as extra key, as the Flutter framework acts on it
|
|
||||||
final extraRoute = intentData[IntentDataKeys.page] as String?;
|
final extraRoute = intentData[IntentDataKeys.page] as String?;
|
||||||
if (allowedShortcutRoutes.contains(extraRoute)) {
|
if (allowedShortcutRoutes.contains(extraRoute)) {
|
||||||
_initialRouteName = extraRoute;
|
_initialRouteName = extraRoute;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_initialFilters == null) {
|
if (_initialFilters == null) {
|
||||||
final extraFilters = (intentData[IntentDataKeys.filters] as List?)?.cast<String>();
|
final extraFilters = (intentData[IntentDataKeys.filters] as List?)?.cast<String>();
|
||||||
_initialFilters = extraFilters?.map(CollectionFilter.fromJson).nonNulls.toSet();
|
_initialFilters = extraFilters?.map(CollectionFilter.fromJson).nonNulls.toSet();
|
||||||
}
|
}
|
||||||
|
|
||||||
_initialExplorerPath = intentData[IntentDataKeys.explorerPath] as String?;
|
_initialExplorerPath = intentData[IntentDataKeys.explorerPath] as String?;
|
||||||
|
|
||||||
switch (appMode) {
|
switch (appMode) {
|
||||||
|
|
@ -211,10 +223,7 @@ class _HomePageState extends State<HomePage> {
|
||||||
case AppMode.edit:
|
case AppMode.edit:
|
||||||
case AppMode.setWallpaper:
|
case AppMode.setWallpaper:
|
||||||
if (intentUri != null) {
|
if (intentUri != null) {
|
||||||
_viewerEntry = await _initViewerEntry(
|
_viewerEntry = await _initViewerEntry(uri: intentUri, mimeType: intentMimeType);
|
||||||
uri: intentUri,
|
|
||||||
mimeType: intentMimeType,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
error = _viewerEntry == null;
|
error = _viewerEntry == null;
|
||||||
default:
|
default:
|
||||||
|
|
@ -230,6 +239,9 @@ class _HomePageState extends State<HomePage> {
|
||||||
context.read<ValueNotifier<AppMode>>().value = appMode;
|
context.read<ValueNotifier<AppMode>>().value = appMode;
|
||||||
unawaited(reportService.setCustomKey('app_mode', appMode.toString()));
|
unawaited(reportService.setCustomKey('app_mode', appMode.toString()));
|
||||||
|
|
||||||
|
// ✅ Remote: inizializza stato icona (grigio/verde)
|
||||||
|
unawaited(RemoteController.instance.initBusFromSettings());
|
||||||
|
|
||||||
switch (appMode) {
|
switch (appMode) {
|
||||||
case AppMode.main:
|
case AppMode.main:
|
||||||
case AppMode.pickCollectionFiltersExternal:
|
case AppMode.pickCollectionFiltersExternal:
|
||||||
|
|
@ -237,18 +249,36 @@ class _HomePageState extends State<HomePage> {
|
||||||
case AppMode.pickMultipleMediaExternal:
|
case AppMode.pickMultipleMediaExternal:
|
||||||
unawaited(GlobalSearch.registerCallback());
|
unawaited(GlobalSearch.registerCallback());
|
||||||
unawaited(AnalysisService.registerCallback());
|
unawaited(AnalysisService.registerCallback());
|
||||||
|
|
||||||
final source = context.read<CollectionSource>();
|
final source = context.read<CollectionSource>();
|
||||||
|
|
||||||
|
// ✅ Aves originale: init SOLO se non già full scope (riaperture istantanee)
|
||||||
if (source.loadedScope != CollectionSource.fullScope) {
|
if (source.loadedScope != CollectionSource.fullScope) {
|
||||||
await reportService.log('Initialize source to start app with mode=$appMode, loaded scope=${source.loadedScope}');
|
await reportService.log(
|
||||||
final loadTopEntriesFirst = settings.homeNavItem.route == CollectionPage.routeName && settings.homeCustomCollection.isEmpty;
|
'Initialize source to start app with mode=$appMode, loaded scope=${source.loadedScope}',
|
||||||
|
);
|
||||||
|
final loadTopEntriesFirst =
|
||||||
|
settings.homeNavItem.route == CollectionPage.routeName &&
|
||||||
|
settings.homeCustomCollection.isEmpty;
|
||||||
source.canAnalyze = true;
|
source.canAnalyze = true;
|
||||||
await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst);
|
await source.init(
|
||||||
|
scope: CollectionSource.fullScope,
|
||||||
|
loadTopEntriesFirst: loadTopEntriesFirst,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ✅ Remote: gestito dal controller, non blocca e non rompe UX Aves
|
||||||
|
unawaited(RemoteController.instance.onAppStart(
|
||||||
|
source: source,
|
||||||
|
resumeBootstrapIfEnabled: true,
|
||||||
|
));
|
||||||
|
|
||||||
case AppMode.screenSaver:
|
case AppMode.screenSaver:
|
||||||
await reportService.log('Initialize source to start screen saver');
|
await reportService.log('Initialize source to start screen saver');
|
||||||
final source = context.read<CollectionSource>();
|
final source = context.read<CollectionSource>();
|
||||||
source.canAnalyze = false;
|
source.canAnalyze = false;
|
||||||
await source.init(scope: settings.screenSaverCollectionFilters);
|
await source.init(scope: settings.screenSaverCollectionFilters);
|
||||||
|
|
||||||
case AppMode.view:
|
case AppMode.view:
|
||||||
if (_isViewerSourceable(_viewerEntry) && _secureUris == null) {
|
if (_isViewerSourceable(_viewerEntry) && _secureUris == null) {
|
||||||
final directory = _viewerEntry?.directory;
|
final directory = _viewerEntry?.directory;
|
||||||
|
|
@ -256,24 +286,23 @@ class _HomePageState extends State<HomePage> {
|
||||||
unawaited(AnalysisService.registerCallback());
|
unawaited(AnalysisService.registerCallback());
|
||||||
await reportService.log('Initialize source to view item in directory $directory');
|
await reportService.log('Initialize source to view item in directory $directory');
|
||||||
final source = context.read<CollectionSource>();
|
final source = context.read<CollectionSource>();
|
||||||
// analysis is necessary to display neighbour items when the initial item is a new one
|
|
||||||
source.canAnalyze = true;
|
source.canAnalyze = true;
|
||||||
await source.init(scope: {StoredAlbumFilter(directory, null)});
|
await source.init(scope: {StoredAlbumFilter(directory, null)});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await _initViewerEssentials();
|
await _initViewerEssentials();
|
||||||
}
|
}
|
||||||
|
|
||||||
case AppMode.edit:
|
case AppMode.edit:
|
||||||
case AppMode.setWallpaper:
|
case AppMode.setWallpaper:
|
||||||
await _initViewerEssentials();
|
await _initViewerEssentials();
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('Home setup complete in ${stopwatch.elapsed.inMilliseconds}ms');
|
debugPrint('Home setup complete in ${stopwatch.elapsed.inMilliseconds}ms');
|
||||||
|
|
||||||
// `pushReplacement` is not enough in some edge cases
|
|
||||||
// e.g. when opening the viewer in `view` mode should replace a viewer in `main` mode
|
|
||||||
unawaited(
|
unawaited(
|
||||||
Navigator.maybeOf(context)?.pushAndRemoveUntil(
|
Navigator.maybeOf(context)?.pushAndRemoveUntil(
|
||||||
await _getRedirectRoute(appMode),
|
await _getRedirectRoute(appMode),
|
||||||
|
|
@ -287,100 +316,37 @@ class _HomePageState extends State<HomePage> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initViewerEssentials() async {
|
Future<void> _initViewerEssentials() async {
|
||||||
// for video playback storage
|
|
||||||
await localMediaDb.init();
|
await localMediaDb.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isViewerSourceable(AvesEntry? viewerEntry) {
|
bool _isViewerSourceable(AvesEntry? viewerEntry) {
|
||||||
return viewerEntry != null && viewerEntry.directory != null && !settings.hiddenFilters.any((filter) => filter.test(viewerEntry));
|
return viewerEntry != null &&
|
||||||
|
viewerEntry.directory != null &&
|
||||||
|
!settings.hiddenFilters.any((filter) => filter.test(viewerEntry));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<AvesEntry?> _initViewerEntry({required String uri, required String? mimeType}) async {
|
Future<AvesEntry?> _initViewerEntry({required String uri, required String? mimeType}) async {
|
||||||
if (uri.startsWith('/')) {
|
if (uri.startsWith('/')) {
|
||||||
// convert this file path to a proper URI
|
|
||||||
uri = Uri.file(uri).toString();
|
uri = Uri.file(uri).toString();
|
||||||
}
|
}
|
||||||
final entry = await mediaFetchService.getEntry(uri, mimeType);
|
final entry = await mediaFetchService.getEntry(uri, mimeType);
|
||||||
if (entry != null) {
|
if (entry != null) {
|
||||||
// cataloguing is essential for coordinates and video rotation
|
|
||||||
await entry.catalog(background: false, force: false, persist: false);
|
await entry.catalog(background: false, force: false, persist: false);
|
||||||
}
|
}
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- DEBUG: apre la pagina di test remota con una seconda connessione al DB ---
|
|
||||||
// --- DEBUG: apre la pagina di test remota con una seconda connessione al DB ---
|
|
||||||
Future<void> _openRemoteTestPage(BuildContext context) async {
|
|
||||||
Database? debugDb;
|
|
||||||
try {
|
|
||||||
final dbDir = await getDatabasesPath();
|
|
||||||
final dbPath = p.join(dbDir, 'metadata.db');
|
|
||||||
|
|
||||||
// Apri il DB in sola lettura (evita lock e conflitti)
|
|
||||||
debugDb = await openDatabase(dbPath, readOnly: true);
|
|
||||||
|
|
||||||
if (!context.mounted) return;
|
|
||||||
|
|
||||||
// Base URL per i remote: se esiste in settings lo usa, altrimenti fallback
|
|
||||||
// final baseUrl = (settings as dynamic).remoteBaseUrl as String?
|
|
||||||
// ?? 'https://prova.patachina.it';
|
|
||||||
final baseUrl = 'https://prova.patachina.it';
|
|
||||||
|
|
||||||
|
|
||||||
await Navigator.of(context).push(MaterialPageRoute(
|
|
||||||
builder: (_) => RemoteTestPage(
|
|
||||||
db: debugDb!,
|
|
||||||
baseUrl: baseUrl,
|
|
||||||
),
|
|
||||||
));
|
|
||||||
} catch (e, st) {
|
|
||||||
print('[RemoteTest] errore apertura DB/pagina: $e\n$st');
|
|
||||||
if (!context.mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Errore RemoteTest: $e')),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
await debugDb?.close();
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// --- DEBUG: wrapper che aggiunge il FAB
|
|
||||||
// solo in debug ---
|
|
||||||
Widget _wrapWithRemoteDebug(BuildContext context, Widget child) {
|
|
||||||
if (!kDebugMode) return child;
|
|
||||||
return Stack(
|
|
||||||
children: [
|
|
||||||
child,
|
|
||||||
Positioned(
|
|
||||||
right: 16,
|
|
||||||
bottom: 16,
|
|
||||||
child: FloatingActionButton(
|
|
||||||
heroTag: 'remote_debug_fab',
|
|
||||||
onPressed: () => _openRemoteTestPage(context),
|
|
||||||
tooltip: 'Remote Test',
|
|
||||||
child: const Icon(Icons.image_search),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Route> _getRedirectRoute(AppMode appMode) async {
|
Future<Route> _getRedirectRoute(AppMode appMode) async {
|
||||||
String routeName;
|
String routeName;
|
||||||
Set<CollectionFilter?>? filters;
|
Set<CollectionFilter?>? filters;
|
||||||
|
|
||||||
switch (appMode) {
|
switch (appMode) {
|
||||||
case AppMode.setWallpaper:
|
case AppMode.setWallpaper:
|
||||||
return DirectMaterialPageRoute(
|
return DirectMaterialPageRoute(
|
||||||
settings: const RouteSettings(name: WallpaperPage.routeName),
|
settings: const RouteSettings(name: WallpaperPage.routeName),
|
||||||
builder: (_) {
|
builder: (_) => WallpaperPage(entry: _viewerEntry),
|
||||||
return WallpaperPage(
|
|
||||||
entry: _viewerEntry,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
case AppMode.view:
|
case AppMode.view:
|
||||||
AvesEntry viewerEntry = _viewerEntry!;
|
AvesEntry viewerEntry = _viewerEntry!;
|
||||||
CollectionLens? collection;
|
CollectionLens? collection;
|
||||||
|
|
@ -388,7 +354,6 @@ Future<void> _openRemoteTestPage(BuildContext context) async {
|
||||||
final source = context.read<CollectionSource>();
|
final source = context.read<CollectionSource>();
|
||||||
final album = viewerEntry.directory;
|
final album = viewerEntry.directory;
|
||||||
if (album != null) {
|
if (album != null) {
|
||||||
// wait for collection to pass the `loading` state
|
|
||||||
final loadingCompleter = Completer();
|
final loadingCompleter = Completer();
|
||||||
final stateNotifier = source.stateNotifier;
|
final stateNotifier = source.stateNotifier;
|
||||||
void _onSourceStateChanged() {
|
void _onSourceStateChanged() {
|
||||||
|
|
@ -406,13 +371,13 @@ Future<void> _openRemoteTestPage(BuildContext context) async {
|
||||||
source: source,
|
source: source,
|
||||||
filters: {StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))},
|
filters: {StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))},
|
||||||
listenToSource: false,
|
listenToSource: false,
|
||||||
// if we group bursts, opening a burst sub-entry should:
|
|
||||||
// - identify and select the containing main entry,
|
|
||||||
// - select the sub-entry in the Viewer page.
|
|
||||||
stackBursts: false,
|
stackBursts: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
final viewerEntryPath = viewerEntry.path;
|
final viewerEntryPath = viewerEntry.path;
|
||||||
final collectionEntry = collection.sortedEntries.firstWhereOrNull((entry) => entry.path == viewerEntryPath);
|
final collectionEntry = collection.sortedEntries.firstWhereOrNull(
|
||||||
|
(entry) => entry.path == viewerEntryPath,
|
||||||
|
);
|
||||||
if (collectionEntry != null) {
|
if (collectionEntry != null) {
|
||||||
viewerEntry = collectionEntry;
|
viewerEntry = collectionEntry;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -423,36 +388,21 @@ Future<void> _openRemoteTestPage(BuildContext context) async {
|
||||||
|
|
||||||
return DirectMaterialPageRoute(
|
return DirectMaterialPageRoute(
|
||||||
settings: const RouteSettings(name: EntryViewerPage.routeName),
|
settings: const RouteSettings(name: EntryViewerPage.routeName),
|
||||||
builder: (_) {
|
builder: (_) => EntryViewerPage(collection: collection, initialEntry: viewerEntry),
|
||||||
return EntryViewerPage(
|
|
||||||
collection: collection,
|
|
||||||
initialEntry: viewerEntry,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
case AppMode.edit:
|
case AppMode.edit:
|
||||||
return DirectMaterialPageRoute(
|
return DirectMaterialPageRoute(
|
||||||
settings: const RouteSettings(name: EntryViewerPage.routeName),
|
settings: const RouteSettings(name: EntryViewerPage.routeName),
|
||||||
builder: (_) {
|
builder: (_) => ImageEditorPage(entry: _viewerEntry!),
|
||||||
return ImageEditorPage(
|
|
||||||
entry: _viewerEntry!,
|
|
||||||
);
|
);
|
||||||
},
|
|
||||||
);
|
default:
|
||||||
case AppMode.initialization:
|
|
||||||
case AppMode.main:
|
|
||||||
case AppMode.pickCollectionFiltersExternal:
|
|
||||||
case AppMode.pickSingleMediaExternal:
|
|
||||||
case AppMode.pickMultipleMediaExternal:
|
|
||||||
case AppMode.pickFilteredMediaInternal:
|
|
||||||
case AppMode.pickUnfilteredMediaInternal:
|
|
||||||
case AppMode.pickFilterInternal:
|
|
||||||
case AppMode.previewMap:
|
|
||||||
case AppMode.screenSaver:
|
|
||||||
case AppMode.slideshow:
|
|
||||||
routeName = _initialRouteName ?? settings.homeNavItem.route;
|
routeName = _initialRouteName ?? settings.homeNavItem.route;
|
||||||
filters = _initialFilters ?? (settings.homeNavItem.route == CollectionPage.routeName ? settings.homeCustomCollection : {});
|
filters = _initialFilters ??
|
||||||
|
(settings.homeNavItem.route == CollectionPage.routeName ? settings.homeCustomCollection : {});
|
||||||
}
|
}
|
||||||
|
|
||||||
Route buildRoute(WidgetBuilder builder) => DirectMaterialPageRoute(
|
Route buildRoute(WidgetBuilder builder) => DirectMaterialPageRoute(
|
||||||
settings: RouteSettings(name: routeName),
|
settings: RouteSettings(name: routeName),
|
||||||
builder: builder,
|
builder: builder,
|
||||||
|
|
@ -500,14 +450,7 @@ Future<void> _openRemoteTestPage(BuildContext context) async {
|
||||||
);
|
);
|
||||||
case CollectionPage.routeName:
|
case CollectionPage.routeName:
|
||||||
default:
|
default:
|
||||||
// <<--- QUI AVVOLGO LA COLLECTION CON IL WRAPPER DI DEBUG
|
return buildRoute((context) => CollectionPage(source: source, filters: filters));
|
||||||
return buildRoute(
|
|
||||||
(context) => _wrapWithRemoteDebug(
|
|
||||||
context,
|
|
||||||
CollectionPage(source: source, filters: filters),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// lib/widgets/home/home_page.dart
|
// lib/widgets/home/home_page.dart
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:aves/remote/collection_source_remote_ext.dart';
|
||||||
import 'package:aves/app_mode.dart';
|
import 'package:aves/app_mode.dart';
|
||||||
import 'package:aves/geo/uri.dart';
|
import 'package:aves/geo/uri.dart';
|
||||||
import 'package:aves/model/app/intent.dart';
|
import 'package:aves/model/app/intent.dart';
|
||||||
|
|
@ -46,13 +47,21 @@ import 'package:latlong2/latlong.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
// --- IMPORT aggiunti/aggiornati per integrazione remota (Fase 1) ---
|
// --- REMOTO / DEBUG ---
|
||||||
import 'package:flutter/foundation.dart' show kDebugMode;
|
import 'package:flutter/foundation.dart' show kDebugMode;
|
||||||
import 'package:sqflite/sqflite.dart';
|
import 'package:sqflite/sqflite.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:aves/remote/remote_test_page.dart' as rtp;
|
import 'package:aves/remote/remote_test_page.dart' as rtp;
|
||||||
import 'package:aves/remote/run_remote_sync.dart' as rrs;
|
|
||||||
import 'package:aves/remote/remote_settings.dart';
|
import 'package:aves/remote/remote_settings.dart';
|
||||||
|
import 'package:aves/remote/remote_http.dart';
|
||||||
|
import 'package:aves/remote/remote_models.dart';
|
||||||
|
import 'package:aves/remote/remote_client.dart';
|
||||||
|
import 'package:aves/remote/auth_client.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
|
// Step 2: progress bus + repository
|
||||||
|
import 'package:aves/remote/remote_sync_bus.dart';
|
||||||
|
import 'package:aves/remote/remote_repository.dart';
|
||||||
|
|
||||||
class HomePage extends StatefulWidget {
|
class HomePage extends StatefulWidget {
|
||||||
static const routeName = '/';
|
static const routeName = '/';
|
||||||
|
|
@ -78,8 +87,12 @@ class _HomePageState extends State<HomePage> {
|
||||||
List<String>? _secureUris;
|
List<String>? _secureUris;
|
||||||
(Object, StackTrace)? _setupError;
|
(Object, StackTrace)? _setupError;
|
||||||
|
|
||||||
// guard UI per schedulare UNA sola run del sync da Home
|
// guard sync remoto: singola esecuzione per avvio
|
||||||
bool _remoteSyncScheduled = false;
|
bool _remoteSyncScheduled = false;
|
||||||
|
bool _remoteSyncActive = false;
|
||||||
|
|
||||||
|
// guard pagina test remota
|
||||||
|
bool _remoteTestOpen = false;
|
||||||
|
|
||||||
static const allowedShortcutRoutes = [
|
static const allowedShortcutRoutes = [
|
||||||
AlbumListPage.routeName,
|
AlbumListPage.routeName,
|
||||||
|
|
@ -106,13 +119,26 @@ class _HomePageState extends State<HomePage> {
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// BOOTSTRAP FLAG (Remote progress ONLY first time)
|
||||||
|
// ============================================================
|
||||||
|
Future<bool> _isRemoteBootstrapDone() async {
|
||||||
|
final storage = FlutterSecureStorage();
|
||||||
|
final v = await storage.read(key: 'remote_bootstrap_done');
|
||||||
|
return v == '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setRemoteBootstrapDone() async {
|
||||||
|
final storage = FlutterSecureStorage();
|
||||||
|
await storage.write(key: 'remote_bootstrap_done', value: '1');
|
||||||
|
}
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
Future<void> _setup() async {
|
Future<void> _setup() async {
|
||||||
try {
|
try {
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
|
|
||||||
if (await windowService.isActivity()) {
|
if (await windowService.isActivity()) {
|
||||||
// do not check whether permission was granted, because some app stores
|
|
||||||
// hide in some countries apps that force quit on permission denial
|
|
||||||
await Permissions.mediaAccess.request();
|
await Permissions.mediaAccess.request();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -129,6 +155,16 @@ class _HomePageState extends State<HomePage> {
|
||||||
await availability.onNewIntent();
|
await availability.onNewIntent();
|
||||||
await androidFileUtils.init();
|
await androidFileUtils.init();
|
||||||
|
|
||||||
|
// Warm-up header remoti (non blocca UI)
|
||||||
|
unawaited(Future(() async {
|
||||||
|
try {
|
||||||
|
final s = await _safeLoadRemoteSettings();
|
||||||
|
if (s.enabled && s.baseUrl.trim().isNotEmpty) {
|
||||||
|
await _safeHeaders();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}));
|
||||||
|
|
||||||
if (!{
|
if (!{
|
||||||
IntentActions.edit,
|
IntentActions.edit,
|
||||||
IntentActions.screenSaver,
|
IntentActions.screenSaver,
|
||||||
|
|
@ -164,8 +200,6 @@ class _HomePageState extends State<HomePage> {
|
||||||
case IntentActions.setWallpaper:
|
case IntentActions.setWallpaper:
|
||||||
appMode = AppMode.setWallpaper;
|
appMode = AppMode.setWallpaper;
|
||||||
case IntentActions.pickItems:
|
case IntentActions.pickItems:
|
||||||
// TODO TLAD apply pick mimetype(s)
|
|
||||||
// some apps define multiple types, separated by a space (maybe other signs too, like `,` `;`?)
|
|
||||||
final multiple = (intentData[IntentDataKeys.allowMultiple] as bool?) ?? false;
|
final multiple = (intentData[IntentDataKeys.allowMultiple] as bool?) ?? false;
|
||||||
debugPrint('pick mimeType=$intentMimeType multiple=$multiple');
|
debugPrint('pick mimeType=$intentMimeType multiple=$multiple');
|
||||||
appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal;
|
appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal;
|
||||||
|
|
@ -187,7 +221,6 @@ class _HomePageState extends State<HomePage> {
|
||||||
if (widgetId == null) {
|
if (widgetId == null) {
|
||||||
error = true;
|
error = true;
|
||||||
} else {
|
} else {
|
||||||
// widget settings may be modified in a different process after channel setup
|
|
||||||
await settings.reload();
|
await settings.reload();
|
||||||
final page = settings.getWidgetOpenPage(widgetId);
|
final page = settings.getWidgetOpenPage(widgetId);
|
||||||
switch (page) {
|
switch (page) {
|
||||||
|
|
@ -203,7 +236,6 @@ class _HomePageState extends State<HomePage> {
|
||||||
unawaited(WidgetService.update(widgetId));
|
unawaited(WidgetService.update(widgetId));
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
// do not use 'route' as extra key, as the Flutter framework acts on it
|
|
||||||
final extraRoute = intentData[IntentDataKeys.page] as String?;
|
final extraRoute = intentData[IntentDataKeys.page] as String?;
|
||||||
if (allowedShortcutRoutes.contains(extraRoute)) {
|
if (allowedShortcutRoutes.contains(extraRoute)) {
|
||||||
_initialRouteName = extraRoute;
|
_initialRouteName = extraRoute;
|
||||||
|
|
@ -250,54 +282,123 @@ class _HomePageState extends State<HomePage> {
|
||||||
unawaited(AnalysisService.registerCallback());
|
unawaited(AnalysisService.registerCallback());
|
||||||
|
|
||||||
final source = context.read<CollectionSource>();
|
final source = context.read<CollectionSource>();
|
||||||
|
|
||||||
|
//////////////////////////////////////////////////////////////////////////////
|
||||||
|
//////////////////////////////////////////////////////////////////////////////
|
||||||
|
//////////////////////////////////////////////////////////////////////////////
|
||||||
|
// STEP 1 STARTUP VELOCE (MARKER 1/6) - NON MODIFICARE FUORI DA QUESTI MARKER
|
||||||
|
// STEP 1 STARTUP VELOCE (MARKER 2/6) - DB cache -> init in background
|
||||||
|
// STEP 1 STARTUP VELOCE (MARKER 3/6) - DB vuoto -> init standard con progress
|
||||||
|
//////////////////////////////////////////////////////////////////////////////
|
||||||
|
//////////////////////////////////////////////////////////////////////////////
|
||||||
|
//////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// Capisco se c'è cache nel DB (locali e/o remoti)
|
||||||
|
bool hasAnyCache = false;
|
||||||
|
try {
|
||||||
|
await localMediaDb.init(); // assicura DB pronto
|
||||||
|
final rows = await localMediaDb.rawDb.rawQuery(
|
||||||
|
'SELECT 1 FROM entry WHERE trashed=0 LIMIT 1',
|
||||||
|
);
|
||||||
|
hasAnyCache = rows.isNotEmpty;
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
final loadTopEntriesFirst =
|
||||||
|
settings.homeNavItem.route == CollectionPage.routeName &&
|
||||||
|
settings.homeCustomCollection.isEmpty;
|
||||||
|
|
||||||
|
// Bootstrap flag remoti (progress SOLO prima volta)
|
||||||
|
final bootstrapDone = await _isRemoteBootstrapDone();
|
||||||
|
final bootstrap = !bootstrapDone;
|
||||||
|
|
||||||
|
// Se la source non è full scope, dobbiamo fare init almeno una volta
|
||||||
if (source.loadedScope != CollectionSource.fullScope) {
|
if (source.loadedScope != CollectionSource.fullScope) {
|
||||||
await reportService.log(
|
await reportService.log(
|
||||||
'Initialize source to start app with mode=$appMode, loaded scope=${source.loadedScope}',
|
'Initialize source to start app with mode=$appMode, loaded scope=${source.loadedScope}',
|
||||||
);
|
);
|
||||||
final loadTopEntriesFirst =
|
|
||||||
settings.homeNavItem.route == CollectionPage.routeName && settings.homeCustomCollection.isEmpty;
|
if (hasAnyCache) {
|
||||||
|
// ✅ DB ha dati: avvio veloce -> init in background (non blocca UI)
|
||||||
|
debugPrint('[startup] DB cache present -> init in background (fast start)');
|
||||||
|
source.canAnalyze = true;
|
||||||
|
|
||||||
|
unawaited(
|
||||||
|
source
|
||||||
|
.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst)
|
||||||
|
.then((_) async {
|
||||||
|
// ✅ Remoti: opzione 1
|
||||||
|
// - se bootstrap DONE -> mostra subito dal DB
|
||||||
|
// - se bootstrap NOT done -> NON mostrare finché non finisce il bootstrap sync
|
||||||
|
final bd = await _isRemoteBootstrapDone();
|
||||||
|
if (bd) {
|
||||||
|
await source.appendRemoteEntriesFromDb();
|
||||||
|
debugPrint('[startup][bg] remote append after init done');
|
||||||
|
} else {
|
||||||
|
debugPrint('[startup][bg] bootstrap not done -> skip remote append (will appear after bootstrap sync)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedula sync remoto UNA volta
|
||||||
|
if (!_remoteSyncScheduled) {
|
||||||
|
_remoteSyncScheduled = true;
|
||||||
|
final sourceRef = source;
|
||||||
|
unawaited(Future.microtask(() => _runRemoteSync(sourceRef, bootstrap: !bd)));
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// ✅ DB vuoto: comportamento Aves standard -> await init (progress locale)
|
||||||
|
debugPrint('[startup] DB empty -> await init (Aves standard)');
|
||||||
source.canAnalyze = true;
|
source.canAnalyze = true;
|
||||||
await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst);
|
await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst);
|
||||||
|
|
||||||
|
// ✅ Remoti: opzione 1
|
||||||
|
// - se bootstrap DONE -> mostra subito dal DB
|
||||||
|
// - se bootstrap NOT done -> non mostrare finché bootstrap sync finisce
|
||||||
|
if (!bootstrap) {
|
||||||
|
// bootstrap==true => primo avvio remoto
|
||||||
|
debugPrint('[startup] bootstrap not done -> skip remote append (will appear after bootstrap sync)');
|
||||||
|
} else {
|
||||||
|
await source.appendRemoteEntriesFromDb();
|
||||||
}
|
}
|
||||||
|
|
||||||
// === FASE 1: SYNC REMOTO POST-INIT (non blocca la UI) ===
|
|
||||||
// In DEBUG: fai seed dei settings se sono vuoti, poi lancia il sync SOLO quando
|
|
||||||
// la sorgente ha finito il loading, con un micro delay di sicurezza.
|
|
||||||
if (!_remoteSyncScheduled) {
|
if (!_remoteSyncScheduled) {
|
||||||
_remoteSyncScheduled = true; // una sola schedulazione per avvio
|
_remoteSyncScheduled = true;
|
||||||
unawaited(Future(() async {
|
final sourceRef = source;
|
||||||
try {
|
unawaited(Future.microtask(() => _runRemoteSync(sourceRef, bootstrap: bootstrap)));
|
||||||
await RemoteSettings.debugSeedIfEmpty();
|
}
|
||||||
final rs = await RemoteSettings.load();
|
}
|
||||||
if (!rs.enabled) return;
|
} else {
|
||||||
|
// Source già full scope (hot state)
|
||||||
|
debugPrint('[startup] source already fullScope');
|
||||||
|
|
||||||
// attesa fine loading
|
// ✅ Remoti: opzione 1
|
||||||
final notifier = source.stateNotifier;
|
// se bootstrap done -> mostra subito dal DB
|
||||||
if (notifier.value == SourceState.loading) {
|
if (bootstrapDone) {
|
||||||
final completer = Completer<void>();
|
await source.appendRemoteEntriesFromDb();
|
||||||
void onState() {
|
} else {
|
||||||
if (notifier.value != SourceState.loading) {
|
debugPrint('[startup] bootstrap not done -> skip remote append (will appear after bootstrap sync)');
|
||||||
notifier.removeListener(onState);
|
}
|
||||||
completer.complete();
|
|
||||||
|
if (!_remoteSyncScheduled) {
|
||||||
|
_remoteSyncScheduled = true;
|
||||||
|
final sourceRef = source;
|
||||||
|
unawaited(Future.microtask(() => _runRemoteSync(sourceRef, bootstrap: bootstrap)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
notifier.addListener(onState);
|
// DIAG: stato (facoltativo)
|
||||||
// nel caso non sia già loading:
|
unawaited(_printRemoteDiag(source, when: ' PRE'));
|
||||||
onState();
|
|
||||||
await completer.future;
|
|
||||||
}
|
|
||||||
|
|
||||||
// piccolo margine per step secondari (tag, ecc.)
|
//////////////////////////////////////////////////////////////////////////////
|
||||||
await Future.delayed(const Duration(milliseconds: 400));
|
//////////////////////////////////////////////////////////////////////////////
|
||||||
|
//////////////////////////////////////////////////////////////////////////////
|
||||||
|
// STEP 1 STARTUP VELOCE (MARKER 4/6) - FINE BLOCCO
|
||||||
|
// STEP 1 STARTUP VELOCE (MARKER 5/6) - QUI PUOI AGGIUNGERE LOG/DIAG
|
||||||
|
// STEP 1 STARTUP VELOCE (MARKER 6/6) - NON MODIFICARE FUORI DA QUESTI MARKER
|
||||||
|
//////////////////////////////////////////////////////////////////////////////
|
||||||
|
//////////////////////////////////////////////////////////////////////////////
|
||||||
|
//////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
// sync in background (la managed ha già il suo guard interno)
|
|
||||||
await rrs.runRemoteSyncOnceManaged();
|
|
||||||
} catch (e, st) {
|
|
||||||
debugPrint('[remote-sync] error: $e\n$st');
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case AppMode.screenSaver:
|
case AppMode.screenSaver:
|
||||||
|
|
@ -314,7 +415,6 @@ class _HomePageState extends State<HomePage> {
|
||||||
unawaited(AnalysisService.registerCallback());
|
unawaited(AnalysisService.registerCallback());
|
||||||
await reportService.log('Initialize source to view item in directory $directory');
|
await reportService.log('Initialize source to view item in directory $directory');
|
||||||
final source = context.read<CollectionSource>();
|
final source = context.read<CollectionSource>();
|
||||||
// analysis is necessary to display neighbour items when the initial item is a new one
|
|
||||||
source.canAnalyze = true;
|
source.canAnalyze = true;
|
||||||
await source.init(scope: {StoredAlbumFilter(directory, null)});
|
await source.init(scope: {StoredAlbumFilter(directory, null)});
|
||||||
}
|
}
|
||||||
|
|
@ -334,8 +434,7 @@ class _HomePageState extends State<HomePage> {
|
||||||
|
|
||||||
debugPrint('Home setup complete in ${stopwatch.elapsed.inMilliseconds}ms');
|
debugPrint('Home setup complete in ${stopwatch.elapsed.inMilliseconds}ms');
|
||||||
|
|
||||||
// `pushReplacement` is not enough in some edge cases
|
// navigazione finale
|
||||||
// e.g. when opening the viewer in `view` mode should replace a viewer in `main` mode
|
|
||||||
unawaited(
|
unawaited(
|
||||||
Navigator.maybeOf(context)?.pushAndRemoveUntil(
|
Navigator.maybeOf(context)?.pushAndRemoveUntil(
|
||||||
await _getRedirectRoute(appMode),
|
await _getRedirectRoute(appMode),
|
||||||
|
|
@ -348,8 +447,142 @@ class _HomePageState extends State<HomePage> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// === SYNC REMOTO (Step 2)
|
||||||
|
// - progress bar SOLO bootstrap (prima volta)
|
||||||
|
// - remoti visibili SOLO dopo bootstrap completato
|
||||||
|
// - dopo bootstrap: niente full sync automatico (fino a Step 3 delta/ws)
|
||||||
|
// ============================================================
|
||||||
|
Future<void> _runRemoteSync(CollectionSource source, {required bool bootstrap}) async {
|
||||||
|
try {
|
||||||
|
final rs = await _safeLoadRemoteSettings();
|
||||||
|
if (!rs.enabled) {
|
||||||
|
debugPrint('[remote-sync] disabled → skip');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se NON bootstrap: per ora non facciamo full fetch ogni avvio
|
||||||
|
if (!bootstrap) {
|
||||||
|
debugPrint('[remote-sync] bootstrap already done -> skip full sync (until Step 3 delta/ws)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se locali ancora in loading, attendi
|
||||||
|
try {
|
||||||
|
if (source.stateNotifier.value == SourceState.loading) {
|
||||||
|
final c = Completer<void>();
|
||||||
|
void onState() {
|
||||||
|
if (source.stateNotifier.value != SourceState.loading) {
|
||||||
|
source.stateNotifier.removeListener(onState);
|
||||||
|
c.complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
source.stateNotifier.addListener(onState);
|
||||||
|
onState();
|
||||||
|
await c.future;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
_remoteSyncActive = true;
|
||||||
|
|
||||||
|
// FULL fetch dal server (bootstrap)
|
||||||
|
final items = await _fetchAllRemoteItems();
|
||||||
|
final total = items.length;
|
||||||
|
final serverIds = items.map((e) => e.id).where((v) => v.isNotEmpty).toSet();
|
||||||
|
|
||||||
|
// progress start SOLO bootstrap
|
||||||
|
RemoteSyncBus.instance.start(phase: 'Sync remoto…', total: total);
|
||||||
|
|
||||||
|
final repo = RemoteRepository(localMediaDb.rawDb);
|
||||||
|
|
||||||
|
// Bootstrap: pulizia totale remoti prima di importare
|
||||||
|
await repo.deleteAllRemotes();
|
||||||
|
|
||||||
|
// upsert chunked con progress reale X/Y
|
||||||
|
const chunkSize = 200;
|
||||||
|
int done = 0;
|
||||||
|
|
||||||
|
for (var offset = 0; offset < total; offset += chunkSize) {
|
||||||
|
final end = (offset + chunkSize < total) ? offset + chunkSize : total;
|
||||||
|
final chunk = items.sublist(offset, end);
|
||||||
|
|
||||||
|
await repo.upsertAll(chunk, chunkSize: chunkSize);
|
||||||
|
|
||||||
|
done = end;
|
||||||
|
RemoteSyncBus.instance.update(
|
||||||
|
phase: 'Sync remoto…',
|
||||||
|
done: done,
|
||||||
|
total: total,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// prune hard-delete (full list autorevole)
|
||||||
|
final pruned = await repo.pruneMissingRemotes(serverIds);
|
||||||
|
debugPrint('[remote-sync] prune deleted=$pruned');
|
||||||
|
|
||||||
|
// ✅ Remoti compaiono SOLO ORA (dopo caricamento completo)
|
||||||
|
await source.appendRemoteEntriesFromDb();
|
||||||
|
|
||||||
|
// segna bootstrap done
|
||||||
|
await _setRemoteBootstrapDone();
|
||||||
|
|
||||||
|
RemoteSyncBus.instance.finish();
|
||||||
|
unawaited(_printRemoteDiag(source, when: ' POST'));
|
||||||
|
} on TimeoutException catch (e) {
|
||||||
|
debugPrint('[remote-sync] TIMEOUT: $e');
|
||||||
|
RemoteSyncBus.instance.clear();
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('[remote-sync] error: $e\n$st');
|
||||||
|
RemoteSyncBus.instance.clear();
|
||||||
|
} finally {
|
||||||
|
_remoteSyncActive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === FETCH remoto reale ===
|
||||||
|
Future<List<RemotePhotoItem>> _fetchAllRemoteItems() async {
|
||||||
|
try {
|
||||||
|
final rs = await _safeLoadRemoteSettings();
|
||||||
|
if (!rs.enabled || rs.baseUrl.trim().isEmpty) {
|
||||||
|
return <RemotePhotoItem>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoteAuth? auth;
|
||||||
|
if (rs.email.isNotEmpty && rs.password.isNotEmpty) {
|
||||||
|
auth = RemoteAuth(baseUrl: rs.baseUrl, email: rs.email, password: rs.password);
|
||||||
|
}
|
||||||
|
|
||||||
|
final client = RemoteJsonClient(rs.baseUrl, rs.indexPath, auth: auth);
|
||||||
|
try {
|
||||||
|
final items = await client.fetchAll();
|
||||||
|
debugPrint('[remote-sync][fetch] fetched ${items.length} items');
|
||||||
|
return items;
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('[remote-sync][fetch] client.fetchAll ERROR: $e\n$st');
|
||||||
|
return <RemotePhotoItem>[];
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('[remote-sync][fetch] ERROR: $e\n$st');
|
||||||
|
return <RemotePhotoItem>[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DIAGNOSTICA ---
|
||||||
|
Future<void> _printRemoteDiag(CollectionSource source, {String when = ''}) async {
|
||||||
|
try {
|
||||||
|
final dbRem = await localMediaDb.loadEntries(origin: 1);
|
||||||
|
final dbCount = dbRem.length;
|
||||||
|
final displayable = dbRem.where((e) => !e.trashed && e.isDisplayable).length;
|
||||||
|
final inSource = source.allEntries.where((e) => e.origin == 1 && !e.trashed).length;
|
||||||
|
final inVisible = source.visibleEntries.where((e) => e.origin == 1 && !e.trashed).length;
|
||||||
|
|
||||||
|
debugPrint('[diag$when] DB remoti=$dbCount, displayable=$displayable, inSource=$inSource, inVisible=$inVisible');
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('[diag$when] ERROR: $e\n$st');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _initViewerEssentials() async {
|
Future<void> _initViewerEssentials() async {
|
||||||
// for video playback storage
|
|
||||||
await localMediaDb.init();
|
await localMediaDb.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -361,44 +594,43 @@ class _HomePageState extends State<HomePage> {
|
||||||
|
|
||||||
Future<AvesEntry?> _initViewerEntry({required String uri, required String? mimeType}) async {
|
Future<AvesEntry?> _initViewerEntry({required String uri, required String? mimeType}) async {
|
||||||
if (uri.startsWith('/')) {
|
if (uri.startsWith('/')) {
|
||||||
// convert this file path to a proper URI
|
|
||||||
uri = Uri.file(uri).toString();
|
uri = Uri.file(uri).toString();
|
||||||
}
|
}
|
||||||
final entry = await mediaFetchService.getEntry(uri, mimeType);
|
final entry = await mediaFetchService.getEntry(uri, mimeType);
|
||||||
if (entry != null) {
|
if (entry != null) {
|
||||||
// cataloguing is essential for coordinates and video rotation
|
|
||||||
await entry.catalog(background: false, force: false, persist: false);
|
await entry.catalog(background: false, force: false, persist: false);
|
||||||
}
|
}
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === DEBUG: apre la pagina di test remota con una seconda connessione al DB ===
|
// === DEBUG: pagina test remoto con DB indipendente ===
|
||||||
Future<void> _openRemoteTestPage(BuildContext context) async {
|
Future<void> _openRemoteTestPage(BuildContext context) async {
|
||||||
|
if (_remoteTestOpen) return;
|
||||||
|
if (_remoteSyncActive) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Attendi fine sync remoto in corso prima di aprire RemoteTest')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_remoteTestOpen = true;
|
||||||
|
|
||||||
Database? debugDb;
|
Database? debugDb;
|
||||||
try {
|
try {
|
||||||
final dbDir = await getDatabasesPath();
|
final dbDir = await getDatabasesPath();
|
||||||
final dbPath = p.join(dbDir, 'metadata.db');
|
final dbPath = p.join(dbDir, 'metadata.db');
|
||||||
// Apri il DB in sola lettura (evita lock e conflitti)
|
|
||||||
//debugDb = await openDatabase(dbPath, readOnly: true);
|
|
||||||
|
|
||||||
// DOPO (R/W, istanza indipendente)
|
debugDb = await openDatabase(
|
||||||
debugDb = await openDatabase(
|
|
||||||
dbPath,
|
dbPath,
|
||||||
singleInstance: false,
|
singleInstance: false,
|
||||||
onConfigure: (db) async {
|
onConfigure: (db) async {
|
||||||
// opzionale ma utile per coerenza con il resto
|
|
||||||
await db.rawQuery('PRAGMA journal_mode=WAL');
|
await db.rawQuery('PRAGMA journal_mode=WAL');
|
||||||
await db.rawQuery('PRAGMA foreign_keys=ON');
|
await db.rawQuery('PRAGMA foreign_keys=ON');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
|
|
||||||
final rs = await RemoteSettings.load();
|
final rs = await _safeLoadRemoteSettings();
|
||||||
final baseUrl = rs.baseUrl.isNotEmpty ? rs.baseUrl : RemoteSettings.defaultBaseUrl;
|
final baseUrl = rs.baseUrl.isNotEmpty ? rs.baseUrl : RemoteSettings.defaultBaseUrl;
|
||||||
|
|
||||||
await Navigator.of(context).push(MaterialPageRoute(
|
await Navigator.of(context).push(MaterialPageRoute(
|
||||||
|
|
@ -418,12 +650,13 @@ debugDb = await openDatabase(
|
||||||
try {
|
try {
|
||||||
await debugDb?.close();
|
await debugDb?.close();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
_remoteTestOpen = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// === DEBUG: dialog impostazioni remote (semplice) ===
|
// === DEBUG: dialog impostazioni remote (semplice) ===
|
||||||
Future<void> _openRemoteSettingsDialog(BuildContext context) async {
|
Future<void> _openRemoteSettingsDialog(BuildContext context) async {
|
||||||
final s = await RemoteSettings.load();
|
final s = await _safeLoadRemoteSettings();
|
||||||
final formKey = GlobalKey<FormState>();
|
final formKey = GlobalKey<FormState>();
|
||||||
bool enabled = s.enabled;
|
bool enabled = s.enabled;
|
||||||
final baseUrlC = TextEditingController(text: s.baseUrl);
|
final baseUrlC = TextEditingController(text: s.baseUrl);
|
||||||
|
|
@ -498,6 +731,10 @@ debugDb = await openDatabase(
|
||||||
password: pwC.text,
|
password: pwC.text,
|
||||||
);
|
);
|
||||||
await upd.save();
|
await upd.save();
|
||||||
|
|
||||||
|
await RemoteHttp.refreshFromSettings();
|
||||||
|
unawaited(RemoteHttp.warmUp());
|
||||||
|
|
||||||
if (context.mounted) Navigator.of(context).pop();
|
if (context.mounted) Navigator.of(context).pop();
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context)
|
ScaffoldMessenger.of(context)
|
||||||
|
|
@ -559,20 +796,20 @@ debugDb = await openDatabase(
|
||||||
return DirectMaterialPageRoute(
|
return DirectMaterialPageRoute(
|
||||||
settings: const RouteSettings(name: WallpaperPage.routeName),
|
settings: const RouteSettings(name: WallpaperPage.routeName),
|
||||||
builder: (_) {
|
builder: (_) {
|
||||||
return WallpaperPage(
|
return WallpaperPage(entry: _viewerEntry);
|
||||||
entry: _viewerEntry,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
case AppMode.view:
|
case AppMode.view:
|
||||||
AvesEntry viewerEntry = _viewerEntry!;
|
AvesEntry viewerEntry = _viewerEntry!;
|
||||||
CollectionLens? collection;
|
CollectionLens? collection;
|
||||||
|
|
||||||
final source = context.read<CollectionSource>();
|
final source = context.read<CollectionSource>();
|
||||||
final album = viewerEntry.directory;
|
final album = viewerEntry.directory;
|
||||||
if (album != null) {
|
if (album != null) {
|
||||||
// wait for collection to pass the `loading` state
|
|
||||||
final loadingCompleter = Completer();
|
final loadingCompleter = Completer();
|
||||||
final stateNotifier = source.stateNotifier;
|
final stateNotifier = source.stateNotifier;
|
||||||
|
|
||||||
void _onSourceStateChanged() {
|
void _onSourceStateChanged() {
|
||||||
if (stateNotifier.value != SourceState.loading) {
|
if (stateNotifier.value != SourceState.loading) {
|
||||||
stateNotifier.removeListener(_onSourceStateChanged);
|
stateNotifier.removeListener(_onSourceStateChanged);
|
||||||
|
|
@ -584,16 +821,10 @@ debugDb = await openDatabase(
|
||||||
_onSourceStateChanged();
|
_onSourceStateChanged();
|
||||||
await loadingCompleter.future;
|
await loadingCompleter.future;
|
||||||
|
|
||||||
// ⚠️ NON lanciamo più il sync qui (evita contese durante il viewer)
|
|
||||||
// unawaited(rrs.runRemoteSyncOnceManaged());
|
|
||||||
|
|
||||||
collection = CollectionLens(
|
collection = CollectionLens(
|
||||||
source: source,
|
source: source,
|
||||||
filters: {StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))},
|
filters: {StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))},
|
||||||
listenToSource: false,
|
listenToSource: false,
|
||||||
// if we group bursts, opening a burst sub-entry should:
|
|
||||||
// - identify and select the containing main entry,
|
|
||||||
// - select the sub-entry in the Viewer page.
|
|
||||||
stackBursts: false,
|
stackBursts: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -607,24 +838,18 @@ debugDb = await openDatabase(
|
||||||
collection = null;
|
collection = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return DirectMaterialPageRoute(
|
return DirectMaterialPageRoute(
|
||||||
settings: const RouteSettings(name: EntryViewerPage.routeName),
|
settings: const RouteSettings(name: EntryViewerPage.routeName),
|
||||||
builder: (_) {
|
builder: (_) => EntryViewerPage(collection: collection, initialEntry: viewerEntry),
|
||||||
return EntryViewerPage(
|
|
||||||
collection: collection,
|
|
||||||
initialEntry: viewerEntry,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
case AppMode.edit:
|
case AppMode.edit:
|
||||||
return DirectMaterialPageRoute(
|
return DirectMaterialPageRoute(
|
||||||
settings: const RouteSettings(name: EntryViewerPage.routeName),
|
settings: const RouteSettings(name: EntryViewerPage.routeName),
|
||||||
builder: (_) {
|
builder: (_) => ImageEditorPage(entry: _viewerEntry!),
|
||||||
return ImageEditorPage(
|
|
||||||
entry: _viewerEntry!,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
case AppMode.initialization:
|
case AppMode.initialization:
|
||||||
case AppMode.main:
|
case AppMode.main:
|
||||||
case AppMode.pickCollectionFiltersExternal:
|
case AppMode.pickCollectionFiltersExternal:
|
||||||
|
|
@ -659,7 +884,7 @@ debugDb = await openDatabase(
|
||||||
source: source,
|
source: source,
|
||||||
filters: {
|
filters: {
|
||||||
LocationFilter.located,
|
LocationFilter.located,
|
||||||
if (filters != null) ...filters,
|
if (filters != null) ...filters!,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return MapPage(
|
return MapPage(
|
||||||
|
|
@ -689,7 +914,6 @@ debugDb = await openDatabase(
|
||||||
);
|
);
|
||||||
case CollectionPage.routeName:
|
case CollectionPage.routeName:
|
||||||
default:
|
default:
|
||||||
// <<--- Wrapper di debug che aggiunge i due FAB (solo in debug)
|
|
||||||
return buildRoute(
|
return buildRoute(
|
||||||
(context) => _wrapWithRemoteDebug(
|
(context) => _wrapWithRemoteDebug(
|
||||||
context,
|
context,
|
||||||
|
|
@ -698,4 +922,51 @@ debugDb = await openDatabase(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------
|
||||||
|
// Utility sicure per remote
|
||||||
|
// -------------------------
|
||||||
|
|
||||||
|
Future<RemoteSettings> _safeLoadRemoteSettings({Duration timeout = const Duration(seconds: 5)}) async {
|
||||||
|
try {
|
||||||
|
return await RemoteSettings.load().timeout(timeout);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[remote] RemoteSettings.load failed: $e — using defaults');
|
||||||
|
return RemoteSettings(
|
||||||
|
enabled: RemoteSettings.defaultEnabled,
|
||||||
|
baseUrl: RemoteSettings.defaultBaseUrl,
|
||||||
|
indexPath: RemoteSettings.defaultIndexPath,
|
||||||
|
email: RemoteSettings.defaultEmail,
|
||||||
|
password: RemoteSettings.defaultPassword,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, String>> _safeHeaders({Duration timeout = const Duration(seconds: 6)}) async {
|
||||||
|
try {
|
||||||
|
return await RemoteHttp.headers().timeout(timeout);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[remote] RemoteHttp.headers failed: $e — returning empty headers');
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _debugClearRemoteKeys() async {
|
||||||
|
if (!kDebugMode) return;
|
||||||
|
try {
|
||||||
|
final storage = FlutterSecureStorage();
|
||||||
|
await storage.delete(key: 'remote_base_url');
|
||||||
|
await storage.delete(key: 'remote_index_path');
|
||||||
|
await storage.delete(key: 'remote_email');
|
||||||
|
await storage.delete(key: 'remote_password');
|
||||||
|
await storage.delete(key: 'remote_enabled');
|
||||||
|
|
||||||
|
// utile anche per test bootstrap:
|
||||||
|
await storage.delete(key: 'remote_bootstrap_done');
|
||||||
|
|
||||||
|
debugPrint('[remote] debugClearRemoteKeys executed');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[remote] debugClearRemoteKeys failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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
|
// lib/widgets/home/home_page.dart
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:aves/remote/collection_source_remote_ext.dart';
|
||||||
import 'package:aves/app_mode.dart';
|
import 'package:aves/app_mode.dart';
|
||||||
import 'package:aves/geo/uri.dart';
|
import 'package:aves/geo/uri.dart';
|
||||||
import 'package:aves/model/app/intent.dart';
|
import 'package:aves/model/app/intent.dart';
|
||||||
|
|
@ -46,17 +47,24 @@ import 'package:latlong2/latlong.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
// --- IMPORT aggiunti per integrazione remota (Fase 1) ---
|
// --- REMOTO / DEBUG ---
|
||||||
import 'package:flutter/foundation.dart' show kDebugMode;
|
import 'package:flutter/foundation.dart' show kDebugMode;
|
||||||
import 'package:sqflite/sqflite.dart';
|
import 'package:sqflite/sqflite.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:aves/remote/remote_test_page.dart';
|
import 'package:aves/remote/remote_test_page.dart' as rtp;
|
||||||
import 'package:aves/remote/run_remote_sync.dart';
|
|
||||||
import 'package:aves/remote/remote_settings.dart';
|
import 'package:aves/remote/remote_settings.dart';
|
||||||
|
import 'package:aves/remote/remote_http.dart';
|
||||||
|
import 'package:aves/remote/remote_models.dart';
|
||||||
|
import 'package:aves/remote/remote_client.dart';
|
||||||
|
import 'package:aves/remote/auth_client.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
|
// Step 2: progress bus + repository
|
||||||
|
import 'package:aves/remote/remote_sync_bus.dart';
|
||||||
|
import 'package:aves/remote/remote_repository.dart';
|
||||||
|
|
||||||
class HomePage extends StatefulWidget {
|
class HomePage extends StatefulWidget {
|
||||||
static const routeName = '/';
|
static const routeName = '/';
|
||||||
// untyped map as it is coming from the platform
|
|
||||||
final Map? intentData;
|
final Map? intentData;
|
||||||
|
|
||||||
const HomePage({
|
const HomePage({
|
||||||
|
|
@ -78,6 +86,10 @@ class _HomePageState extends State<HomePage> {
|
||||||
List<String>? _secureUris;
|
List<String>? _secureUris;
|
||||||
(Object, StackTrace)? _setupError;
|
(Object, StackTrace)? _setupError;
|
||||||
|
|
||||||
|
bool _remoteSyncScheduled = false;
|
||||||
|
bool _remoteSyncActive = false;
|
||||||
|
bool _remoteTestOpen = false;
|
||||||
|
|
||||||
static const allowedShortcutRoutes = [
|
static const allowedShortcutRoutes = [
|
||||||
AlbumListPage.routeName,
|
AlbumListPage.routeName,
|
||||||
CollectionPage.routeName,
|
CollectionPage.routeName,
|
||||||
|
|
@ -103,13 +115,68 @@ class _HomePageState extends State<HomePage> {
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// BOOTSTRAP FLAG (Remote progress ONLY first time)
|
||||||
|
// ============================================================
|
||||||
|
Future<bool> _isRemoteBootstrapDone() async {
|
||||||
|
final storage = FlutterSecureStorage();
|
||||||
|
final v = await storage.read(key: 'remote_bootstrap_done');
|
||||||
|
return v == '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setRemoteBootstrapDone() async {
|
||||||
|
final storage = FlutterSecureStorage();
|
||||||
|
await storage.write(key: 'remote_bootstrap_done', value: '1');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// INIT DEBUG (optional): SourceState + polling entry counts (3s)
|
||||||
|
// ============================================================
|
||||||
|
VoidCallback _attachInitDebug(CollectionSource source, String label) {
|
||||||
|
final sw = Stopwatch()..start();
|
||||||
|
int lastAll = -1;
|
||||||
|
int lastVis = -1;
|
||||||
|
|
||||||
|
void logState() {
|
||||||
|
debugPrint(
|
||||||
|
'[$label] state=${source.stateNotifier.value} '
|
||||||
|
't=${sw.elapsedMilliseconds}ms '
|
||||||
|
'all=${source.allEntries.length} vis=${source.visibleEntries.length} '
|
||||||
|
'loadedScope=${source.loadedScope}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void pollCounts() {
|
||||||
|
final all = source.allEntries.length;
|
||||||
|
final vis = source.visibleEntries.length;
|
||||||
|
if (all != lastAll || vis != lastVis) {
|
||||||
|
lastAll = all;
|
||||||
|
lastVis = vis;
|
||||||
|
debugPrint('[$label] CHANGE t=${sw.elapsedMilliseconds}ms all=$all vis=$vis state=${source.stateNotifier.value}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('[$label] attach listeners');
|
||||||
|
logState();
|
||||||
|
pollCounts();
|
||||||
|
|
||||||
|
source.stateNotifier.addListener(logState);
|
||||||
|
final timer = Timer.periodic(const Duration(milliseconds: 100), (_) => pollCounts());
|
||||||
|
|
||||||
|
return () {
|
||||||
|
timer.cancel();
|
||||||
|
try {
|
||||||
|
source.stateNotifier.removeListener(logState);
|
||||||
|
} catch (_) {}
|
||||||
|
debugPrint('[$label] detach listeners at t=${sw.elapsedMilliseconds}ms');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _setup() async {
|
Future<void> _setup() async {
|
||||||
try {
|
try {
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
|
|
||||||
if (await windowService.isActivity()) {
|
if (await windowService.isActivity()) {
|
||||||
// do not check whether permission was granted, because some app stores
|
|
||||||
// hide in some countries apps that force quit on permission denial
|
|
||||||
await Permissions.mediaAccess.request();
|
await Permissions.mediaAccess.request();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,6 +193,16 @@ class _HomePageState extends State<HomePage> {
|
||||||
await availability.onNewIntent();
|
await availability.onNewIntent();
|
||||||
await androidFileUtils.init();
|
await androidFileUtils.init();
|
||||||
|
|
||||||
|
// Warm-up header remoti (non blocca UI)
|
||||||
|
unawaited(Future(() async {
|
||||||
|
try {
|
||||||
|
final s = await _safeLoadRemoteSettings();
|
||||||
|
if (s.enabled && s.baseUrl.trim().isNotEmpty) {
|
||||||
|
await _safeHeaders();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}));
|
||||||
|
|
||||||
if (!{
|
if (!{
|
||||||
IntentActions.edit,
|
IntentActions.edit,
|
||||||
IntentActions.screenSaver,
|
IntentActions.screenSaver,
|
||||||
|
|
@ -161,8 +238,6 @@ class _HomePageState extends State<HomePage> {
|
||||||
case IntentActions.setWallpaper:
|
case IntentActions.setWallpaper:
|
||||||
appMode = AppMode.setWallpaper;
|
appMode = AppMode.setWallpaper;
|
||||||
case IntentActions.pickItems:
|
case IntentActions.pickItems:
|
||||||
// TODO TLAD apply pick mimetype(s)
|
|
||||||
// some apps define multiple types, separated by a space (maybe other signs too, like `,` `;`?)
|
|
||||||
final multiple = (intentData[IntentDataKeys.allowMultiple] as bool?) ?? false;
|
final multiple = (intentData[IntentDataKeys.allowMultiple] as bool?) ?? false;
|
||||||
debugPrint('pick mimeType=$intentMimeType multiple=$multiple');
|
debugPrint('pick mimeType=$intentMimeType multiple=$multiple');
|
||||||
appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal;
|
appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal;
|
||||||
|
|
@ -184,7 +259,6 @@ class _HomePageState extends State<HomePage> {
|
||||||
if (widgetId == null) {
|
if (widgetId == null) {
|
||||||
error = true;
|
error = true;
|
||||||
} else {
|
} else {
|
||||||
// widget settings may be modified in a different process after channel setup
|
|
||||||
await settings.reload();
|
await settings.reload();
|
||||||
final page = settings.getWidgetOpenPage(widgetId);
|
final page = settings.getWidgetOpenPage(widgetId);
|
||||||
switch (page) {
|
switch (page) {
|
||||||
|
|
@ -200,7 +274,6 @@ class _HomePageState extends State<HomePage> {
|
||||||
unawaited(WidgetService.update(widgetId));
|
unawaited(WidgetService.update(widgetId));
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
// do not use 'route' as extra key, as the Flutter framework acts on it
|
|
||||||
final extraRoute = intentData[IntentDataKeys.page] as String?;
|
final extraRoute = intentData[IntentDataKeys.page] as String?;
|
||||||
if (allowedShortcutRoutes.contains(extraRoute)) {
|
if (allowedShortcutRoutes.contains(extraRoute)) {
|
||||||
_initialRouteName = extraRoute;
|
_initialRouteName = extraRoute;
|
||||||
|
|
@ -219,10 +292,7 @@ class _HomePageState extends State<HomePage> {
|
||||||
case AppMode.edit:
|
case AppMode.edit:
|
||||||
case AppMode.setWallpaper:
|
case AppMode.setWallpaper:
|
||||||
if (intentUri != null) {
|
if (intentUri != null) {
|
||||||
_viewerEntry = await _initViewerEntry(
|
_viewerEntry = await _initViewerEntry(uri: intentUri, mimeType: intentMimeType);
|
||||||
uri: intentUri,
|
|
||||||
mimeType: intentMimeType,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
error = _viewerEntry == null;
|
error = _viewerEntry == null;
|
||||||
default:
|
default:
|
||||||
|
|
@ -245,42 +315,109 @@ class _HomePageState extends State<HomePage> {
|
||||||
case AppMode.pickMultipleMediaExternal:
|
case AppMode.pickMultipleMediaExternal:
|
||||||
unawaited(GlobalSearch.registerCallback());
|
unawaited(GlobalSearch.registerCallback());
|
||||||
unawaited(AnalysisService.registerCallback());
|
unawaited(AnalysisService.registerCallback());
|
||||||
|
|
||||||
final source = context.read<CollectionSource>();
|
final source = context.read<CollectionSource>();
|
||||||
if (source.loadedScope != CollectionSource.fullScope) {
|
|
||||||
await reportService.log('Initialize source to start app with mode=$appMode, loaded scope=${source.loadedScope}');
|
// =========================================================
|
||||||
|
// MAIN INIT + LOCAL-HYDRATE + REMOTE APPEND/SYNC
|
||||||
|
// =========================================================
|
||||||
|
|
||||||
|
// cache DB?
|
||||||
|
bool hasAnyCache = false;
|
||||||
|
try {
|
||||||
|
await localMediaDb.init();
|
||||||
|
final rows = await localMediaDb.rawDb.rawQuery(
|
||||||
|
'SELECT 1 FROM entry WHERE trashed=0 LIMIT 1',
|
||||||
|
);
|
||||||
|
hasAnyCache = rows.isNotEmpty;
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
final bootstrapDone = await _isRemoteBootstrapDone();
|
||||||
|
final bootstrap = !bootstrapDone;
|
||||||
|
|
||||||
|
debugPrint('[BOOT] hasAnyCache=$hasAnyCache bootstrapDone=$bootstrapDone bootstrap=$bootstrap '
|
||||||
|
'loadedScope=${source.loadedScope} state=${source.stateNotifier.value}');
|
||||||
|
|
||||||
final loadTopEntriesFirst =
|
final loadTopEntriesFirst =
|
||||||
settings.homeNavItem.route == CollectionPage.routeName && settings.homeCustomCollection.isEmpty;
|
settings.homeNavItem.route == CollectionPage.routeName &&
|
||||||
source.canAnalyze = true;
|
settings.homeCustomCollection.isEmpty;
|
||||||
|
|
||||||
|
final detach = _attachInitDebug(source, 'INIT');
|
||||||
|
|
||||||
|
// INIT (serve per inizializzare strutture interne)
|
||||||
|
final swInit = Stopwatch()..start();
|
||||||
|
debugPrint('[INIT] calling source.init(...) loadTopEntriesFirst=$loadTopEntriesFirst');
|
||||||
await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst);
|
await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst);
|
||||||
|
swInit.stop();
|
||||||
|
debugPrint('[INIT] source.init DONE in ${swInit.elapsedMilliseconds}ms all=${source.allEntries.length} vis=${source.visibleEntries.length}');
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
// ✅ LOCAL-HYDRATE (key feature): mostra subito i locali dal DB
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
// Se dopo init la Source è ancora vuota/quasi vuota, iniettiamo cache DB origin=0.
|
||||||
|
// (Il tuo DB ha origin=0 -> 6957, quindi qui la galleria diventa istantanea.)
|
||||||
|
try {
|
||||||
|
final curCount = source.visibleEntries.isNotEmpty ? source.visibleEntries.length : source.allEntries.length;
|
||||||
|
if (curCount < 50) {
|
||||||
|
final locals = await localMediaDb.loadEntries(origin: 0); // Set<AvesEntry> nella tua codebase
|
||||||
|
debugPrint('[LOCAL-HYDRATE] db locals=${locals.length} curCount=$curCount');
|
||||||
|
|
||||||
|
if (locals.isNotEmpty) {
|
||||||
|
final existingUris = source.allEntries
|
||||||
|
.where((e) => e.origin == 0 && !e.trashed)
|
||||||
|
.map((e) => e.uri)
|
||||||
|
.whereType<String>()
|
||||||
|
.toSet();
|
||||||
|
|
||||||
|
final toAdd = locals.where((e) {
|
||||||
|
if (e.trashed) return false;
|
||||||
|
if (!e.isDisplayable) return false;
|
||||||
|
final u = e.uri;
|
||||||
|
if (u == null || u.isEmpty) return true;
|
||||||
|
return !existingUris.contains(u);
|
||||||
|
}).toSet();
|
||||||
|
|
||||||
|
if (toAdd.isNotEmpty) {
|
||||||
|
source.addEntries(toAdd);
|
||||||
|
debugPrint('[LOCAL-HYDRATE] added=${toAdd.length}');
|
||||||
|
} else {
|
||||||
|
debugPrint('[LOCAL-HYDRATE] nothing to add (duplicates/filtered)');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// === FASE 1: SYNC REMOTO POST-INIT (non blocca la UI) ===
|
|
||||||
// In DEBUG facciamo prima un seed dei setting se sono vuoti.
|
|
||||||
unawaited(Future(() async {
|
|
||||||
try {
|
|
||||||
await RemoteSettings.debugSeedIfEmpty();
|
|
||||||
final rs = await RemoteSettings.load();
|
|
||||||
if (!rs.enabled) return;
|
|
||||||
|
|
||||||
final dbDir = await getDatabasesPath();
|
|
||||||
final dbPath = p.join(dbDir, 'metadata.db');
|
|
||||||
final db = await openDatabase(dbPath);
|
|
||||||
try {
|
|
||||||
// Prende baseUrl/index/email/pw da RemoteSettings
|
|
||||||
await runRemoteSyncOnce(db: db);
|
|
||||||
} finally {
|
|
||||||
await db.close();
|
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('[remote-sync] error: $e\n$st');
|
debugPrint('[LOCAL-HYDRATE] error: $e\n$st');
|
||||||
}
|
}
|
||||||
}));
|
|
||||||
|
// stop debug logs after 3s
|
||||||
|
Future.delayed(const Duration(seconds: 3), detach);
|
||||||
|
|
||||||
|
// Remoti:
|
||||||
|
// - bootstrap done -> mostra subito dal DB
|
||||||
|
// - bootstrap not done -> compariranno alla fine del bootstrap sync
|
||||||
|
if (await _isRemoteBootstrapDone()) {
|
||||||
|
debugPrint('[REMOTE] append from DB (bootstrap done)');
|
||||||
|
await source.appendRemoteEntriesFromDb();
|
||||||
|
} else {
|
||||||
|
debugPrint('[REMOTE] skip append from DB (bootstrap not done)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// schedule remote sync once
|
||||||
|
if (!_remoteSyncScheduled) {
|
||||||
|
_remoteSyncScheduled = true;
|
||||||
|
final sourceRef = source;
|
||||||
|
unawaited(Future.microtask(() => _runRemoteSync(sourceRef, bootstrap: bootstrap)));
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
case AppMode.screenSaver:
|
case AppMode.screenSaver:
|
||||||
await reportService.log('Initialize source to start screen saver');
|
await reportService.log('Initialize source to start screen saver');
|
||||||
final source2 = context.read<CollectionSource>();
|
final source2 = context.read<CollectionSource>();
|
||||||
source2.canAnalyze = false;
|
source2.canAnalyze = false;
|
||||||
await source2.init(scope: settings.screenSaverCollectionFilters);
|
await source2.init(scope: settings.screenSaverCollectionFilters);
|
||||||
|
break;
|
||||||
|
|
||||||
case AppMode.view:
|
case AppMode.view:
|
||||||
if (_isViewerSourceable(_viewerEntry) && _secureUris == null) {
|
if (_isViewerSourceable(_viewerEntry) && _secureUris == null) {
|
||||||
final directory = _viewerEntry?.directory;
|
final directory = _viewerEntry?.directory;
|
||||||
|
|
@ -288,24 +425,25 @@ class _HomePageState extends State<HomePage> {
|
||||||
unawaited(AnalysisService.registerCallback());
|
unawaited(AnalysisService.registerCallback());
|
||||||
await reportService.log('Initialize source to view item in directory $directory');
|
await reportService.log('Initialize source to view item in directory $directory');
|
||||||
final source = context.read<CollectionSource>();
|
final source = context.read<CollectionSource>();
|
||||||
// analysis is necessary to display neighbour items when the initial item is a new one
|
|
||||||
source.canAnalyze = true;
|
source.canAnalyze = true;
|
||||||
await source.init(scope: {StoredAlbumFilter(directory, null)});
|
await source.init(scope: {StoredAlbumFilter(directory, null)});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await _initViewerEssentials();
|
await _initViewerEssentials();
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case AppMode.edit:
|
case AppMode.edit:
|
||||||
case AppMode.setWallpaper:
|
case AppMode.setWallpaper:
|
||||||
await _initViewerEssentials();
|
await _initViewerEssentials();
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('Home setup complete in ${stopwatch.elapsed.inMilliseconds}ms');
|
debugPrint('Home setup complete in ${stopwatch.elapsed.inMilliseconds}ms');
|
||||||
|
|
||||||
// `pushReplacement` is not enough in some edge cases
|
|
||||||
// e.g. when opening the viewer in `view` mode should replace a viewer in `main` mode
|
|
||||||
unawaited(
|
unawaited(
|
||||||
Navigator.maybeOf(context)?.pushAndRemoveUntil(
|
Navigator.maybeOf(context)?.pushAndRemoveUntil(
|
||||||
await _getRedirectRoute(appMode),
|
await _getRedirectRoute(appMode),
|
||||||
|
|
@ -318,8 +456,93 @@ class _HomePageState extends State<HomePage> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// === SYNC REMOTO (Step 2)
|
||||||
|
// - full sync SOLO bootstrap
|
||||||
|
// - progress bar SOLO bootstrap
|
||||||
|
// - remoti visibili SOLO dopo bootstrap completato
|
||||||
|
// ============================================================
|
||||||
|
Future<void> _runRemoteSync(CollectionSource source, {required bool bootstrap}) async {
|
||||||
|
try {
|
||||||
|
final rs = await _safeLoadRemoteSettings();
|
||||||
|
if (!rs.enabled) {
|
||||||
|
debugPrint('[remote-sync] disabled → skip');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bootstrap) {
|
||||||
|
debugPrint('[remote-sync] bootstrap already done -> skip full sync (until Step 3 delta/ws)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_remoteSyncActive = true;
|
||||||
|
|
||||||
|
final items = await _fetchAllRemoteItems();
|
||||||
|
final total = items.length;
|
||||||
|
final serverIds = items.map((e) => e.id).where((v) => v.isNotEmpty).toSet();
|
||||||
|
|
||||||
|
RemoteSyncBus.instance.start(phase: 'Sync remoto…', total: total);
|
||||||
|
|
||||||
|
final repo = RemoteRepository(localMediaDb.rawDb);
|
||||||
|
await repo.deleteAllRemotes();
|
||||||
|
|
||||||
|
const chunkSize = 200;
|
||||||
|
int done = 0;
|
||||||
|
|
||||||
|
for (var offset = 0; offset < total; offset += chunkSize) {
|
||||||
|
final end = (offset + chunkSize < total) ? offset + chunkSize : total;
|
||||||
|
final chunk = items.sublist(offset, end);
|
||||||
|
|
||||||
|
await repo.upsertAll(chunk, chunkSize: chunkSize);
|
||||||
|
|
||||||
|
done = end;
|
||||||
|
RemoteSyncBus.instance.update(phase: 'Sync remoto…', done: done, total: total);
|
||||||
|
}
|
||||||
|
|
||||||
|
final pruned = await repo.pruneMissingRemotes(serverIds);
|
||||||
|
debugPrint('[remote-sync] prune deleted=$pruned');
|
||||||
|
|
||||||
|
// remoti compaiono ora (bootstrap completato)
|
||||||
|
await source.appendRemoteEntriesFromDb();
|
||||||
|
await _setRemoteBootstrapDone();
|
||||||
|
|
||||||
|
RemoteSyncBus.instance.finish();
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('[remote-sync] error: $e\n$st');
|
||||||
|
RemoteSyncBus.instance.clear();
|
||||||
|
} finally {
|
||||||
|
_remoteSyncActive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<RemotePhotoItem>> _fetchAllRemoteItems() async {
|
||||||
|
try {
|
||||||
|
final rs = await _safeLoadRemoteSettings();
|
||||||
|
if (!rs.enabled || rs.baseUrl.trim().isEmpty) {
|
||||||
|
return <RemotePhotoItem>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoteAuth? auth;
|
||||||
|
if (rs.email.isNotEmpty && rs.password.isNotEmpty) {
|
||||||
|
auth = RemoteAuth(baseUrl: rs.baseUrl, email: rs.email, password: rs.password);
|
||||||
|
}
|
||||||
|
|
||||||
|
final client = RemoteJsonClient(rs.baseUrl, rs.indexPath, auth: auth);
|
||||||
|
try {
|
||||||
|
final items = await client.fetchAll();
|
||||||
|
debugPrint('[remote-sync][fetch] fetched ${items.length} items');
|
||||||
|
return items;
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('[remote-sync][fetch] client.fetchAll ERROR: $e\n$st');
|
||||||
|
return <RemotePhotoItem>[];
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('[remote-sync][fetch] ERROR: $e\n$st');
|
||||||
|
return <RemotePhotoItem>[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _initViewerEssentials() async {
|
Future<void> _initViewerEssentials() async {
|
||||||
// for video playback storage
|
|
||||||
await localMediaDb.init();
|
await localMediaDb.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -331,32 +554,47 @@ class _HomePageState extends State<HomePage> {
|
||||||
|
|
||||||
Future<AvesEntry?> _initViewerEntry({required String uri, required String? mimeType}) async {
|
Future<AvesEntry?> _initViewerEntry({required String uri, required String? mimeType}) async {
|
||||||
if (uri.startsWith('/')) {
|
if (uri.startsWith('/')) {
|
||||||
// convert this file path to a proper URI
|
|
||||||
uri = Uri.file(uri).toString();
|
uri = Uri.file(uri).toString();
|
||||||
}
|
}
|
||||||
final entry = await mediaFetchService.getEntry(uri, mimeType);
|
final entry = await mediaFetchService.getEntry(uri, mimeType);
|
||||||
if (entry != null) {
|
if (entry != null) {
|
||||||
// cataloguing is essential for coordinates and video rotation
|
|
||||||
await entry.catalog(background: false, force: false, persist: false);
|
await entry.catalog(background: false, force: false, persist: false);
|
||||||
}
|
}
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === DEBUG: apre la pagina di test remota con una seconda connessione al DB ===
|
// === DEBUG: pagina test remoto con DB indipendente ===
|
||||||
Future<void> _openRemoteTestPage(BuildContext context) async {
|
Future<void> _openRemoteTestPage(BuildContext context) async {
|
||||||
|
if (_remoteTestOpen) return;
|
||||||
|
if (_remoteSyncActive) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Attendi fine sync remoto in corso prima di aprire RemoteTest')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_remoteTestOpen = true;
|
||||||
|
|
||||||
Database? debugDb;
|
Database? debugDb;
|
||||||
try {
|
try {
|
||||||
final dbDir = await getDatabasesPath();
|
final dbDir = await getDatabasesPath();
|
||||||
final dbPath = p.join(dbDir, 'metadata.db');
|
final dbPath = p.join(dbDir, 'metadata.db');
|
||||||
// Apri il DB in sola lettura (evita lock e conflitti)
|
|
||||||
debugDb = await openDatabase(dbPath, readOnly: true);
|
debugDb = await openDatabase(
|
||||||
|
dbPath,
|
||||||
|
singleInstance: false,
|
||||||
|
onConfigure: (db) async {
|
||||||
|
await db.rawQuery('PRAGMA journal_mode=WAL');
|
||||||
|
await db.rawQuery('PRAGMA foreign_keys=ON');
|
||||||
|
},
|
||||||
|
);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
|
|
||||||
final rs = await RemoteSettings.load();
|
final rs = await _safeLoadRemoteSettings();
|
||||||
final baseUrl = rs.baseUrl.isNotEmpty ? rs.baseUrl : RemoteSettings.defaultBaseUrl;
|
final baseUrl = rs.baseUrl.isNotEmpty ? rs.baseUrl : RemoteSettings.defaultBaseUrl;
|
||||||
|
|
||||||
await Navigator.of(context).push(MaterialPageRoute(
|
await Navigator.of(context).push(MaterialPageRoute(
|
||||||
builder: (_) => RemoteTestPage(
|
builder: (_) => rtp.RemoteTestPage(
|
||||||
db: debugDb!,
|
db: debugDb!,
|
||||||
baseUrl: baseUrl,
|
baseUrl: baseUrl,
|
||||||
),
|
),
|
||||||
|
|
@ -364,146 +602,14 @@ class _HomePageState extends State<HomePage> {
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
// ignore: avoid_print
|
// ignore: avoid_print
|
||||||
print('[RemoteTest] errore apertura DB/pagina: $e\n$st');
|
print('[RemoteTest] errore apertura DB/pagina: $e\n$st');
|
||||||
if (!context.mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Errore RemoteTest: $e')),
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
await debugDb?.close();
|
await debugDb?.close();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
_remoteTestOpen = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// === DEBUG: dialog impostazioni remote (semplice) ===
|
|
||||||
Future<void> _openRemoteSettingsDialog(BuildContext context) async {
|
|
||||||
final s = await RemoteSettings.load();
|
|
||||||
final formKey = GlobalKey<FormState>();
|
|
||||||
bool enabled = s.enabled;
|
|
||||||
final baseUrlC = TextEditingController(text: s.baseUrl);
|
|
||||||
final indexC = TextEditingController(text: s.indexPath);
|
|
||||||
final emailC = TextEditingController(text: s.email);
|
|
||||||
final pwC = TextEditingController(text: s.password);
|
|
||||||
|
|
||||||
await showDialog<void>(
|
|
||||||
context: context,
|
|
||||||
builder: (_) => AlertDialog(
|
|
||||||
title: const Text('Remote Settings'),
|
|
||||||
content: Form(
|
|
||||||
key: formKey,
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
SwitchListTile(
|
|
||||||
title: const Text('Abilita sync remoto'),
|
|
||||||
value: enabled,
|
|
||||||
onChanged: (v) {
|
|
||||||
enabled = v;
|
|
||||||
},
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
TextFormField(
|
|
||||||
controller: baseUrlC,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Base URL',
|
|
||||||
hintText: 'https://prova.patachina.it',
|
|
||||||
),
|
|
||||||
validator: (v) => (v == null || v.isEmpty) ? 'Obbligatorio' : null,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
TextFormField(
|
|
||||||
controller: indexC,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Index path',
|
|
||||||
hintText: 'photos/',
|
|
||||||
),
|
|
||||||
validator: (v) => (v == null || v.isEmpty) ? 'Obbligatorio' : null,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
TextFormField(
|
|
||||||
controller: emailC,
|
|
||||||
decoration: const InputDecoration(labelText: 'User/Email'),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
TextFormField(
|
|
||||||
controller: pwC,
|
|
||||||
obscureText: true,
|
|
||||||
decoration: const InputDecoration(labelText: 'Password'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(context).maybePop(),
|
|
||||||
child: const Text('Annulla'),
|
|
||||||
),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: () async {
|
|
||||||
if (!formKey.currentState!.validate()) return;
|
|
||||||
final upd = RemoteSettings(
|
|
||||||
enabled: enabled,
|
|
||||||
baseUrl: baseUrlC.text.trim(),
|
|
||||||
indexPath: indexC.text.trim(),
|
|
||||||
email: emailC.text.trim(),
|
|
||||||
password: pwC.text,
|
|
||||||
);
|
|
||||||
await upd.save();
|
|
||||||
if (context.mounted) Navigator.of(context).pop();
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context)
|
|
||||||
.showSnackBar(const SnackBar(content: Text('Impostazioni salvate')));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.save),
|
|
||||||
label: const Text('Salva'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
baseUrlC.dispose();
|
|
||||||
indexC.dispose();
|
|
||||||
emailC.dispose();
|
|
||||||
pwC.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- DEBUG: wrapper che aggiunge 2 FAB (Settings + Remote Test) ---
|
|
||||||
Widget _wrapWithRemoteDebug(BuildContext context, Widget child) {
|
|
||||||
if (!kDebugMode) return child;
|
|
||||||
return Stack(
|
|
||||||
children: [
|
|
||||||
child,
|
|
||||||
Positioned(
|
|
||||||
right: 16,
|
|
||||||
bottom: 16,
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
FloatingActionButton(
|
|
||||||
heroTag: 'remote_debug_settings_fab',
|
|
||||||
mini: true,
|
|
||||||
onPressed: () => _openRemoteSettingsDialog(context),
|
|
||||||
tooltip: 'Remote Settings',
|
|
||||||
child: const Icon(Icons.settings),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
FloatingActionButton(
|
|
||||||
heroTag: 'remote_debug_test_fab',
|
|
||||||
onPressed: () => _openRemoteTestPage(context),
|
|
||||||
tooltip: 'Remote Test',
|
|
||||||
child: const Icon(Icons.image_search),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Route> _getRedirectRoute(AppMode appMode) async {
|
Future<Route> _getRedirectRoute(AppMode appMode) async {
|
||||||
String routeName;
|
String routeName;
|
||||||
Set<CollectionFilter?>? filters;
|
Set<CollectionFilter?>? filters;
|
||||||
|
|
@ -512,19 +618,16 @@ class _HomePageState extends State<HomePage> {
|
||||||
case AppMode.setWallpaper:
|
case AppMode.setWallpaper:
|
||||||
return DirectMaterialPageRoute(
|
return DirectMaterialPageRoute(
|
||||||
settings: const RouteSettings(name: WallpaperPage.routeName),
|
settings: const RouteSettings(name: WallpaperPage.routeName),
|
||||||
builder: (_) {
|
builder: (_) => WallpaperPage(entry: _viewerEntry),
|
||||||
return WallpaperPage(
|
|
||||||
entry: _viewerEntry,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
case AppMode.view:
|
case AppMode.view:
|
||||||
AvesEntry viewerEntry = _viewerEntry!;
|
AvesEntry viewerEntry = _viewerEntry!;
|
||||||
CollectionLens? collection;
|
CollectionLens? collection;
|
||||||
|
|
||||||
final source = context.read<CollectionSource>();
|
final source = context.read<CollectionSource>();
|
||||||
final album = viewerEntry.directory;
|
final album = viewerEntry.directory;
|
||||||
if (album != null) {
|
if (album != null) {
|
||||||
// wait for collection to pass the `loading` state
|
|
||||||
final loadingCompleter = Completer();
|
final loadingCompleter = Completer();
|
||||||
final stateNotifier = source.stateNotifier;
|
final stateNotifier = source.stateNotifier;
|
||||||
void _onSourceStateChanged() {
|
void _onSourceStateChanged() {
|
||||||
|
|
@ -542,9 +645,6 @@ class _HomePageState extends State<HomePage> {
|
||||||
source: source,
|
source: source,
|
||||||
filters: {StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))},
|
filters: {StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))},
|
||||||
listenToSource: false,
|
listenToSource: false,
|
||||||
// if we group bursts, opening a burst sub-entry should:
|
|
||||||
// - identify and select the containing main entry,
|
|
||||||
// - select the sub-entry in the Viewer page.
|
|
||||||
stackBursts: false,
|
stackBursts: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -554,39 +654,22 @@ class _HomePageState extends State<HomePage> {
|
||||||
if (collectionEntry != null) {
|
if (collectionEntry != null) {
|
||||||
viewerEntry = collectionEntry;
|
viewerEntry = collectionEntry;
|
||||||
} else {
|
} else {
|
||||||
debugPrint('collection does not contain viewerEntry=$viewerEntry');
|
|
||||||
collection = null;
|
collection = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return DirectMaterialPageRoute(
|
return DirectMaterialPageRoute(
|
||||||
settings: const RouteSettings(name: EntryViewerPage.routeName),
|
settings: const RouteSettings(name: EntryViewerPage.routeName),
|
||||||
builder: (_) {
|
builder: (_) => EntryViewerPage(collection: collection, initialEntry: viewerEntry),
|
||||||
return EntryViewerPage(
|
|
||||||
collection: collection,
|
|
||||||
initialEntry: viewerEntry,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
case AppMode.edit:
|
case AppMode.edit:
|
||||||
return DirectMaterialPageRoute(
|
return DirectMaterialPageRoute(
|
||||||
settings: const RouteSettings(name: EntryViewerPage.routeName),
|
settings: const RouteSettings(name: EntryViewerPage.routeName),
|
||||||
builder: (_) {
|
builder: (_) => ImageEditorPage(entry: _viewerEntry!),
|
||||||
return ImageEditorPage(
|
|
||||||
entry: _viewerEntry!,
|
|
||||||
);
|
);
|
||||||
},
|
|
||||||
);
|
default:
|
||||||
case AppMode.initialization:
|
|
||||||
case AppMode.main:
|
|
||||||
case AppMode.pickCollectionFiltersExternal:
|
|
||||||
case AppMode.pickSingleMediaExternal:
|
|
||||||
case AppMode.pickMultipleMediaExternal:
|
|
||||||
case AppMode.pickFilteredMediaInternal:
|
|
||||||
case AppMode.pickUnfilteredMediaInternal:
|
|
||||||
case AppMode.pickFilterInternal:
|
|
||||||
case AppMode.previewMap:
|
|
||||||
case AppMode.screenSaver:
|
|
||||||
case AppMode.slideshow:
|
|
||||||
routeName = _initialRouteName ?? settings.homeNavItem.route;
|
routeName = _initialRouteName ?? settings.homeNavItem.route;
|
||||||
filters = _initialFilters ??
|
filters = _initialFilters ??
|
||||||
(settings.homeNavItem.route == CollectionPage.routeName ? settings.homeCustomCollection : {});
|
(settings.homeNavItem.route == CollectionPage.routeName ? settings.homeCustomCollection : {});
|
||||||
|
|
@ -610,7 +693,7 @@ class _HomePageState extends State<HomePage> {
|
||||||
source: source,
|
source: source,
|
||||||
filters: {
|
filters: {
|
||||||
LocationFilter.located,
|
LocationFilter.located,
|
||||||
if (filters != null) ...filters,
|
if (filters != null) ...filters!,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return MapPage(
|
return MapPage(
|
||||||
|
|
@ -640,13 +723,31 @@ class _HomePageState extends State<HomePage> {
|
||||||
);
|
);
|
||||||
case CollectionPage.routeName:
|
case CollectionPage.routeName:
|
||||||
default:
|
default:
|
||||||
// <<--- Wrapper di debug che aggiunge i due FAB (solo in debug)
|
return buildRoute((context) => CollectionPage(source: source, filters: filters));
|
||||||
return buildRoute(
|
}
|
||||||
(context) => _wrapWithRemoteDebug(
|
}
|
||||||
context,
|
|
||||||
CollectionPage(source: source, filters: filters),
|
Future<RemoteSettings> _safeLoadRemoteSettings({Duration timeout = const Duration(seconds: 5)}) async {
|
||||||
),
|
try {
|
||||||
|
return await RemoteSettings.load().timeout(timeout);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[remote] RemoteSettings.load failed: $e — using defaults');
|
||||||
|
return RemoteSettings(
|
||||||
|
enabled: RemoteSettings.defaultEnabled,
|
||||||
|
baseUrl: RemoteSettings.defaultBaseUrl,
|
||||||
|
indexPath: RemoteSettings.defaultIndexPath,
|
||||||
|
email: RemoteSettings.defaultEmail,
|
||||||
|
password: RemoteSettings.defaultPassword,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Map<String, String>> _safeHeaders({Duration timeout = const Duration(seconds: 6)}) async {
|
||||||
|
try {
|
||||||
|
return await RemoteHttp.headers().timeout(timeout);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[remote] RemoteHttp.headers failed: $e — returning empty headers');
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
BIN
metadata.db
BIN
metadata.db
Binary file not shown.
Loading…
Reference in a new issue