From 452c3781783126a26d5e5499c6c7b997d9c388c3 Mon Sep 17 00:00:00 2001 From: Fabio Micheluz Date: Sat, 28 Feb 2026 13:42:46 +0100 Subject: [PATCH] f1 --- lib/model/db/db_sqflite.dart | 15 + lib/model/db/db_sqflite.dart.org | 704 +++++++++++++++++++++++++++ lib/model/db/db_sqflite_schema.dart | 54 +- lib/model/db/db_sqflite_upgrade.dart | 64 ++- lib/remote/auth_client.dart | 61 +++ lib/remote/remote_client.dart | 77 +++ lib/remote/remote_models.dart | 121 +++++ lib/remote/remote_repository.dart | 78 +++ lib/remote/remote_test_page.dart | 129 +++++ lib/remote/run_remote_sync.dart | 39 ++ lib/remote/url_utils.dart | 7 + lib/widgets/home/home_page.dart | 96 +++- lib/widgets/home/home_page.dart.old | 439 +++++++++++++++++ 13 files changed, 1841 insertions(+), 43 deletions(-) create mode 100644 lib/model/db/db_sqflite.dart.org create mode 100644 lib/remote/auth_client.dart create mode 100644 lib/remote/remote_client.dart create mode 100644 lib/remote/remote_models.dart create mode 100644 lib/remote/remote_repository.dart create mode 100644 lib/remote/remote_test_page.dart create mode 100644 lib/remote/run_remote_sync.dart create mode 100644 lib/remote/url_utils.dart create mode 100644 lib/widgets/home/home_page.dart.old diff --git a/lib/model/db/db_sqflite.dart b/lib/model/db/db_sqflite.dart index 6d44421e..d09e7cfa 100644 --- a/lib/model/db/db_sqflite.dart +++ b/lib/model/db/db_sqflite.dart @@ -1,3 +1,4 @@ +// lib/model/db/db_sqflite.dart import 'dart:io'; import 'package:aves/model/covers.dart'; @@ -17,6 +18,8 @@ import 'package:aves/services/common/services.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.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 { late Database _db; @@ -53,6 +56,18 @@ class SqfliteLocalMediaDb implements LocalMediaDb { final maxIdRows = await _db.rawQuery('SELECT MAX(id) AS maxId FROM $entryTable'); _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 diff --git a/lib/model/db/db_sqflite.dart.org b/lib/model/db/db_sqflite.dart.org new file mode 100644 index 00000000..6d44421e --- /dev/null +++ b/lib/model/db/db_sqflite.dart.org @@ -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 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 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 dbFileSize() async { + final file = File(await path); + return await file.exists() ? await file.length() : 0; + } + + @override + Future reset() async { + debugPrint('$runtimeType reset'); + await _db.close(); + await deleteDatabase(await path); + await init(); + } + + @override + Future removeIds(Set ids, {Set? 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 clearEntries() async { + final count = await _db.delete(entryTable, where: '1'); + debugPrint('$runtimeType clearEntries deleted $count rows'); + } + + @override + Future> loadEntries({int? origin, String? directory}) async { + String? where; + final whereArgs = []; + + if (origin != null) { + where = 'origin = ?'; + whereArgs.add(origin); + } + + final entries = {}; + 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> loadEntriesById(Set ids) => _getByIds(ids, entryTable, AvesEntry.fromMap); + + @override + Future insertEntries(Set 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 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> 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> searchLiveDuplicates(int origin, Set? 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 clearDates() async { + final count = await _db.delete(dateTakenTable, where: '1'); + debugPrint('$runtimeType clearDates deleted $count rows'); + } + + @override + Future> loadDates() async { + final result = {}; + 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 clearCatalogMetadata() async { + final count = await _db.delete(metadataTable, where: '1'); + debugPrint('$runtimeType clearMetadataEntries deleted $count rows'); + } + + @override + Future> loadCatalogMetadata() async { + final result = {}; + final cursor = await _db.queryCursor(metadataTable, bufferSize: _queryCursorBufferSize); + while (await cursor.moveNext()) { + result.add(CatalogMetadata.fromMap(cursor.current)); + } + return result; + } + + @override + Future> loadCatalogMetadataById(Set ids) => _getByIds(ids, metadataTable, CatalogMetadata.fromMap); + + @override + Future saveCatalogMetadata(Set 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 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 clearAddresses() async { + final count = await _db.delete(addressTable, where: '1'); + debugPrint('$runtimeType clearAddresses deleted $count rows'); + } + + @override + Future> loadAddresses() async { + final result = {}; + final cursor = await _db.queryCursor(addressTable, bufferSize: _queryCursorBufferSize); + while (await cursor.moveNext()) { + result.add(AddressDetails.fromMap(cursor.current)); + } + return result; + } + + @override + Future> loadAddressesById(Set ids) => _getByIds(ids, addressTable, AddressDetails.fromMap); + + @override + Future saveAddresses(Set 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 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 clearVaults() async { + final count = await _db.delete(vaultTable, where: '1'); + debugPrint('$runtimeType clearVaults deleted $count rows'); + } + + @override + Future> loadAllVaults() async { + final result = {}; + final cursor = await _db.queryCursor(vaultTable, bufferSize: _queryCursorBufferSize); + while (await cursor.moveNext()) { + result.add(VaultDetails.fromMap(cursor.current)); + } + return result; + } + + @override + Future addVaults(Set rows) async { + if (rows.isEmpty) return; + final batch = _db.batch(); + rows.forEach((row) => _batchInsertVault(batch, row)); + await batch.commit(noResult: true); + } + + @override + Future 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 removeVaults(Set 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 clearTrashDetails() async { + final count = await _db.delete(trashTable, where: '1'); + debugPrint('$runtimeType clearTrashDetails deleted $count rows'); + } + + @override + Future> loadAllTrashDetails() async { + final result = {}; + final cursor = await _db.queryCursor(trashTable, bufferSize: _queryCursorBufferSize); + while (await cursor.moveNext()) { + result.add(TrashDetails.fromMap(cursor.current)); + } + return result; + } + + @override + Future 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 clearFavourites() async { + final count = await _db.delete(favouriteTable, where: '1'); + debugPrint('$runtimeType clearFavourites deleted $count rows'); + } + + @override + Future> loadAllFavourites() async { + final result = {}; + final cursor = await _db.queryCursor(favouriteTable, bufferSize: _queryCursorBufferSize); + while (await cursor.moveNext()) { + result.add(FavouriteRow.fromMap(cursor.current)); + } + return result; + } + + @override + Future addFavourites(Set rows) async { + if (rows.isEmpty) return; + final batch = _db.batch(); + rows.forEach((row) => _batchInsertFavourite(batch, row)); + await batch.commit(noResult: true); + } + + @override + Future 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 removeFavourites(Set 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 clearCovers() async { + final count = await _db.delete(coverTable, where: '1'); + debugPrint('$runtimeType clearCovers deleted $count rows'); + } + + @override + Future> loadAllCovers() async { + final result = {}; + 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 addCovers(Set rows) async { + if (rows.isEmpty) return; + + final batch = _db.batch(); + rows.forEach((row) => _batchInsertCover(batch, row)); + await batch.commit(noResult: true); + } + + @override + Future 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 removeCovers(Set filters) async { + if (filters.isEmpty) return; + + // for backward compatibility, remove stored JSON instead of removing de/reserialized filters + final obsoleteFilterJson = {}; + + 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 clearDynamicAlbums() async { + final count = await _db.delete(dynamicAlbumTable, where: '1'); + debugPrint('$runtimeType clearDynamicAlbums deleted $count rows'); + return count; + } + + @override + Future> loadAllDynamicAlbums({int bufferSize = _queryCursorBufferSize}) async { + final result = {}; + 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 addDynamicAlbums(Set 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 removeDynamicAlbums(Set 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 clearVideoPlayback() async { + final count = await _db.delete(videoPlaybackTable, where: '1'); + debugPrint('$runtimeType clearVideoPlayback deleted $count rows'); + } + + @override + Future> loadAllVideoPlayback() async { + final result = {}; + 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 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 addVideoPlayback(Set 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 removeVideoPlayback(Set 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> _getByIds(Set ids, String table, T Function(Map row) mapRow) async { + final result = {}; + 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; + } +} diff --git a/lib/model/db/db_sqflite_schema.dart b/lib/model/db/db_sqflite_schema.dart index 373e86dd..362b9341 100644 --- a/lib/model/db/db_sqflite_schema.dart +++ b/lib/model/db/db_sqflite_schema.dart @@ -29,10 +29,12 @@ class SqfliteLocalMediaDbSchema { await Future.forEach(allTables, (table) => createTable(db, table)); } - static Future createTable(Database db, String table) { + // Resa async per poter eseguire più statement per tabella (es. indici). + static Future createTable(Database db, String table) async { switch (table) { case entryTable: - return db.execute( + // Tabella 'entry' con i nuovi campi per la sorgente remota + await db.execute( 'CREATE TABLE $entryTable(' 'id INTEGER PRIMARY KEY' ', contentId INTEGER' @@ -50,17 +52,31 @@ class SqfliteLocalMediaDbSchema { ', durationMillis INTEGER' ', trashed 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: - return db.execute( + await db.execute( 'CREATE TABLE $dateTakenTable(' 'id INTEGER PRIMARY KEY' ', dateMillis INTEGER' ')', ); + return; + case metadataTable: - return db.execute( + await db.execute( 'CREATE TABLE $metadataTable(' 'id INTEGER PRIMARY KEY' ', mimeType TEXT' @@ -74,8 +90,10 @@ class SqfliteLocalMediaDbSchema { ', rating INTEGER' ')', ); + return; + case addressTable: - return db.execute( + await db.execute( 'CREATE TABLE $addressTable(' 'id INTEGER PRIMARY KEY' ', addressLine TEXT' @@ -85,14 +103,18 @@ class SqfliteLocalMediaDbSchema { ', locality TEXT' ')', ); + return; + case favouriteTable: - return db.execute( + await db.execute( 'CREATE TABLE $favouriteTable(' 'id INTEGER PRIMARY KEY' ')', ); + return; + case coverTable: - return db.execute( + await db.execute( 'CREATE TABLE $coverTable(' 'filter TEXT PRIMARY KEY' ', entryId INTEGER' @@ -100,15 +122,19 @@ class SqfliteLocalMediaDbSchema { ', color TEXT' ')', ); + return; + case dynamicAlbumTable: - return db.execute( + await db.execute( 'CREATE TABLE $dynamicAlbumTable(' 'name TEXT PRIMARY KEY' ', filter TEXT' ')', ); + return; + case vaultTable: - return db.execute( + await db.execute( 'CREATE TABLE $vaultTable(' 'name TEXT PRIMARY KEY' ', autoLock INTEGER' @@ -116,21 +142,27 @@ class SqfliteLocalMediaDbSchema { ', lockType TEXT' ')', ); + return; + case trashTable: - return db.execute( + await db.execute( 'CREATE TABLE $trashTable(' 'id INTEGER PRIMARY KEY' ', path TEXT' ', dateMillis INTEGER' ')', ); + return; + case videoPlaybackTable: - return db.execute( + await db.execute( 'CREATE TABLE $videoPlaybackTable(' 'id INTEGER PRIMARY KEY' ', resumeTimeMillis INTEGER' ')', ); + return; + default: throw Exception('unknown table=$table'); } diff --git a/lib/model/db/db_sqflite_upgrade.dart b/lib/model/db/db_sqflite_upgrade.dart index 213abea7..8d9bcc8f 100644 --- a/lib/model/db/db_sqflite_upgrade.dart +++ b/lib/model/db/db_sqflite_upgrade.dart @@ -1,3 +1,4 @@ +// lib/model/db/db_sqflite_upgrade.dart import 'dart:ui'; import 'package:aves/model/covers.dart'; @@ -53,6 +54,9 @@ class LocalMediaDbUpgrader { await _upgradeFrom13(db); case 14: await _upgradeFrom14(db); + // NEW: add remote-source columns & indexes + case 15: + await _upgradeFrom15(db); } oldVersion++; } @@ -245,15 +249,15 @@ class LocalMediaDbUpgrader { ', flags INTEGER' ', rotationDegrees INTEGER' ', xmpSubjects TEXT' - ', xmpTitleDescription TEXT' + ', xmpTitle TEXT' ', latitude REAL' ', longitude REAL' ', rating INTEGER' ')', ); await db.rawInsert( - 'INSERT INTO $newMetadataTable (id,mimeType,dateMillis,flags,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude,rating)' - ' SELECT contentId,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,xmpTitle,latitude,longitude,rating' ' FROM $metadataTable;', ); await db.execute('DROP TABLE $metadataTable;'); @@ -300,24 +304,6 @@ class LocalMediaDbUpgrader { 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` await db.transaction((txn) async { const newCoverTable = '${coverTable}TEMP'; @@ -564,4 +550,40 @@ class LocalMediaDbUpgrader { // (dateTakenTable, metadataTable, addressTable, trashTable, videoPlaybackTable) // for users with a potentially corrupted DB following upgrade to v1.12.4 } + + // ============================================================ + // NEW: v15 - supporto sorgente remota (campi provider/remote*) + // ============================================================ + static Future _upgradeFrom15(Database db) async { + debugPrint('upgrading DB from v15 (remote source columns)'); + + // Controlla se una colonna esiste usando PRAGMA table_info + Future _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 _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);'); + } } diff --git a/lib/remote/auth_client.dart b/lib/remote/auth_client.dart new file mode 100644 index 00000000..759c31fe --- /dev/null +++ b/lib/remote/auth_client.dart @@ -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 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; + 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> authHeaders() async { + _token ??= await login(); + return {'Authorization': 'Bearer $_token'}; + } + + /// Invalida il token (es. dopo 401) e riesegue il login + Future> refreshAndHeaders() async { + _token = null; + return await authHeaders(); + } +} diff --git a/lib/remote/remote_client.dart b/lib/remote/remote_client.dart new file mode 100644 index 00000000..99e34d9e --- /dev/null +++ b/lib/remote/remote_client.dart @@ -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> fetchAll() async { + Map 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 rawList = decoded; + + // Costruiamo a mano la List, tipizzata esplicitamente + final List items = rawList.map((e) { + if (e is! Map) { + throw Exception('Elemento JSON non è una mappa: ${e.runtimeType} -> $e'); + } + return RemotePhotoItem.fromJson(e); + }).toList(); + + return items; + + + } +} diff --git a/lib/remote/remote_models.dart b/lib/remote/remote_models.dart new file mode 100644 index 00000000..748edadc --- /dev/null +++ b/lib/remote/remote_models.dart @@ -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 j) { + final gps = j['gps'] as Map?; + final loc = j['location'] is Map + ? RemoteLocation.fromJson(j['location'] as Map) + : 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 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(), + ); +} diff --git a/lib/remote/remote_repository.dart b/lib/remote/remote_repository.dart new file mode 100644 index 00000000..25f1c84a --- /dev/null +++ b/lib/remote/remote_repository.dart @@ -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 upsertAll(List 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 = { + '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 = { + '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 countRemote() async { + final rows = await db.rawQuery('SELECT COUNT(1) AS c FROM entry WHERE origin=1'); + return (rows.first['c'] as int?) ?? 0; + } +} diff --git a/lib/remote/remote_test_page.dart b/lib/remote/remote_test_page.dart new file mode 100644 index 00000000..8f2ce7f5 --- /dev/null +++ b/lib/remote/remote_test_page.dart @@ -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 createState() => _RemoteTestPageState(); +} + +class _RemoteTestPageState extends State { + late Future> _future; + + @override + void initState() { + super.initState(); + _future = _load(); + } + + Future> _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>( + 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), + ), + ), + ), + ); + } +} diff --git a/lib/remote/run_remote_sync.dart b/lib/remote/run_remote_sync.dart new file mode 100644 index 00000000..a957fd62 --- /dev/null +++ b/lib/remote/run_remote_sync.dart @@ -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 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; + } +} diff --git a/lib/remote/url_utils.dart b/lib/remote/url_utils.dart new file mode 100644 index 00000000..9b0450b8 --- /dev/null +++ b/lib/remote/url_utils.dart @@ -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]); +} diff --git a/lib/widgets/home/home_page.dart b/lib/widgets/home/home_page.dart index 188577e4..44860325 100644 --- a/lib/widgets/home/home_page.dart +++ b/lib/widgets/home/home_page.dart @@ -1,3 +1,4 @@ +// lib/widgets/home/home_page.dart import 'dart:async'; import 'package:aves/app_mode.dart'; @@ -45,6 +46,12 @@ import 'package:latlong2/latlong.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; +// --- IMPORT per debug page remota --- +import 'package:flutter/foundation.dart' show kDebugMode; +import 'package:sqflite/sqflite.dart'; +import 'package:path/path.dart' as p; +import 'package:aves/remote/remote_test_page.dart'; + class HomePage extends StatefulWidget { static const routeName = '/'; @@ -87,13 +94,13 @@ class _HomePageState extends State { @override Widget build(BuildContext context) => AvesScaffold( - body: _setupError != null - ? HomeError( - error: _setupError!.$1, - stack: _setupError!.$2, - ) - : null, - ); + body: _setupError != null + ? HomeError( + error: _setupError!.$1, + stack: _setupError!.$2, + ) + : null, + ); Future _setup() async { try { @@ -301,6 +308,66 @@ class _HomePageState extends State { 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 _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 _getRedirectRoute(AppMode appMode) async { String routeName; Set? filters; @@ -387,9 +454,9 @@ class _HomePageState extends State { filters = _initialFilters ?? (settings.homeNavItem.route == CollectionPage.routeName ? settings.homeCustomCollection : {}); } Route buildRoute(WidgetBuilder builder) => DirectMaterialPageRoute( - settings: RouteSettings(name: routeName), - builder: builder, - ); + settings: RouteSettings(name: routeName), + builder: builder, + ); final source = context.read(); switch (routeName) { @@ -433,7 +500,14 @@ class _HomePageState extends State { ); case CollectionPage.routeName: 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), + ), + ); } } } + diff --git a/lib/widgets/home/home_page.dart.old b/lib/widgets/home/home_page.dart.old new file mode 100644 index 00000000..188577e4 --- /dev/null +++ b/lib/widgets/home/home_page.dart.old @@ -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 createState() => _HomePageState(); +} + +class _HomePageState extends State { + AvesEntry? _viewerEntry; + int? _widgetId; + String? _initialRouteName, _initialSearchQuery; + Set? _initialFilters; + String? _initialExplorerPath; + (LatLng, double?)? _initialLocationZoom; + List? _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 _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(); + 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(); + _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>().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(); + 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(); + 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(); + // 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 _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 _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 _getRedirectRoute(AppMode appMode) async { + String routeName; + Set? 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(); + 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(); + 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)); + } + } +}