502 lines
18 KiB
Dart
502 lines
18 KiB
Dart
// lib/remote/remote_test_page.dart
|
|
import 'package:flutter/material.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 200 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 200',
|
|
);
|
|
|
|
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();
|
|
}
|
|
|
|
String _absUrl(String? relativePath) {
|
|
if (relativePath == null || relativePath.isEmpty) return '';
|
|
if (_baseUrl.isEmpty) return ''; // senza base non possiamo costruire URL
|
|
return buildAbsoluteUri(_baseUrl, relativePath).toString();
|
|
}
|
|
|
|
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')),
|
|
);
|
|
}
|
|
}
|
|
|
|
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.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 heroTag = 'remote_${it.id}';
|
|
|
|
return GestureDetector(
|
|
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 (fullUrl.isEmpty) {
|
|
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),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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 _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),
|
|
);
|
|
}
|
|
}
|