aves_mio1/lib/remote/remote_test_page.dart
FabioMich66 084fa184da
Some checks failed
Quality check / Flutter analysis (push) Has been cancelled
Quality check / CodeQL analysis (java-kotlin) (push) Has been cancelled
ok con video e foto in galleria aves
2026-03-17 12:19:38 +01:00

668 lines
26 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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