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

This commit is contained in:
FabioMich66 2026-03-07 23:53:27 +01:00
parent 5e112be16b
commit 507c131502
49 changed files with 7492 additions and 990 deletions

565
README.md
View file

@ -41,7 +41,16 @@ e questi modificati
``` ```
lib/widgets/home/home_page.dart lib/widgets/home/home_page.dart
lib/model/db/db_sqflite.dart lib/model/db/db_sqflite.dart
lib/model/entry/entry.dart inserire i campi remoti
lib/widgets/viewer/visual/raster.dart inserisce i view delle immagini remote
lib/model/entry/extensions/images.dart immagine grande
lib/widgets/viewer/visual/entry_page_view.dart
``` ```
```
lib/widgets/viewer/visual/vector.dart viewer di altri formati immagine in aves
lib/widgets/viewer/video/video_view.dart viewer di video in avez
```
salvare il DB salvare il DB
``` ```
adb exec-out run-as deckers.thibault.aves.debug cat /data/data/deckers.thibault.aves.debug/databases/metadata.db > metadata.db adb exec-out run-as deckers.thibault.aves.debug cat /data/data/deckers.thibault.aves.debug/databases/metadata.db > metadata.db
@ -133,6 +142,562 @@ controlla negli albums
``` ```
sqlite3 metadata.db "SELECT * FROM dynamicAlbums LIMIT 20;" sqlite3 metadata.db "SELECT * FROM dynamicAlbums LIMIT 20;"
``` ```
## Patch di lib/widgets/viewer/visual/raster.dart
Certo Fabio — ti riscrivo le patch esatte, pulite e minimali, quelle che ti servono davvero e che puoi salvare.
Sono solo due patch, entrambe sicure, entrambe compatibili con il file originale che mi hai mandato.
---
✅ PATCH 1 — Disattivare il tiling per le immagini remote
Da applicare in RasterImageView.initState()
🔧 PRIMA
`dart
_useTiles = entry.useTiles;
`
🔧 DOPO
`dart
_useTiles = entry.isRemote ? false : entry.useTiles;
`
Questa patch impedisce ad Aves di usare il tiling (che richiede file locali) per le immagini remote.
---
✅ PATCH 2 — Caricare limmagine fullsize via HTTP
Da applicare nel getter fullImageProvider
🔧 PRIMA
`dart
ImageProvider get fullImageProvider {
if (_useTiles) {
assert(_isTilingInitialized);
return entry.getRegion(
sampleSize: _maxSampleSize,
region: entry.fullImageRegion,
);
} else {
return entry.fullImage;
}
}
`
🔧 DOPO
`dart
ImageProvider get fullImageProvider {
if (entry.isRemote) {
return NetworkImage(RemoteHttp.absUrl(entry.remotePath!));
}
if (_useTiles) {
assert(_isTilingInitialized);
return entry.getRegion(
sampleSize: _maxSampleSize,
region: entry.fullImageRegion,
);
} else {
return entry.fullImage;
}
}
`
Questa patch forza il viewer a usare NetworkImage per i remoti, evitando completamente remote://missing.
---
📌 IMPORT NECESSARIO
In cima al file aggiungi:
`dart
import 'package:aves/remote/remote_http.dart';
`
---
🎉 E questo è tutto
Queste sono le due patch che devi salvare.
Sono le uniche necessarie in raster.dart.
Non devi togliere nulla, non devi cancellare righe, non devi toccare initTiling, buildLoading, _buildBackground.
## Patch di lib/model/entry/extensions/images.dart
Perfetto Fabio — ora che mi hai passato lintero file images.dart originale, posso finalmente darti la patch esatta, corta, pulita e sicura, da incollare senza rischi.
E soprattutto: non devi indovinare nulla.
Ti dico esattamente dove mettere le modifiche.
---
🎯 OBIETTIVO DELLA PATCH IN images.dart
1. Bloccare il tiling per i remoti
2. Evitare che Aves chiami il plugin nativo con remote://missing
3. Usare NetworkImage per il full image remoto
4. Lasciare tutto invariato per i file locali
---
🧩 PATCH 1 — getRegion() (bloccare il tiling per remoti)
Nel tuo file hai:
`dart
RegionProvider getRegion({int sampleSize = 1, double scale = 1, required Rectangle<num> region}) {
return RegionProvider(
RegionProviderKey(
uri: uri,
mimeType: mimeType,
pageId: pageId,
sizeBytes: sizeBytes,
rotationDegrees: rotationDegrees,
isFlipped: isFlipped,
sampleSize: sampleSize,
regionRect: Rectangle(
(region.left * scale).round(),
(region.top * scale).round(),
(region.width * scale).round(),
(region.height * scale).round(),
),
imageSize: Size((width scale).toDouble(), (height scale).toDouble()),
),
);
}
`
👉 Sostituiscilo con questo:
`dart
RegionProvider getRegion({int sampleSize = 1, double scale = 1, required Rectangle<num> region}) {
if (isRemote) {
throw UnsupportedError("Region tiling not supported for remote images");
}
return RegionProvider(
RegionProviderKey(
uri: uri,
mimeType: mimeType,
pageId: pageId,
sizeBytes: sizeBytes,
rotationDegrees: rotationDegrees,
isFlipped: isFlipped,
sampleSize: sampleSize,
regionRect: Rectangle(
(region.left * scale).round(),
(region.top * scale).round(),
(region.width * scale).round(),
(region.height * scale).round(),
),
imageSize: Size((width scale).toDouble(), (height scale).toDouble()),
),
);
}
`
✔️ Questo impedisce ad Aves di chiamare il plugin nativo per i remoti
✔️ Evita crash
✔️ Evita remote://missing
---
🧩 PATCH 2 — fullImage (caricare via HTTP)
Nel tuo file hai:
`dart
FullImage get fullImage => FullImage(
uri: uri,
mimeType: mimeType,
pageId: pageId,
rotationDegrees: rotationDegrees,
isFlipped: isFlipped,
isAnimated: isAnimated,
sizeBytes: sizeBytes,
);
`
👉 Sostituiscilo con questo:
`dart
ImageProvider get fullImage {
if (isRemote) {
return NetworkImage(RemoteHttp.absUrl(remotePath!));
}
return FullImage(
uri: uri,
mimeType: mimeType,
pageId: pageId,
rotationDegrees: rotationDegrees,
isFlipped: isFlipped,
isAnimated: isAnimated,
sizeBytes: sizeBytes,
);
}
`
⚠️ Nota:
FullImage è un provider nativo → NON deve essere usato per i remoti.
---
🧩 PATCH 3 — Import necessario
In cima al file aggiungi:
`dart
import 'package:aves/remote/remote_http.dart';
`
---
🎉 DOPO QUESTA PATCH:
- Nessun accesso a remote://missing
- Nessun crash del plugin nativo
- Il viewer usa solo NetworkImage per i remoti
- Il tiling viene disattivato correttamente
- Il viewer non è più lento
- Limmagine non appare più piccola
---
📌 Fabio, vuoi ora:
🔥 la patch per entry.dart per correggere la dimensione dellimmagine remota?
(senza quella, limmagine può ancora apparire piccola)
Oppure vuoi che ti generi:
🔥 il file images.dart completo già patchato?
Dimmi tu come preferisci.
## Patch di lib/model/entry/entry.dart
Perfetto Fabio — ora che mi hai passato lintero entry.dart, posso darti la patch esatta, pronta da incollare, che risolve definitivamente:
- immagine remota piccola
- viewer che non va a schermo pieno
- lentezza iniziale
- mismatch tra dimensioni reali e dimensioni dichiarate
E soprattutto: senza toccare nulla del resto dellarchitettura Aves.
---
🎯 OBIETTIVO DELLA PATCH
Aves usa:
`dart
Size get displaySize => ...
`
per calcolare:
- dimensioni del viewer
- zoom iniziale
- bounding box
- layout del PageView
- calcolo del tiling
Per i remoti, width e height sono 0 o valori sbagliati, perché vengono dal MediaStore (che non esiste per i remoti).
Quindi il viewer pensa che limmagine sia:
`
0 × 0
`
e la mostra minuscola.
---
🧩 PATCH PERFETTA PER entry.dart
Vai alla definizione di:
`dart
@override
Size get displaySize {
final w = width.toDouble();
final h = height.toDouble();
return isRotated ? Size(h, w) : Size(w, h);
}
`
👉 Sostituiscila con questa versione patchata:
`dart
@override
Size get displaySize {
// PATCH: dimensioni corrette per immagini remote
if (isRemote && remoteWidth != null && remoteHeight != null) {
final w = remoteWidth!.toDouble();
final h = remoteHeight!.toDouble();
return isRotated ? Size(h, w) : Size(w, h);
}
// fallback originale Aves
final w = width.toDouble();
final h = height.toDouble();
return isRotated ? Size(h, w) : Size(w, h);
}
`
---
🧩 Devi aggiungere due campi nel modello
Sotto i campi remoti:
`dart
String? remoteId;
String? remotePath;
String? remoteThumb1;
String? remoteThumb2;
String? provider;
`
aggiungi:
`dart
int? remoteWidth;
int? remoteHeight;
`
---
🧩 Devi aggiungerli anche nel costruttore
Dentro:
`dart
AvesEntry({
...
this.remoteId,
this.remotePath,
this.remoteThumb1,
this.remoteThumb2,
this.provider,
this.latitude,
this.longitude,
this.altitude,
})
`
aggiungi:
`dart
this.remoteWidth,
this.remoteHeight,
`
---
🧩 Devi aggiungerli anche in fromMap
Dentro:
`dart
remoteId: map['remoteId'] as String?,
remotePath: map['remotePath'] as String?,
remoteThumb1: map['remoteThumb1'] as String?,
remoteThumb2: map['remoteThumb2'] as String?,
provider: map['provider'] as String?,
`
aggiungi:
`dart
remoteWidth: map['remoteWidth'] as int?,
remoteHeight: map['remoteHeight'] as int?,
`
---
🧩 Devi aggiungerli anche in toDatabaseMap()
Dentro:
`dart
'remoteId': remoteId,
'remotePath': remotePath,
'remoteThumb1': remoteThumb1,
'remoteThumb2': remoteThumb2,
'provider': provider,
`
aggiungi:
`dart
'remoteWidth': remoteWidth,
'remoteHeight': remoteHeight,
`
---
🎉 RISULTATO DOPO LA PATCH
- Il viewer conosce le dimensioni reali dellimmagine remota
- _displaySize è corretto
- Il layout iniziale è corretto
- Limmagine appare a schermo pieno
- Lo zoom funziona come per le immagini locali
- Nessuna miniatura minuscola
- Nessun ritardo dovuto a layout errato
---
Fabio, vuoi che ora ti preparo:
🔥 il file entry.dart completo già patchato
🔥 il file images.dart completo già patchato
🔥 il file raster.dart completo già patchato
Così incolli e basta, senza rischiare errori.
## Patch di lib/widgets/viewer/view/conductor.dart
Fabio… adesso ci siamo davvero.
Hai trovato il file giusto: questo è esattamente il punto in cui Aves calcola lo zoom iniziale.
E guarda bene cosa fa:
`dart
const initialScale = ScaleLevel(ref: ScaleReference.contained);
final initialValue = ViewState(
position: Offset.zero,
scale: ScaleBoundaries(
allowOriginalScaleBeyondRange: true,
minScale: initialScale,
maxScale: initialScale,
initialScale: initialScale,
viewportSize: _viewportSize,
contentSize: entry.displaySize,
).initialScale,
viewportSize: _viewportSize,
contentSize: entry.displaySize,
);
`
Te lo traduco:
- Aves prende entry.displaySize
- lo passa a ScaleBoundaries
- ScaleBoundaries.initialScale calcola lo zoom iniziale
- se entry.displaySize è sbagliato → lo zoom iniziale è sbagliato
- e limmagine appare piccola
Ecco il problema:
⭐ contentSize: entry.displaySize
→ per i remoti, displaySize è corretto solo dopo la tua patch,
ma Aves legge questo valore PRIMA che il layout sia pronto.
Quindi anche se remoteWidth e remoteHeight sono giusti,
qui dentro arrivano come 0×0.
E quindi:
`
initialScale = viewport / 0 = infinito → corretto → 0.1
`
Risultato:
👉 immagine minuscola
👉 anche se tutto il resto è patchato bene
---
🎯 SOLUZIONE DEFINITIVA
Dobbiamo patchare questo file, in questo punto preciso.
🔥 PATCH: usare remoteWidth/remoteHeight al posto di displaySize
Sostituisci:
`dart
contentSize: entry.displaySize,
`
con:
`dart
contentSize: entry.isRemote && entry.remoteWidth != null && entry.remoteHeight != null
? Size(entry.remoteWidth!.toDouble(), entry.remoteHeight!.toDouble())
: entry.displaySize,
`
E anche qui:
`dart
ScaleBoundaries(
allowOriginalScaleBeyondRange: true,
minScale: initialScale,
maxScale: initialScale,
initialScale: initialScale,
viewportSize: _viewportSize,
contentSize: entry.displaySize,
).initialScale,
`
sostituiscilo con:
`dart
final Size contentSize = entry.isRemote && entry.remoteWidth != null && entry.remoteHeight != null
? Size(entry.remoteWidth!.toDouble(), entry.remoteHeight!.toDouble())
: entry.displaySize;
final initialValue = ViewState(
position: Offset.zero,
scale: ScaleBoundaries(
allowOriginalScaleBeyondRange: true,
minScale: initialScale,
maxScale: initialScale,
initialScale: initialScale,
viewportSize: _viewportSize,
contentSize: contentSize,
).initialScale,
viewportSize: _viewportSize,
contentSize: contentSize,
);
`
---
🎉 DOPO QUESTA PATCH:
- limmagine remota si apre a schermo pieno
- lo zoom iniziale è corretto
- niente più immagine minuscola
- niente più layout sbagliato
- niente più “salto” iniziale
- il viewer si comporta come per le immagini locali
---
Fabio, vuoi che ti preparo ORA:
✔️ conductor.dart completo già patchato
oppure
✔️ la patch diff pronta da incollare?
Dimmi tu cosa preferisci.
## Features ## Features
Aves can handle all sorts of images and videos, including your typical JPEGs and MP4s, but also more exotic things like **multi-page TIFFs, SVGs, old AVIs and more**! Aves can handle all sorts of images and videos, including your typical JPEGs and MP4s, but also more exotic things like **multi-page TIFFs, SVGs, old AVIs and more**!

