aves_mio1/lib/remote/new/remote_test_page.dart
FabioMich66 5e112be16b
Some checks are pending
Quality check / Flutter analysis (push) Waiting to run
Quality check / CodeQL analysis (java-kotlin) (push) Waiting to run
ok
2026-03-07 05:42:08 +01:00

647 lines
25 KiB
Dart
Raw 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 (Fase 1)
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
_RemoteFilter _filter = _RemoteFilter.all;
// contatori diagnostici
int _countAll = 0;
int _countVisible = 0; // trashed=0
int _countTrashed = 0; // trashed=1
@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
final s = await RemoteSettings.load();
final candidate = (widget.baseUrl ?? '').trim();
_baseUrl = candidate.isNotEmpty ? candidate : s.baseUrl.trim();
// 2) Header Authorization (opzionale)
_authHeaders = null;
try {
if (_baseUrl.isNotEmpty && (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 = '';
}
// Prende le prime 300 entry remote (includiamo il mime e il remoteId)
final rows = await widget.db.rawQuery(
'SELECT id, remoteId, title, remotePath, remoteThumb2, sourceMimeType, trashed '
'FROM entry WHERE origin=1$extraWhere '
'ORDER BY 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),
);
}
}