f1
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:
Fabio Micheluz 2026-02-28 13:42:46 +01:00
parent 2c988f959b
commit 452c378178
13 changed files with 1841 additions and 43 deletions

View file

@ -1,3 +1,4 @@
// lib/model/db/db_sqflite.dart
import 'dart:io'; import 'dart:io';
import 'package:aves/model/covers.dart'; import 'package:aves/model/covers.dart';
@ -17,6 +18,8 @@ import 'package:aves/services/common/services.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
// --- import per la sync remota (POC) ---
import 'package:aves/remote/run_remote_sync.dart' as remote;
class SqfliteLocalMediaDb implements LocalMediaDb { class SqfliteLocalMediaDb implements LocalMediaDb {
late Database _db; late Database _db;
@ -53,6 +56,18 @@ class SqfliteLocalMediaDb implements LocalMediaDb {
final maxIdRows = await _db.rawQuery('SELECT MAX(id) AS maxId FROM $entryTable'); final maxIdRows = await _db.rawQuery('SELECT MAX(id) AS maxId FROM $entryTable');
_lastId = (maxIdRows.firstOrNull?['maxId'] as int?) ?? 0; _lastId = (maxIdRows.firstOrNull?['maxId'] as int?) ?? 0;
// --- Sync remota (SOLO in DEBUG) ---------------------------------------
// Lancia una sincronizzazione remota "una tantum" all'avvio,
// senza bloccare lo start dell'app.
if (kDebugMode) {
// ignore: unawaited_futures
remote.runRemoteSyncOnce(
db: _db,
baseUrl: 'https://prova.patachina.it',
indexPath: 'photos', // <<< se diverso, imposta il tuo path qui
);
}
} }
@override @override

View file

@ -0,0 +1,704 @@
import 'dart:io';
import 'package:aves/model/covers.dart';
import 'package:aves/model/db/db.dart';
import 'package:aves/model/db/db_sqflite_schema.dart';
import 'package:aves/model/db/db_sqflite_upgrade.dart';
import 'package:aves/model/dynamic_albums.dart';
import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/favourites.dart';
import 'package:aves/model/filters/filters.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/model/vaults/details.dart';
import 'package:aves/model/viewer/video_playback.dart';
import 'package:aves/services/common/services.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:sqflite/sqflite.dart';
class SqfliteLocalMediaDb implements LocalMediaDb {
late Database _db;
@override
Future<String> get path async => pContext.join(await getDatabasesPath(), 'metadata.db');
static const entryTable = SqfliteLocalMediaDbSchema.entryTable;
static const dateTakenTable = SqfliteLocalMediaDbSchema.dateTakenTable;
static const metadataTable = SqfliteLocalMediaDbSchema.metadataTable;
static const addressTable = SqfliteLocalMediaDbSchema.addressTable;
static const favouriteTable = SqfliteLocalMediaDbSchema.favouriteTable;
static const coverTable = SqfliteLocalMediaDbSchema.coverTable;
static const dynamicAlbumTable = SqfliteLocalMediaDbSchema.dynamicAlbumTable;
static const vaultTable = SqfliteLocalMediaDbSchema.vaultTable;
static const trashTable = SqfliteLocalMediaDbSchema.trashTable;
static const videoPlaybackTable = SqfliteLocalMediaDbSchema.videoPlaybackTable;
static const _entryInsertSliceMaxCount = 10000; // number of entries
static const _queryCursorBufferSize = 1000; // number of rows
static int _lastId = 0;
@override
int get nextId => ++_lastId;
@override
Future<void> init() async {
_db = await openDatabase(
await path,
onCreate: (db, version) => SqfliteLocalMediaDbSchema.createLatestVersion(db),
onUpgrade: LocalMediaDbUpgrader.upgradeDb,
version: 15,
);
final maxIdRows = await _db.rawQuery('SELECT MAX(id) AS maxId FROM $entryTable');
_lastId = (maxIdRows.firstOrNull?['maxId'] as int?) ?? 0;
}
@override
Future<int> dbFileSize() async {
final file = File(await path);
return await file.exists() ? await file.length() : 0;
}
@override
Future<void> reset() async {
debugPrint('$runtimeType reset');
await _db.close();
await deleteDatabase(await path);
await init();
}
@override
Future<void> removeIds(Set<int> ids, {Set<EntryDataType>? dataTypes}) async {
if (ids.isEmpty) return;
final _dataTypes = dataTypes ?? EntryDataType.values.toSet();
// using array in `whereArgs` and using it with `where arg IN ?` is a pain, so we prefer `batch` instead
final batch = _db.batch();
const where = 'id = ?';
const coverWhere = 'entryId = ?';
ids.forEach((id) {
final whereArgs = [id];
if (_dataTypes.contains(EntryDataType.basic)) {
batch.delete(entryTable, where: where, whereArgs: whereArgs);
}
if (_dataTypes.contains(EntryDataType.catalog)) {
batch.delete(dateTakenTable, where: where, whereArgs: whereArgs);
batch.delete(metadataTable, where: where, whereArgs: whereArgs);
}
if (_dataTypes.contains(EntryDataType.address)) {
batch.delete(addressTable, where: where, whereArgs: whereArgs);
}
if (_dataTypes.contains(EntryDataType.references)) {
batch.delete(favouriteTable, where: where, whereArgs: whereArgs);
batch.delete(coverTable, where: coverWhere, whereArgs: whereArgs);
batch.delete(trashTable, where: where, whereArgs: whereArgs);
batch.delete(videoPlaybackTable, where: where, whereArgs: whereArgs);
}
});
await batch.commit(noResult: true);
}
// entries
@override
Future<void> clearEntries() async {
final count = await _db.delete(entryTable, where: '1');
debugPrint('$runtimeType clearEntries deleted $count rows');
}
@override
Future<Set<AvesEntry>> loadEntries({int? origin, String? directory}) async {
String? where;
final whereArgs = <Object?>[];
if (origin != null) {
where = 'origin = ?';
whereArgs.add(origin);
}
final entries = <AvesEntry>{};
if (directory != null) {
final separator = pContext.separator;
if (!directory.endsWith(separator)) {
directory = '$directory$separator';
}
where = '${where != null ? '$where AND ' : ''}path LIKE ?';
whereArgs.add('$directory%');
final cursor = await _db.queryCursor(entryTable, where: where, whereArgs: whereArgs, bufferSize: _queryCursorBufferSize);
final dirLength = directory.length;
while (await cursor.moveNext()) {
final row = cursor.current;
// skip entries in subfolders
final path = row['path'] as String?;
if (path != null && !path.substring(dirLength).contains(separator)) {
entries.add(AvesEntry.fromMap(row));
}
}
} else {
final cursor = await _db.queryCursor(entryTable, where: where, whereArgs: whereArgs, bufferSize: _queryCursorBufferSize);
while (await cursor.moveNext()) {
entries.add(AvesEntry.fromMap(cursor.current));
}
}
return entries;
}
@override
Future<Set<AvesEntry>> loadEntriesById(Set<int> ids) => _getByIds(ids, entryTable, AvesEntry.fromMap);
@override
Future<void> insertEntries(Set<AvesEntry> entries) async {
if (entries.isEmpty) return;
final stopwatch = Stopwatch()..start();
// slice entries to avoid memory issues
int inserted = 0;
await Future.forEach(entries.slices(_entryInsertSliceMaxCount), (slice) async {
debugPrint('$runtimeType saveEntries inserting slice of [${inserted + 1}, ${inserted + slice.length}] entries');
final batch = _db.batch();
slice.forEach((entry) => _batchInsertEntry(batch, entry));
await batch.commit(noResult: true);
inserted += slice.length;
});
debugPrint('$runtimeType saveEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries');
}
@override
Future<void> updateEntry(int id, AvesEntry entry) async {
final batch = _db.batch();
batch.delete(entryTable, where: 'id = ?', whereArgs: [id]);
_batchInsertEntry(batch, entry);
await batch.commit(noResult: true);
}
void _batchInsertEntry(Batch batch, AvesEntry entry) {
batch.insert(
entryTable,
entry.toDatabaseMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
@override
Future<Set<AvesEntry>> searchLiveEntries(String query, {int? limit}) async {
final rows = await _db.query(
entryTable,
where: '(title LIKE ? OR path LIKE ?) AND trashed = ?',
whereArgs: ['%$query%', '%$query%', 0],
orderBy: 'sourceDateTakenMillis DESC',
limit: limit,
);
return rows.map(AvesEntry.fromMap).toSet();
}
@override
Future<Set<AvesEntry>> searchLiveDuplicates(int origin, Set<AvesEntry>? entries) async {
String where = 'origin = ? AND trashed = ?';
if (entries != null) {
where += ' AND contentId IN (${entries.map((v) => v.contentId).join(',')})';
}
final rows = await _db.rawQuery(
'SELECT *, MAX(id) AS id'
' FROM $entryTable'
' WHERE $where'
' GROUP BY contentId'
' HAVING COUNT(id) > 1',
[origin, 0],
);
final duplicates = rows.map(AvesEntry.fromMap).toSet();
if (duplicates.isNotEmpty) {
debugPrint('$runtimeType found duplicates=$duplicates');
}
// returns most recent duplicate for each duplicated content ID
return duplicates;
}
// date taken
@override
Future<void> clearDates() async {
final count = await _db.delete(dateTakenTable, where: '1');
debugPrint('$runtimeType clearDates deleted $count rows');
}
@override
Future<Map<int?, int?>> loadDates() async {
final result = <int?, int?>{};
final cursor = await _db.queryCursor(dateTakenTable, bufferSize: _queryCursorBufferSize);
while (await cursor.moveNext()) {
final row = cursor.current;
result[row['id'] as int] = row['dateMillis'] as int? ?? 0;
}
return result;
}
// catalog metadata
@override
Future<void> clearCatalogMetadata() async {
final count = await _db.delete(metadataTable, where: '1');
debugPrint('$runtimeType clearMetadataEntries deleted $count rows');
}
@override
Future<Set<CatalogMetadata>> loadCatalogMetadata() async {
final result = <CatalogMetadata>{};
final cursor = await _db.queryCursor(metadataTable, bufferSize: _queryCursorBufferSize);
while (await cursor.moveNext()) {
result.add(CatalogMetadata.fromMap(cursor.current));
}
return result;
}
@override
Future<Set<CatalogMetadata>> loadCatalogMetadataById(Set<int> ids) => _getByIds(ids, metadataTable, CatalogMetadata.fromMap);
@override
Future<void> saveCatalogMetadata(Set<CatalogMetadata> metadataEntries) async {
if (metadataEntries.isEmpty) return;
final stopwatch = Stopwatch()..start();
try {
final batch = _db.batch();
metadataEntries.forEach((metadata) => _batchInsertMetadata(batch, metadata));
await batch.commit(noResult: true);
debugPrint('$runtimeType saveMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries');
} catch (error, stack) {
debugPrint('$runtimeType failed to save metadata with error=$error\n$stack');
}
}
@override
Future<void> updateCatalogMetadata(int id, CatalogMetadata? metadata) async {
final batch = _db.batch();
batch.delete(dateTakenTable, where: 'id = ?', whereArgs: [id]);
batch.delete(metadataTable, where: 'id = ?', whereArgs: [id]);
_batchInsertMetadata(batch, metadata);
await batch.commit(noResult: true);
}
void _batchInsertMetadata(Batch batch, CatalogMetadata? metadata) {
if (metadata == null) return;
if (metadata.dateMillis != 0) {
batch.insert(
dateTakenTable,
{
'id': metadata.id,
'dateMillis': metadata.dateMillis,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
batch.insert(
metadataTable,
metadata.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
// address
@override
Future<void> clearAddresses() async {
final count = await _db.delete(addressTable, where: '1');
debugPrint('$runtimeType clearAddresses deleted $count rows');
}
@override
Future<Set<AddressDetails>> loadAddresses() async {
final result = <AddressDetails>{};
final cursor = await _db.queryCursor(addressTable, bufferSize: _queryCursorBufferSize);
while (await cursor.moveNext()) {
result.add(AddressDetails.fromMap(cursor.current));
}
return result;
}
@override
Future<Set<AddressDetails>> loadAddressesById(Set<int> ids) => _getByIds(ids, addressTable, AddressDetails.fromMap);
@override
Future<void> saveAddresses(Set<AddressDetails> addresses) async {
if (addresses.isEmpty) return;
final stopwatch = Stopwatch()..start();
final batch = _db.batch();
addresses.forEach((address) => _batchInsertAddress(batch, address));
await batch.commit(noResult: true);
debugPrint('$runtimeType saveAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${addresses.length} entries');
}
@override
Future<void> updateAddress(int id, AddressDetails? address) async {
final batch = _db.batch();
batch.delete(addressTable, where: 'id = ?', whereArgs: [id]);
_batchInsertAddress(batch, address);
await batch.commit(noResult: true);
}
void _batchInsertAddress(Batch batch, AddressDetails? address) {
if (address == null) return;
batch.insert(
addressTable,
address.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
// vaults
@override
Future<void> clearVaults() async {
final count = await _db.delete(vaultTable, where: '1');
debugPrint('$runtimeType clearVaults deleted $count rows');
}
@override
Future<Set<VaultDetails>> loadAllVaults() async {
final result = <VaultDetails>{};
final cursor = await _db.queryCursor(vaultTable, bufferSize: _queryCursorBufferSize);
while (await cursor.moveNext()) {
result.add(VaultDetails.fromMap(cursor.current));
}
return result;
}
@override
Future<void> addVaults(Set<VaultDetails> rows) async {
if (rows.isEmpty) return;
final batch = _db.batch();
rows.forEach((row) => _batchInsertVault(batch, row));
await batch.commit(noResult: true);
}
@override
Future<void> updateVault(String oldName, VaultDetails row) async {
final batch = _db.batch();
batch.delete(vaultTable, where: 'name = ?', whereArgs: [oldName]);
_batchInsertVault(batch, row);
await batch.commit(noResult: true);
}
void _batchInsertVault(Batch batch, VaultDetails row) {
batch.insert(
vaultTable,
row.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
@override
Future<void> removeVaults(Set<VaultDetails> rows) async {
if (rows.isEmpty) return;
// using array in `whereArgs` and using it with `where arg IN ?` is a pain, so we prefer `batch` instead
final batch = _db.batch();
rows.map((v) => v.name).forEach((name) => batch.delete(vaultTable, where: 'name = ?', whereArgs: [name]));
await batch.commit(noResult: true);
}
// trash
@override
Future<void> clearTrashDetails() async {
final count = await _db.delete(trashTable, where: '1');
debugPrint('$runtimeType clearTrashDetails deleted $count rows');
}
@override
Future<Set<TrashDetails>> loadAllTrashDetails() async {
final result = <TrashDetails>{};
final cursor = await _db.queryCursor(trashTable, bufferSize: _queryCursorBufferSize);
while (await cursor.moveNext()) {
result.add(TrashDetails.fromMap(cursor.current));
}
return result;
}
@override
Future<void> updateTrash(int id, TrashDetails? details) async {
final batch = _db.batch();
batch.delete(trashTable, where: 'id = ?', whereArgs: [id]);
_batchInsertTrashDetails(batch, details);
await batch.commit(noResult: true);
}
void _batchInsertTrashDetails(Batch batch, TrashDetails? details) {
if (details == null) return;
batch.insert(
trashTable,
details.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
// favourites
@override
Future<void> clearFavourites() async {
final count = await _db.delete(favouriteTable, where: '1');
debugPrint('$runtimeType clearFavourites deleted $count rows');
}
@override
Future<Set<FavouriteRow>> loadAllFavourites() async {
final result = <FavouriteRow>{};
final cursor = await _db.queryCursor(favouriteTable, bufferSize: _queryCursorBufferSize);
while (await cursor.moveNext()) {
result.add(FavouriteRow.fromMap(cursor.current));
}
return result;
}
@override
Future<void> addFavourites(Set<FavouriteRow> rows) async {
if (rows.isEmpty) return;
final batch = _db.batch();
rows.forEach((row) => _batchInsertFavourite(batch, row));
await batch.commit(noResult: true);
}
@override
Future<void> updateFavouriteId(int id, FavouriteRow row) async {
final batch = _db.batch();
batch.delete(favouriteTable, where: 'id = ?', whereArgs: [id]);
_batchInsertFavourite(batch, row);
await batch.commit(noResult: true);
}
void _batchInsertFavourite(Batch batch, FavouriteRow row) {
batch.insert(
favouriteTable,
row.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
@override
Future<void> removeFavourites(Set<FavouriteRow> rows) async {
if (rows.isEmpty) return;
final ids = rows.map((row) => row.entryId);
if (ids.isEmpty) return;
// using array in `whereArgs` and using it with `where arg IN ?` is a pain, so we prefer `batch` instead
final batch = _db.batch();
ids.forEach((id) => batch.delete(favouriteTable, where: 'id = ?', whereArgs: [id]));
await batch.commit(noResult: true);
}
// covers
@override
Future<void> clearCovers() async {
final count = await _db.delete(coverTable, where: '1');
debugPrint('$runtimeType clearCovers deleted $count rows');
}
@override
Future<Set<CoverRow>> loadAllCovers() async {
final result = <CoverRow>{};
final cursor = await _db.queryCursor(coverTable, bufferSize: _queryCursorBufferSize);
while (await cursor.moveNext()) {
final rowMap = cursor.current;
final row = CoverRow.fromMap(rowMap);
if (row != null) {
result.add(row);
} else {
debugPrint('$runtimeType failed to deserialize cover from row=$rowMap');
}
}
return result;
}
@override
Future<void> addCovers(Set<CoverRow> rows) async {
if (rows.isEmpty) return;
final batch = _db.batch();
rows.forEach((row) => _batchInsertCover(batch, row));
await batch.commit(noResult: true);
}
@override
Future<void> updateCoverEntryId(int id, CoverRow row) async {
final batch = _db.batch();
batch.delete(coverTable, where: 'entryId = ?', whereArgs: [id]);
_batchInsertCover(batch, row);
await batch.commit(noResult: true);
}
void _batchInsertCover(Batch batch, CoverRow row) {
batch.insert(
coverTable,
row.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
@override
Future<void> removeCovers(Set<CollectionFilter> filters) async {
if (filters.isEmpty) return;
// for backward compatibility, remove stored JSON instead of removing de/reserialized filters
final obsoleteFilterJson = <String>{};
final rows = await _db.query(coverTable);
rows.forEach((row) {
final filterJson = row['filter'] as String?;
if (filterJson != null) {
final filter = CollectionFilter.fromJson(filterJson);
if (filters.any((v) => filter == v)) {
obsoleteFilterJson.add(filterJson);
}
}
});
// using array in `whereArgs` and using it with `where arg IN ?` is a pain, so we prefer `batch` instead
final batch = _db.batch();
obsoleteFilterJson.forEach((filterJson) => batch.delete(coverTable, where: 'filter = ?', whereArgs: [filterJson]));
await batch.commit(noResult: true);
}
// dynamic albums
@override
Future<int> clearDynamicAlbums() async {
final count = await _db.delete(dynamicAlbumTable, where: '1');
debugPrint('$runtimeType clearDynamicAlbums deleted $count rows');
return count;
}
@override
Future<Set<DynamicAlbumRow>> loadAllDynamicAlbums({int bufferSize = _queryCursorBufferSize}) async {
final result = <DynamicAlbumRow>{};
try {
final cursor = await _db.queryCursor(dynamicAlbumTable, bufferSize: bufferSize);
while (await cursor.moveNext()) {
final rowMap = cursor.current;
final row = DynamicAlbumRow.fromMap(rowMap);
if (row != null) {
result.add(row);
} else {
debugPrint('$runtimeType failed to deserialize dynamic album from row=$rowMap');
}
}
} catch (error, stack) {
debugPrint('$runtimeType failed to query table=$dynamicAlbumTable error=$error\n$stack');
if (bufferSize > 1) {
// a large row may prevent reading from the table because of cursor window size limit,
// so we retry without buffer to read as many rows as we can, and removing the others
debugPrint('$runtimeType retry to query table=$dynamicAlbumTable with no cursor buffer');
final safeRows = await loadAllDynamicAlbums(bufferSize: 1);
final clearedCount = await clearDynamicAlbums();
await addDynamicAlbums(safeRows);
final addedCount = safeRows.length;
final lostCount = clearedCount - addedCount;
debugPrint('$runtimeType kept $addedCount rows, lost $lostCount rows from table=$dynamicAlbumTable');
return safeRows;
}
}
return result;
}
@override
Future<void> addDynamicAlbums(Set<DynamicAlbumRow> rows) async {
if (rows.isEmpty) return;
final batch = _db.batch();
rows.forEach((row) => _batchInsertDynamicAlbum(batch, row));
await batch.commit(noResult: true);
}
void _batchInsertDynamicAlbum(Batch batch, DynamicAlbumRow row) {
batch.insert(
dynamicAlbumTable,
row.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
@override
Future<void> removeDynamicAlbums(Set<String> names) async {
if (names.isEmpty) return;
// using array in `whereArgs` and using it with `where arg IN ?` is a pain, so we prefer `batch` instead
final batch = _db.batch();
names.forEach((name) => batch.delete(dynamicAlbumTable, where: 'name = ?', whereArgs: [name]));
await batch.commit(noResult: true);
}
// video playback
@override
Future<void> clearVideoPlayback() async {
final count = await _db.delete(videoPlaybackTable, where: '1');
debugPrint('$runtimeType clearVideoPlayback deleted $count rows');
}
@override
Future<Set<VideoPlaybackRow>> loadAllVideoPlayback() async {
final result = <VideoPlaybackRow>{};
final cursor = await _db.queryCursor(videoPlaybackTable, bufferSize: _queryCursorBufferSize);
while (await cursor.moveNext()) {
final rowMap = cursor.current;
final row = VideoPlaybackRow.fromMap(rowMap);
if (row != null) {
result.add(row);
} else {
debugPrint('$runtimeType failed to deserialize video playback from row=$rowMap');
}
}
return result;
}
@override
Future<VideoPlaybackRow?> loadVideoPlayback(int id) async {
final rows = await _db.query(videoPlaybackTable, where: 'id = ?', whereArgs: [id]);
if (rows.isEmpty) return null;
return VideoPlaybackRow.fromMap(rows.first);
}
@override
Future<void> addVideoPlayback(Set<VideoPlaybackRow> rows) async {
if (rows.isEmpty) return;
final batch = _db.batch();
rows.forEach((row) => _batchInsertVideoPlayback(batch, row));
await batch.commit(noResult: true);
}
void _batchInsertVideoPlayback(Batch batch, VideoPlaybackRow row) {
batch.insert(
videoPlaybackTable,
row.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
@override
Future<void> removeVideoPlayback(Set<int> ids) async {
if (ids.isEmpty) return;
// using array in `whereArgs` and using it with `where arg IN ?` is a pain, so we prefer `batch` instead
final batch = _db.batch();
ids.forEach((id) => batch.delete(videoPlaybackTable, where: 'id = ?', whereArgs: [id]));
await batch.commit(noResult: true);
}
// convenience methods
Future<Set<T>> _getByIds<T>(Set<int> ids, String table, T Function(Map<String, Object?> row) mapRow) async {
final result = <T>{};
if (ids.isNotEmpty) {
final cursor = await _db.queryCursor(table, where: 'id IN (${ids.join(',')})', bufferSize: _queryCursorBufferSize);
while (await cursor.moveNext()) {
result.add(mapRow(cursor.current));
}
}
return result;
}
}

View file

@ -29,10 +29,12 @@ class SqfliteLocalMediaDbSchema {
await Future.forEach(allTables, (table) => createTable(db, table)); await Future.forEach(allTables, (table) => createTable(db, table));
} }
static Future<void> createTable(Database db, String table) { // Resa async per poter eseguire più statement per tabella (es. indici).
static Future<void> createTable(Database db, String table) async {
switch (table) { switch (table) {
case entryTable: case entryTable:
return db.execute( // Tabella 'entry' con i nuovi campi per la sorgente remota
await db.execute(
'CREATE TABLE $entryTable(' 'CREATE TABLE $entryTable('
'id INTEGER PRIMARY KEY' 'id INTEGER PRIMARY KEY'
', contentId INTEGER' ', contentId INTEGER'
@ -50,17 +52,31 @@ class SqfliteLocalMediaDbSchema {
', durationMillis INTEGER' ', durationMillis INTEGER'
', trashed INTEGER DEFAULT 0' ', trashed INTEGER DEFAULT 0'
', origin INTEGER DEFAULT 0' ', origin INTEGER DEFAULT 0'
// --- campi per la sorgente remota ---
', provider TEXT' // es. "json@patachina"
', remoteId TEXT' // es. sha256 del path relativo o id server
', remotePath TEXT' // es. "photos/original/.../file.jpg"
', remoteThumb1 TEXT' // es. "photos/thumbs/..._min.jpg"
', remoteThumb2 TEXT' // es. "photos/thumbs/..._avg.jpg"
')', ')',
); );
// Indici utili per query e merge locale+remoto
await db.execute('CREATE INDEX IF NOT EXISTS idx_entry_origin ON $entryTable(origin);');
await db.execute('CREATE INDEX IF NOT EXISTS idx_entry_remoteId ON $entryTable(remoteId);');
return;
case dateTakenTable: case dateTakenTable:
return db.execute( await db.execute(
'CREATE TABLE $dateTakenTable(' 'CREATE TABLE $dateTakenTable('
'id INTEGER PRIMARY KEY' 'id INTEGER PRIMARY KEY'
', dateMillis INTEGER' ', dateMillis INTEGER'
')', ')',
); );
return;
case metadataTable: case metadataTable:
return db.execute( await db.execute(
'CREATE TABLE $metadataTable(' 'CREATE TABLE $metadataTable('
'id INTEGER PRIMARY KEY' 'id INTEGER PRIMARY KEY'
', mimeType TEXT' ', mimeType TEXT'
@ -74,8 +90,10 @@ class SqfliteLocalMediaDbSchema {
', rating INTEGER' ', rating INTEGER'
')', ')',
); );
return;
case addressTable: case addressTable:
return db.execute( await db.execute(
'CREATE TABLE $addressTable(' 'CREATE TABLE $addressTable('
'id INTEGER PRIMARY KEY' 'id INTEGER PRIMARY KEY'
', addressLine TEXT' ', addressLine TEXT'
@ -85,14 +103,18 @@ class SqfliteLocalMediaDbSchema {
', locality TEXT' ', locality TEXT'
')', ')',
); );
return;
case favouriteTable: case favouriteTable:
return db.execute( await db.execute(
'CREATE TABLE $favouriteTable(' 'CREATE TABLE $favouriteTable('
'id INTEGER PRIMARY KEY' 'id INTEGER PRIMARY KEY'
')', ')',
); );
return;
case coverTable: case coverTable:
return db.execute( await db.execute(
'CREATE TABLE $coverTable(' 'CREATE TABLE $coverTable('
'filter TEXT PRIMARY KEY' 'filter TEXT PRIMARY KEY'
', entryId INTEGER' ', entryId INTEGER'
@ -100,15 +122,19 @@ class SqfliteLocalMediaDbSchema {
', color TEXT' ', color TEXT'
')', ')',
); );
return;
case dynamicAlbumTable: case dynamicAlbumTable:
return db.execute( await db.execute(
'CREATE TABLE $dynamicAlbumTable(' 'CREATE TABLE $dynamicAlbumTable('
'name TEXT PRIMARY KEY' 'name TEXT PRIMARY KEY'
', filter TEXT' ', filter TEXT'
')', ')',
); );
return;
case vaultTable: case vaultTable:
return db.execute( await db.execute(
'CREATE TABLE $vaultTable(' 'CREATE TABLE $vaultTable('
'name TEXT PRIMARY KEY' 'name TEXT PRIMARY KEY'
', autoLock INTEGER' ', autoLock INTEGER'
@ -116,21 +142,27 @@ class SqfliteLocalMediaDbSchema {
', lockType TEXT' ', lockType TEXT'
')', ')',
); );
return;
case trashTable: case trashTable:
return db.execute( await db.execute(
'CREATE TABLE $trashTable(' 'CREATE TABLE $trashTable('
'id INTEGER PRIMARY KEY' 'id INTEGER PRIMARY KEY'
', path TEXT' ', path TEXT'
', dateMillis INTEGER' ', dateMillis INTEGER'
')', ')',
); );
return;
case videoPlaybackTable: case videoPlaybackTable:
return db.execute( await db.execute(
'CREATE TABLE $videoPlaybackTable(' 'CREATE TABLE $videoPlaybackTable('
'id INTEGER PRIMARY KEY' 'id INTEGER PRIMARY KEY'
', resumeTimeMillis INTEGER' ', resumeTimeMillis INTEGER'
')', ')',
); );
return;
default: default:
throw Exception('unknown table=$table'); throw Exception('unknown table=$table');
} }

View file

@ -1,3 +1,4 @@
// lib/model/db/db_sqflite_upgrade.dart
import 'dart:ui'; import 'dart:ui';
import 'package:aves/model/covers.dart'; import 'package:aves/model/covers.dart';
@ -53,6 +54,9 @@ class LocalMediaDbUpgrader {
await _upgradeFrom13(db); await _upgradeFrom13(db);
case 14: case 14:
await _upgradeFrom14(db); await _upgradeFrom14(db);
// NEW: add remote-source columns & indexes
case 15:
await _upgradeFrom15(db);
} }
oldVersion++; oldVersion++;
} }
@ -245,15 +249,15 @@ class LocalMediaDbUpgrader {
', flags INTEGER' ', flags INTEGER'
', rotationDegrees INTEGER' ', rotationDegrees INTEGER'
', xmpSubjects TEXT' ', xmpSubjects TEXT'
', xmpTitleDescription TEXT' ', xmpTitle TEXT'
', latitude REAL' ', latitude REAL'
', longitude REAL' ', longitude REAL'
', rating INTEGER' ', rating INTEGER'
')', ')',
); );
await db.rawInsert( await db.rawInsert(
'INSERT INTO $newMetadataTable (id,mimeType,dateMillis,flags,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude,rating)' 'INSERT INTO $newMetadataTable (id,mimeType,dateMillis,flags,rotationDegrees,xmpSubjects,xmpTitle,latitude,longitude,rating)'
' SELECT contentId,mimeType,dateMillis,flags,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude,rating' ' SELECT contentId,mimeType,dateMillis,flags,rotationDegrees,xmpSubjects,xmpTitle,latitude,longitude,rating'
' FROM $metadataTable;', ' FROM $metadataTable;',
); );
await db.execute('DROP TABLE $metadataTable;'); await db.execute('DROP TABLE $metadataTable;');
@ -300,24 +304,6 @@ class LocalMediaDbUpgrader {
await db.execute('ALTER TABLE $newVideoPlaybackTable RENAME TO $videoPlaybackTable;'); await db.execute('ALTER TABLE $newVideoPlaybackTable RENAME TO $videoPlaybackTable;');
}); });
// rename column `contentId` to `id`
// remove column `path`
await db.transaction((txn) async {
const newFavouriteTable = '${favouriteTable}TEMP';
await db.execute(
'CREATE TABLE $newFavouriteTable ('
'id INTEGER PRIMARY KEY'
')',
);
await db.rawInsert(
'INSERT INTO $newFavouriteTable (id)'
' SELECT contentId'
' FROM $favouriteTable;',
);
await db.execute('DROP TABLE $favouriteTable;');
await db.execute('ALTER TABLE $newFavouriteTable RENAME TO $favouriteTable;');
});
// rename column `contentId` to `entryId` // rename column `contentId` to `entryId`
await db.transaction((txn) async { await db.transaction((txn) async {
const newCoverTable = '${coverTable}TEMP'; const newCoverTable = '${coverTable}TEMP';
@ -564,4 +550,40 @@ class LocalMediaDbUpgrader {
// (dateTakenTable, metadataTable, addressTable, trashTable, videoPlaybackTable) // (dateTakenTable, metadataTable, addressTable, trashTable, videoPlaybackTable)
// for users with a potentially corrupted DB following upgrade to v1.12.4 // for users with a potentially corrupted DB following upgrade to v1.12.4
} }
// ============================================================
// NEW: v15 - supporto sorgente remota (campi provider/remote*)
// ============================================================
static Future<void> _upgradeFrom15(Database db) async {
debugPrint('upgrading DB from v15 (remote source columns)');
// Controlla se una colonna esiste usando PRAGMA table_info
Future<bool> _hasColumn(String table, String column) async {
// Nota: non puoi parametrizzare il nome tabella con '?', quindi interpoliamo la costante.
final rows = await db.rawQuery('PRAGMA table_info($table)');
for (final row in rows) {
final name = row['name'] as String?;
if (name == column) return true;
}
return false;
}
// Aggiunge la colonna solo se mancante
Future<void> _addColumnIfMissing(String table, String col, String type) async {
if (!await _hasColumn(table, col)) {
await db.execute('ALTER TABLE $table ADD COLUMN $col $type;');
}
}
// --- entry: campi per la sorgente remota ---
await _addColumnIfMissing(entryTable, 'provider', 'TEXT'); // es. "json@patachina"
await _addColumnIfMissing(entryTable, 'remoteId', 'TEXT'); // sha256 path relativo o id server
await _addColumnIfMissing(entryTable, 'remotePath', 'TEXT'); // "photos/original/.../file.jpg"
await _addColumnIfMissing(entryTable, 'remoteThumb1', 'TEXT'); // "photos/thumbs/..._min.jpg"
await _addColumnIfMissing(entryTable, 'remoteThumb2', 'TEXT'); // "photos/thumbs/..._avg.jpg"
// Indici utili (idempotenti)
await db.execute('CREATE INDEX IF NOT EXISTS idx_entry_origin ON $entryTable(origin);');
await db.execute('CREATE INDEX IF NOT EXISTS idx_entry_remoteId ON $entryTable(remoteId);');
}
} }

View file

@ -0,0 +1,61 @@
// lib/remote/auth_client.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
class RemoteAuth {
final Uri base;
final String email;
final String password;
String? _token;
RemoteAuth({
required String baseUrl,
required this.email,
required this.password,
}) : base = Uri.parse(baseUrl.endsWith('/') ? baseUrl : '$baseUrl/');
Uri get _loginUri => base.resolve('auth/login');
/// Esegue il login e memorizza il token
Future<String> login() async {
print('POST URL: $_loginUri');
print('Body: ${json.encode({'email': email, 'password': password})}');
final res = await http.post(
_loginUri,
headers: {'Content-Type': 'application/json'},
body: json.encode({'email': email, 'password': password}),
).timeout(const Duration(seconds: 20));
print('Status: ${res.statusCode}');
print('Response: ${utf8.decode(res.bodyBytes)}');
if (res.statusCode != 200) {
throw Exception('Login fallito: HTTP ${res.statusCode} ${res.reasonPhrase}');
}
final map = json.decode(utf8.decode(res.bodyBytes)) as Map<String, dynamic>;
final token = map['token'] as String?;
if (token == null || token.isEmpty) {
throw Exception('Login fallito: token assente nella risposta');
}
_token = token;
return token;
}
/// Ritorna gli headers con Bearer, lanciando il login se necessario
Future<Map<String, String>> authHeaders() async {
_token ??= await login();
return {'Authorization': 'Bearer $_token'};
}
/// Invalida il token (es. dopo 401) e riesegue il login
Future<Map<String, String>> refreshAndHeaders() async {
_token = null;
return await authHeaders();
}
}

View file

@ -0,0 +1,77 @@
// lib/remote/remote_client.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'remote_models.dart';
import 'auth_client.dart';
class RemoteJsonClient {
final Uri indexUri; // es. https://prova.patachina.it/photos/
final RemoteAuth? auth; // opzionale: se presente, aggiunge Bearer
RemoteJsonClient(
String baseUrl,
String indexPath, {
this.auth,
}) : indexUri = Uri.parse(baseUrl.endsWith('/') ? baseUrl : '$baseUrl/')
.resolve(indexPath);
Future<List<RemotePhotoItem>> fetchAll() async {
Map<String, String> headers = {};
if (auth != null) {
headers = await auth!.authHeaders();
}
// DEBUG: stampa la URL precisa
// ignore: avoid_print
print('[remote-client] GET $indexUri');
http.Response res;
try {
res = await http.get(indexUri, headers: headers).timeout(const Duration(seconds: 20));
} catch (e) {
throw Exception('Errore rete su $indexUri: $e');
}
// Retry 1 volta in caso di 401 (token scaduto/invalidato)
if (res.statusCode == 401 && auth != null) {
headers = await auth!.refreshAndHeaders();
res = await http.get(indexUri, headers: headers).timeout(const Duration(seconds: 20));
}
// Follow 301/302 mantenendo Authorization
if ((res.statusCode == 301 || res.statusCode == 302) && res.headers['location'] != null) {
final loc = res.headers['location']!;
final redirectUri = indexUri.resolve(loc);
res = await http.get(redirectUri, headers: headers).timeout(const Duration(seconds: 20));
}
if (res.statusCode != 200) {
final snippet = utf8.decode(res.bodyBytes.take(200).toList());
throw Exception('HTTP ${res.statusCode} ${res.reasonPhrase} su $indexUri. Body: $snippet');
}
final body = utf8.decode(res.bodyBytes);
// DEBUG
print('DEBUG JSON: $body');
// Qui siamo espliciti: ci aspettiamo SEMPRE una lista top-level
final dynamic decoded = json.decode(body);
if (decoded is! List) {
throw Exception('JSON inatteso: atteso array top-level, ricevuto ${decoded.runtimeType}');
}
final List<dynamic> rawList = decoded;
// Costruiamo a mano la List<RemotePhotoItem>, tipizzata esplicitamente
final List<RemotePhotoItem> items = rawList.map<RemotePhotoItem>((e) {
if (e is! Map<String, dynamic>) {
throw Exception('Elemento JSON non è una mappa: ${e.runtimeType} -> $e');
}
return RemotePhotoItem.fromJson(e);
}).toList();
return items;
}
}

View file

@ -0,0 +1,121 @@
// lib/remote/remote_models.dart
class RemotePhotoItem {
final String id;
final String name;
final String path;
final String? thub1, thub2;
final String? mimeType;
final int? width, height, sizeBytes;
final DateTime? takenAtUtc;
final double? lat, lng, alt;
final String? dataExifLegacy;
final String? user;
final int? durationMillis;
final RemoteLocation? location;
RemotePhotoItem({
required this.id,
required this.name,
required this.path,
this.thub1,
this.thub2,
this.mimeType,
this.width,
this.height,
this.sizeBytes,
this.takenAtUtc,
this.lat,
this.lng,
this.alt,
this.dataExifLegacy,
this.user,
this.durationMillis,
this.location,
});
// URL completo costruito solo in fase di lettura
String get uri => "https://prova.patachina.it/$path";
static DateTime? _tryParseIsoUtc(dynamic v) {
if (v == null) return null;
try { return DateTime.parse(v.toString()).toUtc(); } catch (_) { return null; }
}
static double? _toDouble(dynamic v) {
if (v == null) return null;
if (v is num) return v.toDouble();
return double.tryParse(v.toString());
}
static int? _toMillis(dynamic v) {
if (v == null) return null;
final num? n = (v is num) ? v : num.tryParse(v.toString());
if (n == null) return null;
return n >= 1000 ? n.toInt() : (n * 1000).toInt();
}
factory RemotePhotoItem.fromJson(Map<String, dynamic> j) {
final gps = j['gps'] as Map<String, dynamic>?;
final loc = j['location'] is Map<String, dynamic>
? RemoteLocation.fromJson(j['location'] as Map<String, dynamic>)
: null;
return RemotePhotoItem(
id: (j['id'] ?? j['name']).toString(),
name: (j['name'] ?? '').toString(),
path: (j['path'] ?? '').toString(),
thub1: j['thub1']?.toString(),
thub2: j['thub2']?.toString(),
mimeType: j['mime_type']?.toString(),
width: (j['width'] as num?)?.toInt(),
height: (j['height'] as num?)?.toInt(),
sizeBytes: (j['size_bytes'] as num?)?.toInt(),
takenAtUtc: _tryParseIsoUtc(j['taken_at']),
dataExifLegacy: j['data']?.toString(),
lat: gps != null ? _toDouble(gps['lat']) : null,
lng: gps != null ? _toDouble(gps['lng']) : null,
alt: gps != null ? _toDouble(gps['alt']) : null,
user: j['user']?.toString(),
durationMillis: _toMillis(j['duration']),
location: loc,
);
}
}
class RemoteLocation {
final String? continent;
final String? country;
final String? region;
final String? postcode;
final String? city;
final String? countyCode;
final String? address;
final String? timezone;
final String? timeOffset;
RemoteLocation({
this.continent,
this.country,
this.region,
this.postcode,
this.city,
this.countyCode,
this.address,
this.timezone,
this.timeOffset,
});
factory RemoteLocation.fromJson(Map<String, dynamic> j) => RemoteLocation(
continent: j['continent']?.toString(),
country: j['country']?.toString(),
region: j['region']?.toString(),
postcode: j['postcode']?.toString(),
city: j['city']?.toString(),
countyCode:j['county_code']?.toString(),
address: j['address']?.toString(),
timezone: j['timezone']?.toString(),
timeOffset:j['time']?.toString(),
);
}

View file

@ -0,0 +1,78 @@
// lib/remote/remote_repository.dart
import 'package:sqflite/sqflite.dart';
import 'remote_models.dart';
class RemoteRepository {
final Database db;
RemoteRepository(this.db);
Future<void> upsertAll(List<RemotePhotoItem> items) async {
await db.transaction((txn) async {
for (final it in items) {
// cerca se esiste già una entry per quel remoteId
final existing = await txn.query(
'entry',
columns: ['id'],
where: 'remoteId = ?',
whereArgs: [it.id],
limit: 1,
);
final int? existingId = existing.isNotEmpty ? (existing.first['id'] as int?) : null;
final row = <String, Object?>{
'id': existingId, // se esiste sostituiamo, altrimenti INSERT nuovo
'contentId': null,
'uri': null,
'path': null,
'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, // <-- ora valorizzato anche per i video
'trashed': 0,
'origin': 1,
'provider': 'json@patachina',
'remoteId': it.id,
'remotePath': it.path,
'remoteThumb1': it.thub1,
'remoteThumb2': it.thub2,
};
// INSERT OR REPLACE (se 'id' è valorizzato, sostituisce; se null, crea nuovo)
final newId = await txn.insert(
'entry',
row,
conflictAlgorithm: ConflictAlgorithm.replace,
);
// opzionale: salva indirizzo (se il backend lo fornisce)
if (it.location != null) {
final addr = <String, Object?>{
'id': newId,
'addressLine': it.location!.address,
'countryCode': null, // county_code != country code
'countryName': it.location!.country,
'adminArea': it.location!.region,
'locality': it.location!.city,
};
await txn.insert(
'address',
addr,
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
}
});
}
Future<int> countRemote() async {
final rows = await db.rawQuery('SELECT COUNT(1) AS c FROM entry WHERE origin=1');
return (rows.first['c'] as int?) ?? 0;
}
}

View file

@ -0,0 +1,129 @@
// lib/remote/remote_test_page.dart
import 'package:flutter/material.dart';
import 'package:sqflite/sqflite.dart';
class RemoteTestPage extends StatefulWidget {
final Database db;
final String baseUrl; // es. https://prova.patachina.it
const RemoteTestPage({super.key, required this.db, required this.baseUrl});
@override
State<RemoteTestPage> createState() => _RemoteTestPageState();
}
class _RemoteTestPageState extends State<RemoteTestPage> {
late Future<List<_RemoteRow>> _future;
@override
void initState() {
super.initState();
_future = _load();
}
Future<List<_RemoteRow>> _load() async {
// prendi le prime 200 entry remote
final rows = await widget.db.rawQuery(
"SELECT id, title, remotePath, remoteThumb2 FROM entry WHERE origin=1 ORDER BY id DESC LIMIT 200",
);
return rows.map((r) => _RemoteRow(
id: r['id'] as int,
title: (r['title'] as String?) ?? '',
remotePath: r['remotePath'] as String?,
remoteThumb2: r['remoteThumb2'] as String?,
)).toList();
}
String _url(String? rel) {
if (rel == null || rel.isEmpty) return '';
var base = widget.baseUrl;
if (!base.endsWith('/')) base = '$base/';
final cleaned = rel.startsWith('/') ? rel.substring(1) : rel;
return '$base$cleaned';
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('[DEBUG] Remote Test')),
body: FutureBuilder<List<_RemoteRow>>(
future: _future,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return Center(child: Text('Errore: ${snap.error}'));
}
final items = snap.data ?? const <_RemoteRow>[];
if (items.isEmpty) {
return 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 thumbUrl = _url(it.remoteThumb2 ?? it.remotePath);
final fullUrl = _url(it.remotePath);
return GestureDetector(
onTap: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => _RemoteFullPage(title: it.title, url: fullUrl),
));
},
child: Hero(
tag: 'remote_${it.id}',
child: DecoratedBox(
decoration: BoxDecoration(border: Border.all(color: Colors.black12)),
child: thumbUrl.isEmpty
? const ColoredBox(color: Colors.black12)
: Image.network(
thumbUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const Center(child: Icon(Icons.broken_image)),
),
),
),
);
},
);
},
),
);
}
}
class _RemoteRow {
final int id;
final String title;
final String? remotePath;
final String? remoteThumb2;
_RemoteRow({required this.id, required this.title, this.remotePath, this.remoteThumb2});
}
class _RemoteFullPage extends StatelessWidget {
final String title;
final String url;
const _RemoteFullPage({super.key, required this.title, required this.url});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(title.isEmpty ? 'Remote' : title)),
body: Center(
child: url.isEmpty
? const Text('URL non valido')
: InteractiveViewer(
maxScale: 5,
child: Image.network(
url,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const Icon(Icons.broken_image, size: 64),
),
),
),
);
}
}

View file

@ -0,0 +1,39 @@
// lib/remote/run_remote_sync.dart
import 'package:sqflite/sqflite.dart';
import 'remote_client.dart';
import 'remote_repository.dart';
import 'auth_client.dart';
/// Esegue login, scarica /photos e fa upsert nel DB.
/// Configura qui baseUrl/indexPath/email/password (o spostali in Settings).
Future<void> runRemoteSyncOnce({
required Database db,
String baseUrl = 'https://prova.patachina.it',
String indexPath = 'photos/', // rotta protetta dal tuo server
String email = 'fabio@gmail.com', // TODO: passare da Settings/secure storage
String password = 'master66', // idem
}) async {
try {
final auth = RemoteAuth(baseUrl: baseUrl, email: email, password: password);
// login (facoltativo: RemoteJsonClient lo chiamerebbe on-demand)
await auth.login();
final client = RemoteJsonClient(baseUrl, indexPath, auth: auth);
final items = await client.fetchAll();
print("TIPO ITEMS: ${items.runtimeType}");
print("TIPO ELEMENTO 0: ${items.first.runtimeType}");
final repo = RemoteRepository(db);
await repo.upsertAll(items);
final c = await repo.countRemote();
// ignore: avoid_print
print('[remote-sync] importati remoti: $c (base=$baseUrl, index=$indexPath)');
} catch (e, st) {
// ignore: avoid_print
print('[remote-sync][ERROR] $e\n$st');
rethrow;
}
}

View file

@ -0,0 +1,7 @@
// lib/remote/url_utils.dart
Uri buildAbsoluteUri(String baseUrl, String relativePath) {
final base = Uri.parse(baseUrl.endsWith('/') ? baseUrl : '$baseUrl/');
final cleaned = relativePath.startsWith('/') ? relativePath.substring(1) : relativePath;
final segments = cleaned.split('/').where((s) => s.isNotEmpty).toList();
return base.replace(pathSegments: [...base.pathSegments, ...segments]);
}

View file

@ -1,3 +1,4 @@
// lib/widgets/home/home_page.dart
import 'dart:async'; import 'dart:async';
import 'package:aves/app_mode.dart'; import 'package:aves/app_mode.dart';
@ -45,6 +46,12 @@ import 'package:latlong2/latlong.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
// --- IMPORT per debug page remota ---
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart' as p;
import 'package:aves/remote/remote_test_page.dart';
class HomePage extends StatefulWidget { class HomePage extends StatefulWidget {
static const routeName = '/'; static const routeName = '/';
@ -87,13 +94,13 @@ class _HomePageState extends State<HomePage> {
@override @override
Widget build(BuildContext context) => AvesScaffold( Widget build(BuildContext context) => AvesScaffold(
body: _setupError != null body: _setupError != null
? HomeError( ? HomeError(
error: _setupError!.$1, error: _setupError!.$1,
stack: _setupError!.$2, stack: _setupError!.$2,
) )
: null, : null,
); );
Future<void> _setup() async { Future<void> _setup() async {
try { try {
@ -301,6 +308,66 @@ class _HomePageState extends State<HomePage> {
return entry; return entry;
} }
// --- DEBUG: apre la pagina di test remota con una seconda connessione al DB ---
// --- DEBUG: apre la pagina di test remota con una seconda connessione al DB ---
Future<void> _openRemoteTestPage(BuildContext context) async {
Database? debugDb;
try {
final dbDir = await getDatabasesPath();
final dbPath = p.join(dbDir, 'metadata.db');
// Apri il DB in sola lettura (evita lock e conflitti)
debugDb = await openDatabase(dbPath, readOnly: true);
if (!context.mounted) return;
// Base URL per i remote: se esiste in settings lo usa, altrimenti fallback
// final baseUrl = (settings as dynamic).remoteBaseUrl as String?
// ?? 'https://prova.patachina.it';
final baseUrl = 'https://prova.patachina.it';
await Navigator.of(context).push(MaterialPageRoute(
builder: (_) => RemoteTestPage(
db: debugDb!,
baseUrl: baseUrl,
),
));
} catch (e, st) {
print('[RemoteTest] errore apertura DB/pagina: $e\n$st');
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Errore RemoteTest: $e')),
);
} finally {
try {
await debugDb?.close();
} catch (_) {}
}
}
// --- DEBUG: wrapper che aggiunge il FAB
// solo in debug ---
Widget _wrapWithRemoteDebug(BuildContext context, Widget child) {
if (!kDebugMode) return child;
return Stack(
children: [
child,
Positioned(
right: 16,
bottom: 16,
child: FloatingActionButton(
heroTag: 'remote_debug_fab',
onPressed: () => _openRemoteTestPage(context),
tooltip: 'Remote Test',
child: const Icon(Icons.image_search),
),
),
],
);
}
Future<Route> _getRedirectRoute(AppMode appMode) async { Future<Route> _getRedirectRoute(AppMode appMode) async {
String routeName; String routeName;
Set<CollectionFilter?>? filters; Set<CollectionFilter?>? filters;
@ -387,9 +454,9 @@ class _HomePageState extends State<HomePage> {
filters = _initialFilters ?? (settings.homeNavItem.route == CollectionPage.routeName ? settings.homeCustomCollection : {}); filters = _initialFilters ?? (settings.homeNavItem.route == CollectionPage.routeName ? settings.homeCustomCollection : {});
} }
Route buildRoute(WidgetBuilder builder) => DirectMaterialPageRoute( Route buildRoute(WidgetBuilder builder) => DirectMaterialPageRoute(
settings: RouteSettings(name: routeName), settings: RouteSettings(name: routeName),
builder: builder, builder: builder,
); );
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
switch (routeName) { switch (routeName) {
@ -433,7 +500,14 @@ class _HomePageState extends State<HomePage> {
); );
case CollectionPage.routeName: case CollectionPage.routeName:
default: default:
return buildRoute((context) => CollectionPage(source: source, filters: filters)); // <<--- QUI AVVOLGO LA COLLECTION CON IL WRAPPER DI DEBUG
return buildRoute(
(context) => _wrapWithRemoteDebug(
context,
CollectionPage(source: source, filters: filters),
),
);
} }
} }
} }

View file

@ -0,0 +1,439 @@
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';
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;
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:
// TODO TLAD apply pick mimetype(s)
// some apps define multiple types, separated by a space (maybe other signs too, like `,` `;`?)
final multiple = (intentData[IntentDataKeys.allowMultiple] as bool?) ?? false;
debugPrint('pick mimeType=$intentMimeType multiple=$multiple');
appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal;
case IntentActions.pickCollectionFilters:
appMode = AppMode.pickCollectionFiltersExternal;
case IntentActions.screenSaver:
appMode = AppMode.screenSaver;
_initialRouteName = ScreenSaverPage.routeName;
case IntentActions.screenSaverSettings:
_initialRouteName = ScreenSaverSettingsPage.routeName;
case IntentActions.search:
_initialRouteName = SearchPage.routeName;
_initialSearchQuery = intentData[IntentDataKeys.query] as String?;
case IntentActions.widgetSettings:
_initialRouteName = HomeWidgetSettingsPage.routeName;
_widgetId = (intentData[IntentDataKeys.widgetId] as int?) ?? 0;
case IntentActions.widgetOpen:
final widgetId = intentData[IntentDataKeys.widgetId] as int?;
if (widgetId == null) {
error = true;
} else {
// widget settings may be modified in a different process after channel setup
await settings.reload();
final page = settings.getWidgetOpenPage(widgetId);
switch (page) {
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:
// do not use 'route' as extra key, as the Flutter framework acts on it
final extraRoute = intentData[IntentDataKeys.page] as String?;
if (allowedShortcutRoutes.contains(extraRoute)) {
_initialRouteName = extraRoute;
}
}
if (_initialFilters == null) {
final extraFilters = (intentData[IntentDataKeys.filters] as List?)?.cast<String>();
_initialFilters = extraFilters?.map(CollectionFilter.fromJson).nonNulls.toSet();
}
_initialExplorerPath = intentData[IntentDataKeys.explorerPath] as String?;
switch (appMode) {
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);
}
case AppMode.screenSaver:
await reportService.log('Initialize source to start screen saver');
final source = context.read<CollectionSource>();
source.canAnalyze = false;
await source.init(scope: settings.screenSaverCollectionFilters);
case AppMode.view:
if (_isViewerSourceable(_viewerEntry) && _secureUris == null) {
final directory = _viewerEntry?.directory;
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();
}
case AppMode.edit:
case AppMode.setWallpaper:
await _initViewerEssentials();
default:
break;
}
debugPrint('Home setup complete in ${stopwatch.elapsed.inMilliseconds}ms');
// `pushReplacement` is not enough in some edge cases
// e.g. when opening the viewer in `view` mode should replace a viewer in `main` mode
unawaited(
Navigator.maybeOf(context)?.pushAndRemoveUntil(
await _getRedirectRoute(appMode),
(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;
}
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;
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:
return buildRoute((context) => CollectionPage(source: source, filters: filters));
}
}
}