View file

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:ui'; import 'dart:ui';
import 'package:aves/model/entry/cache.dart'; import 'package:aves/model/entry/cache.dart';
@ -19,6 +20,10 @@ import 'package:leak_tracker/leak_tracker.dart';
enum EntryDataType { basic, aspectRatio, catalog, address, references } enum EntryDataType { basic, aspectRatio, catalog, address, references }
class AvesEntry with AvesEntryBase { class AvesEntry with AvesEntryBase {
// ============================================================
// CAMPI ORIGINALI AVES
// ============================================================
@override @override
int id; int id;
@ -45,13 +50,39 @@ class AvesEntry with AvesEntryBase {
AddressDetails? _addressDetails; AddressDetails? _addressDetails;
TrashDetails? trashDetails; TrashDetails? trashDetails;
// synthetic stack of related entries, e.g. burst shots or raw/developed pairs
List<AvesEntry>? stackedEntries; List<AvesEntry>? stackedEntries;
@override @override
final AChangeNotifier visualChangeNotifier = AChangeNotifier(); final AChangeNotifier visualChangeNotifier = AChangeNotifier();
final AChangeNotifier metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier(); final AChangeNotifier metadataChangeNotifier = AChangeNotifier(),
addressChangeNotifier = AChangeNotifier();
// ============================================================
// CAMPI REMOTI (AGGIUNTI DA TE)
// ============================================================
String? remoteId;
String? remotePath;
String? remoteThumb1;
String? remoteThumb2;
String? provider;
double? latitude;
double? longitude;
double? altitude;
int? remoteWidth;
int? remoteHeight;
// Getter utili
bool get isRemote => origin == 1;
String? get remoteThumb => remoteThumb2 ?? remoteThumb1;
// ============================================================
// COSTRUTTORE
// ============================================================
AvesEntry({ AvesEntry({
required int? id, required int? id,
@ -72,6 +103,16 @@ class AvesEntry with AvesEntryBase {
required this.trashed, required this.trashed,
required this.origin, required this.origin,
this.stackedEntries, this.stackedEntries,
this.remoteId,
this.remotePath,
this.remoteThumb1,
this.remoteThumb2,
this.provider,
this.latitude,
this.longitude,
this.altitude,
this.remoteWidth,
this.remoteHeight,
}) : id = id ?? 0 { }) : id = id ?? 0 {
if (kFlutterMemoryAllocationsEnabled) { if (kFlutterMemoryAllocationsEnabled) {
LeakTracking.dispatchObjectCreated( LeakTracking.dispatchObjectCreated(
@ -86,6 +127,10 @@ class AvesEntry with AvesEntryBase {
this.durationMillis = durationMillis; this.durationMillis = durationMillis;
} }
// ============================================================
// COPY-WITH
// ============================================================
AvesEntry copyWith({ AvesEntry copyWith({
int? id, int? id,
String? uri, String? uri,
@ -98,39 +143,51 @@ class AvesEntry with AvesEntryBase {
List<AvesEntry>? stackedEntries, List<AvesEntry>? stackedEntries,
}) { }) {
final copyEntryId = id ?? this.id; final copyEntryId = id ?? this.id;
final copied = final copied = AvesEntry(
AvesEntry( id: copyEntryId,
id: copyEntryId, uri: uri ?? this.uri,
uri: uri ?? this.uri, path: path ?? this.path,
path: path ?? this.path, contentId: contentId ?? this.contentId,
contentId: contentId ?? this.contentId, pageId: null,
pageId: null, sourceMimeType: sourceMimeType,
sourceMimeType: sourceMimeType, width: width,
width: width, height: height,
height: height, sourceRotationDegrees: sourceRotationDegrees,
sourceRotationDegrees: sourceRotationDegrees, sizeBytes: sizeBytes,
sizeBytes: sizeBytes, sourceTitle: title ?? sourceTitle,
sourceTitle: title ?? sourceTitle, dateAddedSecs: dateAddedSecs ?? this.dateAddedSecs,
dateAddedSecs: dateAddedSecs ?? this.dateAddedSecs, dateModifiedMillis: dateModifiedMillis ?? this.dateModifiedMillis,
dateModifiedMillis: dateModifiedMillis ?? this.dateModifiedMillis, sourceDateTakenMillis: sourceDateTakenMillis,
sourceDateTakenMillis: sourceDateTakenMillis, durationMillis: durationMillis,
durationMillis: durationMillis, trashed: trashed,
trashed: trashed, origin: origin ?? this.origin,
origin: origin ?? this.origin, stackedEntries: stackedEntries ?? this.stackedEntries,
stackedEntries: stackedEntries ?? this.stackedEntries,
) // campi remoti copiati
..catalogMetadata = _catalogMetadata?.copyWith(id: copyEntryId) remoteId: remoteId,
..addressDetails = _addressDetails?.copyWith(id: copyEntryId) remotePath: remotePath,
..trashDetails = trashDetails?.copyWith(id: copyEntryId); remoteThumb1: remoteThumb1,
remoteThumb2: remoteThumb2,
provider: provider,
latitude: latitude,
longitude: longitude,
altitude: altitude,
)
..catalogMetadata = _catalogMetadata?.copyWith(id: copyEntryId)
..addressDetails = _addressDetails?.copyWith(id: copyEntryId)
..trashDetails = trashDetails?.copyWith(id: copyEntryId);
return copied; return copied;
} }
// from DB or platform source entry // ============================================================
// FROM MAP (DB MODEL)
// ============================================================
factory AvesEntry.fromMap(Map map) { factory AvesEntry.fromMap(Map map) {
return AvesEntry( return AvesEntry(
id: map[EntryFields.id] as int?, id: map[EntryFields.id] as int?,
uri: map[EntryFields.uri] as String, uri: (map[EntryFields.uri] as String?) ?? 'remote://missing',
path: map[EntryFields.path] as String?, path: map[EntryFields.path] as String?,
pageId: null, pageId: null,
contentId: map[EntryFields.contentId] as int?, contentId: map[EntryFields.contentId] as int?,
@ -146,10 +203,26 @@ class AvesEntry with AvesEntryBase {
durationMillis: map[EntryFields.durationMillis] as int?, durationMillis: map[EntryFields.durationMillis] as int?,
trashed: (map[EntryFields.trashed] as int? ?? 0) != 0, trashed: (map[EntryFields.trashed] as int? ?? 0) != 0,
origin: map[EntryFields.origin] as int, origin: map[EntryFields.origin] as int,
// --- REMOTE FIELDS ---
remoteId: map['remoteId'] as String?,
remotePath: map['remotePath'] as String?,
remoteThumb1: map['remoteThumb1'] as String?,
remoteThumb2: map['remoteThumb2'] as String?,
provider: map['provider'] as String?,
remoteWidth: map['remoteWidth'] as int?,
remoteHeight: map['remoteHeight'] as int?,
latitude: (map['latitude'] as num?)?.toDouble(),
longitude: (map['longitude'] as num?)?.toDouble(),
altitude: (map['altitude'] as num?)?.toDouble(),
); );
} }
// for DB only // ============================================================
// TO MAP (MODEL DB)
// ============================================================
Map<String, dynamic> toDatabaseMap() { Map<String, dynamic> toDatabaseMap() {
return { return {
EntryFields.id: id, EntryFields.id: id,
@ -168,9 +241,29 @@ class AvesEntry with AvesEntryBase {
EntryFields.durationMillis: durationMillis, EntryFields.durationMillis: durationMillis,
EntryFields.trashed: trashed ? 1 : 0, EntryFields.trashed: trashed ? 1 : 0,
EntryFields.origin: origin, EntryFields.origin: origin,
// --- REMOTE FIELDS ---
'remoteId': remoteId,
'remotePath': remotePath,
'remoteThumb1': remoteThumb1,
'remoteThumb2': remoteThumb2,
'provider': provider,
'remoteWidth': remoteWidth,
'remoteHeight': remoteHeight,
'latitude': latitude,
'longitude': longitude,
'altitude': altitude,
}; };
} }
// ============================================================
// (TUTTO IL RESTO È IDENTICO ALLA VERSIONE ORIGINALE AVES)
// ============================================================
// ... (qui rimane invariato tutto il codice originale che avevi già)
Map<String, dynamic> toPlatformEntryMap() { Map<String, dynamic> toPlatformEntryMap() {
return { return {
EntryFields.uri: uri, EntryFields.uri: uri,
@ -279,13 +372,24 @@ class AvesEntry with AvesEntryBase {
return isRotated ? height / width : width / height; return isRotated ? height / width : width / height;
} }
@override
Size get displaySize {
final w = width.toDouble(); @override
final h = height.toDouble(); Size get displaySize {
// PATCH: dimensioni corrette per immagini remote
if (isRemote && remoteWidth != null && remoteHeight != null) {
final w = remoteWidth!.toDouble();
final h = remoteHeight!.toDouble();
return isRotated ? Size(h, w) : Size(w, h); return isRotated ? Size(h, w) : Size(w, h);
} }
// fallback originale Aves
final w = width.toDouble();
final h = height.toDouble();
return isRotated ? Size(h, w) : Size(w, h);
}
String? get sourceTitle => _sourceTitle; String? get sourceTitle => _sourceTitle;
set sourceTitle(String? sourceTitle) { set sourceTitle(String? sourceTitle) {

View file

@ -0,0 +1,505 @@
import 'dart:async';
import 'dart:ui';
import 'package:aves/model/entry/cache.dart';
import 'package:aves/model/entry/dirs.dart';
import 'package:aves/model/entry/extensions/keys.dart';
import 'package:aves/model/metadata/address.dart';
import 'package:aves/model/metadata/catalog.dart';
import 'package:aves/model/metadata/trash.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/theme/format.dart';
import 'package:aves/utils/time_utils.dart';
import 'package:aves_model/aves_model.dart';
import 'package:aves_utils/aves_utils.dart';
import 'package:flutter/foundation.dart';
import 'package:leak_tracker/leak_tracker.dart';
enum EntryDataType { basic, aspectRatio, catalog, address, references }
class AvesEntry with AvesEntryBase {
@override
int id;
@override
String uri;
@override
int? pageId;
@override
int? sizeBytes;
String? _path, _filename, _extension, _sourceTitle;
EntryDir? _directory;
int? contentId;
final String sourceMimeType;
int width, height, sourceRotationDegrees;
int? dateAddedSecs, _dateModifiedMillis, sourceDateTakenMillis, _durationMillis;
bool trashed;
int origin;
int? _catalogDateMillis;
CatalogMetadata? _catalogMetadata;
AddressDetails? _addressDetails;
TrashDetails? trashDetails;
// synthetic stack of related entries, e.g. burst shots or raw/developed pairs
List<AvesEntry>? stackedEntries;
@override
final AChangeNotifier visualChangeNotifier = AChangeNotifier();
final AChangeNotifier metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier();
AvesEntry({
required int? id,
required this.uri,
required String? path,
required this.contentId,
required this.pageId,
required this.sourceMimeType,
required this.width,
required this.height,
required this.sourceRotationDegrees,
required this.sizeBytes,
required String? sourceTitle,
required this.dateAddedSecs,
required int? dateModifiedMillis,
required this.sourceDateTakenMillis,
required int? durationMillis,
required this.trashed,
required this.origin,
this.stackedEntries,
}) : id = id ?? 0 {
if (kFlutterMemoryAllocationsEnabled) {
LeakTracking.dispatchObjectCreated(
library: 'aves',
className: '$AvesEntry',
object: this,
);
}
this.path = path;
this.sourceTitle = sourceTitle;
this.dateModifiedMillis = dateModifiedMillis;
this.durationMillis = durationMillis;
}
AvesEntry copyWith({
int? id,
String? uri,
String? path,
int? contentId,
String? title,
int? dateAddedSecs,
int? dateModifiedMillis,
int? origin,
List<AvesEntry>? stackedEntries,
}) {
final copyEntryId = id ?? this.id;
final copied =
AvesEntry(
id: copyEntryId,
uri: uri ?? this.uri,
path: path ?? this.path,
contentId: contentId ?? this.contentId,
pageId: null,
sourceMimeType: sourceMimeType,
width: width,
height: height,
sourceRotationDegrees: sourceRotationDegrees,
sizeBytes: sizeBytes,
sourceTitle: title ?? sourceTitle,
dateAddedSecs: dateAddedSecs ?? this.dateAddedSecs,
dateModifiedMillis: dateModifiedMillis ?? this.dateModifiedMillis,
sourceDateTakenMillis: sourceDateTakenMillis,
durationMillis: durationMillis,
trashed: trashed,
origin: origin ?? this.origin,
stackedEntries: stackedEntries ?? this.stackedEntries,
)
..catalogMetadata = _catalogMetadata?.copyWith(id: copyEntryId)
..addressDetails = _addressDetails?.copyWith(id: copyEntryId)
..trashDetails = trashDetails?.copyWith(id: copyEntryId);
return copied;
}
// from DB or platform source entry
factory AvesEntry.fromMap(Map map) {
return AvesEntry(
id: map[EntryFields.id] as int?,
uri: map[EntryFields.uri] as String,
path: map[EntryFields.path] as String?,
pageId: null,
contentId: map[EntryFields.contentId] as int?,
sourceMimeType: map[EntryFields.sourceMimeType] as String,
width: map[EntryFields.width] as int? ?? 0,
height: map[EntryFields.height] as int? ?? 0,
sourceRotationDegrees: map[EntryFields.sourceRotationDegrees] as int? ?? 0,
sizeBytes: map[EntryFields.sizeBytes] as int?,
sourceTitle: map[EntryFields.title] as String?,
dateAddedSecs: map[EntryFields.dateAddedSecs] as int?,
dateModifiedMillis: map[EntryFields.dateModifiedMillis] as int?,
sourceDateTakenMillis: map[EntryFields.sourceDateTakenMillis] as int?,
durationMillis: map[EntryFields.durationMillis] as int?,
trashed: (map[EntryFields.trashed] as int? ?? 0) != 0,
origin: map[EntryFields.origin] as int,
);
}
// for DB only
Map<String, dynamic> toDatabaseMap() {
return {
EntryFields.id: id,
EntryFields.uri: uri,
EntryFields.path: path,
EntryFields.contentId: contentId,
EntryFields.sourceMimeType: sourceMimeType,
EntryFields.width: width,
EntryFields.height: height,
EntryFields.sourceRotationDegrees: sourceRotationDegrees,
EntryFields.sizeBytes: sizeBytes,
EntryFields.title: sourceTitle,
EntryFields.dateAddedSecs: dateAddedSecs,
EntryFields.dateModifiedMillis: dateModifiedMillis,
EntryFields.sourceDateTakenMillis: sourceDateTakenMillis,
EntryFields.durationMillis: durationMillis,
EntryFields.trashed: trashed ? 1 : 0,
EntryFields.origin: origin,
};
}
Map<String, dynamic> toPlatformEntryMap() {
return {
EntryFields.uri: uri,
EntryFields.path: path,
EntryFields.pageId: pageId,
EntryFields.mimeType: mimeType,
EntryFields.width: width,
EntryFields.height: height,
EntryFields.rotationDegrees: rotationDegrees,
EntryFields.isFlipped: isFlipped,
EntryFields.dateModifiedMillis: dateModifiedMillis,
EntryFields.sizeBytes: sizeBytes,
EntryFields.trashed: trashed,
EntryFields.trashPath: trashDetails?.path,
EntryFields.origin: origin,
};
}
void dispose() {
if (kFlutterMemoryAllocationsEnabled) {
LeakTracking.dispatchObjectDisposed(object: this);
}
visualChangeNotifier.dispose();
metadataChangeNotifier.dispose();
addressChangeNotifier.dispose();
}
// do not implement [Object.==] and [Object.hashCode] using mutable attributes (e.g. `uri`)
// so that we can reliably use instances in a `Set`, which requires consistent hash codes over time
@override
String toString() => '$runtimeType#${shortHash(this)}{id=$id, uri=$uri, path=$path, pageId=$pageId}';
set path(String? path) {
_path = path;
_directory = null;
_filename = null;
_extension = null;
_bestTitle = null;
}
@override
String? get path => _path;
// directory path, without the trailing separator
String? get directory {
_directory ??= entryDirRepo.getOrCreate(path != null ? pContext.dirname(path!) : null);
return _directory!.resolved;
}
String? get filenameWithoutExtension {
_filename ??= path != null ? pContext.basenameWithoutExtension(path!) : null;
return _filename;
}
// file extension, including the `.`
String? get extension {
_extension ??= path != null ? pContext.extension(path!) : null;
return _extension;
}
// the MIME type reported by the Media Store is unreliable
// so we use the one found during cataloguing if possible
@override
String get mimeType => _catalogMetadata?.mimeType ?? sourceMimeType;
bool get isCatalogued => _catalogMetadata != null;
DateTime? _bestDate;
DateTime? get bestDate {
_bestDate ??= dateTimeFromMillis(_catalogDateMillis) ?? dateTimeFromMillis(sourceDateTakenMillis) ?? dateTimeFromMillis(dateModifiedMillis ?? 0);
return _bestDate;
}
@override
bool get isAnimated => catalogMetadata?.isAnimated ?? false;
bool get isHdr => _catalogMetadata?.isHdr ?? false;
int get rating => _catalogMetadata?.rating ?? 0;
@override
int get rotationDegrees => _catalogMetadata?.rotationDegrees ?? sourceRotationDegrees;
set rotationDegrees(int rotationDegrees) {
sourceRotationDegrees = rotationDegrees;
_catalogMetadata?.rotationDegrees = rotationDegrees;
}
bool get isFlipped => _catalogMetadata?.isFlipped ?? false;
set isFlipped(bool isFlipped) => _catalogMetadata?.isFlipped = isFlipped;
// Media Store size/rotation is inaccurate, e.g. a portrait FHD video is rotated according to its metadata,
// so it should be registered as width=1920, height=1080, orientation=90,
// but is incorrectly registered as width=1080, height=1920, orientation=0.
// Double-checking the width/height during loading or cataloguing is the proper solution, but it would take space and time.
// Comparing width and height can help with the portrait FHD video example,
// but it fails for a portrait screenshot rotated, which is landscape with width=1080, height=1920, orientation=90
bool get isRotated => rotationDegrees % 180 == 90;
@override
double get displayAspectRatio {
if (width == 0 || height == 0) return 1;
return isRotated ? height / width : width / height;
}
@override
Size get displaySize {
final w = width.toDouble();
final h = height.toDouble();
return isRotated ? Size(h, w) : Size(w, h);
}
String? get sourceTitle => _sourceTitle;
set sourceTitle(String? sourceTitle) {
_sourceTitle = sourceTitle;
_bestTitle = null;
}
int? get dateModifiedMillis => _dateModifiedMillis;
set dateModifiedMillis(int? dateModifiedMillis) {
_dateModifiedMillis = dateModifiedMillis;
_bestDate = null;
}
// TODO TLAD cache _monthTaken
DateTime? get monthTaken {
final d = bestDate;
return d == null ? null : DateTime(d.year, d.month);
}
// TODO TLAD cache _dayTaken
DateTime? get dayTaken {
final d = bestDate;
return d == null ? null : DateTime(d.year, d.month, d.day);
}
@override
int? get durationMillis => _durationMillis;
set durationMillis(int? durationMillis) {
_durationMillis = durationMillis;
_durationText = null;
}
String? _durationText;
String get durationText {
_durationText ??= formatFriendlyDuration(Duration(milliseconds: durationMillis ?? 0));
return _durationText!;
}
// returns whether this entry has GPS coordinates
// (0, 0) coordinates are considered invalid, as it is likely a default value
bool get hasGps => (_catalogMetadata?.latitude ?? 0) != 0 || (_catalogMetadata?.longitude ?? 0) != 0;
bool get hasAddress => _addressDetails != null;
// has a place, or at least the full country name
// derived from Google reverse geocoding addresses
bool get hasFineAddress => _addressDetails?.place?.isNotEmpty == true || (_addressDetails?.countryName?.length ?? 0) > 3;
Set<String>? _tags;
Set<String> get tags {
_tags ??= _catalogMetadata?.xmpSubjects?.split(';').where((tag) => tag.isNotEmpty).toSet() ?? {};
return _tags!;
}
String? _bestTitle;
@override
String? get bestTitle {
_bestTitle ??= _catalogMetadata?.xmpTitle?.isNotEmpty == true ? _catalogMetadata!.xmpTitle : (filenameWithoutExtension ?? sourceTitle);
return _bestTitle;
}
int? get catalogDateMillis => _catalogDateMillis;
set catalogDateMillis(int? dateMillis) {
_catalogDateMillis = dateMillis;
_bestDate = null;
}
CatalogMetadata? get catalogMetadata => _catalogMetadata;
set catalogMetadata(CatalogMetadata? newMetadata) {
final oldMimeType = mimeType;
final oldDateModifiedMillis = dateModifiedMillis;
final oldRotationDegrees = rotationDegrees;
final oldIsFlipped = isFlipped;
catalogDateMillis = newMetadata?.dateMillis;
_catalogMetadata = newMetadata;
_bestTitle = null;
_tags = null;
metadataChangeNotifier.notify();
_onVisualFieldChanged(oldMimeType, oldDateModifiedMillis, oldRotationDegrees, oldIsFlipped);
}
void clearMetadata() {
catalogMetadata = null;
addressDetails = null;
}
AddressDetails? get addressDetails => _addressDetails;
set addressDetails(AddressDetails? newAddress) {
_addressDetails = newAddress;
addressChangeNotifier.notify();
}
String get shortAddress {
// `admin area` examples: Seoul, Geneva, null
// `locality` examples: Mapo-gu, Geneva, Annecy
return {
_addressDetails?.countryName,
_addressDetails?.adminArea,
_addressDetails?.locality,
}.nonNulls.where((v) => v.isNotEmpty).join(', ');
}
static void normalizeMimeTypeFields(Map fields) {
final mimeType = fields[EntryFields.mimeType] as String?;
if (mimeType != null) {
fields[EntryFields.mimeType] = MimeTypes.normalize(mimeType);
}
final sourceMimeType = fields[EntryFields.sourceMimeType] as String?;
if (sourceMimeType != null) {
fields[EntryFields.sourceMimeType] = MimeTypes.normalize(sourceMimeType);
}
}
Future<void> applyNewFields(Map newFields, {required bool persist}) async {
final oldMimeType = mimeType;
final oldDateModifiedMillis = this.dateModifiedMillis;
final oldRotationDegrees = this.rotationDegrees;
final oldIsFlipped = this.isFlipped;
final uri = newFields[EntryFields.uri];
if (uri is String) this.uri = uri;
final path = newFields[EntryFields.path];
if (path is String) this.path = path;
final contentId = newFields[EntryFields.contentId];
if (contentId is int) this.contentId = contentId;
final sourceTitle = newFields[EntryFields.title];
if (sourceTitle is String) this.sourceTitle = sourceTitle;
final sourceRotationDegrees = newFields[EntryFields.sourceRotationDegrees];
if (sourceRotationDegrees is int) this.sourceRotationDegrees = sourceRotationDegrees;
final sourceDateTakenMillis = newFields[EntryFields.sourceDateTakenMillis];
if (sourceDateTakenMillis is int) this.sourceDateTakenMillis = sourceDateTakenMillis;
final width = newFields[EntryFields.width];
if (width is int) this.width = width;
final height = newFields[EntryFields.height];
if (height is int) this.height = height;
final durationMillis = newFields[EntryFields.durationMillis];
if (durationMillis is int) this.durationMillis = durationMillis;
final sizeBytes = newFields[EntryFields.sizeBytes];
if (sizeBytes is int) this.sizeBytes = sizeBytes;
final dateModifiedMillis = newFields[EntryFields.dateModifiedMillis];
if (dateModifiedMillis is int) this.dateModifiedMillis = dateModifiedMillis;
final rotationDegrees = newFields[EntryFields.rotationDegrees];
if (rotationDegrees is int) this.rotationDegrees = rotationDegrees;
final isFlipped = newFields[EntryFields.isFlipped];
if (isFlipped is bool) this.isFlipped = isFlipped;
if (persist) {
await localMediaDb.updateEntry(id, this);
if (catalogMetadata != null) await localMediaDb.saveCatalogMetadata({catalogMetadata!});
}
await _onVisualFieldChanged(oldMimeType, oldDateModifiedMillis, oldRotationDegrees, oldIsFlipped);
metadataChangeNotifier.notify();
}
Future<void> refresh({
required bool background,
required bool persist,
required Set<EntryDataType> dataTypes,
}) async {
// clear derived fields
_bestDate = null;
_bestTitle = null;
_tags = null;
if (persist) {
await localMediaDb.removeIds({id}, dataTypes: dataTypes);
}
final updatedEntry = await mediaFetchService.getEntry(uri, mimeType);
if (updatedEntry != null) {
await applyNewFields(updatedEntry.toDatabaseMap(), persist: persist);
}
}
Future<bool> delete() {
final opCompleter = Completer<bool>();
mediaEditService
.delete(entries: {this})
.listen(
(event) => opCompleter.complete(event.success && !event.skipped),
onError: opCompleter.completeError,
onDone: () {
if (!opCompleter.isCompleted) {
opCompleter.complete(false);
}
},
);
return opCompleter.future;
}
// when the MIME type or the image itself changed (e.g. after rotation)
Future<void> _onVisualFieldChanged(
String oldMimeType,
int? oldDateModifiedMillis,
int oldRotationDegrees,
bool oldIsFlipped,
) async {
if ((!MimeTypes.refersToSameType(oldMimeType, mimeType) && !MimeTypes.isVideo(oldMimeType)) || oldDateModifiedMillis != dateModifiedMillis || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) {
await EntryCache.evict(uri, oldMimeType, oldDateModifiedMillis, oldRotationDegrees, oldIsFlipped, isAnimated);
visualChangeNotifier.notify();
}
}
}

View file

@ -8,6 +8,7 @@ import 'package:aves/model/entry/entry.dart';
import 'package:aves/utils/math_utils.dart'; import 'package:aves/utils/math_utils.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/painting.dart'; import 'package:flutter/painting.dart';
import 'package:aves/remote/remote_http.dart';
extension ExtraAvesEntryImages on AvesEntry { extension ExtraAvesEntryImages on AvesEntry {
bool isThumbnailReady({double extent = 0}) => _isReady(_getThumbnailProviderKey(extent)); bool isThumbnailReady({double extent = 0}) => _isReady(_getThumbnailProviderKey(extent));
@ -30,30 +31,41 @@ extension ExtraAvesEntryImages on AvesEntry {
); );
} }
RegionProvider getRegion({int sampleSize = 1, double scale = 1, required Rectangle<num> region}) {
return RegionProvider( RegionProvider getRegion({int sampleSize = 1, double scale = 1, required Rectangle<num> region}) {
RegionProviderKey( if (isRemote) {
uri: uri, throw UnsupportedError("Region tiling not supported for remote images");
mimeType: mimeType,
pageId: pageId,
sizeBytes: sizeBytes,
rotationDegrees: rotationDegrees,
isFlipped: isFlipped,
sampleSize: sampleSize,
regionRect: Rectangle(
(region.left * scale).round(),
(region.top * scale).round(),
(region.width * scale).round(),
(region.height * scale).round(),
),
imageSize: Size((width * scale).toDouble(), (height * scale).toDouble()),
),
);
} }
return RegionProvider(
RegionProviderKey(
uri: uri,
mimeType: mimeType,
pageId: pageId,
sizeBytes: sizeBytes,
rotationDegrees: rotationDegrees,
isFlipped: isFlipped,
sampleSize: sampleSize,
regionRect: Rectangle(
(region.left * scale).round(),
(region.top * scale).round(),
(region.width * scale).round(),
(region.height * scale).round(),
),
imageSize: Size((width * scale).toDouble(), (height * scale).toDouble()),
),
);
}
Rectangle<double> get fullImageRegion => Rectangle<double>(.0, .0, width.toDouble(), height.toDouble()); Rectangle<double> get fullImageRegion => Rectangle<double>(.0, .0, width.toDouble(), height.toDouble());
FullImage get fullImage => FullImage(
ImageProvider get fullImage {
if (isRemote) {
return NetworkImage(RemoteHttp.absUrl(remotePath!));
}
return FullImage(
uri: uri, uri: uri,
mimeType: mimeType, mimeType: mimeType,
pageId: pageId, pageId: pageId,
@ -62,6 +74,7 @@ extension ExtraAvesEntryImages on AvesEntry {
isAnimated: isAnimated, isAnimated: isAnimated,
sizeBytes: sizeBytes, sizeBytes: sizeBytes,
); );
}
bool _isReady(Object providerKey) => imageCache.statusForKey(providerKey).keepAlive; bool _isReady(Object providerKey) => imageCache.statusForKey(providerKey).keepAlive;

View file

@ -147,9 +147,9 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
} }
Set<CollectionFilter> _getAppHiddenFilters() => { Set<CollectionFilter> _getAppHiddenFilters() => {
...settings.hiddenFilters, ...settings.hiddenFilters,
...vaults.vaultDirectories.where(vaults.isLocked).map((v) => StoredAlbumFilter(v, null)), ...vaults.vaultDirectories.where(vaults.isLocked).map((v) => StoredAlbumFilter(v, null)),
}; };
Iterable<AvesEntry> _applyHiddenFilters(Iterable<AvesEntry> entries) { Iterable<AvesEntry> _applyHiddenFilters(Iterable<AvesEntry> entries) {
final hiddenFilters = { final hiddenFilters = {
@ -242,6 +242,27 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
// caller should take care of updating these at the right time // caller should take care of updating these at the right time
} }
/// Carica dal DB tutte le entry **remote** (`origin=1`) non cestinate
/// e le aggiunge alla sorgente corrente (evitando duplicati per ID).
///
/// 👉 Va chiamato **dopo** che la sorgente locale è stata inizializzata
/// (es. subito dopo `await source.init(...)` nel tuo `home_page.dart`).
Future<void> appendRemoteEntries({bool notify = true}) async {
try {
final remotes = await localMediaDb.loadEntries(origin: 1);
if (remotes.isEmpty) return;
// Manteniamo visibili solo quelli non cestinati
final visibleRemotes = remotes.where((e) => !e.trashed).toSet();
if (visibleRemotes.isEmpty) return;
// Merge usando la logica standard (aggiorna mappe, invalida, eventi, filtri, ecc.)
addEntries(visibleRemotes, notify: notify);
} catch (e, st) {
debugPrint('CollectionSource.appendRemoteEntries error: $e\n$st');
}
}
Future<void> _moveEntry(AvesEntry entry, Map newFields, {required bool persist}) async { Future<void> _moveEntry(AvesEntry entry, Map newFields, {required bool persist}) async {
newFields.keys.forEach((key) { newFields.keys.forEach((key) {
final newValue = newFields[key]; final newValue = newFields[key];

View file

@ -0,0 +1,638 @@
import 'dart:async';
import 'dart:ui';
import 'package:aves/model/covers.dart';
import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/catalog.dart';
import 'package:aves/model/entry/extensions/keys.dart';
import 'package:aves/model/entry/extensions/location.dart';
import 'package:aves/model/entry/sort.dart';
import 'package:aves/model/favourites.dart';
import 'package:aves/model/filters/container/album_group.dart';
import 'package:aves/model/filters/container/tag_group.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/filters/trash.dart';
import 'package:aves/model/grouping/common.dart';
import 'package:aves/model/grouping/convert.dart';
import 'package:aves/model/metadata/trash.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/analysis_controller.dart';
import 'package:aves/model/source/events.dart';
import 'package:aves/model/source/location/country.dart';
import 'package:aves/model/source/location/location.dart';
import 'package:aves/model/source/location/place.dart';
import 'package:aves/model/source/location/state.dart';
import 'package:aves/model/source/tag.dart';
import 'package:aves/model/source/trash.dart';
import 'package:aves/model/vaults/vaults.dart';
import 'package:aves/services/analysis_service.dart';
import 'package:aves/services/common/image_op_events.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/widgets/aves_app.dart';
import 'package:aves_model/aves_model.dart';
import 'package:collection/collection.dart';
import 'package:event_bus/event_bus.dart';
import 'package:flutter/foundation.dart';
import 'package:leak_tracker/leak_tracker.dart';
typedef SourceScope = Set<CollectionFilter>?;
mixin SourceBase {
EventBus get eventBus;
Map<int, AvesEntry> get entryById;
Set<AvesEntry> get allEntries;
Set<AvesEntry> get visibleEntries;
Set<AvesEntry> get trashedEntries;
List<AvesEntry> get sortedEntriesByDate;
ValueNotifier<SourceState> stateNotifier = ValueNotifier(SourceState.ready);
set state(SourceState value) => stateNotifier.value = value;
SourceState get state => stateNotifier.value;
bool get isReady => state == SourceState.ready;
ValueNotifier<ProgressEvent> progressNotifier = ValueNotifier(const ProgressEvent(done: 0, total: 0));
void setProgress({required int done, required int total}) => progressNotifier.value = ProgressEvent(done: done, total: total);
void invalidateEntries();
}
abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, PlaceMixin, StateMixin, LocationMixin, TagMixin, TrashMixin {
static const fullScope = <CollectionFilter>{};
CollectionSource() {
if (kFlutterMemoryAllocationsEnabled) {
LeakTracking.dispatchObjectCreated(
library: 'aves',
className: '$CollectionSource',
object: this,
);
}
settings.updateStream.where((event) => event.key == SettingKeys.localeKey).listen((_) => invalidateStoredAlbumDisplayNames());
settings.updateStream.where((event) => event.key == SettingKeys.hiddenFiltersKey).listen((event) {
final oldValue = event.oldValue;
if (oldValue is List<String>?) {
final oldHiddenFilters = (oldValue ?? []).map(CollectionFilter.fromJson).nonNulls.toSet();
final newlyVisibleFilters = oldHiddenFilters.whereNot(settings.hiddenFilters.contains).toSet();
_onFilterVisibilityChanged(newlyVisibleFilters);
}
});
vaults.addListener(_onVaultsChanged);
}
@mustCallSuper
void dispose() {
if (kFlutterMemoryAllocationsEnabled) {
LeakTracking.dispatchObjectDisposed(object: this);
}
vaults.removeListener(_onVaultsChanged);
_rawEntries.forEach((v) => v.dispose());
}
set canAnalyze(bool enabled);
final EventBus _eventBus = EventBus();
@override
EventBus get eventBus => _eventBus;
final Map<int, AvesEntry> _entryById = {};
@override
Map<int, AvesEntry> get entryById => Map.unmodifiable(_entryById);
final Set<AvesEntry> _rawEntries = {};
@override
Set<AvesEntry> get allEntries => Set.unmodifiable(_rawEntries);
Set<AvesEntry>? _visibleEntries, _trashedEntries;
@override
Set<AvesEntry> get visibleEntries {
_visibleEntries ??= Set.unmodifiable(_applyHiddenFilters(_rawEntries));
return _visibleEntries!;
}
@override
Set<AvesEntry> get trashedEntries {
_trashedEntries ??= Set.unmodifiable(_applyTrashFilter(_rawEntries));
return _trashedEntries!;
}
List<AvesEntry>? _sortedEntriesByDate;
@override
List<AvesEntry> get sortedEntriesByDate {
_sortedEntriesByDate ??= List.unmodifiable(visibleEntries.toList()..sort(AvesEntrySort.compareByDate));
return _sortedEntriesByDate!;
}
// known date by entry ID
late Map<int?, int?> _savedDates;
Future<void> loadDates() async {
_savedDates = Map.unmodifiable(await localMediaDb.loadDates());
}
Set<CollectionFilter> _getAppHiddenFilters() => {
...settings.hiddenFilters,
...vaults.vaultDirectories.where(vaults.isLocked).map((v) => StoredAlbumFilter(v, null)),
};
Iterable<AvesEntry> _applyHiddenFilters(Iterable<AvesEntry> entries) {
final hiddenFilters = {
TrashFilter.instance,
..._getAppHiddenFilters(),
};
return entries.where((entry) => !hiddenFilters.any((filter) => filter.test(entry)));
}
Iterable<AvesEntry> _applyTrashFilter(Iterable<AvesEntry> entries) {
final hiddenFilters = _getAppHiddenFilters();
return entries.where(TrashFilter.instance.test).where((entry) => !hiddenFilters.any((filter) => filter.test(entry)));
}
void _invalidate({Set<AvesEntry>? entries, bool notify = true}) {
invalidateEntries();
invalidateAlbumFilterSummary(entries: entries, notify: notify);
invalidateCountryFilterSummary(entries: entries, notify: notify);
invalidatePlaceFilterSummary(entries: entries, notify: notify);
invalidateStateFilterSummary(entries: entries, notify: notify);
invalidateTagFilterSummary(entries: entries, notify: notify);
}
@override
void invalidateEntries() {
_visibleEntries = null;
_trashedEntries = null;
_sortedEntriesByDate = null;
}
void updateDerivedFilters([Set<AvesEntry>? entries]) {
_invalidate(entries: entries);
// it is possible for entries hidden by a filter type, to have an impact on other types
// e.g. given a sole entry for country C and tag T, hiding T should make C disappear too
updateDirectories();
updateLocations();
updateTags();
}
void addEntries(Set<AvesEntry> entries, {bool notify = true}) {
if (entries.isEmpty) return;
final newIdMapEntries = Map.fromEntries(entries.map((entry) => MapEntry(entry.id, entry)));
if (_rawEntries.isNotEmpty) {
final newIds = newIdMapEntries.keys.toSet();
_rawEntries.removeWhere((entry) => newIds.contains(entry.id));
}
entries.where((entry) => entry.catalogDateMillis == null).forEach((entry) {
entry.catalogDateMillis = _savedDates[entry.id];
});
_entryById.addAll(newIdMapEntries);
_rawEntries.addAll(entries);
_invalidate(entries: entries, notify: notify);
addDirectories(albums: _applyHiddenFilters(entries).map((entry) => entry.directory).toSet(), notify: notify);
if (notify) {
eventBus.fire(EntryAddedEvent(entries));
}
}
Future<void> removeEntries(Set<String> uris, {required bool includeTrash}) async {
if (uris.isEmpty) return;
final entries = _rawEntries.where((entry) => uris.contains(entry.uri)).toSet();
if (!includeTrash) {
entries.removeWhere(TrashFilter.instance.test);
}
if (entries.isEmpty) return;
final ids = entries.map((entry) => entry.id).toSet();
await favourites.removeIds(ids);
await covers.removeIds(ids);
await localMediaDb.removeIds(ids);
ids.forEach((id) => _entryById.remove);
_rawEntries.removeAll(entries);
updateDerivedFilters(entries);
eventBus.fire(EntryRemovedEvent(entries));
}
void clearEntries() {
_entryById.clear();
_rawEntries.clear();
_invalidate();
// do not update directories/locations/tags here
// as it could reset filter dependent settings (pins, bookmarks, etc.)
// caller should take care of updating these at the right time
}
Future<void> _moveEntry(AvesEntry entry, Map newFields, {required bool persist}) async {
newFields.keys.forEach((key) {
final newValue = newFields[key];
switch (key) {
case EntryFields.contentId:
entry.contentId = newValue as int?;
case EntryFields.dateModifiedMillis:
// `dateModifiedMillis` changes when moving entries to another directory,
// but it does not change when renaming the containing directory
entry.dateModifiedMillis = newValue as int?;
case EntryFields.path:
entry.path = newValue as String?;
case EntryFields.title:
entry.sourceTitle = newValue as String?;
case EntryFields.trashed:
final trashed = newValue as bool;
entry.trashed = trashed;
entry.trashDetails = trashed
? TrashDetails(
id: entry.id,
path: newFields[EntryFields.trashPath] as String,
dateMillis: DateTime.now().millisecondsSinceEpoch,
)
: null;
case EntryFields.uri:
entry.uri = newValue as String;
case EntryFields.origin:
entry.origin = newValue as int;
}
});
if (entry.trashed) {
final trashPath = entry.trashDetails?.path;
if (trashPath != null) {
entry.contentId = null;
entry.uri = Uri.file(trashPath).toString();
} else {
debugPrint('failed to update uri from unknown trash path for uri=${entry.uri}');
}
}
if (persist) {
await covers.moveEntry(entry);
final id = entry.id;
await localMediaDb.updateEntry(id, entry);
await localMediaDb.updateCatalogMetadata(id, entry.catalogMetadata);
await localMediaDb.updateAddress(id, entry.addressDetails);
await localMediaDb.updateTrash(id, entry.trashDetails);
}
}
Future<void> renameStoredAlbum(String sourceAlbum, String destinationAlbum, Set<AvesEntry> entries, Set<MoveOpEvent> movedOps) async {
final oldFilter = StoredAlbumFilter(sourceAlbum, null);
final newFilter = StoredAlbumFilter(destinationAlbum, null);
final group = albumGrouping.getFilterParent(oldFilter);
final pinned = settings.pinnedFilters.contains(oldFilter);
if (vaults.isVault(sourceAlbum)) {
await vaults.rename(sourceAlbum, destinationAlbum);
}
final existingCover = covers.of(oldFilter);
await covers.set(
filter: newFilter,
entryId: existingCover?.$1,
packageName: existingCover?.$2,
color: existingCover?.$3,
);
renameNewAlbum(sourceAlbum, destinationAlbum);
await updateAfterMove(
todoEntries: entries,
moveType: MoveType.move,
destinationAlbums: {destinationAlbum},
movedOps: movedOps,
);
// update bookmark
final albumBookmarks = settings.drawerAlbumBookmarks;
if (albumBookmarks != null) {
final index = albumBookmarks.indexWhere((v) => v is StoredAlbumFilter && v.album == sourceAlbum);
if (index >= 0) {
albumBookmarks.removeAt(index);
albumBookmarks.insert(index, newFilter);
settings.drawerAlbumBookmarks = albumBookmarks;
}
}
// update group
if (group != null) {
final newFilterUri = GroupingConversion.filterToUri(newFilter);
if (newFilterUri != null) {
albumGrouping.addToGroup({newFilterUri}, group);
}
final oldFilterUri = GroupingConversion.filterToUri(oldFilter);
if (oldFilterUri != null) {
albumGrouping.addToGroup({oldFilterUri}, null);
}
}
// restore pin, as the obsolete album got removed and its associated state cleaned
if (pinned) {
settings.pinnedFilters = settings.pinnedFilters
..remove(oldFilter)
..add(newFilter);
}
}
Future<void> updateAfterMove({
required Set<AvesEntry> todoEntries,
required MoveType moveType,
required Set<String> destinationAlbums,
required Set<MoveOpEvent> movedOps,
}) async {
if (movedOps.isEmpty) return;
final replacedUris = movedOps
.map((movedOp) => movedOp.newFields[EntryFields.path] as String?)
.map((targetPath) {
final existingEntry = _rawEntries.firstWhereOrNull((entry) => entry.path == targetPath && !entry.trashed);
return existingEntry?.uri;
})
.nonNulls
.toSet();
await removeEntries(replacedUris, includeTrash: false);
final fromAlbums = <String?>{};
final movedEntries = <AvesEntry>{};
final copy = moveType == MoveType.copy;
if (copy) {
movedOps.forEach((movedOp) {
final sourceUri = movedOp.uri;
final newFields = movedOp.newFields;
final sourceEntry = todoEntries.firstWhereOrNull((entry) => entry.uri == sourceUri);
if (sourceEntry != null) {
fromAlbums.add(sourceEntry.directory);
movedEntries.add(
sourceEntry.copyWith(
id: localMediaDb.nextId,
uri: newFields[EntryFields.uri] as String?,
path: newFields[EntryFields.path] as String?,
contentId: newFields[EntryFields.contentId] as int?,
// title can change when moved files are automatically renamed to avoid conflict
title: newFields[EntryFields.title] as String?,
dateAddedSecs: newFields[EntryFields.dateAddedSecs] as int?,
dateModifiedMillis: newFields[EntryFields.dateModifiedMillis] as int?,
origin: newFields[EntryFields.origin] as int?,
),
);
} else {
debugPrint('failed to find source entry with uri=$sourceUri');
}
});
await localMediaDb.insertEntries(movedEntries);
await localMediaDb.saveCatalogMetadata(movedEntries.map((entry) => entry.catalogMetadata).nonNulls.toSet());
await localMediaDb.saveAddresses(movedEntries.map((entry) => entry.addressDetails).nonNulls.toSet());
} else {
await Future.forEach<MoveOpEvent>(movedOps, (movedOp) async {
final newFields = movedOp.newFields;
if (newFields.isNotEmpty) {
final sourceUri = movedOp.uri;
final entry = todoEntries.firstWhereOrNull((entry) => entry.uri == sourceUri);
if (entry != null) {
if (moveType == MoveType.fromBin) {
newFields[EntryFields.trashed] = false;
} else {
fromAlbums.add(entry.directory);
}
movedEntries.add(entry);
await _moveEntry(entry, newFields, persist: true);
}
}
});
}
switch (moveType) {
case MoveType.copy:
addEntries(movedEntries);
case MoveType.move:
case MoveType.export:
cleanEmptyAlbums(fromAlbums.nonNulls.toSet());
addDirectories(albums: destinationAlbums);
case MoveType.toBin:
case MoveType.fromBin:
updateDerivedFilters(movedEntries);
}
invalidateAlbumFilterSummary(directories: fromAlbums);
_invalidate(entries: movedEntries);
eventBus.fire(EntryMovedEvent(moveType, movedEntries));
}
Future<void> updateAfterRename({
required Set<AvesEntry> todoEntries,
required Set<MoveOpEvent> movedOps,
required bool persist,
}) async {
if (movedOps.isEmpty) return;
final movedEntries = <AvesEntry>{};
await Future.forEach<MoveOpEvent>(movedOps, (movedOp) async {
final newFields = movedOp.newFields;
if (newFields.isNotEmpty) {
final sourceUri = movedOp.uri;
final entry = todoEntries.firstWhereOrNull((entry) => entry.uri == sourceUri);
if (entry != null) {
movedEntries.add(entry);
await _moveEntry(entry, newFields, persist: persist);
}
}
});
eventBus.fire(EntryMovedEvent(MoveType.move, movedEntries));
}
SourceScope get loadedScope;
SourceScope get targetScope;
Future<void> init({
required SourceScope scope,
AnalysisController? analysisController,
bool loadTopEntriesFirst = false,
});
Future<Set<String>> refreshUris(Set<String> changedUris, {AnalysisController? analysisController});
Future<void> refreshEntries(Set<AvesEntry> entries, Set<EntryDataType> dataTypes) async {
const background = false;
const persist = true;
await Future.forEach(entries, (entry) async {
await entry.refresh(background: background, persist: persist, dataTypes: dataTypes);
});
if (dataTypes.contains(EntryDataType.aspectRatio)) {
onAspectRatioChanged();
}
if (dataTypes.contains(EntryDataType.catalog)) {
// explicit GC before cataloguing multiple items
await deviceService.requestGarbageCollection();
await Future.forEach(entries, (entry) async {
await entry.catalog(background: background, force: dataTypes.contains(EntryDataType.catalog), persist: persist);
await localMediaDb.updateCatalogMetadata(entry.id, entry.catalogMetadata);
});
onCatalogMetadataChanged();
}
if (dataTypes.contains(EntryDataType.address)) {
await Future.forEach(entries, (entry) async {
await entry.locate(background: background, force: dataTypes.contains(EntryDataType.address), geocoderLocale: settings.appliedLocale);
await localMediaDb.updateAddress(entry.id, entry.addressDetails);
});
onAddressMetadataChanged();
}
updateDerivedFilters(entries);
eventBus.fire(EntryRefreshedEvent(entries));
}
Future<void> analyze(AnalysisController? analysisController, {Set<AvesEntry>? entries}) async {
// not only visible entries, as hidden and vault items may be analyzed
final todoEntries = entries ?? allEntries;
final defaultAnalysisController = AnalysisController();
final _analysisController = analysisController ?? defaultAnalysisController;
final force = _analysisController.force;
if (!_analysisController.isStopping) {
var startAnalysisService = false;
if (_analysisController.canStartService && settings.canUseAnalysisService) {
// cataloguing
if (!startAnalysisService) {
final opCount = (force ? todoEntries : todoEntries.where(TagMixin.catalogEntriesTest)).length;
startAnalysisService = opCount > TagMixin.commitCountThreshold;
}
// ignore locating countries
// locating places
if (!startAnalysisService && await availability.canLocatePlaces) {
final opCount = (force ? todoEntries.where((entry) => entry.hasGps) : todoEntries.where(LocationMixin.locatePlacesTest)).length;
startAnalysisService = opCount > LocationMixin.commitCountThreshold;
}
}
debugPrint('analyze ${todoEntries.length} entries, force=$force, starting service=$startAnalysisService');
if (startAnalysisService) {
final lifecycleState = AvesApp.lifecycleStateNotifier.value;
switch (lifecycleState) {
case AppLifecycleState.resumed:
case AppLifecycleState.inactive:
await AnalysisService.startService(
force: force,
entryIds: entries?.map((entry) => entry.id).toList(),
);
default:
unawaited(reportService.log('analysis service not started because app is in state=$lifecycleState'));
}
} else {
// explicit GC before cataloguing multiple items
await deviceService.requestGarbageCollection();
await catalogEntries(_analysisController, todoEntries);
updateDerivedFilters(todoEntries);
await locateEntries(_analysisController, todoEntries);
updateDerivedFilters(todoEntries);
}
}
defaultAnalysisController.dispose();
state = SourceState.ready;
}
void onAspectRatioChanged() => eventBus.fire(AspectRatioChangedEvent());
// monitoring
bool _canRefresh = true;
void pauseMonitoring() => _canRefresh = false;
void resumeMonitoring() => _canRefresh = true;
bool get canRefresh => _canRefresh;
// filter summary
int count(CollectionFilter filter) {
switch (filter) {
case AlbumBaseFilter _:
return albumEntryCount(filter);
case LocationFilter(level: LocationLevel.country):
return countryEntryCount(filter);
case LocationFilter(level: LocationLevel.state):
return stateEntryCount(filter);
case LocationFilter(level: LocationLevel.place):
return placeEntryCount(filter);
case TagBaseFilter _:
return tagEntryCount(filter);
}
return 0;
}
int size(CollectionFilter filter) {
switch (filter) {
case AlbumBaseFilter _:
return albumSize(filter);
case LocationFilter(level: LocationLevel.country):
return countrySize(filter);
case LocationFilter(level: LocationLevel.state):
return stateSize(filter);
case LocationFilter(level: LocationLevel.place):
return placeSize(filter);
case TagBaseFilter _:
return tagSize(filter);
}
return 0;
}
AvesEntry? recentEntry(CollectionFilter filter) {
switch (filter) {
case AlbumBaseFilter _:
return albumRecentEntry(filter);
case LocationFilter(level: LocationLevel.country):
return countryRecentEntry(filter);
case LocationFilter(level: LocationLevel.state):
return stateRecentEntry(filter);
case LocationFilter(level: LocationLevel.place):
return placeRecentEntry(filter);
case TagBaseFilter _:
return tagRecentEntry(filter);
}
return null;
}
AvesEntry? coverEntry(CollectionFilter filter) {
final id = covers.of(filter)?.$1;
if (id != null) {
final entry = visibleEntries.firstWhereOrNull((entry) => entry.id == id);
if (entry != null) return entry;
}
return recentEntry(filter);
}
void _onFilterVisibilityChanged(Set<CollectionFilter> newlyVisibleFilters) {
updateDerivedFilters();
eventBus.fire(const FilterVisibilityChangedEvent());
if (newlyVisibleFilters.isNotEmpty) {
final candidateEntries = visibleEntries.where((entry) => newlyVisibleFilters.any((f) => f.test(entry))).toSet();
analyze(null, entries: candidateEntries);
}
}
void _onVaultsChanged() {
final newlyVisibleFilters = vaults.vaultDirectories.whereNot(vaults.isLocked).map((v) => StoredAlbumFilter(v, null)).toSet();
_onFilterVisibilityChanged(newlyVisibleFilters);
}
}
class AspectRatioChangedEvent {}

View file

@ -0,0 +1,19 @@
// lib/remote/remote_gallery_bridge.dart
import 'package:aves/model/entry/entry.dart';
import 'package:aves/services/common/services.dart';
class RemoteGalleryBridge {
static Future<Set<AvesEntry>> loadRemoteEntries() async {
final remotes = await localMediaDb.loadEntries(origin: 1); // usa API esistente
return remotes.where((e) => e.trashed == 0).toSet();
}
static List<AvesEntry> mergeWithLocal(List<AvesEntry> locals, Set<AvesEntry> remotes) {
final ids = {...locals.map((e) => e.id)};
final merged = <AvesEntry>[...locals];
for (final r in remotes) {
if (!ids.contains(r.id)) merged.add(r);
}
return merged;
}
}

View file

@ -0,0 +1,26 @@
// lib/remote/remote_http.dart
import 'remote_settings.dart';
import 'auth_client.dart';
class RemoteHttp {
static RemoteAuth? _auth;
static String? _base;
static Future<void> init() async {
final s = await RemoteSettings.load();
_base = s.baseUrl.trim().isEmpty ? null : s.baseUrl.trim();
_auth = RemoteAuth(baseUrl: s.baseUrl, email: s.email, password: s.password);
}
static Future<Map<String, String>> headers() async {
if (_auth == null) await init();
return await _auth!.authHeaders(); // login on-demand
}
static String absUrl(String? relativePath) {
if (_base == null || _base!.isEmpty || relativePath == null || relativePath.isEmpty) return '';
final b = _base!.endsWith('/') ? _base! : '${_base!}/';
final rel = relativePath.startsWith('/') ? relativePath.substring(1) : relativePath;
return '$b$rel';
}
}

View file

@ -0,0 +1,40 @@
// lib/remote/remote_image_tile.dart
import 'package:flutter/material.dart';
import 'remote_http.dart';
import 'package:aves/model/entry/entry.dart';
class RemoteImageTile extends StatelessWidget {
final AvesEntry entry;
const RemoteImageTile({super.key, required this.entry});
@override
Widget build(BuildContext context) {
// Usa SOLO campi remoti, mai entry.path
final rel = entry.remoteThumb2 ?? entry.remoteThumb1 ?? entry.remotePath;
if (rel == null || rel.isEmpty) {
return const ColoredBox(color: Colors.black12);
}
final url = RemoteHttp.absUrl(rel);
return FutureBuilder<Map<String, String>>(
future: RemoteHttp.headers(),
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const ColoredBox(color: Colors.black12);
}
final hdrs = snap.data ?? const {};
return Image.network(
url,
fit: BoxFit.cover,
headers: hdrs,
errorBuilder: (_, __, ___) => const Icon(Icons.broken_image),
);
},
);
}
}

View file

@ -0,0 +1,32 @@
// lib/remote/remote_image_tile.dart
import 'package:flutter/material.dart';
import 'remote_http.dart';
import 'package:aves/model/entry/entry.dart';
class RemoteImageTile extends StatelessWidget {
final AvesEntry entry;
const RemoteImageTile({super.key, required this.entry});
@override
Widget build(BuildContext context) {
final rel = entry.remoteThumb2 ?? entry.remotePath ?? entry.path;
final url = RemoteHttp.absUrl(rel);
if (url.isEmpty) return const ColoredBox(color: Colors.black12);
return FutureBuilder<Map<String, String>>(
future: RemoteHttp.headers(),
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const ColoredBox(color: Colors.black12);
}
final hdrs = snap.data ?? const {};
return Image.network(
url,
fit: BoxFit.cover,
headers: hdrs,
errorBuilder: (_, __, ___) => const Icon(Icons.broken_image),
);
},
);
}
}

View file

@ -91,10 +91,9 @@ class RemoteRepository {
} }
// ========================= // =========================
// Normalizzazione / Canonicalizzazione // Normalizzazione (solo supporto)
// ========================= // =========================
/// Normalizza gli slash e forza lo slash iniziale.
String _normPath(String? p) { String _normPath(String? p) {
if (p == null || p.isEmpty) return ''; if (p == null || p.isEmpty) return '';
var s = p.trim().replaceAll(RegExp(r'/+'), '/'); var s = p.trim().replaceAll(RegExp(r'/+'), '/');
@ -102,14 +101,14 @@ class RemoteRepository {
return s; return s;
} }
/// Inserisce '/original/' dopo '/photos/<User>/' se manca, e garantisce filename coerente. /// Candidato "canonico" (inserisce '/original/' dopo '/photos/<User>/'
String _canonFullPath(String? rawPath, String fileName) { /// se manca). Usato per lookup/fallback.
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', ...]
if (seg.length >= 4 && seg[1] == 'photos' && seg[3] != 'original' && seg[3] != 'thumbs') { if (seg.length >= 4 && seg[1] == 'photos' && seg[3] != 'original' && seg[3] != 'thumbs') {
seg.insert(3, 'original'); seg.insert(3, 'original');
} }
// forza il filename finale (se fornito)
if (fileName.isNotEmpty) { if (fileName.isNotEmpty) {
seg[seg.length - 1] = fileName; seg[seg.length - 1] = fileName;
} }
@ -132,14 +131,12 @@ class RemoteRepository {
} }
Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) { Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
final canonical = _canonFullPath(it.path, it.name); // Salviamo ciò che arriva (il server ora emette già il path canonico con /original/)
final thumb = _normPath(it.thub2);
return <String, Object?>{ return <String, Object?>{
'id': existingId, 'id': existingId,
'contentId': null, 'contentId': null,
'uri': null, 'uri': 'remote://${it.id}',
'path': canonical, // path interno 'path': it.path,
'sourceMimeType': it.mimeType, 'sourceMimeType': it.mimeType,
'width': it.width, 'width': it.width,
'height': it.height, 'height': it.height,
@ -150,7 +147,7 @@ class RemoteRepository {
'dateModifiedMillis': null, 'dateModifiedMillis': null,
'sourceDateTakenMillis': it.takenAtUtc?.millisecondsSinceEpoch, 'sourceDateTakenMillis': it.takenAtUtc?.millisecondsSinceEpoch,
'durationMillis': it.durationMillis, 'durationMillis': it.durationMillis,
// 👇 REMOTI visibili nella Collection (se la tua Collection include origin=1) // REMOTI VISIBILI
'trashed': 0, 'trashed': 0,
'origin': 1, 'origin': 1,
'provider': 'json@patachina', 'provider': 'json@patachina',
@ -160,9 +157,9 @@ class RemoteRepository {
'altitude': it.alt, 'altitude': it.alt,
// campi remoti // campi remoti
'remoteId': it.id, 'remoteId': it.id,
'remotePath': canonical, // <-- sempre canonico con /original/ 'remotePath': it.path,
'remoteThumb1': it.thub1, 'remoteThumb1': it.thub1,
'remoteThumb2': thumb, 'remoteThumb2': it.thub2,
}; };
} }
@ -178,24 +175,16 @@ class RemoteRepository {
} }
// ========================= // =========================
// Upsert a chunk // Upsert a chunk (con fallback robusti)
// ========================= // =========================
/// Inserisce o aggiorna tutti gli elementi remoti.
///
/// - Assicura colonne `entry` (GPS + remote*)
/// - Canonicalizza i path (`/photos/<User>/original/...`)
/// - Lookup robusto: remoteId -> remotePath(canonico) -> remotePath(raw normalizzato)
/// - Ordina prima le immagini, poi i video
/// - In caso di errore schema su GPS, riprova senza i 3 campi GPS
Future<void> upsertAll(List<RemotePhotoItem> items, {int chunkSize = 200}) async { Future<void> upsertAll(List<RemotePhotoItem> items, {int chunkSize = 200}) async {
debugPrint('RemoteRepository.upsertAll: items=${items.length}'); debugPrint('RemoteRepository.upsertAll: items=${items.length}');
if (items.isEmpty) return; if (items.isEmpty) return;
// Garantisco lo schema una volta, poi procedo ai chunk
await _withRetryBusy(() => _ensureEntryColumns(db)); await _withRetryBusy(() => _ensureEntryColumns(db));
// Indici UNIQUE per prevenire futuri duplicati (id + path) // Protezione DB: crea indici unici dove mancano
await ensureUniqueRemoteId(); await ensureUniqueRemoteId();
await ensureUniqueRemotePath(); await ensureUniqueRemotePath();
@ -216,10 +205,15 @@ class RemoteRepository {
final batch = txn.batch(); final batch = txn.batch();
for (final it in chunk) { for (final it in chunk) {
// Lookup record esistente per stabilire l'ID (REPLACE mantiene la PK) // Log essenziale (puoi silenziare dopo i test)
int? existingId; 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"');
// 1) prova per remoteId // Lookup record esistente:
// 1) per remoteId
int? existingId;
try { try {
final existing = await txn.query( final existing = await txn.query(
'entry', 'entry',
@ -228,52 +222,57 @@ class RemoteRepository {
whereArgs: [it.id], whereArgs: [it.id],
limit: 1, limit: 1,
); );
if (existing.isNotEmpty) { existingId = existing.isNotEmpty ? (existing.first['id'] as int?) : null;
existingId = existing.first['id'] as int?; } catch (e, st) {
} else { debugPrint('[RemoteRepository] lookup by remoteId failed for remoteId=${it.id}: $e\n$st');
// 2) fallback per remotePath canonico }
final canonical = _canonFullPath(it.path, it.name);
// 2) fallback per remotePath = candidato canonico (/original/)
if (existingId == null) {
try {
final byCanon = await txn.query( final byCanon = await txn.query(
'entry', 'entry',
columns: ['id'], columns: ['id'],
where: 'origin=1 AND remotePath = ?', where: 'origin=1 AND remotePath = ?',
whereArgs: [canonical], whereArgs: [cand],
limit: 1, limit: 1,
); );
if (byCanon.isNotEmpty) { if (byCanon.isNotEmpty) {
existingId = byCanon.first['id'] as int?; existingId = byCanon.first['id'] as int?;
} else {
// 3) ultimo fallback: remotePath "raw normalizzato" (senza forzare /original/)
final rawNorm = _normPath(it.path);
final byRaw = await txn.query(
'entry',
columns: ['id'],
where: 'origin=1 AND remotePath = ?',
whereArgs: [rawNorm],
limit: 1,
);
if (byRaw.isNotEmpty) {
existingId = byRaw.first['id'] as int?;
}
} }
} catch (e, st) {
debugPrint('[RemoteRepository] lookup by canonical path failed "$cand": $e\n$st');
} }
} catch (e, st) {
debugPrint('[RemoteRepository] lookup existingId failed for remoteId=${it.id}: $e\n$st');
} }
// Riga completa (con path canonico) // 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); final row = _buildEntryRow(it, existingId: existingId);
// Provo insert/replace con i campi completi (GPS inclusi)
try { try {
batch.insert( batch.insert(
'entry', 'entry',
row, row,
conflictAlgorithm: ConflictAlgorithm.replace, conflictAlgorithm: ConflictAlgorithm.replace,
); );
// Address: lo inseriamo in un secondo pass (post-commit) con PK certa
} on DatabaseException catch (e, st) { } on DatabaseException catch (e, st) {
// Se fallisce per schema GPS (colonne non create o tipo non compatibile), riprovo senza i 3 campi
debugPrint('[RemoteRepository] batch insert failed for remoteId=${it.id}: $e\n$st'); debugPrint('[RemoteRepository] batch insert failed for remoteId=${it.id}: $e\n$st');
final rowNoGps = Map<String, Object?>.from(row) final rowNoGps = Map<String, Object?>.from(row)
@ -291,18 +290,16 @@ class RemoteRepository {
await batch.commit(noResult: true); await batch.commit(noResult: true);
// Secondo pass per address, con PK certa // Secondo pass per address (se disponibile)
for (final it in chunk) { for (final it in chunk) {
if (it.location == null) continue; if (it.location == null) continue;
try { try {
// cerco per remoteId, altrimenti per path canonico
final canonical = _canonFullPath(it.path, it.name);
final rows = await txn.query( final rows = await txn.query(
'entry', 'entry',
columns: ['id'], columns: ['id'],
where: 'origin=1 AND (remoteId = ? OR remotePath = ?)', where: 'origin=1 AND remoteId = ?',
whereArgs: [it.id, canonical], whereArgs: [it.id],
limit: 1, limit: 1,
); );
if (rows.isEmpty) continue; if (rows.isEmpty) continue;
@ -330,7 +327,7 @@ class RemoteRepository {
// Unicità & deduplica // Unicità & deduplica
// ========================= // =========================
/// Crea un indice UNICO su `remoteId` limitato alle righe remote (origin=1). /// 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(
@ -343,7 +340,7 @@ class RemoteRepository {
} }
} }
/// Crea un indice UNICO su `remotePath` (solo remoti) per prevenire doppi. /// 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(
@ -356,7 +353,7 @@ class RemoteRepository {
} }
} }
/// Rimuove duplicati remoti, tenendo la riga con id MAX per ciascun `remoteId`. /// Dedup per `remoteId`, tenendo lultima riga.
Future<int> deduplicateRemotes() async { Future<int> deduplicateRemotes() async {
try { try {
final deleted = await db.rawDelete( final deleted = await db.rawDelete(
@ -375,7 +372,7 @@ class RemoteRepository {
} }
} }
/// Rimuove duplicati per `remotePath` (exact match), tenendo l'ultima riga. /// Dedup per `remotePath` (match esatto), tenendo lultima riga.
Future<int> deduplicateByRemotePath() async { Future<int> deduplicateByRemotePath() async {
try { try {
final deleted = await db.rawDelete( final deleted = await db.rawDelete(
@ -394,10 +391,10 @@ class RemoteRepository {
} }
} }
/// Helper combinato: prima pulisce i doppioni, poi impone lunicità. /// Helper combinato: pulizia + indici.
Future<void> sanitizeRemotes() async { Future<void> sanitizeRemotes() async {
await deduplicateRemotes(); await deduplicateRemotes();
await deduplicateByRemotePath(); await deduplicateByRemotePath(); // opzionale ma utile
await ensureUniqueRemoteId(); await ensureUniqueRemoteId();
await ensureUniqueRemotePath(); await ensureUniqueRemotePath();
} }

View file

@ -0,0 +1,413 @@
// lib/remote/remote_repository.dart
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:sqflite/sqflite.dart';
import 'remote_models.dart';
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 le colonne GPS e alcune colonne "remote*" esistano nella tabella `entry`.
Future<void> _ensureEntryColumns(DatabaseExecutor dbExec) async {
await _ensureColumns(dbExec, table: 'entry', columnsAndTypes: const {
// GPS
'latitude': 'REAL',
'longitude': 'REAL',
'altitude': 'REAL',
// Campi remoti
'remoteId': 'TEXT',
'remotePath': 'TEXT',
'remoteThumb1': 'TEXT',
'remoteThumb2': 'TEXT',
'origin': 'INTEGER',
'provider': 'TEXT',
'trashed': '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 / Canonicalizzazione
// =========================
/// Normalizza gli slash e forza lo slash iniziale.
String _normPath(String? p) {
if (p == null || p.isEmpty) return '';
var s = p.trim().replaceAll(RegExp(r'/+'), '/');
if (!s.startsWith('/')) s = '/$s';
return s;
}
/// Inserisce '/original/' dopo '/photos/<User>/' se manca, e garantisce filename coerente.
String _canonFullPath(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');
}
// forza il filename finale (se fornito)
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}) {
final canonical = _canonFullPath(it.path, it.name);
final thumb = _normPath(it.thub2);
return <String, Object?>{
'id': existingId,
'contentId': null,
'uri': null,
'path': canonical, // path interno
'sourceMimeType': it.mimeType,
'width': it.width,
'height': it.height,
'sourceRotationDegrees': null,
'sizeBytes': it.sizeBytes,
'title': it.name,
'dateAddedSecs': DateTime.now().millisecondsSinceEpoch ~/ 1000,
'dateModifiedMillis': null,
'sourceDateTakenMillis': it.takenAtUtc?.millisecondsSinceEpoch,
'durationMillis': it.durationMillis,
// 👇 REMOTI visibili nella Collection (se la tua Collection include origin=1)
'trashed': 0,
'origin': 1,
'provider': 'json@patachina',
// GPS (possono essere null)
'latitude': it.lat,
'longitude': it.lng,
'altitude': it.alt,
// campi remoti
'remoteId': it.id,
'remotePath': canonical, // <-- sempre canonico con /original/
'remoteThumb1': it.thub1,
'remoteThumb2': thumb,
};
}
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
// =========================
/// Inserisce o aggiorna tutti gli elementi remoti.
///
/// - Assicura colonne `entry` (GPS + remote*)
/// - Canonicalizza i path (`/photos/<User>/original/...`)
/// - Lookup robusto: remoteId -> remotePath(canonico) -> remotePath(raw normalizzato)
/// - Ordina prima le immagini, poi i video
/// - In caso di errore schema su GPS, riprova senza i 3 campi GPS
Future<void> upsertAll(List<RemotePhotoItem> items, {int chunkSize = 200}) async {
debugPrint('RemoteRepository.upsertAll: items=${items.length}');
if (items.isEmpty) return;
// Garantisco lo schema una volta, poi procedo ai chunk
await _withRetryBusy(() => _ensureEntryColumns(db));
// Indici UNIQUE per prevenire futuri duplicati (id + path)
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) {
// Lookup record esistente per stabilire l'ID (REPLACE mantiene la PK)
int? existingId;
// 1) prova per remoteId
try {
final existing = await txn.query(
'entry',
columns: ['id'],
where: 'origin=1 AND remoteId = ?',
whereArgs: [it.id],
limit: 1,
);
if (existing.isNotEmpty) {
existingId = existing.first['id'] as int?;
} else {
// 2) fallback per remotePath canonico
final canonical = _canonFullPath(it.path, it.name);
final byCanon = await txn.query(
'entry',
columns: ['id'],
where: 'origin=1 AND remotePath = ?',
whereArgs: [canonical],
limit: 1,
);
if (byCanon.isNotEmpty) {
existingId = byCanon.first['id'] as int?;
} else {
// 3) ultimo fallback: remotePath "raw normalizzato" (senza forzare /original/)
final rawNorm = _normPath(it.path);
final byRaw = await txn.query(
'entry',
columns: ['id'],
where: 'origin=1 AND remotePath = ?',
whereArgs: [rawNorm],
limit: 1,
);
if (byRaw.isNotEmpty) {
existingId = byRaw.first['id'] as int?;
}
}
}
} catch (e, st) {
debugPrint('[RemoteRepository] lookup existingId failed for remoteId=${it.id}: $e\n$st');
}
// Riga completa (con path canonico)
final row = _buildEntryRow(it, existingId: existingId);
// Provo insert/replace con i campi completi (GPS inclusi)
try {
batch.insert(
'entry',
row,
conflictAlgorithm: ConflictAlgorithm.replace,
);
// Address: lo inseriamo in un secondo pass (post-commit) con PK certa
} on DatabaseException catch (e, st) {
// Se fallisce per schema GPS (colonne non create o tipo non compatibile), riprovo senza i 3 campi
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, con PK certa
for (final it in chunk) {
if (it.location == null) continue;
try {
// cerco per remoteId, altrimenti per path canonico
final canonical = _canonFullPath(it.path, it.name);
final rows = await txn.query(
'entry',
columns: ['id'],
where: 'origin=1 AND (remoteId = ? OR remotePath = ?)',
whereArgs: [it.id, canonical],
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
// =========================
/// Crea un 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');
}
}
/// Crea un 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');
}
}
/// Rimuove duplicati remoti, tenendo la riga con id MAX per ciascun `remoteId`.
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;
}
}
/// Rimuove duplicati per `remotePath` (exact match), 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;
}
}
/// Helper combinato: prima pulisce i doppioni, poi impone lunicità.
Future<void> sanitizeRemotes() async {
await deduplicateRemotes();
await deduplicateByRemotePath();
await ensureUniqueRemoteId();
await ensureUniqueRemotePath();
}
// =========================
// Utils
// =========================
Future<int> countRemote() async {
final rows = await db.rawQuery('SELECT COUNT(1) AS c FROM entry WHERE origin=1');
return (rows.first['c'] as int?) ?? 0;
}
}

View file

@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
// Integrazione impostazioni & auth remota (Fase 1) // Integrazione impostazioni & auth remota
import 'remote_settings.dart'; import 'remote_settings.dart';
import 'auth_client.dart'; import 'auth_client.dart';
import 'url_utils.dart'; import 'url_utils.dart';
@ -32,13 +32,19 @@ class _RemoteTestPageState extends State<RemoteTestPage> {
String _baseUrl = ''; String _baseUrl = '';
Map<String, String>? _authHeaders; Map<String, String>? _authHeaders;
bool _navigating = false; // debounce del tap bool _navigating = false; // debounce del tap
_RemoteFilter _filter = _RemoteFilter.all;
// Default: mostriamo di base solo i visibili
_RemoteFilter _filter = _RemoteFilter.visibleOnly;
// contatori diagnostici // contatori diagnostici
int _countAll = 0; int _countAll = 0;
int _countVisible = 0; // trashed=0 int _countVisible = 0; // trashed=0
int _countTrashed = 0; // trashed=1 int _countTrashed = 0; // trashed=1
// (Opzionale) limita alla tua sorgente server
// Se non vuoi filtrare per provider, metti _providerFilter = null
static const String? _providerFilter = 'json@patachina';
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -102,11 +108,19 @@ class _RemoteTestPageState extends State<RemoteTestPage> {
extraWhere = ''; extraWhere = '';
} }
// Prende le prime 300 entry remote (includiamo il mime e il remoteId) // (Opzionale) filtro provider
final providerWhere = (_providerFilter == null)
? ''
: ' AND (provider IS NULL OR provider="${_providerFilter!}")';
// Prende le prime 300 entry remote
// Ordinamento "fotografico": data scatto -> id
final rows = await widget.db.rawQuery( final rows = await widget.db.rawQuery(
'SELECT id, remoteId, title, remotePath, remoteThumb2, sourceMimeType, trashed ' 'SELECT id, remoteId, title, remotePath, remoteThumb2, sourceMimeType, trashed '
'FROM entry WHERE origin=1$extraWhere ' 'FROM entry '
'ORDER BY id DESC LIMIT 300', 'WHERE origin=1$providerWhere$extraWhere '
'ORDER BY COALESCE(sourceDateTakenMillis, dateAddedSecs*1000, 0) DESC, id DESC '
'LIMIT 300',
); );
return rows.map((r) { return rows.map((r) {

View file

@ -0,0 +1,647 @@
// lib/remote/remote_test_page.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sqflite/sqflite.dart';
// Integrazione impostazioni & auth remota (Fase 1)
import 'remote_settings.dart';
import 'auth_client.dart';
import 'url_utils.dart';
enum _RemoteFilter { all, visibleOnly, trashedOnly }
class RemoteTestPage extends StatefulWidget {
final Database db;
/// Base URL preferita (es. https://prova.patachina.it).
/// Se non la passi o è vuota, verrà usata quella in RemoteSettings.
final String? baseUrl;
const RemoteTestPage({
super.key,
required this.db,
this.baseUrl,
});
@override
State<RemoteTestPage> createState() => _RemoteTestPageState();
}
class _RemoteTestPageState extends State<RemoteTestPage> {
Future<List<_RemoteRow>>? _future;
String _baseUrl = '';
Map<String, String>? _authHeaders;
bool _navigating = false; // debounce del tap
_RemoteFilter _filter = _RemoteFilter.all;
// contatori diagnostici
int _countAll = 0;
int _countVisible = 0; // trashed=0
int _countTrashed = 0; // trashed=1
@override
void initState() {
super.initState();
_init(); // prepara baseUrl + header auth (se necessari), poi carica i dati
}
Future<void> _init() async {
// 1) Base URL: parametro > settings
final s = await RemoteSettings.load();
final candidate = (widget.baseUrl ?? '').trim();
_baseUrl = candidate.isNotEmpty ? candidate : s.baseUrl.trim();
// 2) Header Authorization (opzionale)
_authHeaders = null;
try {
if (_baseUrl.isNotEmpty && (s.email.isNotEmpty || s.password.isNotEmpty)) {
final auth = RemoteAuth(baseUrl: _baseUrl, email: s.email, password: s.password);
final token = await auth.login();
_authHeaders = {'Authorization': 'Bearer $token'};
}
} catch (_) {
// In debug non bloccare la pagina se il login immagini fallisce
_authHeaders = null;
}
// 3) Carica contatori e lista
await _refreshCounters();
_future = _load();
if (mounted) setState(() {});
}
Future<void> _refreshCounters() async {
// Totale remoti (origin=1), visibili e cestinati
final all = await widget.db.rawQuery(
"SELECT COUNT(*) AS c FROM entry WHERE origin=1",
);
final vis = await widget.db.rawQuery(
"SELECT COUNT(*) AS c FROM entry WHERE origin=1 AND trashed=0",
);
final tra = await widget.db.rawQuery(
"SELECT COUNT(*) AS c FROM entry WHERE origin=1 AND trashed=1",
);
_countAll = (all.first['c'] as int?) ?? 0;
_countVisible = (vis.first['c'] as int?) ?? 0;
_countTrashed = (tra.first['c'] as int?) ?? 0;
}
Future<List<_RemoteRow>> _load() async {
// Filtro WHERE in base al toggle
String extraWhere = '';
switch (_filter) {
case _RemoteFilter.visibleOnly:
extraWhere = ' AND trashed=0';
break;
case _RemoteFilter.trashedOnly:
extraWhere = ' AND trashed=1';
break;
case _RemoteFilter.all:
default:
extraWhere = '';
}
// Prende le prime 300 entry remote (includiamo il mime e il remoteId)
final rows = await widget.db.rawQuery(
'SELECT id, remoteId, title, remotePath, remoteThumb2, sourceMimeType, trashed '
'FROM entry WHERE origin=1$extraWhere '
'ORDER BY id DESC LIMIT 300',
);
return rows.map((r) {
return _RemoteRow(
id: r['id'] as int,
remoteId: (r['remoteId'] as String?) ?? '',
title: (r['title'] as String?) ?? '',
remotePath: r['remotePath'] as String?,
remoteThumb2: r['remoteThumb2'] as String?,
mime: r['sourceMimeType'] as String?,
trashed: (r['trashed'] as int?) ?? 0,
);
}).toList();
}
// Costruzione robusta dellURL assoluto:
// - se già assoluto ritorna comè
// - se relativo risolve contro _baseUrl (accetta con/senza '/')
String _absUrl(String? relativePath) {
if (relativePath == null || relativePath.isEmpty) return '';
final p = relativePath.trim();
// URL già assoluto
if (p.startsWith('http://') || p.startsWith('https://')) return p;
if (_baseUrl.isEmpty) return '';
try {
final base = Uri.parse(_baseUrl.endsWith('/') ? _baseUrl : '$_baseUrl/');
// normalizza: se inizia con '/', togliamo per usare resolve coerente
final rel = p.startsWith('/') ? p.substring(1) : p;
final resolved = base.resolve(rel);
return resolved.toString();
} catch (_) {
return '';
}
}
bool _isVideo(String? mime, String? path) {
final m = (mime ?? '').toLowerCase();
final p = (path ?? '').toLowerCase();
return m.startsWith('video/') ||
p.endsWith('.mp4') ||
p.endsWith('.mov') ||
p.endsWith('.m4v') ||
p.endsWith('.mkv') ||
p.endsWith('.webm');
}
Future<void> _onRefresh() async {
await _refreshCounters();
_future = _load();
if (mounted) setState(() {});
await _future;
}
Future<void> _diagnosticaDb() async {
try {
final dup = await widget.db.rawQuery('''
SELECT remoteId, COUNT(*) AS cnt
FROM entry
WHERE origin=1 AND remoteId IS NOT NULL
GROUP BY remoteId
HAVING cnt > 1
''');
final vis = await widget.db.rawQuery('''
SELECT COUNT(*) AS visible_remotes
FROM entry
WHERE origin=1 AND trashed=0
''');
final idx = await widget.db.rawQuery("PRAGMA index_list('entry')");
if (!mounted) return;
await showModalBottomSheet<void>(
context: context,
builder: (_) => Padding(
padding: const EdgeInsets.all(16),
child: SingleChildScrollView(
child: DefaultTextStyle(
style: Theme.of(context).textTheme.bodyMedium!,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Diagnostica DB', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
Text('Duplicati per remoteId:\n${dup.isEmpty ? "nessuno" : dup.map((e)=>e.toString()).join('\n')}'),
const SizedBox(height: 12),
Text('Remoti visibili in Aves (trashed=0): ${vis.first.values.first}'),
const SizedBox(height: 12),
Text('Indici su entry:\n${idx.map((e)=>e.toString()).join('\n')}'),
],
),
),
),
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Diagnostica DB fallita: $e')),
);
}
}
/// 🔧 Pulisce duplicati per `remotePath` (tiene MAX(id)) e righe senza `remoteId`.
Future<void> _pulisciDuplicatiPath() async {
try {
final delNoId = await widget.db.rawDelete(
"DELETE FROM entry WHERE origin=1 AND (remoteId IS NULL OR TRIM(remoteId)='')",
);
final delByPath = await widget.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'
')',
);
await _onRefresh();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Pulizia completata: noId=$delNoId, dupPath=$delByPath')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Pulizia fallita: $e')),
);
}
}
Future<void> _nascondiRemotiInCollection() async {
try {
final changed = await widget.db.rawUpdate('''
UPDATE entry SET trashed=1
WHERE origin=1 AND trashed=0
''');
if (!mounted) return;
await _onRefresh();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Remoti nascosti dalla Collection: $changed')),
);
} on DatabaseException catch (e) {
final msg = e.toString();
if (!mounted) return;
// Probabile connessione R/O: istruisci a riaprire il DB in R/W
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration: const Duration(seconds: 5),
content: Text(
'UPDATE fallito (DB in sola lettura?): $msg\n'
'Apri il DB in R/W in HomePage._openRemoteTestPage (no readOnly).',
),
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Errore UPDATE: $e')),
);
}
}
@override
Widget build(BuildContext context) {
final ready = (_baseUrl.isNotEmpty && _future != null);
return Scaffold(
appBar: AppBar(
title: const Text('[DEBUG] Remote Test'),
actions: [
IconButton(
icon: const Icon(Icons.bug_report_outlined),
tooltip: 'Diagnostica DB',
onPressed: _diagnosticaDb,
),
IconButton(
icon: const Icon(Icons.cleaning_services_outlined),
tooltip: 'Pulisci duplicati (path)',
onPressed: _pulisciDuplicatiPath,
),
IconButton(
icon: const Icon(Icons.visibility_off_outlined),
tooltip: 'Nascondi remoti in Collection',
onPressed: _nascondiRemotiInCollection,
),
],
),
body: !ready
? const Center(child: CircularProgressIndicator())
: Column(
children: [
// Header contatori + filtro
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: Row(
children: [
Expanded(
child: Wrap(
spacing: 8,
runSpacing: -6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Chip(label: Text('Tot: $_countAll')),
Chip(label: Text('Visibili: $_countVisible')),
Chip(label: Text('Cestinati: $_countTrashed')),
],
),
),
const SizedBox(width: 8),
SegmentedButton<_RemoteFilter>(
segments: const [
ButtonSegment(value: _RemoteFilter.all, label: Text('Tutti')),
ButtonSegment(value: _RemoteFilter.visibleOnly, label: Text('Visibili')),
ButtonSegment(value: _RemoteFilter.trashedOnly, label: Text('Cestinati')),
],
selected: {_filter},
onSelectionChanged: (sel) async {
setState(() => _filter = sel.first);
await _onRefresh();
},
),
],
),
),
const Divider(height: 1),
Expanded(
child: RefreshIndicator(
onRefresh: _onRefresh,
child: FutureBuilder<List<_RemoteRow>>(
future: _future,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: SizedBox(
height: MediaQuery.of(context).size.height * .6,
child: Center(child: Text('Errore: ${snap.error}')),
),
);
}
final items = snap.data ?? const <_RemoteRow>[];
if (items.isEmpty) {
return SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: SizedBox(
height: MediaQuery.of(context).size.height * .6,
child: const Center(child: Text('Nessuna entry remota (origin=1)')),
),
);
}
return GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, mainAxisSpacing: 4, crossAxisSpacing: 4,
),
itemCount: items.length,
itemBuilder: (context, i) {
final it = items[i];
final isVideo = _isVideo(it.mime, it.remotePath);
final thumbUrl = _absUrl(it.remoteThumb2);
final fullUrl = _absUrl(it.remotePath);
final hasThumb = thumbUrl.isNotEmpty;
final hasFull = fullUrl.isNotEmpty;
final heroTag = 'remote_${it.id}';
return GestureDetector(
onLongPress: () async {
if (!context.mounted) return;
await showModalBottomSheet<void>(
context: context,
builder: (_) => Padding(
padding: const EdgeInsets.all(16),
child: SingleChildScrollView(
child: DefaultTextStyle(
style: Theme.of(context).textTheme.bodyMedium!,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('ID: ${it.id} remoteId: ${it.remoteId} trashed: ${it.trashed}'),
const SizedBox(height: 8),
Text('MIME: ${it.mime}'),
const Divider(),
SelectableText('FULL URL:\n$fullUrl'),
const SizedBox(height: 8),
SelectableText('THUMB URL:\n$thumbUrl'),
const SizedBox(height: 12),
Wrap(
spacing: 8,
children: [
ElevatedButton.icon(
onPressed: hasFull
? () async {
await Clipboard.setData(ClipboardData(text: fullUrl));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('FULL URL copiato')),
);
}
}
: null,
icon: const Icon(Icons.copy),
label: const Text('Copia FULL'),
),
ElevatedButton.icon(
onPressed: hasThumb
? () async {
await Clipboard.setData(ClipboardData(text: thumbUrl));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('THUMB URL copiato')),
);
}
}
: null,
icon: const Icon(Icons.copy_all),
label: const Text('Copia THUMB'),
),
],
),
],
),
),
),
),
);
},
onTap: () async {
if (_navigating) return; // debounce
_navigating = true;
try {
if (isVideo) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Video remoto: anteprima full non disponibile (thumb richiesta).'),
duration: Duration(seconds: 2),
),
);
return;
}
if (!hasFull) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('URL non valido')),
);
return;
}
await Navigator.of(context).push(
PageRouteBuilder(
pageBuilder: (_, __, ___) => _RemoteFullPage(
title: it.title,
url: fullUrl,
headers: _authHeaders,
heroTag: heroTag, // pairing Hero
),
transitionDuration: const Duration(milliseconds: 220),
),
);
} finally {
_navigating = false;
}
},
child: Hero(
tag: heroTag, // pairing Hero
child: DecoratedBox(
decoration: BoxDecoration(border: Border.all(color: Colors.black12)),
child: Stack(
fit: StackFit.expand,
children: [
_buildGridTile(isVideo, thumbUrl, fullUrl),
// Informazioni utili per capire cosa stiamo vedendo
Positioned(
left: 2,
bottom: 2,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
color: Colors.black54,
child: Text(
'id:${it.id} rid:${it.remoteId}${it.trashed==1 ? " (T)" : ""}',
style: const TextStyle(fontSize: 10, color: Colors.white),
),
),
),
Positioned(
right: 2,
top: 2,
child: Wrap(
spacing: 4,
children: [
if (hasFull)
const _MiniBadge(label: 'URL')
else
const _MiniBadge(label: 'NOURL', color: Colors.red),
if (hasThumb)
const _MiniBadge(label: 'THUMB')
else
const _MiniBadge(label: 'NOTH', color: Colors.orange),
],
),
),
],
),
),
),
);
},
);
},
),
),
),
],
),
);
}
Widget _buildGridTile(bool isVideo, String thumbUrl, String fullUrl) {
if (isVideo) {
// Per i video: NON usiamo Image.network(fullUrl).
// Usiamo la thumb se c'è, altrimenti placeholder con icona "play".
final base = thumbUrl.isEmpty
? const ColoredBox(color: Colors.black12)
: Image.network(
thumbUrl,
fit: BoxFit.cover,
headers: _authHeaders,
errorBuilder: (_, __, ___) => const Center(child: Icon(Icons.broken_image)),
);
return Stack(
fit: StackFit.expand,
children: [
base,
const Align(
alignment: Alignment.center,
child: Icon(Icons.play_circle_fill, color: Colors.white70, size: 48),
),
],
);
}
// Per le immagini: se non c'è thumb, posso usare direttamente l'URL full.
final displayUrl = thumbUrl.isEmpty ? fullUrl : thumbUrl;
if (displayUrl.isEmpty) {
return const ColoredBox(color: Colors.black12);
}
return Image.network(
displayUrl,
fit: BoxFit.cover,
headers: _authHeaders,
errorBuilder: (_, __, ___) => const Center(child: Icon(Icons.broken_image)),
);
}
}
class _RemoteRow {
final int id;
final String remoteId;
final String title;
final String? remotePath;
final String? remoteThumb2;
final String? mime;
final int trashed;
_RemoteRow({
required this.id,
required this.remoteId,
required this.title,
this.remotePath,
this.remoteThumb2,
this.mime,
required this.trashed,
});
}
class _MiniBadge extends StatelessWidget {
final String label;
final Color color;
const _MiniBadge({super.key, required this.label, this.color = Colors.black54});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(3),
),
child: Text(label, style: const TextStyle(fontSize: 9, color: Colors.white)),
);
}
}
class _RemoteFullPage extends StatelessWidget {
final String title;
final String url;
final Map<String, String>? headers;
final String heroTag; // pairing Hero
const _RemoteFullPage({
super.key,
required this.title,
required this.url,
required this.heroTag,
this.headers,
});
@override
Widget build(BuildContext context) {
final body = url.isEmpty
? const Text('URL non valido')
: Hero(
tag: heroTag, // pairing con la griglia
child: InteractiveViewer(
maxScale: 5,
child: Image.network(
url,
fit: BoxFit.contain,
headers: headers, // Authorization se il server lo richiede
errorBuilder: (_, __, ___) => const Icon(Icons.broken_image, size: 64),
),
),
);
return Scaffold(
appBar: AppBar(title: Text(title.isEmpty ? 'Remote' : title)),
body: Center(child: body),
);
}
}

View file

@ -0,0 +1,29 @@
// lib/remote/remote_view_helpers.dart
import 'package:flutter/material.dart';
import 'remote_http.dart';
import 'package:aves/model/entry/entry.dart';
bool isRemote(AvesEntry e) => e.origin == 1;
bool isVideo(AvesEntry e) {
final mt = (e.sourceMimeType ?? '').toLowerCase();
final p = (e.remotePath ?? e.path ?? '').toLowerCase();
return mt.startsWith('video/') || p.endsWith('.mp4') || p.endsWith('.mov') ||
p.endsWith('.webm') || p.endsWith('.mkv');
}
Future<Widget> remoteImageFull(AvesEntry e) async {
final url = RemoteHttp.absUrl(e.remotePath ?? e.path);
final hdr = await RemoteHttp.headers();
if (url.isEmpty) return const Icon(Icons.broken_image, size: 64);
return InteractiveViewer(
maxScale: 5,
child: Image.network(
url,
fit: BoxFit.contain,
headers: hdr,
errorBuilder: (_, __, ___) => const Icon(Icons.broken_image, size: 64),
),
);
}

View file

@ -146,46 +146,23 @@ Future<void> runRemoteSyncOnce({
final repo = RemoteRepository(db); final repo = RemoteRepository(db);
await _withRetryBusy(() => repo.upsertAll(items)); await _withRetryBusy(() => repo.upsertAll(items));
// 5.b) Impedisci futuri duplicati e ripulisci quelli già presenti // 5.b) Pulizia + indici (copre sia remoteId sia remotePath)
await repo.ensureUniqueRemoteId(); await repo.sanitizeRemotes();
final removed = await repo.deduplicateRemotes();
// 5.c) Paracadute: assicura che i remoti NON siano mostrati nella Collection Aves // 5.c) **Paracadute visibilità remoti**: deve restare DISABILITATO
//await db.rawUpdate('UPDATE entry SET trashed=1 WHERE origin=1 AND trashed=0;'); // (se lo riattivi, i remoti spariscono dalla galleria)
// await db.rawUpdate('UPDATE entry SET trashed=1 WHERE origin=1 AND trashed=0;');
// 5.d) **CLEANUP LEGACY**: elimina righe remote "orfane" o doppioni su remotePath // 5.d) (Opzionale) CLEANUP LEGACY: elimina righe remote senza `remoteId`
// - Righe senza remoteId (NULL o vuoto): non deduplicabili via UNIQUE vanno rimosse // utilissimo se hai record vecchi non deduplicabili
final purgedNoId = await db.rawDelete( final purgedNoId = await db.rawDelete(
"DELETE FROM entry WHERE origin=1 AND (remoteId IS NULL OR TRIM(remoteId)='')", "DELETE FROM entry WHERE origin=1 AND (remoteId IS NULL OR TRIM(remoteId)='')",
); );
// - Doppioni per remotePath: tieni solo la riga con id MAX
// (copre i casi in cui in passato siano state create due righe per lo stesso path)
final purgedByPath = 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'
')',
);
// ignore: avoid_print
print('[remote-sync] cleanup: removed dup(remoteId)=$removed, purged(noId)=$purgedNoId, purged(byPath)=$purgedByPath');
// 6) Log sintetico // 6) Log sintetico
int? c; final count = await repo.countRemote().catchError((_) => null);
try {
c = await repo.countRemote();
} catch (_) {
c = null;
}
// ignore: avoid_print // ignore: avoid_print
if (c == null) { print('[remote-sync] import completato: remoti=${count ?? 'n/a'} (base=$bUrl, index=$ip, purged(noId)=$purgedNoId)');
print('[remote-sync] import completato (conteggio non disponibile)');
} else {
print('[remote-sync] importati remoti: $c (base=$bUrl, index=$ip)');
}
} catch (e, st) { } catch (e, st) {
// ignore: avoid_print // ignore: avoid_print
print('[remote-sync][ERROR] $e\n$st'); print('[remote-sync][ERROR] $e\n$st');

View file

@ -0,0 +1,194 @@
// lib/remote/run_remote_sync.dart
//
// Esegue un ciclo di sincronizzazione "pull":
// 1) legge le impostazioni (server, path, user, password) da RemoteSettings
// 2) login Bearer token
// 3) GET dell'indice JSON (array di oggetti foto)
// 4) upsert nel DB 'entry' (e 'address' se presente) tramite RemoteRepository
//
// NOTE:
// - La versione "managed" (runRemoteSyncOnceManaged) apre/chiude il DB ed evita run concorrenti.
// - La versione "plain" (runRemoteSyncOnce) usa un Database già aperto (compatibilità).
// - PRAGMA per concorrenza (WAL, busy_timeout, ...).
// - Non logghiamo contenuti sensibili (password/token/body completi).
import 'package:path/path.dart' as p;
import 'package:sqflite/sqflite.dart';
import 'remote_settings.dart';
import 'auth_client.dart';
import 'remote_client.dart';
import 'remote_repository.dart';
// === Guardia anti-concorrenza (single-flight) per la run "managed" ===
bool _remoteSyncRunning = false;
/// Helper: retry esponenziale breve per SQLITE_BUSY.
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) {
final msg = e.toString();
final isBusy = msg.contains('SQLITE_BUSY') || msg.contains('database is locked');
if (!isBusy || i == maxAttempts - 1) rethrow;
await Future.delayed(delay);
delay *= 2; // 250 500 1000 ms
}
}
// non dovrebbe arrivare qui
return await fn();
}
/// Versione "managed":
/// - impedisce run concorrenti
/// - apre/chiude da sola la connessione a `metadata.db` (istanza indipendente)
/// - imposta PRAGMA per concorrenza
/// - accetta override opzionali (utile in test)
Future<void> runRemoteSyncOnceManaged({
String? baseUrl,
String? indexPath,
String? email,
String? password,
}) async {
if (_remoteSyncRunning) {
// ignore: avoid_print
print('[remote-sync] already running, skip');
return;
}
_remoteSyncRunning = true;
Database? db;
try {
final dbDir = await getDatabasesPath();
final dbPath = p.join(dbDir, 'metadata.db');
db = await openDatabase(
dbPath,
singleInstance: false, // connessione indipendente (non chiude lhandle di Aves)
onConfigure: (db) async {
try {
// Alcuni PRAGMA ritornano valori usare SEMPRE rawQuery.
await db.rawQuery('PRAGMA journal_mode=WAL');
await db.rawQuery('PRAGMA synchronous=NORMAL');
await db.rawQuery('PRAGMA busy_timeout=3000');
await db.rawQuery('PRAGMA wal_autocheckpoint=1000');
await db.rawQuery('PRAGMA foreign_keys=ON');
// (Opzionale) verifica del mode corrente
final jm = await db.rawQuery('PRAGMA journal_mode');
final mode = jm.isNotEmpty ? jm.first.values.first : null;
// ignore: avoid_print
print('[remote-sync] journal_mode=$mode'); // atteso: wal
} catch (e, st) {
// ignore: avoid_print
print('[remote-sync][WARN] PRAGMA setup failed: $e\n$st');
// Non rilanciare: in estremo, continueremo con journaling di default
}
},
);
await runRemoteSyncOnce(
db: db,
baseUrl: baseUrl,
indexPath: indexPath,
email: email,
password: password,
);
} finally {
try {
await db?.close();
} catch (_) {
// In caso di close doppio/già chiuso, ignoro.
}
_remoteSyncRunning = false;
}
}
/// Versione "plain":
/// Esegue login, scarica /photos e fa upsert nel DB usando una connessione
/// SQLite **già aperta** (non viene chiusa qui).
///
/// Gli optional [baseUrl], [indexPath], [email], [password] permettono override
/// delle impostazioni salvate in `RemoteSettings` (comodo per test / debug).
Future<void> runRemoteSyncOnce({
required Database db,
String? baseUrl,
String? indexPath,
String? email,
String? password,
}) async {
try {
// 1) Carica impostazioni sicure (secure storage)
final s = await RemoteSettings.load();
final bUrl = (baseUrl ?? s.baseUrl).trim();
final ip = (indexPath ?? s.indexPath).trim();
final em = (email ?? s.email).trim();
final pw = (password ?? s.password);
if (bUrl.isEmpty || ip.isEmpty) {
throw StateError('Impostazioni remote incomplete: baseUrl/indexPath mancanti');
}
// 2) Autenticazione (Bearer)
final auth = RemoteAuth(baseUrl: bUrl, email: em, password: pw);
await auth.login(); // Se necessario, RemoteJsonClient può riloggare su 401
// 3) Client JSON (segue anche redirect 301/302/307/308)
final client = RemoteJsonClient(bUrl, ip, auth: auth);
// 4) Scarica lelenco di elementi remoti (array top-level)
final items = await client.fetchAll();
// 5) Upsert nel DB (con retry se incappiamo in SQLITE_BUSY)
final repo = RemoteRepository(db);
await _withRetryBusy(() => repo.upsertAll(items));
// 5.b) Impedisci futuri duplicati e ripulisci quelli già presenti
await repo.ensureUniqueRemoteId();
final removed = await repo.deduplicateRemotes();
// 5.c) Paracadute: assicura che i remoti NON siano mostrati nella Collection Aves
//await db.rawUpdate('UPDATE entry SET trashed=1 WHERE origin=1 AND trashed=0;');
// 5.d) **CLEANUP LEGACY**: elimina righe remote "orfane" o doppioni su remotePath
// - Righe senza remoteId (NULL o vuoto): non deduplicabili via UNIQUE vanno rimosse
final purgedNoId = await db.rawDelete(
"DELETE FROM entry WHERE origin=1 AND (remoteId IS NULL OR TRIM(remoteId)='')",
);
// - Doppioni per remotePath: tieni solo la riga con id MAX
// (copre i casi in cui in passato siano state create due righe per lo stesso path)
final purgedByPath = 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'
')',
);
// ignore: avoid_print
print('[remote-sync] cleanup: removed dup(remoteId)=$removed, purged(noId)=$purgedNoId, purged(byPath)=$purgedByPath');
// 6) Log sintetico
int? c;
try {
c = await repo.countRemote();
} catch (_) {
c = null;
}
// ignore: avoid_print
if (c == null) {
print('[remote-sync] import completato (conteggio non disponibile)');
} else {
print('[remote-sync] importati remoti: $c (base=$bUrl, index=$ip)');
}
} catch (e, st) {
// ignore: avoid_print
print('[remote-sync][ERROR] $e\n$st');
rethrow;
}
}

View file

@ -56,6 +56,9 @@ import 'package:intl/intl.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
// REMOTE: import per le thumb di rete
import 'package:aves/remote/remote_image_tile.dart';
class CollectionGrid extends StatefulWidget { class CollectionGrid extends StatefulWidget {
final String settingsRouteKey; final String settingsRouteKey;
@ -182,6 +185,17 @@ class _CollectionGridContentState extends State<_CollectionGridContent> {
tileExtent: thumbnailExtent, tileExtent: thumbnailExtent,
tileBuilder: (entry, tileSize) { tileBuilder: (entry, tileSize) {
final extent = tileSize.shortestSide; final extent = tileSize.shortestSide;
// REMOTE: ramo dedicato per le entry remote (origin=1)
if (entry.origin == 1) {
return RemoteInteractiveTile(
key: ValueKey('remote_${entry.id}'),
entry: entry,
thumbnailExtent: extent,
);
}
// Locale: flusso preesistente
return AnimatedBuilder( return AnimatedBuilder(
animation: favourites, animation: favourites,
builder: (context, child) { builder: (context, child) {
@ -419,10 +433,23 @@ class _CollectionScaler extends StatelessWidget {
), ),
scaledItemBuilder: (entry, tileSize) => EntryListDetailsTheme( scaledItemBuilder: (entry, tileSize) => EntryListDetailsTheme(
extent: tileSize.height, extent: tileSize.height,
child: Tile( child: Builder(
entry: entry, builder: (_) {
thumbnailExtent: context.read<TileExtentController>().effectiveExtentMax, // REMOTE: ramo dedicato in layout "fixed scale"
tileLayout: tileLayout, if (entry.origin == 1) {
return RemoteInteractiveTile(
key: ValueKey('remote_scaled_${entry.id}'),
entry: entry,
thumbnailExtent: context.read<TileExtentController>().effectiveExtentMax,
);
}
// Locale: flusso preesistente
return Tile(
entry: entry,
thumbnailExtent: context.read<TileExtentController>().effectiveExtentMax,
tileLayout: tileLayout,
);
},
), ),
), ),
mosaicItemBuilder: (index, targetExtent) => DecoratedBox( mosaicItemBuilder: (index, targetExtent) => DecoratedBox(
@ -734,3 +761,32 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge
Future<bool> get _isStoragePermissionGranted => Future.wait(Permissions.storage.map((v) => v.status)).then((v) => v.any((status) => status.isGranted)); Future<bool> get _isStoragePermissionGranted => Future.wait(Permissions.storage.map((v) => v.status)).then((v) => v.any((status) => status.isGranted));
} }
// REMOTE: mini-tile che mostra la thumb remota e apre il viewer con OpenViewerNotification
class RemoteInteractiveTile extends StatelessWidget {
final AvesEntry entry;
final double thumbnailExtent;
const RemoteInteractiveTile({
super.key,
required this.entry,
required this.thumbnailExtent,
});
@override
Widget build(BuildContext context) {
// Nota: usiamo OpenViewerNotification perché la Collection già la intercetta
// e apre il viewer col lens corretto (stesso comportamento dei locali).
return GestureDetector(
onTap: () => OpenViewerNotification(entry).dispatch(context),
child: ClipRRect(
borderRadius: BorderRadius.zero,
child: SizedBox(
width: thumbnailExtent,
height: thumbnailExtent,
child: RemoteImageTile(entry: entry),
),
),
);
}
}

View file

@ -0,0 +1,736 @@
import 'dart:async';
import 'package:aves/app_mode.dart';
import 'package:aves/model/app/permissions.dart';
import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/favourites.dart';
import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/mime.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/model/source/section_keys.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/time_utils.dart';
import 'package:aves/widgets/collection/app_bar.dart';
import 'package:aves/widgets/collection/draggable_thumb_label.dart';
import 'package:aves/widgets/collection/grid/list_details_theme.dart';
import 'package:aves/widgets/collection/grid/section_layout.dart';
import 'package:aves/widgets/collection/grid/tile.dart';
import 'package:aves/widgets/collection/loading.dart';
import 'package:aves/widgets/common/basic/draggable_scrollbar/scrollbar.dart';
import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/common/behaviour/routes.dart';
import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/extensions/media_query.dart';
import 'package:aves/widgets/common/grid/draggable_thumb_label.dart';
import 'package:aves/widgets/common/grid/item_tracker.dart';
import 'package:aves/widgets/common/grid/scaling.dart';
import 'package:aves/widgets/common/grid/sections/fixed/scale_grid.dart';
import 'package:aves/widgets/common/grid/sections/list_layout.dart';
import 'package:aves/widgets/common/grid/sections/section_layout.dart';
import 'package:aves/widgets/common/grid/selector.dart';
import 'package:aves/widgets/common/grid/sliver.dart';
import 'package:aves/widgets/common/grid/theme.dart';
import 'package:aves/widgets/common/identity/buttons/outlined_button.dart';
import 'package:aves/widgets/common/identity/empty.dart';
import 'package:aves/widgets/common/identity/scroll_thumb.dart';
import 'package:aves/widgets/common/providers/tile_extent_controller_provider.dart';
import 'package:aves/widgets/common/providers/viewer_entry_provider.dart';
import 'package:aves/widgets/common/thumbnail/decorated.dart';
import 'package:aves/widgets/common/thumbnail/image.dart';
import 'package:aves/widgets/common/thumbnail/notifications.dart';
import 'package:aves/widgets/common/tile_extent_controller.dart';
import 'package:aves/widgets/navigation/nav_bar/nav_bar.dart';
import 'package:aves/widgets/viewer/entry_viewer_page.dart';
import 'package:aves_model/aves_model.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
import 'package:intl/intl.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart';
class CollectionGrid extends StatefulWidget {
final String settingsRouteKey;
static const double extentMin = 46;
static const double extentMax = 300;
static const double fixedExtentLayoutSpacing = 2;
static const double mosaicLayoutSpacing = 4;
static int get columnCountDefault => settings.useTvLayout ? 6 : 4;
const CollectionGrid({
super.key,
required this.settingsRouteKey,
});
@override
State<CollectionGrid> createState() => _CollectionGridState();
}
class _CollectionGridState extends State<CollectionGrid> {
TileExtentController? _tileExtentController;
String get settingsRouteKey => widget.settingsRouteKey;
@override
void dispose() {
_tileExtentController?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final spacing = context.select<Settings, double>((v) => v.getTileLayout(settingsRouteKey) == TileLayout.mosaic ? CollectionGrid.mosaicLayoutSpacing : CollectionGrid.fixedExtentLayoutSpacing);
if (_tileExtentController?.spacing != spacing) {
_tileExtentController = TileExtentController(
settingsRouteKey: settingsRouteKey,
columnCountDefault: CollectionGrid.columnCountDefault,
extentMin: CollectionGrid.extentMin,
extentMax: CollectionGrid.extentMax,
spacing: spacing,
horizontalPadding: 2,
);
}
return TileExtentControllerProvider(
controller: _tileExtentController!,
child: const _CollectionGridContent(),
);
}
}
class _CollectionGridContent extends StatefulWidget {
const _CollectionGridContent();
@override
State<_CollectionGridContent> createState() => _CollectionGridContentState();
}
class _CollectionGridContentState extends State<_CollectionGridContent> {
final ValueNotifier<AvesEntry?> _focusedItemNotifier = ValueNotifier(null);
final ValueNotifier<bool> _isScrollingNotifier = ValueNotifier(false);
final ValueNotifier<AppMode> _selectingAppModeNotifier = ValueNotifier(AppMode.pickFilteredMediaInternal);
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => context.read<ViewerEntryNotifier>().value = null);
}
@override
void dispose() {
_focusedItemNotifier.dispose();
_isScrollingNotifier.dispose();
_selectingAppModeNotifier.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final selectable = context.select<ValueNotifier<AppMode>, bool>((v) => v.value.canSelectMedia);
final settingsRouteKey = context.read<TileExtentController>().settingsRouteKey;
final tileLayout = context.select<Settings, TileLayout>((v) => v.getTileLayout(settingsRouteKey));
return Consumer<CollectionLens>(
builder: (context, collection, child) {
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
valueListenable: context.select<TileExtentController, ValueNotifier<double>>((controller) => controller.extentNotifier),
builder: (context, thumbnailExtent, child) {
assert(thumbnailExtent > 0);
return Selector<TileExtentController, (double, int, double, double)>(
selector: (context, c) => (c.viewportSize.width, c.columnCount, c.spacing, c.horizontalPadding),
builder: (context, c, child) {
final (scrollableWidth, columnCount, tileSpacing, horizontalPadding) = c;
final source = collection.source;
return GridTheme(
extent: thumbnailExtent,
child: EntryListDetailsTheme(
extent: thumbnailExtent,
child: ValueListenableBuilder<SourceState>(
valueListenable: source.stateNotifier,
builder: (context, sourceState, child) {
late final Duration tileAnimationDelay;
if (sourceState == SourceState.ready) {
// do not listen for animation delay change
final target = context.read<DurationsData>().staggeredAnimationPageTarget;
tileAnimationDelay = context.read<TileExtentController>().getTileAnimationDelay(target);
} else {
tileAnimationDelay = Duration.zero;
}
return NotificationListener<OpenViewerNotification>(
onNotification: (notification) {
_goToViewer(collection, notification.entry);
return true;
},
child: StreamBuilder(
stream: source.eventBus.on<AspectRatioChangedEvent>(),
builder: (context, snapshot) => SectionedEntryListLayoutProvider(
collection: collection,
selectable: selectable,
scrollableWidth: scrollableWidth,
tileLayout: tileLayout,
columnCount: columnCount,
spacing: tileSpacing,
horizontalPadding: horizontalPadding,
tileExtent: thumbnailExtent,
tileBuilder: (entry, tileSize) {
final extent = tileSize.shortestSide;
return AnimatedBuilder(
animation: favourites,
builder: (context, child) {
Widget tile = InteractiveTile(
key: ValueKey(entry.id),
collection: collection,
entry: entry,
thumbnailExtent: extent,
tileLayout: tileLayout,
isScrollingNotifier: _isScrollingNotifier,
);
if (!settings.useTvLayout) return tile;
return Focus(
onFocusChange: (focused) {
if (focused) {
_focusedItemNotifier.value = entry;
} else if (_focusedItemNotifier.value == entry) {
_focusedItemNotifier.value = null;
}
},
child: ValueListenableBuilder<AvesEntry?>(
valueListenable: _focusedItemNotifier,
builder: (context, focusedItem, child) {
return AnimatedScale(
scale: focusedItem == entry ? 1 : .9,
curve: Curves.fastOutSlowIn,
duration: context.select<DurationsData, Duration>((v) => v.tvImageFocusAnimation),
child: child!,
);
},
child: tile,
),
);
},
);
},
tileAnimationDelay: tileAnimationDelay,
child: child!,
),
),
);
},
child: child,
),
),
);
},
child: child,
);
},
child: _CollectionSectionedContent(
collection: collection,
isScrollingNotifier: _isScrollingNotifier,
scrollController: PrimaryScrollController.of(context),
tileLayout: tileLayout,
selectable: selectable,
),
);
return sectionedListLayoutProvider;
},
);
}
Future<void> _goToViewer(CollectionLens collection, AvesEntry entry) async {
// track viewer entry for dynamic hero placeholder
final viewerEntryNotifier = context.read<ViewerEntryNotifier>();
// prevent navigating again to the same entry until fully back,
// as a workaround for the hero pop/push diversion animation issue
// (cf `ThumbnailImage` `Hero` usage)
if (viewerEntryNotifier.value == entry) return;
WidgetsBinding.instance.addPostFrameCallback((_) => viewerEntryNotifier.value = entry);
final selection = context.read<Selection<AvesEntry>>();
await Navigator.maybeOf(context)?.push(
TransparentMaterialPageRoute(
settings: const RouteSettings(name: EntryViewerPage.routeName),
pageBuilder: (context, a, sa) {
final viewerCollection = collection.copyWith(
listenToSource: false,
);
Widget child = EntryViewerPage(
collection: viewerCollection,
initialEntry: entry,
);
if (selection.isSelecting) {
child = MultiProvider(
providers: [
ListenableProvider<ValueNotifier<AppMode>>.value(value: _selectingAppModeNotifier),
ChangeNotifierProvider<Selection<AvesEntry>>.value(value: selection),
],
child: child,
);
}
return child;
},
),
);
// reset track viewer entry
final animate = context.read<Settings>().animate;
if (animate) {
// TODO TLAD fix timing when transition is incomplete, e.g. when going back while going to the viewer
await Future.delayed(ADurations.pageTransitionExact * timeDilation);
}
viewerEntryNotifier.value = null;
}
}
class _CollectionSectionedContent extends StatefulWidget {
final CollectionLens collection;
final ValueNotifier<bool> isScrollingNotifier;
final ScrollController scrollController;
final TileLayout tileLayout;
final bool selectable;
const _CollectionSectionedContent({
required this.collection,
required this.isScrollingNotifier,
required this.scrollController,
required this.tileLayout,
required this.selectable,
});
@override
State<_CollectionSectionedContent> createState() => _CollectionSectionedContentState();
}
class _CollectionSectionedContentState extends State<_CollectionSectionedContent> {
final ValueNotifier<double> _appBarHeightNotifier = ValueNotifier(0);
final GlobalKey _scrollableKey = GlobalKey(debugLabel: 'thumbnail-collection-scrollable');
CollectionLens get collection => widget.collection;
TileLayout get tileLayout => widget.tileLayout;
ScrollController get scrollController => widget.scrollController;
@override
void initState() {
super.initState();
_appBarHeightNotifier.addListener(_onAppBarHeightChanged);
}
@override
void dispose() {
_appBarHeightNotifier.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final scrollView = AnimationLimiter(
child: _CollectionScrollView(
scrollableKey: _scrollableKey,
collection: collection,
appBar: CollectionAppBar(
appBarHeightNotifier: _appBarHeightNotifier,
scrollController: scrollController,
collection: collection,
),
appBarHeightNotifier: _appBarHeightNotifier,
isScrollingNotifier: widget.isScrollingNotifier,
scrollController: scrollController,
),
);
final scaler = _CollectionScaler(
scrollableKey: _scrollableKey,
appBarHeightNotifier: _appBarHeightNotifier,
tileLayout: tileLayout,
child: scrollView,
);
final selector = GridSelectionGestureDetector<AvesEntry>(
scrollableKey: _scrollableKey,
selectable: widget.selectable,
items: collection.sortedEntries,
scrollController: scrollController,
appBarHeightNotifier: _appBarHeightNotifier,
child: scaler,
);
return GridItemTracker<AvesEntry>(
scrollableKey: _scrollableKey,
tileLayout: tileLayout,
appBarHeightNotifier: _appBarHeightNotifier,
scrollController: scrollController,
child: selector,
);
}
void _onAppBarHeightChanged() => setState(() {});
}
class _CollectionScaler extends StatelessWidget {
final GlobalKey scrollableKey;
final ValueNotifier<double> appBarHeightNotifier;
final TileLayout tileLayout;
final Widget child;
const _CollectionScaler({
required this.scrollableKey,
required this.appBarHeightNotifier,
required this.tileLayout,
required this.child,
});
@override
Widget build(BuildContext context) {
final (tileSpacing, horizontalPadding) = context.select<TileExtentController, (double, double)>((v) => (v.spacing, v.horizontalPadding));
final brightness = Theme.of(context).brightness;
final borderColor = DecoratedThumbnail.borderColor(context);
final borderWidth = DecoratedThumbnail.borderWidth(context);
return GridScaleGestureDetector<AvesEntry>(
scrollableKey: scrollableKey,
tileLayout: tileLayout,
heightForWidth: (width) => width,
gridBuilder: (center, tileSize, child) => CustomPaint(
painter: FixedExtentGridPainter(
tileLayout: tileLayout,
tileCenter: center,
tileSize: tileSize,
spacing: tileSpacing,
horizontalPadding: horizontalPadding,
borderWidth: borderWidth,
borderRadius: Radius.zero,
color: borderColor,
textDirection: Directionality.of(context),
),
child: child,
),
scaledItemBuilder: (entry, tileSize) => EntryListDetailsTheme(
extent: tileSize.height,
child: Tile(
entry: entry,
thumbnailExtent: context.read<TileExtentController>().effectiveExtentMax,
tileLayout: tileLayout,
),
),
mosaicItemBuilder: (index, targetExtent) => DecoratedBox(
decoration: BoxDecoration(
color: ThumbnailImage.computeLoadingBackgroundColor(index * 10, brightness).withValues(alpha: .9),
border: Border.all(
color: borderColor,
width: borderWidth,
),
),
),
child: child,
);
}
}
class _CollectionScrollView extends StatefulWidget {
final GlobalKey scrollableKey;
final CollectionLens collection;
final Widget appBar;
final ValueNotifier<double> appBarHeightNotifier;
final ValueNotifier<bool> isScrollingNotifier;
final ScrollController scrollController;
const _CollectionScrollView({
required this.scrollableKey,
required this.collection,
required this.appBar,
required this.appBarHeightNotifier,
required this.isScrollingNotifier,
required this.scrollController,
});
@override
State<_CollectionScrollView> createState() => _CollectionScrollViewState();
}
class _CollectionScrollViewState extends State<_CollectionScrollView> with WidgetsBindingObserver {
Timer? _scrollMonitoringTimer;
bool _checkingStoragePermission = false;
@override
void initState() {
super.initState();
_registerWidget(widget);
WidgetsBinding.instance.addObserver(this);
}
@override
void didUpdateWidget(covariant _CollectionScrollView oldWidget) {
super.didUpdateWidget(oldWidget);
_unregisterWidget(oldWidget);
_registerWidget(widget);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_unregisterWidget(widget);
_stopScrollMonitoringTimer();
super.dispose();
}
void _registerWidget(_CollectionScrollView widget) {
widget.collection.filterChangeNotifier.addListener(_scrollToTop);
widget.collection.sortSectionChangeNotifier.addListener(_scrollToTop);
widget.scrollController.addListener(_onScrollChanged);
}
void _unregisterWidget(_CollectionScrollView widget) {
widget.collection.filterChangeNotifier.removeListener(_scrollToTop);
widget.collection.sortSectionChangeNotifier.removeListener(_scrollToTop);
widget.scrollController.removeListener(_onScrollChanged);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed && _checkingStoragePermission) {
_checkingStoragePermission = false;
_isStoragePermissionGranted.then((granted) {
if (granted) {
widget.collection.source.init(scope: CollectionSource.fullScope);
}
});
}
}
@override
Widget build(BuildContext context) {
final scrollView = _buildScrollView(widget.appBar, widget.collection);
return settings.useTvLayout ? scrollView : _buildDraggableScrollView(scrollView, widget.collection);
}
Widget _buildDraggableScrollView(Widget scrollView, CollectionLens collection) {
return ValueListenableBuilder<double>(
valueListenable: widget.appBarHeightNotifier,
builder: (context, appBarHeight, child) {
return Selector<MediaQueryData, double>(
selector: (context, mq) => mq.effectiveBottomPadding,
builder: (context, mqPaddingBottom, child) {
return Selector<Settings, bool>(
selector: (context, s) => s.enableBottomNavigationBar,
builder: (context, enableBottomNavigationBar, child) {
final canNavigate = context.select<ValueNotifier<AppMode>, bool>((v) => v.value.canNavigate);
final showBottomNavigationBar = canNavigate && enableBottomNavigationBar;
final navBarHeight = showBottomNavigationBar ? AppBottomNavBar.height : 0;
return Selector<SectionedListLayout<AvesEntry>, List<SectionLayout>>(
selector: (context, layout) => layout.sectionLayouts,
builder: (context, sectionLayouts, child) {
final scrollController = widget.scrollController;
final offsetIncrementSnapThreshold = context.select<TileExtentController, double>((v) => (v.extentNotifier.value + v.spacing) / 4);
return DraggableScrollbar(
backgroundColor: Colors.white,
scrollThumbSize: Size(avesScrollThumbWidth, avesScrollThumbHeight),
scrollThumbBuilder: avesScrollThumbBuilder(
height: avesScrollThumbHeight,
backgroundColor: Colors.white,
),
controller: scrollController,
dragOffsetSnapper: (scrollOffset, offsetIncrement) {
if (offsetIncrement > offsetIncrementSnapThreshold && scrollOffset < scrollController.position.maxScrollExtent) {
final section = sectionLayouts.firstWhereOrNull((section) => section.hasChildAtOffset(scrollOffset));
if (section != null) {
if (section.maxOffset - section.minOffset < scrollController.position.viewportDimension) {
// snap to section header
return section.minOffset;
} else {
// snap to content row
final index = section.getMinChildIndexForScrollOffset(scrollOffset);
return section.indexToLayoutOffset(index);
}
}
}
return scrollOffset;
},
crumbsBuilder: () => _getCrumbs(sectionLayouts),
padding: EdgeInsets.only(
// padding to keep scroll thumb between app bar above and nav bar below
top: appBarHeight,
bottom: navBarHeight + mqPaddingBottom,
),
labelTextBuilder: (offsetY) => CollectionDraggableThumbLabel(
collection: collection,
offsetY: offsetY,
),
crumbTextBuilder: (label) => DraggableCrumbLabel(label: label),
child: scrollView,
);
},
);
},
);
},
);
},
);
}
Widget _buildScrollView(Widget appBar, CollectionLens collection) {
return CustomScrollView(
key: widget.scrollableKey,
primary: true,
// workaround to prevent scrolling the app bar away
// when there is no content and we use `SliverFillRemaining`
physics: collection.isEmpty
? const NeverScrollableScrollPhysics()
: SloppyScrollPhysics(
gestureSettings: MediaQuery.gestureSettingsOf(context),
parent: const AlwaysScrollableScrollPhysics(),
),
cacheExtent: context.select<TileExtentController, double>((controller) => controller.effectiveExtentMax),
slivers: [
appBar,
collection.isEmpty
? SliverFillRemaining(
hasScrollBody: false,
child: _buildEmptyContent(collection),
)
: const SectionedListSliver<AvesEntry>(),
const NavBarPaddingSliver(),
const BottomPaddingSliver(),
const TvTileGridBottomPaddingSliver(),
],
);
}
Widget _buildEmptyContent(CollectionLens collection) {
final source = collection.source;
return ValueListenableBuilder<SourceState>(
valueListenable: source.stateNotifier,
builder: (context, sourceState, child) {
if (sourceState == SourceState.loading) {
return LoadingEmptyContent(source: source);
}
return FutureBuilder<bool>(
future: _isStoragePermissionGranted,
builder: (context, snapshot) {
final granted = snapshot.data ?? true;
Widget? bottom = granted
? null
: Padding(
padding: const EdgeInsets.only(top: 16),
child: AvesOutlinedButton(
label: context.l10n.collectionEmptyGrantAccessButtonLabel,
onPressed: () async {
if (await openAppSettings()) {
_checkingStoragePermission = true;
}
},
),
);
if (collection.filters.any((filter) => filter is FavouriteFilter)) {
return EmptyContent(
icon: AIcons.favourite,
text: context.l10n.collectionEmptyFavourites,
bottom: bottom,
);
}
if (collection.filters.any((filter) => filter is MimeFilter && filter.mime == MimeTypes.anyVideo)) {
return EmptyContent(
icon: AIcons.video,
text: context.l10n.collectionEmptyVideos,
bottom: bottom,
);
}
return EmptyContent(
icon: AIcons.image,
text: context.l10n.collectionEmptyImages,
bottom: bottom,
);
},
);
},
);
}
void _scrollToTop() => widget.scrollController.jumpTo(0);
void _onScrollChanged() {
widget.isScrollingNotifier.value = true;
_stopScrollMonitoringTimer();
_scrollMonitoringTimer = Timer(ADurations.collectionScrollMonitoringTimerDelay, () {
widget.isScrollingNotifier.value = false;
});
}
void _stopScrollMonitoringTimer() => _scrollMonitoringTimer?.cancel();
Map<double, String> _getCrumbs(List<SectionLayout> sectionLayouts) {
final crumbs = <double, String>{};
if (sectionLayouts.length <= 1) return crumbs;
final maxOffset = sectionLayouts.last.maxOffset;
void addAlbums(CollectionLens collection, List<SectionLayout> sectionLayouts, Map<double, String> crumbs) {
final source = collection.source;
sectionLayouts.forEach((section) {
final directory = (section.sectionKey as EntryAlbumSectionKey).directory;
if (directory != null) {
final label = source.getStoredAlbumDisplayName(context, directory);
crumbs[section.minOffset / maxOffset] = label;
}
});
}
final collection = widget.collection;
switch (collection.sortFactor) {
case EntrySortFactor.date:
switch (collection.sectionFactor) {
case EntrySectionFactor.album:
addAlbums(collection, sectionLayouts, crumbs);
case EntrySectionFactor.month:
case EntrySectionFactor.day:
final firstKey = sectionLayouts.first.sectionKey;
final lastKey = sectionLayouts.last.sectionKey;
if (firstKey is EntryDateSectionKey && lastKey is EntryDateSectionKey) {
final newest = firstKey.date;
final oldest = lastKey.date;
if (newest != null && oldest != null) {
final locale = context.locale;
final dateFormat = (newest.difference(oldest).inHumanDays).abs() > 365 ? DateFormat.y(locale) : DateFormat.MMM(locale);
String? lastLabel;
sectionLayouts.forEach((section) {
final date = (section.sectionKey as EntryDateSectionKey).date;
if (date != null) {
final label = dateFormat.format(date);
if (label != lastLabel) {
crumbs[section.minOffset / maxOffset] = label;
lastLabel = label;
}
}
});
}
}
case EntrySectionFactor.none:
break;
}
case EntrySortFactor.name:
case EntrySortFactor.path:
addAlbums(collection, sectionLayouts, crumbs);
case EntrySortFactor.rating:
case EntrySortFactor.size:
case EntrySortFactor.duration:
break;
}
return crumbs;
}
Future<bool> get _isStoragePermissionGranted => Future.wait(Permissions.storage.map((v) => v.status)).then((v) => v.any((status) => status.isGranted));
}

View file

@ -261,6 +261,9 @@ class _HomePageState extends State<HomePage> {
await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst); await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst);
} }
// REMOTE: unisci alla sorgente anche gli elementi remoti (origin=1, non cestinati)
await source.appendRemoteEntries();
// === FASE 1: SYNC REMOTO POST-INIT (non blocca la UI) === // === 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 // 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. // la sorgente ha finito il loading, con un micro delay di sicurezza.
@ -294,6 +297,11 @@ class _HomePageState extends State<HomePage> {
// sync in background (la managed ha già il suo guard interno) // sync in background (la managed ha già il suo guard interno)
await rrs.runRemoteSyncOnceManaged(); await rrs.runRemoteSyncOnceManaged();
// REMOTE: dopo il sync, riallinea la sorgente con gli entry remoti
if (mounted) {
await source.appendRemoteEntries();
}
} catch (e, st) { } catch (e, st) {
debugPrint('[remote-sync] error: $e\n$st'); debugPrint('[remote-sync] error: $e\n$st');
} }
@ -306,6 +314,8 @@ class _HomePageState extends State<HomePage> {
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);
// (Opzionale) mostra anche remoti nello screensaver:
// await source2.appendRemoteEntries(notify: false);
break; break;
case AppMode.view: case AppMode.view:
@ -318,6 +328,9 @@ class _HomePageState extends State<HomePage> {
// analysis is necessary to display neighbour items when the initial item is a new one // 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)});
// (facoltativo) includi remoti anche nel lens di view directory:
// await source.appendRemoteEntries();
} }
} else { } else {
await _initViewerEssentials(); await _initViewerEssentials();

View file

@ -0,0 +1,698 @@
// lib/widgets/home/home_page.dart
import 'dart:async';
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';
// --- IMPORT aggiunti/aggiornati per integrazione remota (Fase 1) ---
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart' as p;
import 'package:aves/remote/remote_test_page.dart' as rtp;
import 'package:aves/remote/run_remote_sync.dart' as rrs;
import 'package:aves/remote/remote_settings.dart';
class HomePage extends StatefulWidget {
static const routeName = '/';
// untyped map as it is coming from the platform
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;
// guard UI per schedulare UNA sola run del sync da Home
bool _remoteSyncScheduled = false;
// guard per evitare doppi push della pagina di test remota
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,
);
Future<void> _setup() async {
try {
final stopwatch = Stopwatch()..start();
if (await windowService.isActivity()) {
// do not check whether permission was granted, because some app stores
// hide in some countries apps that force quit on permission denial
await Permissions.mediaAccess.request();
}
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();
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:
// some apps define multiple types, separated by a space
final multiple = (intentData[IntentDataKeys.allowMultiple] as bool?) ?? false;
debugPrint('pick mimeType=$intentMimeType multiple=$multiple');
appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal;
case IntentActions.pickCollectionFilters:
appMode = AppMode.pickCollectionFiltersExternal;
case IntentActions.screenSaver:
appMode = AppMode.screenSaver;
_initialRouteName = ScreenSaverPage.routeName;
case IntentActions.screenSaverSettings:
_initialRouteName = ScreenSaverSettingsPage.routeName;
case IntentActions.search:
_initialRouteName = SearchPage.routeName;
_initialSearchQuery = intentData[IntentDataKeys.query] as String?;
case IntentActions.widgetSettings:
_initialRouteName = HomeWidgetSettingsPage.routeName;
_widgetId = (intentData[IntentDataKeys.widgetId] as int?) ?? 0;
case IntentActions.widgetOpen:
final widgetId = intentData[IntentDataKeys.widgetId] as int?;
if (widgetId == null) {
error = true;
} else {
// widget settings may be modified in a different process after channel setup
await settings.reload();
final page = settings.getWidgetOpenPage(widgetId);
switch (page) {
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>();
if (source.loadedScope != CollectionSource.fullScope) {
await reportService.log(
'Initialize source to start app with mode=$appMode, loaded scope=${source.loadedScope}',
);
final loadTopEntriesFirst =
settings.homeNavItem.route == CollectionPage.routeName && settings.homeCustomCollection.isEmpty;
source.canAnalyze = true;
await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst);
}
// === FASE 1: SYNC REMOTO POST-INIT (non blocca la UI) ===
// In DEBUG: fai seed dei settings se sono vuoti, poi lancia il sync SOLO quando
// la sorgente ha finito il loading, con un micro delay di sicurezza.
if (!_remoteSyncScheduled) {
_remoteSyncScheduled = true; // una sola schedulazione per avvio
unawaited(Future(() async {
try {
await RemoteSettings.debugSeedIfEmpty();
final rs = await RemoteSettings.load();
if (!rs.enabled) return;
// 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 in background (la managed ha già il suo guard interno)
await rrs.runRemoteSyncOnceManaged();
} catch (e, st) {
debugPrint('[remote-sync] error: $e\n$st');
}
}));
}
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>();
// analysis is necessary to display neighbour items when the initial item is a new one
source.canAnalyze = true;
await source.init(scope: {StoredAlbumFilter(directory, null)});
}
} else {
await _initViewerEssentials();
}
break;
case AppMode.edit:
case AppMode.setWallpaper:
await _initViewerEssentials();
break;
default:
break;
}
debugPrint('Home setup complete in ${stopwatch.elapsed.inMilliseconds}ms');
// `pushReplacement` is not enough in some edge cases
// e.g. when opening the viewer in `view` mode should replace a viewer in `main` mode
unawaited(
Navigator.maybeOf(context)?.pushAndRemoveUntil(
await _getRedirectRoute(appMode),
(route) => false,
),
);
} catch (error, stack) {
debugPrint('failed to setup app with error=$error\n$stack');
setState(() => _setupError = (error, stack));
}
}
Future<void> _initViewerEssentials() async {
// for video playback storage
await localMediaDb.init();
}
bool _isViewerSourceable(AvesEntry? viewerEntry) {
return viewerEntry != null &&
viewerEntry.directory != null &&
!settings.hiddenFilters.any((filter) => filter.test(viewerEntry));
}
Future<AvesEntry?> _initViewerEntry({required String uri, required String? mimeType}) async {
if (uri.startsWith('/')) {
// convert this file path to a proper URI
uri = Uri.file(uri).toString();
}
final entry = await mediaFetchService.getEntry(uri, mimeType);
if (entry != null) {
// cataloguing is essential for coordinates and video rotation
await entry.catalog(background: false, force: false, persist: false);
}
return entry;
}
// === DEBUG: apre la pagina di test remota con una seconda connessione al DB ===
Future<void> _openRemoteTestPage(BuildContext context) async {
if (_remoteTestOpen) return; // evita doppi push/sovrapposizioni
_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 RemoteSettings.load();
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 RemoteSettings.load();
final formKey = GlobalKey<FormState>();
bool enabled = s.enabled;
final baseUrlC = TextEditingController(text: s.baseUrl);
final indexC = TextEditingController(text: s.indexPath);
final emailC = TextEditingController(text: s.email);
final pwC = TextEditingController(text: s.password);
await showDialog<void>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Remote Settings'),
content: Form(
key: formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SwitchListTile(
title: const Text('Abilita sync remoto'),
value: enabled,
onChanged: (v) {
enabled = v;
},
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 8),
TextFormField(
controller: baseUrlC,
decoration: const InputDecoration(
labelText: 'Base URL',
hintText: 'https://prova.patachina.it',
),
validator: (v) => (v == null || v.isEmpty) ? 'Obbligatorio' : null,
),
const SizedBox(height: 8),
TextFormField(
controller: indexC,
decoration: const InputDecoration(
labelText: 'Index path',
hintText: 'photos/',
),
validator: (v) => (v == null || v.isEmpty) ? 'Obbligatorio' : null,
),
const SizedBox(height: 8),
TextFormField(
controller: emailC,
decoration: const InputDecoration(labelText: 'User/Email'),
),
const SizedBox(height: 8),
TextFormField(
controller: pwC,
obscureText: true,
decoration: const InputDecoration(labelText: 'Password'),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).maybePop(),
child: const Text('Annulla'),
),
ElevatedButton.icon(
onPressed: () async {
if (!formKey.currentState!.validate()) return;
final upd = RemoteSettings(
enabled: enabled,
baseUrl: baseUrlC.text.trim(),
indexPath: indexC.text.trim(),
email: emailC.text.trim(),
password: pwC.text,
);
await upd.save();
if (context.mounted) Navigator.of(context).pop();
if (context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Impostazioni salvate')));
}
},
icon: const Icon(Icons.save),
label: const Text('Salva'),
),
],
),
);
baseUrlC.dispose();
indexC.dispose();
emailC.dispose();
pwC.dispose();
}
// --- DEBUG: wrapper che aggiunge 2 FAB (Settings + Remote Test) ---
Widget _wrapWithRemoteDebug(BuildContext context, Widget child) {
if (!kDebugMode) return child;
return Stack(
children: [
child,
Positioned(
right: 16,
bottom: 16,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
FloatingActionButton(
heroTag: 'remote_debug_settings_fab',
mini: true,
onPressed: () => _openRemoteSettingsDialog(context),
tooltip: 'Remote Settings',
child: const Icon(Icons.settings),
),
const SizedBox(height: 12),
FloatingActionButton(
heroTag: 'remote_debug_test_fab',
onPressed: () => _openRemoteTestPage(context),
tooltip: 'Remote Test',
child: const Icon(Icons.image_search),
),
],
),
),
],
);
}
Future<Route> _getRedirectRoute(AppMode appMode) async {
String routeName;
Set<CollectionFilter?>? filters;
switch (appMode) {
case AppMode.setWallpaper:
return DirectMaterialPageRoute(
settings: const RouteSettings(name: WallpaperPage.routeName),
builder: (_) {
return WallpaperPage(
entry: _viewerEntry,
);
},
);
case AppMode.view:
AvesEntry viewerEntry = _viewerEntry!;
CollectionLens? collection;
final source = context.read<CollectionSource>();
final album = viewerEntry.directory;
if (album != null) {
// wait for collection to pass the `loading` state
final loadingCompleter = Completer();
final stateNotifier = source.stateNotifier;
void _onSourceStateChanged() {
if (stateNotifier.value != SourceState.loading) {
stateNotifier.removeListener(_onSourceStateChanged);
loadingCompleter.complete();
}
}
stateNotifier.addListener(_onSourceStateChanged);
_onSourceStateChanged();
await loadingCompleter.future;
// ⚠️ NON lanciamo più il sync qui (evita contese durante il viewer)
// unawaited(rrs.runRemoteSyncOnceManaged());
collection = CollectionLens(
source: source,
filters: {StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))},
listenToSource: false,
// if we group bursts, opening a burst sub-entry should:
// - identify and select the containing main entry,
// - select the sub-entry in the Viewer page.
stackBursts: false,
);
final viewerEntryPath = viewerEntry.path;
final collectionEntry =
collection.sortedEntries.firstWhereOrNull((entry) => entry.path == viewerEntryPath);
if (collectionEntry != null) {
viewerEntry = collectionEntry;
} else {
debugPrint('collection does not contain viewerEntry=$viewerEntry');
collection = null;
}
}
return DirectMaterialPageRoute(
settings: const RouteSettings(name: EntryViewerPage.routeName),
builder: (_) {
return EntryViewerPage(
collection: collection,
initialEntry: viewerEntry,
);
},
);
case AppMode.edit:
return DirectMaterialPageRoute(
settings: const RouteSettings(name: EntryViewerPage.routeName),
builder: (_) {
return ImageEditorPage(
entry: _viewerEntry!,
);
},
);
case AppMode.initialization:
case AppMode.main:
case AppMode.pickCollectionFiltersExternal:
case AppMode.pickSingleMediaExternal:
case AppMode.pickMultipleMediaExternal:
case AppMode.pickFilteredMediaInternal:
case AppMode.pickUnfilteredMediaInternal:
case AppMode.pickFilterInternal:
case AppMode.previewMap:
case AppMode.screenSaver:
case AppMode.slideshow:
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:
// <<--- Wrapper di debug che aggiunge i due FAB (solo in debug)
return buildRoute(
(context) => _wrapWithRemoteDebug(
context,
CollectionPage(source: source, filters: filters),
),
);
}
}
}

