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