aves_mio22/lib/remote/remote_settings_page.dart
2026-04-18 20:05:02 +02:00

437 lines
14 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/remote/collection_source_remote_ext.dart'; // per appendRemoteEntriesFromDb()
import 'package:aves/remote/remote_controller.dart';
import 'package:aves/remote/remote_sync_bus.dart';
import 'remote_settings.dart';
import 'remote_http.dart';
import 'remote_origin.dart';
// debug store
import 'remote_state_store.dart';
class RemoteSettingsPage extends StatefulWidget {
const RemoteSettingsPage({super.key});
@override
State<RemoteSettingsPage> createState() => _RemoteSettingsPageState();
}
class _RemoteSettingsPageState extends State<RemoteSettingsPage> {
final _form = GlobalKey<FormState>();
bool _loaded = false;
bool _saving = false;
bool _enabled = RemoteSettings.defaultEnabled;
final _baseUrl = TextEditingController(text: RemoteSettings.defaultBaseUrl);
final _indexPath = TextEditingController(text: RemoteSettings.defaultIndexPath);
final _email = TextEditingController();
final _password = TextEditingController();
// ✅ WS URL (opzionale)
final _wsUrl = TextEditingController(text: RemoteSettings.defaultWsUrl);
// ✅ Debug store
final _store = RemoteStateStore();
// ✅ Auto-mode tracking for wsUrl
bool _wsUrlAuto = true;
bool _updatingWsUrl = false;
String _lastBaseUrlForWs = '';
@override
void initState() {
super.initState();
_baseUrl.addListener(_onBaseUrlChanged);
_wsUrl.addListener(_onWsUrlChanged);
_load();
}
@override
void dispose() {
_baseUrl.removeListener(_onBaseUrlChanged);
_wsUrl.removeListener(_onWsUrlChanged);
_baseUrl.dispose();
_indexPath.dispose();
_email.dispose();
_password.dispose();
_wsUrl.dispose();
super.dispose();
}
Future<void> _load() async {
try {
final s = await RemoteSettings.load();
if (!mounted) return;
setState(() {
_enabled = s.enabled;
_baseUrl.text = s.baseUrl;
_indexPath.text = s.indexPath;
_email.text = s.email;
_password.text = s.password;
_wsUrl.text = s.wsUrl;
_loaded = true;
});
// init auto-mode after controllers are filled
_lastBaseUrlForWs = _baseUrl.text.trim();
final currentWs = _wsUrl.text.trim();
final derived = _deriveWsUrl(_lastBaseUrlForWs);
_wsUrlAuto = currentWs.isEmpty || currentWs == derived;
} catch (e) {
_showSnack('Impossibile leggere le impostazioni sicure: $e');
if (!mounted) return;
setState(() {
_enabled = RemoteSettings.defaultEnabled;
_baseUrl.text = RemoteSettings.defaultBaseUrl;
_indexPath.text = RemoteSettings.defaultIndexPath;
_email.text = '';
_password.text = '';
_wsUrl.text = RemoteSettings.defaultWsUrl;
_loaded = true;
});
_lastBaseUrlForWs = _baseUrl.text.trim();
final currentWs = _wsUrl.text.trim();
final derived = _deriveWsUrl(_lastBaseUrlForWs);
_wsUrlAuto = currentWs.isEmpty || currentWs == derived;
}
}
// ---------- validators ----------
String? _validateBaseUrl(String? v) {
final s = (v ?? '').trim();
if (s.isEmpty) return 'Obbligatorio';
final uri = Uri.tryParse(s);
if (uri == null || !(uri.hasScheme && (uri.scheme == 'http' || uri.scheme == 'https'))) {
return 'URL non valida (deve iniziare con http/https)';
}
if (RegExp(r'[\u200B-\u200F\u202A-\u202E\u2060-\u2064\uFEFF]').hasMatch(s)) {
return 'URL contiene caratteri non validi (invisibili)';
}
return null;
}
String? _validateIndex(String? v) {
final s = (v ?? '').trim();
if (s.isEmpty) return 'Obbligatorio';
return null;
}
String? _validateWsUrl(String? v) {
final s = (v ?? '').trim();
if (s.isEmpty) return null; // vuoto = derivazione automatica
final uri = Uri.tryParse(s);
if (uri == null || !(uri.hasScheme && (uri.scheme == 'ws' || uri.scheme == 'wss'))) {
return 'WS URL non valida (deve iniziare con ws/wss)';
}
if (RegExp(r'[\u200B-\u200F\u202A-\u202E\u2060-\u2064\uFEFF]').hasMatch(s)) {
return 'WS URL contiene caratteri non validi (invisibili)';
}
return null;
}
// ---------- ws url derive + auto-update ----------
String _deriveWsUrl(String baseUrl) {
final uri = Uri.tryParse(baseUrl.trim());
if (uri == null) return '';
final host = uri.host;
final scheme = uri.scheme == 'http' ? 'ws' : 'wss';
final parts = host.split('.');
if (parts.isEmpty) return '$scheme://$host';
parts[0] = '${parts[0]}-ws';
return '$scheme://${parts.join('.')}';
}
void _onWsUrlChanged() {
if (_updatingWsUrl) return;
final txt = _wsUrl.text.trim();
// Se l'utente cancella -> torna automatico
if (txt.isEmpty) {
_wsUrlAuto = true;
return;
}
// Se l'utente scrive qualcosa -> manuale
_wsUrlAuto = false;
}
void _onBaseUrlChanged() {
final base = _baseUrl.text.trim();
if (base.isEmpty) return;
// evita rumore: aggiorna solo se davvero cambiato
if (base == _lastBaseUrlForWs) return;
if (_wsUrlAuto) {
final derived = _deriveWsUrl(base);
_updatingWsUrl = true;
_wsUrl.text = derived;
_updatingWsUrl = false;
}
_lastBaseUrlForWs = base;
}
String _resolvedWsUrlForUi() {
final ws = _wsUrl.text.trim();
if (ws.isNotEmpty) return ws;
return _deriveWsUrl(_baseUrl.text.trim());
}
// ---------- runtime effects ----------
Future<void> _applyRuntimeEffects() async {
try {
final source = context.read<CollectionSource>();
if (!_enabled) {
final remotesInMemory =
source.allEntries.where((e) => e.origin == RemoteOrigin.value).toSet();
if (remotesInMemory.isNotEmpty) {
source.removeEntriesFromMemory(remotesInMemory);
}
RemoteSyncBus.instance.setDisabled();
} else {
await source.appendRemoteEntriesFromDb();
await RemoteController.instance.initBusFromSettings();
}
// Delego al controller la gestione completa (WS/progressive)
await RemoteController.instance.onAppStart(
source: source,
resumeBootstrapIfEnabled: false,
);
} catch (_) {
// ignore: se non c'è CollectionSource nel contesto
}
}
Future<void> _save() async {
if (!(_form.currentState?.validate() ?? false)) return;
setState(() => _saving = true);
try {
final s = RemoteSettings(
enabled: _enabled,
baseUrl: _baseUrl.text.trim(),
indexPath: _indexPath.text.trim(),
email: _email.text.trim(),
password: _password.text,
wsUrl: _wsUrl.text.trim(),
);
await s.save();
await RemoteHttp.refreshFromSettings();
await RemoteHttp.warmUp();
await _applyRuntimeEffects();
if (!mounted) return;
_showSnack('Impostazioni remote salvate');
Navigator.of(context).maybePop();
} catch (e) {
if (!mounted) return;
_showSnack('Salvataggio fallito: $e');
} finally {
if (mounted) setState(() => _saving = false);
}
}
void _showSnack(String msg) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
behavior: SnackBarBehavior.fixed,
content: Text(msg),
duration: const Duration(seconds: 3),
),
);
}
Widget _kv(String k, String v) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 150,
child: Text(
k,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
Expanded(
child: SelectableText(v),
),
],
);
}
Widget _buildDebugPanel() {
final resolvedWs = _resolvedWsUrlForUi();
return Card(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ExpansionTile(
title: const Text('Debug (WS / Sync)'),
subtitle: const Text('Stato runtime, sessione WS, lastSync, ring buffer'),
childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
children: [
ValueListenableBuilder<RemoteSyncState>(
valueListenable: RemoteSyncBus.instance.stateNotifier,
builder: (context, st, _) => _kv('Sync state', st.name),
),
const SizedBox(height: 6),
_kv('WS URL (usato)', resolvedWs.isEmpty ? '' : resolvedWs),
const SizedBox(height: 6),
_kv('WS URL mode', _wsUrlAuto ? 'AUTO (segue Base URL)' : 'MANUAL'),
const SizedBox(height: 6),
FutureBuilder<String?>(
future: _store.getLastSyncIso(),
builder: (context, snap) {
final v = snap.data;
return _kv('lastSyncIso', (v == null || v.isEmpty) ? '' : v);
},
),
const SizedBox(height: 6),
FutureBuilder<String>(
future: _store.getOrCreateSessionId(),
builder: (context, snap) {
final v = snap.data;
return _kv('ws_session_id', (v == null || v.isEmpty) ? '' : v);
},
),
const SizedBox(height: 6),
FutureBuilder<List<String>>(
future: _store.loadProcessedRing(),
builder: (context, snap) {
final n = snap.data?.length ?? 0;
return _kv('processed_events_ring', '$n');
},
),
const SizedBox(height: 12),
const Divider(),
const Text(
'Note:\n'
'• Se WS URL è vuoto oppure coincide col derivato, resta in AUTO.\n'
'• Se scrivi WS URL manualmente, passa in MANUAL e non viene più toccato.\n'
'• Cancellando WS URL torni in AUTO.',
style: TextStyle(fontSize: 12),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text('Remote Settings')),
body: !_loaded
? const Center(child: CircularProgressIndicator())
: AbsorbPointer(
absorbing: _saving,
child: Form(
key: _form,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
SwitchListTile(
title: const Text('Abilita sync remoto'),
value: _enabled,
onChanged: (v) => setState(() => _enabled = v),
),
const SizedBox(height: 8),
TextFormField(
controller: _baseUrl,
decoration: const InputDecoration(
labelText: 'Base URL (es. https://server.tld)',
hintText: 'https://example.org',
),
keyboardType: TextInputType.url,
autovalidateMode: AutovalidateMode.onUserInteraction,
validator: _validateBaseUrl,
),
const SizedBox(height: 12),
TextFormField(
controller: _wsUrl,
decoration: const InputDecoration(
labelText: 'WebSocket URL (opzionale)',
hintText: 'wss://example-ws.org',
),
keyboardType: TextInputType.url,
autovalidateMode: AutovalidateMode.onUserInteraction,
validator: _validateWsUrl,
),
const SizedBox(height: 12),
TextFormField(
controller: _indexPath,
decoration: const InputDecoration(
labelText: 'Index path (es. photos/)',
hintText: 'photos/',
),
autovalidateMode: AutovalidateMode.onUserInteraction,
validator: _validateIndex,
),
const SizedBox(height: 12),
TextFormField(
controller: _email,
decoration: const InputDecoration(
labelText: 'User/Email',
hintText: 'utente@example.org',
),
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 12),
TextFormField(
controller: _password,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Password',
),
),
const SizedBox(height: 16),
_buildDebugPanel(),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _saving ? null : _save,
icon: _saving
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
theme.colorScheme.onPrimary,
),
),
)
: const Icon(Icons.save),
label: Text(_saving ? 'Salvataggio in corso...' : 'Salva'),
),
),
],
),
),
),
);
}
}