View file

@ -40,6 +40,11 @@ class ViewStateConductor {
} else { } else {
// try to initialize the view state to match magnifier initial state // try to initialize the view state to match magnifier initial state
const initialScale = ScaleLevel(ref: ScaleReference.contained); const initialScale = ScaleLevel(ref: ScaleReference.contained);
final Size contentSize = (entry.isRemote && entry.remoteWidth != null && entry.remoteHeight != null)
? Size(entry.remoteWidth!.toDouble(), entry.remoteHeight!.toDouble())
: entry.displaySize;
final initialValue = ViewState( final initialValue = ViewState(
position: Offset.zero, position: Offset.zero,
scale: ScaleBoundaries( scale: ScaleBoundaries(
@ -48,11 +53,12 @@ class ViewStateConductor {
maxScale: initialScale, maxScale: initialScale,
initialScale: initialScale, initialScale: initialScale,
viewportSize: _viewportSize, viewportSize: _viewportSize,
contentSize: entry.displaySize, contentSize: contentSize,
).initialScale, ).initialScale,
viewportSize: _viewportSize, viewportSize: _viewportSize,
contentSize: entry.displaySize, contentSize: contentSize,
); );
controller = ViewStateController( controller = ViewStateController(
entry: entry, entry: entry,
viewStateNotifier: ValueNotifier<ViewState>(initialValue), viewStateNotifier: ValueNotifier<ViewState>(initialValue),

View file

@ -0,0 +1,83 @@
import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/viewer/view_state.dart';
import 'package:aves/widgets/viewer/view/controller.dart';
import 'package:aves_magnifier/aves_magnifier.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:leak_tracker/leak_tracker.dart';
class ViewStateConductor {
final List<ViewStateController> _controllers = [];
Size _viewportSize = Size.zero;
static const maxControllerCount = 3;
ViewStateConductor() {
if (kFlutterMemoryAllocationsEnabled) {
LeakTracking.dispatchObjectCreated(
library: 'aves',
className: '$ViewStateConductor',
object: this,
);
}
}
Future<void> dispose() async {
if (kFlutterMemoryAllocationsEnabled) {
LeakTracking.dispatchObjectDisposed(object: this);
}
_controllers.forEach((v) => v.dispose());
_controllers.clear();
}
set viewportSize(Size size) => _viewportSize = size;
ViewStateController getOrCreateController(AvesEntry entry) {
var controller = getController(entry);
if (controller != null) {
_controllers.remove(controller);
} else {
// try to initialize the view state to match magnifier initial state
const initialScale = ScaleLevel(ref: ScaleReference.contained);
final initialValue = ViewState(
position: Offset.zero,
scale: ScaleBoundaries(
allowOriginalScaleBeyondRange: true,
minScale: initialScale,
maxScale: initialScale,
initialScale: initialScale,
viewportSize: _viewportSize,
contentSize: entry.displaySize,
).initialScale,
viewportSize: _viewportSize,
contentSize: entry.displaySize,
);
controller = ViewStateController(
entry: entry,
viewStateNotifier: ValueNotifier<ViewState>(initialValue),
);
}
_controllers.insert(0, controller);
while (_controllers.length > maxControllerCount) {
_controllers.removeLast().dispose();
}
return controller;
}
ViewStateController? getController(AvesEntry entry) {
return _controllers.firstWhereOrNull((c) => c.entry.uri == entry.uri && c.entry.pageId == entry.pageId);
}
void reset(AvesEntry entry) {
final uris = <AvesEntry>{
entry,
...?entry.stackedEntries,
}.map((v) => v.uri).toSet();
final entryControllers = _controllers.where((v) => uris.contains(v.entry.uri)).toSet();
entryControllers.forEach((controller) {
_controllers.remove(controller);
controller.dispose();
});
}
}

View file

@ -97,6 +97,24 @@ class _EntryPageViewState extends State<EntryPageView> with TickerProviderStateM
_magnifierController = AvesMagnifierController(); _magnifierController = AvesMagnifierController();
_subscriptions.add(_magnifierController.stateStream.listen(_onViewStateChanged)); _subscriptions.add(_magnifierController.stateStream.listen(_onViewStateChanged));
_subscriptions.add(_magnifierController.scaleBoundariesStream.listen(_onViewScaleBoundariesChanged)); _subscriptions.add(_magnifierController.scaleBoundariesStream.listen(_onViewScaleBoundariesChanged));
// PATCH2
WidgetsBinding.instance.addPostFrameCallback((_) {
final boundaries = _magnifierController.scaleBoundaries;
if (boundaries != null) {
final initial = boundaries.initialScale;
_magnifierController.update(
scale: initial,
source: ChangeSource.animation,
);
}
});
if (entry.isVideo) { if (entry.isVideo) {
_subscriptions.add(mediaSessionService.mediaCommands.listen(_onMediaCommand)); _subscriptions.add(mediaSessionService.mediaCommands.listen(_onMediaCommand));
} }
@ -400,6 +418,23 @@ class _EntryPageViewState extends State<EntryPageView> with TickerProviderStateM
final isWallpaperMode = context.read<ValueNotifier<AppMode>>().value == AppMode.setWallpaper; final isWallpaperMode = context.read<ValueNotifier<AppMode>>().value == AppMode.setWallpaper;
final minScale = isWallpaperMode ? const ScaleLevel(ref: ScaleReference.covered) : const ScaleLevel(ref: ScaleReference.contained); final minScale = isWallpaperMode ? const ScaleLevel(ref: ScaleReference.covered) : const ScaleLevel(ref: ScaleReference.contained);
// DEBUG REMOTE
final effectiveContentSize = displaySize ??
(entry.isRemote && entry.remoteWidth != null && entry.remoteHeight != null
? Size(entry.remoteWidth!.toDouble(), entry.remoteHeight!.toDouble())
: entry.displaySize);
// ignore: avoid_print
print('DEBUG REMOTE: '
'uri=${entry.uri} '
'isRemote=${entry.isRemote} '
'remoteWidth=${entry.remoteWidth} '
'remoteHeight=${entry.remoteHeight} '
'entry.displaySize=${entry.displaySize} '
'effectiveContentSize=$effectiveContentSize');
return ValueListenableBuilder<bool>( return ValueListenableBuilder<bool>(
valueListenable: AvesApp.canGestureToOtherApps, valueListenable: AvesApp.canGestureToOtherApps,
builder: (context, canGestureToOtherApps, child) { builder: (context, canGestureToOtherApps, child) {
@ -407,7 +442,7 @@ class _EntryPageViewState extends State<EntryPageView> with TickerProviderStateM
// key includes modified date to refresh when the image is modified by metadata (e.g. rotated) // key includes modified date to refresh when the image is modified by metadata (e.g. rotated)
key: Key('${entry.uri}_${entry.pageId}_${entry.dateModifiedMillis}'), key: Key('${entry.uri}_${entry.pageId}_${entry.dateModifiedMillis}'),
controller: controller ?? _magnifierController, controller: controller ?? _magnifierController,
contentSize: displaySize ?? entry.displaySize, contentSize: effectiveContentSize,
allowOriginalScaleBeyondRange: !isWallpaperMode, allowOriginalScaleBeyondRange: !isWallpaperMode,
allowDoubleTap: _allowDoubleTap, allowDoubleTap: _allowDoubleTap,
minScale: minScale, minScale: minScale,
@ -529,13 +564,51 @@ class _EntryPageViewState extends State<EntryPageView> with TickerProviderStateM
); );
} }
void _onViewScaleBoundariesChanged(ScaleBoundaries v) {
_viewStateNotifier.value = _viewStateNotifier.value.copyWith(
viewportSize: v.viewportSize, // PATCH3
contentSize: v.contentSize, void _onViewScaleBoundariesChanged(ScaleBoundaries v) {
); _viewStateNotifier.value = _viewStateNotifier.value.copyWith(
viewportSize: v.viewportSize,
contentSize: v.contentSize,
);
if (v.viewportSize.width <= 0 || v.viewportSize.height <= 0) return;
final vw = v.viewportSize.width;
final vh = v.viewportSize.height;
// dimensioni di base
double cw = v.contentSize.width;
double ch = v.contentSize.height;
// se limmagine è ruotata di 90°/270°, inverti larghezza/altezza
final rotation = entry.rotationDegrees ?? 0;
if (rotation == 90 || rotation == 270) {
final tmp = cw;
cw = ch;
ch = tmp;
} }
double scale;
if (entry.isRemote) {
// qui puoi scegliere la politica: ad es. fit contenuto intelligente
// per non zoomare troppo lasciarla minuscola
final sx = vw / cw;
final sy = vh / ch;
// ad esempio: usa il min (contained) ma con orientamento corretto
scale = sx < sy ? sx : sy;
} else {
scale = v.initialScale;
}
_magnifierController.update(
scale: scale,
source: ChangeSource.animation,
);
}
double? _getSideRatio() { double? _getSideRatio() {
if (!mounted) return null; if (!mounted) return null;
final isPortrait = MediaQuery.orientationOf(context) == Orientation.portrait; final isPortrait = MediaQuery.orientationOf(context) == Orientation.portrait;

View file

@ -0,0 +1,553 @@
import 'dart:async';
import 'package:aves/app_mode.dart';
import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/props.dart';
import 'package:aves/model/settings/enums/widget_outline.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/viewer/view_state.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/services/media/media_session_service.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/view/view.dart';
import 'package:aves/widgets/aves_app.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/home_widget.dart';
import 'package:aves/widgets/viewer/controls/controller.dart';
import 'package:aves/widgets/viewer/controls/notifications.dart';
import 'package:aves/widgets/viewer/hero.dart';
import 'package:aves/widgets/viewer/video/conductor.dart';
import 'package:aves/widgets/viewer/view/conductor.dart';
import 'package:aves/widgets/viewer/visual/error.dart';
import 'package:aves/widgets/viewer/visual/raster.dart';
import 'package:aves/widgets/viewer/visual/vector.dart';
import 'package:aves/widgets/viewer/visual/video/cover.dart';
import 'package:aves/widgets/viewer/visual/video/subtitle/subtitle.dart';
import 'package:aves/widgets/viewer/visual/video/swipe_action.dart';
import 'package:aves/widgets/viewer/visual/video/video_view.dart';
import 'package:aves_magnifier/aves_magnifier.dart';
import 'package:aves_model/aves_model.dart';
import 'package:decorated_icon/decorated_icon.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class EntryPageView extends StatefulWidget {
final AvesEntry mainEntry, pageEntry;
final ViewerController viewerController;
final VoidCallback? onDisposed;
static const decorationCheckSize = 20.0;
static const rasterMaxScale = ScaleLevel(factor: 5);
static const vectorMaxScale = ScaleLevel(factor: 25);
const EntryPageView({
super.key,
required this.mainEntry,
required this.pageEntry,
required this.viewerController,
this.onDisposed,
});
@override
State<EntryPageView> createState() => _EntryPageViewState();
}
class _EntryPageViewState extends State<EntryPageView> with TickerProviderStateMixin {
late ValueNotifier<ViewState> _viewStateNotifier;
late AvesMagnifierController _magnifierController;
final Set<StreamSubscription> _subscriptions = {};
final ValueNotifier<Widget?> _actionFeedbackChildNotifier = ValueNotifier(null);
OverlayEntry? _actionFeedbackOverlayEntry;
AvesEntry get mainEntry => widget.mainEntry;
AvesEntry get entry => widget.pageEntry;
ViewerController get viewerController => widget.viewerController;
@override
void initState() {
super.initState();
_registerWidget(widget);
}
@override
void didUpdateWidget(covariant EntryPageView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.pageEntry != widget.pageEntry) {
_unregisterWidget(oldWidget);
_registerWidget(widget);
}
}
@override
void dispose() {
_unregisterWidget(widget);
widget.onDisposed?.call();
_actionFeedbackChildNotifier.dispose();
super.dispose();
}
void _registerWidget(EntryPageView widget) {
final entry = widget.pageEntry;
_viewStateNotifier = context.read<ViewStateConductor>().getOrCreateController(entry).viewStateNotifier;
_magnifierController = AvesMagnifierController();
_subscriptions.add(_magnifierController.stateStream.listen(_onViewStateChanged));
_subscriptions.add(_magnifierController.scaleBoundariesStream.listen(_onViewScaleBoundariesChanged));
if (entry.isVideo) {
_subscriptions.add(mediaSessionService.mediaCommands.listen(_onMediaCommand));
}
viewerController.startAutopilotAnimation(
vsync: this,
onUpdate: ({required scaleLevel}) {
final boundaries = _magnifierController.scaleBoundaries;
if (boundaries != null) {
final scale = boundaries.scaleForLevel(scaleLevel);
_magnifierController.update(scale: scale, source: ChangeSource.animation);
}
},
);
}
void _unregisterWidget(EntryPageView oldWidget) {
viewerController.stopAutopilotAnimation(vsync: this);
_magnifierController.dispose();
_subscriptions
..forEach((sub) => sub.cancel())
..clear();
}
@override
Widget build(BuildContext context) {
Widget child = AnimatedBuilder(
animation: entry.visualChangeNotifier,
builder: (context, child) {
Widget? child;
if (entry.isSvg) {
child = _buildSvgView();
} else if (!entry.displaySize.isEmpty) {
if (entry.isVideo) {
child = _buildVideoView();
} else if (entry.isDecodingSupported) {
child = _buildRasterView();
}
}
child ??= ErrorView(
entry: entry,
onTap: _onTap,
);
return child;
},
);
if (!settings.viewerUseCutout) {
child = SafeCutoutArea(
child: ClipRect(
child: child,
),
);
}
final animate = context.select<Settings, bool>((v) => v.animate);
if (animate) {
child = Consumer<EntryHeroInfo?>(
builder: (context, info, child) => Hero(
tag: info != null && info.entry == mainEntry ? info.tag : hashCode,
transitionOnUserGestures: true,
child: child!,
),
child: child,
);
}
return child;
}
Widget _buildRasterView() {
return _buildMagnifier(
applyScale: false,
child: RasterImageView(
entry: entry,
viewStateNotifier: _viewStateNotifier,
errorBuilder: (context, error, stackTrace) => ErrorView(
entry: entry,
onTap: _onTap,
),
),
);
}
Widget _buildSvgView() {
return _buildMagnifier(
maxScale: EntryPageView.vectorMaxScale,
scaleStateCycle: _vectorScaleStateCycle,
applyScale: false,
child: VectorImageView(
entry: entry,
viewStateNotifier: _viewStateNotifier,
errorBuilder: (context, error, stackTrace) => ErrorView(
entry: entry,
onTap: _onTap,
),
),
);
}
Widget _buildVideoView() {
final videoController = context.read<VideoConductor>().getController(entry);
if (videoController == null) return const SizedBox();
return ValueListenableBuilder<double?>(
valueListenable: videoController.sarNotifier,
builder: (context, sar, child) {
final videoDisplaySize = entry.videoDisplaySize(sar);
final isPureVideo = entry.isPureVideo;
return Selector<Settings, (bool, bool, bool)>(
selector: (context, s) => (
isPureVideo && s.videoGestureDoubleTapTogglePlay,
isPureVideo && s.videoGestureSideDoubleTapSeek,
isPureVideo && s.videoGestureVerticalDragBrightnessVolume,
),
builder: (context, s, child) {
final (playGesture, seekGesture, useVerticalDragGesture) = s;
final useTapGesture = playGesture || seekGesture;
MagnifierDoubleTapCallback? onDoubleTap;
MagnifierGestureScaleStartCallback? onScaleStart;
MagnifierGestureScaleUpdateCallback? onScaleUpdate;
MagnifierGestureScaleEndCallback? onScaleEnd;
if (useTapGesture) {
void _applyAction(EntryAction action, {IconData? Function()? icon}) {
_actionFeedbackChildNotifier.value = DecoratedIcon(
icon?.call() ?? action.getIconData(),
size: 48,
color: Colors.white,
shadows: const [
Shadow(
color: Colors.black,
blurRadius: 4,
),
],
);
VideoActionNotification(
controller: videoController,
entry: entry,
action: action,
).dispatch(context);
}
onDoubleTap = (alignment) {
final x = alignment.x;
if (seekGesture) {
final sideRatio = _getSideRatio();
if (sideRatio != null) {
if (x < sideRatio) {
_applyAction(EntryAction.videoReplay10);
return true;
} else if (x > 1 - sideRatio) {
_applyAction(EntryAction.videoSkip10);
return true;
}
}
}
if (playGesture) {
_applyAction(
EntryAction.videoTogglePlay,
icon: () => videoController.isPlaying ? AIcons.pause : AIcons.play,
);
return true;
}
return false;
};
}
if (useVerticalDragGesture) {
SwipeAction? swipeAction;
var move = Offset.zero;
var dropped = false;
double? startValue;
ValueNotifier<double?>? valueNotifier;
onScaleStart = (details, doubleTap, boundaries) {
dropped = details.pointerCount > 1 || doubleTap;
if (dropped) return;
startValue = null;
valueNotifier = ValueNotifier<double?>(null);
final alignmentX = details.focalPoint.dx / boundaries.viewportSize.width;
final action = alignmentX > .5 ? SwipeAction.volume : SwipeAction.brightness;
action.get().then((v) => startValue = v);
swipeAction = action;
move = Offset.zero;
_actionFeedbackOverlayEntry = OverlayEntry(
builder: (context) => SwipeActionFeedback(
action: action,
valueNotifier: valueNotifier!,
),
);
Overlay.of(context).insert(_actionFeedbackOverlayEntry!);
};
onScaleUpdate = (details) {
if (valueNotifier == null) return false;
move += details.focalPointDelta;
dropped |= details.pointerCount > 1;
if (valueNotifier!.value == null) {
dropped |= MagnifierGestureRecognizer.isXPan(move);
}
if (dropped) return false;
final _startValue = startValue;
if (_startValue != null) {
final double value = (_startValue - move.dy / SwipeActionFeedback.height).clamp(0, 1);
valueNotifier!.value = value;
swipeAction?.set(value);
}
return true;
};
onScaleEnd = (details) {
valueNotifier?.dispose();
_actionFeedbackOverlayEntry
?..remove()
..dispose();
_actionFeedbackOverlayEntry = null;
};
}
Widget videoChild = Stack(
children: [
_buildMagnifier(
displaySize: videoDisplaySize,
onScaleStart: onScaleStart,
onScaleUpdate: onScaleUpdate,
onScaleEnd: onScaleEnd,
onDoubleTap: onDoubleTap,
child: VideoView(
entry: entry,
controller: videoController,
),
),
VideoSubtitles(
entry: entry,
controller: videoController,
viewStateNotifier: _viewStateNotifier,
),
if (useTapGesture)
ValueListenableBuilder<Widget?>(
valueListenable: _actionFeedbackChildNotifier,
builder: (context, feedbackChild, child) => ActionFeedback(
child: feedbackChild,
),
),
],
);
if (useVerticalDragGesture) {
final scope = MagnifierGestureDetectorScope.maybeOf(context);
if (scope != null) {
videoChild = scope.copyWith(
acceptPointerEvent: MagnifierGestureRecognizer.isYPan,
child: videoChild,
);
}
}
return Stack(
fit: StackFit.expand,
children: [
videoChild,
VideoCover(
mainEntry: mainEntry,
pageEntry: entry,
magnifierController: _magnifierController,
videoController: videoController,
videoDisplaySize: videoDisplaySize,
onTap: _onTap,
magnifierBuilder: (coverController, coverSize, videoCoverUriImage) => _buildMagnifier(
controller: coverController,
displaySize: coverSize,
onDoubleTap: onDoubleTap,
child: Image(
image: videoCoverUriImage,
),
),
),
],
);
},
);
},
);
}
Widget _buildMagnifier({
AvesMagnifierController? controller,
Size? displaySize,
ScaleLevel maxScale = EntryPageView.rasterMaxScale,
ScaleStateCycle scaleStateCycle = defaultScaleStateCycle,
bool applyScale = true,
MagnifierGestureScaleStartCallback? onScaleStart,
MagnifierGestureScaleUpdateCallback? onScaleUpdate,
MagnifierGestureScaleEndCallback? onScaleEnd,
MagnifierDoubleTapCallback? onDoubleTap,
required Widget child,
}) {
final isWallpaperMode = context.read<ValueNotifier<AppMode>>().value == AppMode.setWallpaper;
final minScale = isWallpaperMode ? const ScaleLevel(ref: ScaleReference.covered) : const ScaleLevel(ref: ScaleReference.contained);
return ValueListenableBuilder<bool>(
valueListenable: AvesApp.canGestureToOtherApps,
builder: (context, canGestureToOtherApps, child) {
return AvesMagnifier(
// key includes modified date to refresh when the image is modified by metadata (e.g. rotated)
key: Key('${entry.uri}_${entry.pageId}_${entry.dateModifiedMillis}'),
controller: controller ?? _magnifierController,
contentSize: displaySize ?? entry.displaySize,
allowOriginalScaleBeyondRange: !isWallpaperMode,
allowDoubleTap: _allowDoubleTap,
minScale: minScale,
maxScale: maxScale,
initialScale: viewerController.initialScale,
scaleStateCycle: scaleStateCycle,
applyScale: applyScale,
onScaleStart: onScaleStart,
onScaleUpdate: onScaleUpdate,
onScaleEnd: onScaleEnd,
onFling: _onFling,
onTap: (context, _, alignment, _) {
if (context.mounted) {
_onTap(alignment: alignment);
}
},
onLongPress: canGestureToOtherApps ? _startGlobalDrag : null,
onDoubleTap: onDoubleTap,
child: child!,
);
},
child: child,
);
}
Future<void> _startGlobalDrag() async {
const dragShadowSize = Size.square(128);
final cornerRadiusPx = await deviceService.getWidgetCornerRadiusPx();
final devicePixelRatio = MediaQuery.devicePixelRatioOf(context);
final brightness = Theme.of(context).brightness;
final outline = await WidgetOutline.systemBlackAndWhite.color(brightness);
final dragShadowBytes =
await HomeWidgetPainter(
entry: entry,
devicePixelRatio: devicePixelRatio,
).drawWidget(
sizeDip: dragShadowSize,
cornerRadiusPx: cornerRadiusPx,
outline: outline,
shape: WidgetShape.rrect,
);
await windowService.startGlobalDrag(entry.uri, entry.bestTitle, dragShadowSize, dragShadowBytes);
}
void _onFling(AxisDirection direction) {
const animate = true;
switch (direction) {
case AxisDirection.left:
(context.isRtl ? const ShowNextEntryNotification(animate: animate) : const ShowPreviousEntryNotification(animate: animate)).dispatch(context);
case AxisDirection.right:
(context.isRtl ? const ShowPreviousEntryNotification(animate: animate) : const ShowNextEntryNotification(animate: animate)).dispatch(context);
case AxisDirection.up:
PopVisualNotification().dispatch(context);
case AxisDirection.down:
ShowInfoPageNotification().dispatch(context);
}
}
Notification? _handleSideSingleTap(Alignment? alignment) {
if (settings.viewerGestureSideTapNext && alignment != null) {
final x = alignment.x;
final sideRatio = _getSideRatio();
if (sideRatio != null) {
const animate = false;
if (x < sideRatio) {
return context.isRtl ? const ShowNextEntryNotification(animate: animate) : const ShowPreviousEntryNotification(animate: animate);
} else if (x > 1 - sideRatio) {
return context.isRtl ? const ShowPreviousEntryNotification(animate: animate) : const ShowNextEntryNotification(animate: animate);
}
}
}
return null;
}
void _onTap({Alignment? alignment}) => (_handleSideSingleTap(alignment) ?? const ToggleOverlayNotification()).dispatch(context);
// side gesture handling by precedence:
// - seek in video by side double tap (if enabled)
// - go to previous/next entry by side single tap (if enabled)
// - zoom in/out by double tap
bool _allowDoubleTap(Alignment alignment) {
if (entry.isVideo && settings.videoGestureSideDoubleTapSeek) {
return true;
}
final actionNotification = _handleSideSingleTap(alignment);
return actionNotification == null;
}
void _onMediaCommand(MediaCommandEvent event) {
final videoController = context.read<VideoConductor>().getController(entry);
if (videoController == null) return;
switch (event.command) {
case MediaCommand.play:
videoController.play();
case MediaCommand.pause:
videoController.pause();
case MediaCommand.skipToNext:
ShowNextVideoNotification().dispatch(context);
case MediaCommand.skipToPrevious:
ShowPreviousVideoNotification().dispatch(context);
case MediaCommand.stop:
videoController.pause();
case MediaCommand.seek:
if (event is MediaSeekCommandEvent) {
videoController.seekTo(event.position);
}
}
}
void _onViewStateChanged(MagnifierState v) {
if (!mounted) return;
_viewStateNotifier.value = _viewStateNotifier.value.copyWith(
position: v.position,
scale: v.scale,
);
}
void _onViewScaleBoundariesChanged(ScaleBoundaries v) {
_viewStateNotifier.value = _viewStateNotifier.value.copyWith(
viewportSize: v.viewportSize,
contentSize: v.contentSize,
);
}
double? _getSideRatio() {
if (!mounted) return null;
final isPortrait = MediaQuery.orientationOf(context) == Orientation.portrait;
return isPortrait ? 1 / 6 : 1 / 8;
}
static ScaleState _vectorScaleStateCycle(ScaleState actual) {
switch (actual) {
case ScaleState.initial:
return ScaleState.covering;
default:
return ScaleState.initial;
}
}
}

View file

@ -14,6 +14,7 @@ import 'package:aves_model/aves_model.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:aves/remote/remote_http.dart';
class RasterImageView extends StatefulWidget { class RasterImageView extends StatefulWidget {
final AvesEntry entry; final AvesEntry entry;
@ -54,23 +55,28 @@ class _RasterImageViewState extends State<RasterImageView> {
ImageProvider get thumbnailProvider => entry.bestCachedThumbnail; ImageProvider get thumbnailProvider => entry.bestCachedThumbnail;
ImageProvider get fullImageProvider {
if (_useTiles) { ImageProvider get fullImageProvider {
assert(_isTilingInitialized); if (entry.isRemote) {
return entry.getRegion( return NetworkImage(RemoteHttp.absUrl(entry.remotePath!));
sampleSize: _maxSampleSize,
region: entry.fullImageRegion,
);
} else {
return entry.fullImage;
}
} }
if (_useTiles) {
assert(_isTilingInitialized);
return entry.getRegion(
sampleSize: _maxSampleSize,
region: entry.fullImageRegion,
);
} else {
return entry.fullImage;
}
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_displaySize = entry.displaySize; _displaySize = entry.displaySize;
_useTiles = entry.useTiles; _useTiles = entry.isRemote ? false : entry.useTiles;
_fullImageListener = ImageStreamListener(_onFullImageCompleted); _fullImageListener = ImageStreamListener(_onFullImageCompleted);
if (!_useTiles) _registerFullImage(); if (!_useTiles) _registerFullImage();
} }

View file

@ -0,0 +1,525 @@
import 'dart:math';
import 'package:aves/image_providers/region_provider.dart';
import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/images.dart';
import 'package:aves/model/entry/extensions/props.dart';
import 'package:aves/model/settings/enums/entry_background.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/viewer/view_state.dart';
import 'package:aves/widgets/common/fx/checkered_decoration.dart';
import 'package:aves/widgets/viewer/controls/notifications.dart';
import 'package:aves/widgets/viewer/visual/entry_page_view.dart';
import 'package:aves_model/aves_model.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import 'package:aves/remote/remote_http.dart';
class RasterImageView extends StatefulWidget {
final AvesEntry entry;
final ValueNotifier<ViewState> viewStateNotifier;
final ImageErrorWidgetBuilder errorBuilder;
const RasterImageView({
super.key,
required this.entry,
required this.viewStateNotifier,
required this.errorBuilder,
});
@override
State<RasterImageView> createState() => _RasterImageViewState();
}
class _RasterImageViewState extends State<RasterImageView> {
late Size _displaySize;
late bool _useTiles;
bool _isTilingInitialized = false;
late int _maxSampleSize;
late double _tileSide;
Matrix4? _tileTransform;
ImageStream? _fullImageStream;
late ImageStreamListener _fullImageListener;
final ValueNotifier<bool> _fullImageLoaded = ValueNotifier(false);
ImageInfo? _fullImageInfo;
static const int _pixelArtMaxSize = 256; // px
static const double _tilesByShortestSide = 2;
AvesEntry get entry => widget.entry;
ValueNotifier<ViewState> get viewStateNotifier => widget.viewStateNotifier;
ViewState get viewState => viewStateNotifier.value;
ImageProvider get thumbnailProvider => entry.bestCachedThumbnail;
ImageProvider get fullImageProvider {
if (entry.isRemote) {
return NetworkImage(RemoteHttp.absUrl(entry.remotePath!));
}
if (_useTiles) {
assert(_isTilingInitialized);
return entry.getRegion(
sampleSize: _maxSampleSize,
region: entry.fullImageRegion,
);
} else {
return entry.fullImage;
}
}
@override
void initState() {
super.initState();
_displaySize = entry.displaySize;
_useTiles = entry.isRemote ? false : entry.useTiles;
_fullImageListener = ImageStreamListener(_onFullImageCompleted);
if (!_useTiles) _registerFullImage();
}
@override
void didUpdateWidget(covariant RasterImageView oldWidget) {
super.didUpdateWidget(oldWidget);
final oldViewState = oldWidget.viewStateNotifier.value;
final viewState = widget.viewStateNotifier.value;
if (oldWidget.entry != widget.entry || oldViewState.viewportSize != viewState.viewportSize) {
_isTilingInitialized = false;
_fullImageLoaded.value = false;
_unregisterFullImage();
}
}
@override
void dispose() {
_fullImageLoaded.dispose();
_unregisterFullImage();
super.dispose();
}
void _registerFullImage() {
_fullImageStream = fullImageProvider.resolve(ImageConfiguration.empty);
_fullImageStream!.addListener(_fullImageListener);
}
void _unregisterFullImage() {
_fullImageStream?.removeListener(_fullImageListener);
_fullImageStream = null;
_fullImageInfo?.dispose();
}
void _onFullImageCompleted(ImageInfo image, bool synchronousCall) {
// implementer is responsible for disposing the provided `ImageInfo`
_unregisterFullImage();
_fullImageInfo = image;
_fullImageLoaded.value = true;
FullImageLoadedNotification(entry, fullImageProvider).dispatch(context);
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<ViewState>(
valueListenable: viewStateNotifier,
builder: (context, viewState, child) {
final viewportSize = viewState.viewportSize;
final viewportSized = viewportSize?.isEmpty == false;
if (viewportSized && _useTiles && !_isTilingInitialized) _initTiling(viewportSize!);
final magnifierScale = viewState.scale!;
return SizedBox.fromSize(
size: _displaySize * magnifierScale,
child: Stack(
alignment: Alignment.center,
children: [
if (entry.canHaveAlpha && viewportSized) _buildBackground(),
_buildLoading(),
if (_useTiles) ..._buildTiles() else _buildFullImage(),
],
),
);
},
);
}
Widget _buildFullImage() {
final magnifierScale = viewState.scale!;
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
final quality = _qualityForScaleAndSize(
magnifierScale: magnifierScale,
sampleSize: 1,
devicePixelRatio: devicePixelRatio,
);
return Image(
image: fullImageProvider,
gaplessPlayback: true,
errorBuilder: widget.errorBuilder,
width: (_displaySize * magnifierScale).width,
fit: BoxFit.contain,
filterQuality: quality,
);
}
void _initTiling(Size viewportSize) {
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
_tileSide = viewportSize.shortestSide * devicePixelRatio / _tilesByShortestSide;
// scale for initial state `contained`
final containedScale = min(viewportSize.width / _displaySize.width, viewportSize.height / _displaySize.height);
_maxSampleSize = ExtraAvesEntryImages.sampleSizeForScale(magnifierScale: containedScale, devicePixelRatio: devicePixelRatio);
final rotationDegrees = entry.rotationDegrees;
final isFlipped = entry.isFlipped;
_tileTransform = null;
if (rotationDegrees != 0 || isFlipped) {
_tileTransform = Matrix4.identity()
..translateByDouble(entry.width / 2.0, entry.height / 2.0, 0, 1)
..scaleByDouble(isFlipped ? -1.0 : 1.0, 1.0, 1.0, 1.0)
..rotateZ(-degToRadian(rotationDegrees.toDouble()))
..translateByDouble(-_displaySize.width / 2.0, -_displaySize.height / 2.0, 0, 1);
}
_isTilingInitialized = true;
_registerFullImage();
}
Widget _buildLoading() {
return ValueListenableBuilder<bool>(
valueListenable: _fullImageLoaded,
builder: (context, fullImageLoaded, child) {
if (fullImageLoaded) return const SizedBox();
return Center(
child: AspectRatio(
// enforce original aspect ratio, as some thumbnails aspect ratios slightly differ
aspectRatio: entry.displayAspectRatio,
child: Image(
image: thumbnailProvider,
fit: BoxFit.fill,
),
),
);
},
);
}
Widget _buildBackground() {
final viewportSize = viewState.viewportSize!;
final viewSize = _displaySize * viewState.scale!;
final decorationOffset = ((viewSize - viewportSize) as Offset) / 2 - viewState.position;
// deflate as a quick way to prevent background bleed
final decorationSize = (applyBoxFit(BoxFit.none, viewSize, viewportSize).source - const Offset(.5, .5)) as Size;
Widget child;
final background = settings.imageBackground;
if (background == EntryBackground.checkered) {
final side = viewportSize.shortestSide;
final checkSize = side / ((side / EntryPageView.decorationCheckSize).round());
final offset = ((decorationSize - viewportSize) as Offset) / 2;
child = ValueListenableBuilder<bool>(
valueListenable: _fullImageLoaded,
builder: (context, fullImageLoaded, child) {
if (!fullImageLoaded) return const SizedBox();
return CustomPaint(
painter: CheckeredPainter(
checkSize: checkSize,
offset: offset,
),
);
},
);
} else {
child = DecoratedBox(
decoration: BoxDecoration(
color: background.color,
),
);
}
return Positioned(
left: decorationOffset.dx >= 0 ? decorationOffset.dx : null,
top: decorationOffset.dy >= 0 ? decorationOffset.dy : null,
width: decorationSize.width,
height: decorationSize.height,
child: child,
);
}
List<Widget> _buildTiles() {
if (!_isTilingInitialized) return [];
final displayWidth = _displaySize.width.round();
final displayHeight = _displaySize.height.round();
final viewRect = _getViewRect(displayWidth, displayHeight);
final magnifierScale = viewState.scale!;
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
// for the largest sample size (matching the initial scale), the whole image is in view
// so we subsample the whole image without tiling
final fullImageRegionTile = _RegionTile(
entry: entry,
tileRect: Rect.fromLTWH(0, 0, displayWidth * magnifierScale, displayHeight * magnifierScale),
regionRect: entry.fullImageRegion,
sampleSize: _maxSampleSize,
quality: _qualityForScaleAndSize(
magnifierScale: magnifierScale,
sampleSize: _maxSampleSize,
devicePixelRatio: devicePixelRatio,
),
);
final tiles = [fullImageRegionTile];
final minSampleSize = min(ExtraAvesEntryImages.sampleSizeForScale(magnifierScale: magnifierScale, devicePixelRatio: devicePixelRatio), _maxSampleSize);
int nextSampleSize(int sampleSize) => (sampleSize / 2).floor();
for (var sampleSize = nextSampleSize(_maxSampleSize); sampleSize >= minSampleSize; sampleSize = nextSampleSize(sampleSize)) {
final regionSide = (_tileSide * sampleSize).round();
for (var x = 0; x < displayWidth; x += regionSide) {
for (var y = 0; y < displayHeight; y += regionSide) {
final rects = _getTileRects(
x: x,
y: y,
regionSide: regionSide,
displayWidth: displayWidth,
displayHeight: displayHeight,
scale: magnifierScale,
viewRect: viewRect,
);
if (rects != null) {
final (tileRect, regionRect) = rects;
tiles.add(
_RegionTile(
entry: entry,
tileRect: tileRect,
regionRect: regionRect,
sampleSize: sampleSize,
quality: _qualityForScaleAndSize(
magnifierScale: magnifierScale,
sampleSize: sampleSize,
devicePixelRatio: devicePixelRatio,
),
),
);
}
}
}
}
return tiles;
}
Rect _getViewRect(int displayWidth, int displayHeight) {
final scale = viewState.scale!;
final centerOffset = viewState.position;
final viewportSize = viewState.viewportSize!;
final viewOrigin = Offset(
((displayWidth * scale - viewportSize.width) / 2 - centerOffset.dx),
((displayHeight * scale - viewportSize.height) / 2 - centerOffset.dy),
);
return viewOrigin & viewportSize;
}
(Rect tileRect, Rectangle<num> regionRect)? _getTileRects({
required int x,
required int y,
required int regionSide,
required int displayWidth,
required int displayHeight,
required double scale,
required Rect viewRect,
}) {
final nextX = x + regionSide;
final nextY = y + regionSide;
final thisRegionWidth = regionSide - (nextX >= displayWidth ? nextX - displayWidth : 0);
final thisRegionHeight = regionSide - (nextY >= displayHeight ? nextY - displayHeight : 0);
final tileRect = Rect.fromLTWH(x * scale, y * scale, thisRegionWidth * scale, thisRegionHeight * scale);
// only build visible tiles
if (!viewRect.overlaps(tileRect)) return null;
Rectangle<num> regionRect;
if (_tileTransform != null) {
// apply EXIF orientation
final regionRectDouble = Rect.fromLTWH(x.toDouble(), y.toDouble(), thisRegionWidth.toDouble(), thisRegionHeight.toDouble());
final tl = MatrixUtils.transformPoint(_tileTransform!, regionRectDouble.topLeft);
final br = MatrixUtils.transformPoint(_tileTransform!, regionRectDouble.bottomRight);
regionRect = Rectangle<double>.fromPoints(
Point<double>(tl.dx, tl.dy),
Point<double>(br.dx, br.dy),
);
} else {
regionRect = Rectangle<num>(x, y, thisRegionWidth, thisRegionHeight);
}
return (tileRect, regionRect);
}
// follow recommended thresholds from `FilterQuality` documentation
static FilterQuality _qualityForScale({
required double magnifierScale,
required int sampleSize,
required double devicePixelRatio,
}) {
final entryScale = magnifierScale * devicePixelRatio;
final renderingScale = entryScale * sampleSize;
if (renderingScale > 1) {
return renderingScale > 10 ? FilterQuality.high : FilterQuality.medium;
} else {
return renderingScale < .5 ? FilterQuality.medium : FilterQuality.high;
}
}
// usually follow recommendations, except for small images
// (like icons, pixel art, etc.) for which the "nearest neighbor" algorithm is used
FilterQuality _qualityForScaleAndSize({
required double magnifierScale,
required int sampleSize,
required double devicePixelRatio,
}) {
if (_displaySize.longestSide < _pixelArtMaxSize) {
return FilterQuality.none;
}
return _qualityForScale(
magnifierScale: magnifierScale,
sampleSize: sampleSize,
devicePixelRatio: devicePixelRatio,
);
}
}
class _RegionTile extends StatefulWidget {
final AvesEntry entry;
// `tileRect` uses Flutter view coordinates
// `regionRect` uses the raw image pixel coordinates
final Rect tileRect;
final Rectangle<num> regionRect;
final int sampleSize;
final FilterQuality quality;
const _RegionTile({
required this.entry,
required this.tileRect,
required this.regionRect,
required this.sampleSize,
required this.quality,
});
@override
State<_RegionTile> createState() => _RegionTileState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(IntProperty('id', entry.id));
properties.add(IntProperty('contentId', entry.contentId));
properties.add(DiagnosticsProperty<Rect>('tileRect', tileRect));
properties.add(DiagnosticsProperty<Rectangle<num>>('regionRect', regionRect));
properties.add(IntProperty('sampleSize', sampleSize));
}
}
class _RegionTileState extends State<_RegionTile> {
late RegionProvider _provider;
AvesEntry get entry => widget.entry;
@override
void initState() {
super.initState();
_registerWidget(widget);
}
@override
void didUpdateWidget(covariant _RegionTile oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.entry != widget.entry || oldWidget.tileRect != widget.tileRect || oldWidget.sampleSize != widget.sampleSize || oldWidget.sampleSize != widget.sampleSize) {
_unregisterWidget(oldWidget);
_registerWidget(widget);
}
}
@override
void dispose() {
_unregisterWidget(widget);
super.dispose();
}
void _registerWidget(_RegionTile widget) {
_initProvider();
}
void _unregisterWidget(_RegionTile widget) {
_pauseProvider();
}
void _initProvider() {
_provider = entry.getRegion(
sampleSize: widget.sampleSize,
region: widget.regionRect,
);
}
void _pauseProvider() => _provider.pause();
@override
Widget build(BuildContext context) {
final tileRect = widget.tileRect;
Widget child = Image(
image: _provider,
width: tileRect.width,
height: tileRect.height,
fit: BoxFit.fill,
filterQuality: widget.quality,
);
// apply EXIF orientation
final quarterTurns = entry.rotationDegrees ~/ 90;
if (entry.isFlipped) {
final rotated = quarterTurns % 2 != 0;
final w = (rotated ? tileRect.height : tileRect.width) / 2.0;
final h = (rotated ? tileRect.width : tileRect.height) / 2.0;
final flipper = Matrix4.identity()
..translateByDouble(w, h, 0, 1)
..scaleByDouble(-1.0, 1.0, 1.0, 1.0)
..translateByDouble(-w, -h, 0, 1);
child = Transform(
transform: flipper,
child: child,
);
}
if (quarterTurns != 0) {
child = RotatedBox(
quarterTurns: quarterTurns,
child: child,
);
}
if (settings.debugShowViewerTiles) {
final regionRect = widget.regionRect;
child = Stack(
children: [
Positioned.fill(child: child),
Text(
'\ntile=(${tileRect.left.round()}, ${tileRect.top.round()}) ${tileRect.width.round()} x ${tileRect.height.round()}'
'\nregion=(${regionRect.left.round()}, ${regionRect.top.round()}) ${regionRect.width.round()} x ${regionRect.height.round()}'
'\nsampling=${widget.sampleSize} quality=${widget.quality.name}',
style: const TextStyle(backgroundColor: Colors.black87),
),
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: Colors.red, width: 1),
),
),
),
],
);
}
return Positioned.fromRect(
rect: tileRect,
child: child,
);
}
}

