f1
This commit is contained in:
parent
2c988f959b
commit
452c378178
13 changed files with 1841 additions and 43 deletions
|
|
@ -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
|
||||
|
|
|
|||
704
lib/model/db/db_sqflite.dart.org
Normal file
704
lib/model/db/db_sqflite.dart.org
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -29,10 +29,12 @@ class SqfliteLocalMediaDbSchema {
|
|||
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) {
|
||||
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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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);');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
61
lib/remote/auth_client.dart
Normal file
61
lib/remote/auth_client.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
77
lib/remote/remote_client.dart
Normal file
77
lib/remote/remote_client.dart
Normal 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;
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
121
lib/remote/remote_models.dart
Normal file
121
lib/remote/remote_models.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
78
lib/remote/remote_repository.dart
Normal file
78
lib/remote/remote_repository.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
129
lib/remote/remote_test_page.dart
Normal file
129
lib/remote/remote_test_page.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
39
lib/remote/run_remote_sync.dart
Normal file
39
lib/remote/run_remote_sync.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
7
lib/remote/url_utils.dart
Normal file
7
lib/remote/url_utils.dart
Normal 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]);
|
||||
}
|
||||
|
|
@ -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<HomePage> {
|
|||
|
||||
@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<void> _setup() async {
|
||||
try {
|
||||
|
|
@ -301,6 +308,66 @@ class _HomePageState extends State<HomePage> {
|
|||
return entry;
|
||||
}
|
||||
|
||||
// --- DEBUG: apre la pagina di test remota con una seconda connessione al DB ---
|
||||
// --- DEBUG: apre la pagina di test remota con una seconda connessione al DB ---
|
||||
Future<void> _openRemoteTestPage(BuildContext context) async {
|
||||
Database? debugDb;
|
||||
try {
|
||||
final dbDir = await getDatabasesPath();
|
||||
final dbPath = p.join(dbDir, 'metadata.db');
|
||||
|
||||
// Apri il DB in sola lettura (evita lock e conflitti)
|
||||
debugDb = await openDatabase(dbPath, readOnly: true);
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
// Base URL per i remote: se esiste in settings lo usa, altrimenti fallback
|
||||
// final baseUrl = (settings as dynamic).remoteBaseUrl as String?
|
||||
// ?? 'https://prova.patachina.it';
|
||||
final baseUrl = 'https://prova.patachina.it';
|
||||
|
||||
|
||||
await Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => RemoteTestPage(
|
||||
db: debugDb!,
|
||||
baseUrl: baseUrl,
|
||||
),
|
||||
));
|
||||
} catch (e, st) {
|
||||
print('[RemoteTest] errore apertura DB/pagina: $e\n$st');
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Errore RemoteTest: $e')),
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
await debugDb?.close();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- DEBUG: wrapper che aggiunge il FAB
|
||||
// solo in debug ---
|
||||
Widget _wrapWithRemoteDebug(BuildContext context, Widget child) {
|
||||
if (!kDebugMode) return child;
|
||||
return Stack(
|
||||
children: [
|
||||
child,
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: FloatingActionButton(
|
||||
heroTag: 'remote_debug_fab',
|
||||
onPressed: () => _openRemoteTestPage(context),
|
||||
tooltip: 'Remote Test',
|
||||
child: const Icon(Icons.image_search),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<Route> _getRedirectRoute(AppMode appMode) async {
|
||||
String routeName;
|
||||
Set<CollectionFilter?>? filters;
|
||||
|
|
@ -387,9 +454,9 @@ class _HomePageState extends State<HomePage> {
|
|||
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<CollectionSource>();
|
||||
switch (routeName) {
|
||||
|
|
@ -433,7 +500,14 @@ class _HomePageState extends State<HomePage> {
|
|||
);
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
439
lib/widgets/home/home_page.dart.old
Normal file
439
lib/widgets/home/home_page.dart.old
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue