647 lines
25 KiB
Dart
647 lines
25 KiB
Dart
// 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 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<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),
|
||
);
|
||
}
|
||
}
|