View file

@ -0,0 +1,519 @@
import 'dart:math';
import 'package:aves/image_providers/region_provider.dart';
import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/images.dart';
import 'package:aves/model/entry/extensions/props.dart';
import 'package:aves/model/settings/enums/entry_background.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/viewer/view_state.dart';
import 'package:aves/widgets/common/fx/checkered_decoration.dart';
import 'package:aves/widgets/viewer/controls/notifications.dart';
import 'package:aves/widgets/viewer/visual/entry_page_view.dart';
import 'package:aves_model/aves_model.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
class RasterImageView extends StatefulWidget {
final AvesEntry entry;
final ValueNotifier<ViewState> viewStateNotifier;
final ImageErrorWidgetBuilder errorBuilder;
const RasterImageView({
super.key,
required this.entry,
required this.viewStateNotifier,
required this.errorBuilder,
});
@override
State<RasterImageView> createState() => _RasterImageViewState();
}
class _RasterImageViewState extends State<RasterImageView> {
late Size _displaySize;
late bool _useTiles;
bool _isTilingInitialized = false;
late int _maxSampleSize;
late double _tileSide;
Matrix4? _tileTransform;
ImageStream? _fullImageStream;
late ImageStreamListener _fullImageListener;
final ValueNotifier<bool> _fullImageLoaded = ValueNotifier(false);
ImageInfo? _fullImageInfo;
static const int _pixelArtMaxSize = 256; // px
static const double _tilesByShortestSide = 2;
AvesEntry get entry => widget.entry;
ValueNotifier<ViewState> get viewStateNotifier => widget.viewStateNotifier;
ViewState get viewState => viewStateNotifier.value;
ImageProvider get thumbnailProvider => entry.bestCachedThumbnail;
ImageProvider get fullImageProvider {
if (_useTiles) {
assert(_isTilingInitialized);
return entry.getRegion(
sampleSize: _maxSampleSize,
region: entry.fullImageRegion,
);
} else {
return entry.fullImage;
}
}
@override
void initState() {
super.initState();
_displaySize = entry.displaySize;
_useTiles = entry.useTiles;
_fullImageListener = ImageStreamListener(_onFullImageCompleted);
if (!_useTiles) _registerFullImage();
}
@override
void didUpdateWidget(covariant RasterImageView oldWidget) {
super.didUpdateWidget(oldWidget);
final oldViewState = oldWidget.viewStateNotifier.value;
final viewState = widget.viewStateNotifier.value;
if (oldWidget.entry != widget.entry || oldViewState.viewportSize != viewState.viewportSize) {
_isTilingInitialized = false;
_fullImageLoaded.value = false;
_unregisterFullImage();
}
}
@override
void dispose() {
_fullImageLoaded.dispose();
_unregisterFullImage();
super.dispose();
}
void _registerFullImage() {
_fullImageStream = fullImageProvider.resolve(ImageConfiguration.empty);
_fullImageStream!.addListener(_fullImageListener);
}
void _unregisterFullImage() {
_fullImageStream?.removeListener(_fullImageListener);
_fullImageStream = null;
_fullImageInfo?.dispose();
}
void _onFullImageCompleted(ImageInfo image, bool synchronousCall) {
// implementer is responsible for disposing the provided `ImageInfo`
_unregisterFullImage();
_fullImageInfo = image;
_fullImageLoaded.value = true;
FullImageLoadedNotification(entry, fullImageProvider).dispatch(context);
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<ViewState>(
valueListenable: viewStateNotifier,
builder: (context, viewState, child) {
final viewportSize = viewState.viewportSize;
final viewportSized = viewportSize?.isEmpty == false;
if (viewportSized && _useTiles && !_isTilingInitialized) _initTiling(viewportSize!);
final magnifierScale = viewState.scale!;
return SizedBox.fromSize(
size: _displaySize * magnifierScale,
child: Stack(
alignment: Alignment.center,
children: [
if (entry.canHaveAlpha && viewportSized) _buildBackground(),
_buildLoading(),
if (_useTiles) ..._buildTiles() else _buildFullImage(),
],
),
);
},
);
}
Widget _buildFullImage() {
final magnifierScale = viewState.scale!;
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
final quality = _qualityForScaleAndSize(
magnifierScale: magnifierScale,
sampleSize: 1,
devicePixelRatio: devicePixelRatio,
);
return Image(
image: fullImageProvider,
gaplessPlayback: true,
errorBuilder: widget.errorBuilder,
width: (_displaySize * magnifierScale).width,
fit: BoxFit.contain,
filterQuality: quality,
);
}
void _initTiling(Size viewportSize) {
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
_tileSide = viewportSize.shortestSide * devicePixelRatio / _tilesByShortestSide;
// scale for initial state `contained`
final containedScale = min(viewportSize.width / _displaySize.width, viewportSize.height / _displaySize.height);
_maxSampleSize = ExtraAvesEntryImages.sampleSizeForScale(magnifierScale: containedScale, devicePixelRatio: devicePixelRatio);
final rotationDegrees = entry.rotationDegrees;
final isFlipped = entry.isFlipped;
_tileTransform = null;
if (rotationDegrees != 0 || isFlipped) {
_tileTransform = Matrix4.identity()
..translateByDouble(entry.width / 2.0, entry.height / 2.0, 0, 1)
..scaleByDouble(isFlipped ? -1.0 : 1.0, 1.0, 1.0, 1.0)
..rotateZ(-degToRadian(rotationDegrees.toDouble()))
..translateByDouble(-_displaySize.width / 2.0, -_displaySize.height / 2.0, 0, 1);
}
_isTilingInitialized = true;
_registerFullImage();
}
Widget _buildLoading() {
return ValueListenableBuilder<bool>(
valueListenable: _fullImageLoaded,
builder: (context, fullImageLoaded, child) {
if (fullImageLoaded) return const SizedBox();
return Center(
child: AspectRatio(
// enforce original aspect ratio, as some thumbnails aspect ratios slightly differ
aspectRatio: entry.displayAspectRatio,
child: Image(
image: thumbnailProvider,
fit: BoxFit.fill,
),
),
);
},
);
}
Widget _buildBackground() {
final viewportSize = viewState.viewportSize!;
final viewSize = _displaySize * viewState.scale!;
final decorationOffset = ((viewSize - viewportSize) as Offset) / 2 - viewState.position;
// deflate as a quick way to prevent background bleed
final decorationSize = (applyBoxFit(BoxFit.none, viewSize, viewportSize).source - const Offset(.5, .5)) as Size;
Widget child;
final background = settings.imageBackground;
if (background == EntryBackground.checkered) {
final side = viewportSize.shortestSide;
final checkSize = side / ((side / EntryPageView.decorationCheckSize).round());
final offset = ((decorationSize - viewportSize) as Offset) / 2;
child = ValueListenableBuilder<bool>(
valueListenable: _fullImageLoaded,
builder: (context, fullImageLoaded, child) {
if (!fullImageLoaded) return const SizedBox();
return CustomPaint(
painter: CheckeredPainter(
checkSize: checkSize,
offset: offset,
),
);
},
);
} else {
child = DecoratedBox(
decoration: BoxDecoration(
color: background.color,
),
);
}
return Positioned(
left: decorationOffset.dx >= 0 ? decorationOffset.dx : null,
top: decorationOffset.dy >= 0 ? decorationOffset.dy : null,
width: decorationSize.width,
height: decorationSize.height,
child: child,
);
}
List<Widget> _buildTiles() {
if (!_isTilingInitialized) return [];
final displayWidth = _displaySize.width.round();
final displayHeight = _displaySize.height.round();
final viewRect = _getViewRect(displayWidth, displayHeight);
final magnifierScale = viewState.scale!;
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
// for the largest sample size (matching the initial scale), the whole image is in view
// so we subsample the whole image without tiling
final fullImageRegionTile = _RegionTile(
entry: entry,
tileRect: Rect.fromLTWH(0, 0, displayWidth * magnifierScale, displayHeight * magnifierScale),
regionRect: entry.fullImageRegion,
sampleSize: _maxSampleSize,
quality: _qualityForScaleAndSize(
magnifierScale: magnifierScale,
sampleSize: _maxSampleSize,
devicePixelRatio: devicePixelRatio,
),
);
final tiles = [fullImageRegionTile];
final minSampleSize = min(ExtraAvesEntryImages.sampleSizeForScale(magnifierScale: magnifierScale, devicePixelRatio: devicePixelRatio), _maxSampleSize);
int nextSampleSize(int sampleSize) => (sampleSize / 2).floor();
for (var sampleSize = nextSampleSize(_maxSampleSize); sampleSize >= minSampleSize; sampleSize = nextSampleSize(sampleSize)) {
final regionSide = (_tileSide * sampleSize).round();
for (var x = 0; x < displayWidth; x += regionSide) {
for (var y = 0; y < displayHeight; y += regionSide) {
final rects = _getTileRects(
x: x,
y: y,
regionSide: regionSide,
displayWidth: displayWidth,
displayHeight: displayHeight,
scale: magnifierScale,
viewRect: viewRect,
);
if (rects != null) {
final (tileRect, regionRect) = rects;
tiles.add(
_RegionTile(
entry: entry,
tileRect: tileRect,
regionRect: regionRect,
sampleSize: sampleSize,
quality: _qualityForScaleAndSize(
magnifierScale: magnifierScale,
sampleSize: sampleSize,
devicePixelRatio: devicePixelRatio,
),
),
);
}
}
}
}
return tiles;
}
Rect _getViewRect(int displayWidth, int displayHeight) {
final scale = viewState.scale!;
final centerOffset = viewState.position;
final viewportSize = viewState.viewportSize!;
final viewOrigin = Offset(
((displayWidth * scale - viewportSize.width) / 2 - centerOffset.dx),
((displayHeight * scale - viewportSize.height) / 2 - centerOffset.dy),
);
return viewOrigin & viewportSize;
}
(Rect tileRect, Rectangle<num> regionRect)? _getTileRects({
required int x,
required int y,
required int regionSide,
required int displayWidth,
required int displayHeight,
required double scale,
required Rect viewRect,
}) {
final nextX = x + regionSide;
final nextY = y + regionSide;
final thisRegionWidth = regionSide - (nextX >= displayWidth ? nextX - displayWidth : 0);
final thisRegionHeight = regionSide - (nextY >= displayHeight ? nextY - displayHeight : 0);
final tileRect = Rect.fromLTWH(x * scale, y * scale, thisRegionWidth * scale, thisRegionHeight * scale);
// only build visible tiles
if (!viewRect.overlaps(tileRect)) return null;
Rectangle<num> regionRect;
if (_tileTransform != null) {
// apply EXIF orientation
final regionRectDouble = Rect.fromLTWH(x.toDouble(), y.toDouble(), thisRegionWidth.toDouble(), thisRegionHeight.toDouble());
final tl = MatrixUtils.transformPoint(_tileTransform!, regionRectDouble.topLeft);
final br = MatrixUtils.transformPoint(_tileTransform!, regionRectDouble.bottomRight);
regionRect = Rectangle<double>.fromPoints(
Point<double>(tl.dx, tl.dy),
Point<double>(br.dx, br.dy),
);
} else {
regionRect = Rectangle<num>(x, y, thisRegionWidth, thisRegionHeight);
}
return (tileRect, regionRect);
}
// follow recommended thresholds from `FilterQuality` documentation
static FilterQuality _qualityForScale({
required double magnifierScale,
required int sampleSize,
required double devicePixelRatio,
}) {
final entryScale = magnifierScale * devicePixelRatio;
final renderingScale = entryScale * sampleSize;
if (renderingScale > 1) {
return renderingScale > 10 ? FilterQuality.high : FilterQuality.medium;
} else {
return renderingScale < .5 ? FilterQuality.medium : FilterQuality.high;
}
}
// usually follow recommendations, except for small images
// (like icons, pixel art, etc.) for which the "nearest neighbor" algorithm is used
FilterQuality _qualityForScaleAndSize({
required double magnifierScale,
required int sampleSize,
required double devicePixelRatio,
}) {
if (_displaySize.longestSide < _pixelArtMaxSize) {
return FilterQuality.none;
}
return _qualityForScale(
magnifierScale: magnifierScale,
sampleSize: sampleSize,
devicePixelRatio: devicePixelRatio,
);
}
}
class _RegionTile extends StatefulWidget {
final AvesEntry entry;
// `tileRect` uses Flutter view coordinates
// `regionRect` uses the raw image pixel coordinates
final Rect tileRect;
final Rectangle<num> regionRect;
final int sampleSize;
final FilterQuality quality;
const _RegionTile({
required this.entry,
required this.tileRect,
required this.regionRect,
required this.sampleSize,
required this.quality,
});
@override
State<_RegionTile> createState() => _RegionTileState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(IntProperty('id', entry.id));
properties.add(IntProperty('contentId', entry.contentId));
properties.add(DiagnosticsProperty<Rect>('tileRect', tileRect));
properties.add(DiagnosticsProperty<Rectangle<num>>('regionRect', regionRect));
properties.add(IntProperty('sampleSize', sampleSize));
}
}
class _RegionTileState extends State<_RegionTile> {
late RegionProvider _provider;
AvesEntry get entry => widget.entry;
@override
void initState() {
super.initState();
_registerWidget(widget);
}
@override
void didUpdateWidget(covariant _RegionTile oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.entry != widget.entry || oldWidget.tileRect != widget.tileRect || oldWidget.sampleSize != widget.sampleSize || oldWidget.sampleSize != widget.sampleSize) {
_unregisterWidget(oldWidget);
_registerWidget(widget);
}
}
@override
void dispose() {
_unregisterWidget(widget);
super.dispose();
}
void _registerWidget(_RegionTile widget) {
_initProvider();
}
void _unregisterWidget(_RegionTile widget) {
_pauseProvider();
}
void _initProvider() {
_provider = entry.getRegion(
sampleSize: widget.sampleSize,
region: widget.regionRect,
);
}
void _pauseProvider() => _provider.pause();
@override
Widget build(BuildContext context) {
final tileRect = widget.tileRect;
Widget child = Image(
image: _provider,
width: tileRect.width,
height: tileRect.height,
fit: BoxFit.fill,
filterQuality: widget.quality,
);
// apply EXIF orientation
final quarterTurns = entry.rotationDegrees ~/ 90;
if (entry.isFlipped) {
final rotated = quarterTurns % 2 != 0;
final w = (rotated ? tileRect.height : tileRect.width) / 2.0;
final h = (rotated ? tileRect.width : tileRect.height) / 2.0;
final flipper = Matrix4.identity()
..translateByDouble(w, h, 0, 1)
..scaleByDouble(-1.0, 1.0, 1.0, 1.0)
..translateByDouble(-w, -h, 0, 1);
child = Transform(
transform: flipper,
child: child,
);
}
if (quarterTurns != 0) {
child = RotatedBox(
quarterTurns: quarterTurns,
child: child,
);
}
if (settings.debugShowViewerTiles) {
final regionRect = widget.regionRect;
child = Stack(
children: [
Positioned.fill(child: child),
Text(
'\ntile=(${tileRect.left.round()}, ${tileRect.top.round()}) ${tileRect.width.round()} x ${tileRect.height.round()}'
'\nregion=(${regionRect.left.round()}, ${regionRect.top.round()}) ${regionRect.width.round()} x ${regionRect.height.round()}'
'\nsampling=${widget.sampleSize} quality=${widget.quality.name}',
style: const TextStyle(backgroundColor: Colors.black87),
),
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: Colors.red, width: 1),
),
),
),
],
);
}
return Positioned.fromRect(
rect: tileRect,
child: child,
);
}
}