330 lines
14 KiB
Dart
330 lines
14 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
|
import '/models/creche_settings.dart';
|
|
|
|
const _bg = Color(0xFF0D1117);
|
|
const _card = Color(0xFF161B22);
|
|
const _blue = Color(0xFF4FC3F7);
|
|
const _green = Color(0xFF2ECC71);
|
|
const _red = Color(0xFFE74C3C);
|
|
|
|
class SettingsScreen extends ConsumerStatefulWidget {
|
|
const SettingsScreen({super.key});
|
|
@override
|
|
ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
|
|
}
|
|
|
|
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|
final _nameCtrl = TextEditingController();
|
|
final _addrCtrl = TextEditingController();
|
|
final _slogCtrl = TextEditingController();
|
|
final _latCtrl = TextEditingController();
|
|
final _lngCtrl = TextEditingController();
|
|
final _radCtrl = TextEditingController();
|
|
final _ipCtrl = TextEditingController();
|
|
List<String> _ips = [];
|
|
bool _loading = true;
|
|
bool _saving = false;
|
|
String? _error;
|
|
|
|
@override
|
|
void initState() { super.initState(); _load(); }
|
|
|
|
@override
|
|
void dispose() {
|
|
_nameCtrl.dispose(); _addrCtrl.dispose(); _slogCtrl.dispose();
|
|
_latCtrl.dispose(); _lngCtrl.dispose(); _radCtrl.dispose(); _ipCtrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _load() async {
|
|
setState(() { _loading = true; _error = null; });
|
|
try {
|
|
final sb = Supabase.instance.client;
|
|
var data = await sb.from('creche_settings').select().limit(1).maybeSingle();
|
|
|
|
if (data == null) {
|
|
// Criar linha de configurações default
|
|
await sb.from('creche_settings').upsert({
|
|
'id': 1,
|
|
'name': 'Creche e Berçário Sementes do Futuro',
|
|
'slogan': 'Conforto, cuidado e aprendizagem',
|
|
'geofence_radius_meters': 150,
|
|
'allowed_ips': [],
|
|
});
|
|
data = await sb.from('creche_settings').select().eq('id', 1).maybeSingle();
|
|
}
|
|
|
|
if (data != null) {
|
|
final s = CrecheSettings.fromMap(data);
|
|
_nameCtrl.text = s.name;
|
|
_addrCtrl.text = s.address ?? '';
|
|
_slogCtrl.text = s.slogan;
|
|
_latCtrl.text = s.geofenceLat?.toString() ?? '';
|
|
_lngCtrl.text = s.geofenceLng?.toString() ?? '';
|
|
_radCtrl.text = s.geofenceRadiusMeters.toString();
|
|
_ips = List.from(s.allowedIps);
|
|
}
|
|
} catch (e) {
|
|
if (mounted) setState(() => _error = e.toString());
|
|
} finally {
|
|
if (mounted) setState(() => _loading = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _save() async {
|
|
setState(() { _saving = true; _error = null; });
|
|
try {
|
|
await Supabase.instance.client.from('creche_settings').upsert({
|
|
'id': 1,
|
|
'name': _nameCtrl.text.trim(),
|
|
'address': _addrCtrl.text.trim().isEmpty ? null : _addrCtrl.text.trim(),
|
|
'slogan': _slogCtrl.text.trim(),
|
|
'geofence_lat': double.tryParse(_latCtrl.text),
|
|
'geofence_lng': double.tryParse(_lngCtrl.text),
|
|
'geofence_radius_meters': int.tryParse(_radCtrl.text) ?? 150,
|
|
'allowed_ips': _ips,
|
|
});
|
|
if (mounted) _snack('Configurações guardadas! ✓', ok: true);
|
|
} catch (e) {
|
|
if (mounted) setState(() => _error = e.toString());
|
|
} finally {
|
|
if (mounted) setState(() => _saving = false);
|
|
}
|
|
}
|
|
|
|
void _addIp() {
|
|
final ip = _ipCtrl.text.trim();
|
|
if (ip.isNotEmpty && !_ips.contains(ip)) {
|
|
setState(() { _ips.add(ip); _ipCtrl.clear(); });
|
|
}
|
|
}
|
|
|
|
void _snack(String msg, {bool ok = false}) => ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
|
content: Text(msg, style: const TextStyle(color: Colors.white)),
|
|
backgroundColor: ok ? _green : _red,
|
|
behavior: SnackBarBehavior.floating,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
|
));
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: _bg,
|
|
appBar: AppBar(
|
|
backgroundColor: _card, elevation: 0,
|
|
title: const Text('Configurações', style: TextStyle(color: _blue, fontWeight: FontWeight.bold)),
|
|
actions: [
|
|
IconButton(icon: const Icon(Icons.refresh, color: _blue), onPressed: _load),
|
|
],
|
|
),
|
|
body: _loading
|
|
? const Center(child: CircularProgressIndicator(color: _blue))
|
|
: _error != null
|
|
? _buildError()
|
|
: _buildForm(),
|
|
);
|
|
}
|
|
|
|
// ── Error inline (sem widget separado que pode ter layout issues) ──
|
|
Widget _buildError() => SingleChildScrollView(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
|
const SizedBox(height: 40),
|
|
Container(
|
|
padding: const EdgeInsets.all(18),
|
|
decoration: BoxDecoration(
|
|
color: _red.withOpacity(0.08), borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(color: _red.withOpacity(0.3))),
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
const Row(children: [
|
|
Icon(Icons.error_outline, color: _red, size: 22),
|
|
SizedBox(width: 8),
|
|
Expanded(child: Text('Erro ao carregar configurações',
|
|
style: TextStyle(color: _red, fontWeight: FontWeight.bold))),
|
|
]),
|
|
const SizedBox(height: 10),
|
|
Text(_error!, style: const TextStyle(color: Color(0xFFFF6B6B), fontSize: 11, fontFamily: 'monospace')),
|
|
]),
|
|
),
|
|
const SizedBox(height: 16),
|
|
// Diagnóstico
|
|
Container(
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(color: _card, borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(color: Colors.orange.withOpacity(0.3))),
|
|
child: const Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Text('🔒 Possível causa: RLS em falta',
|
|
style: TextStyle(color: Colors.orange, fontWeight: FontWeight.bold)),
|
|
SizedBox(height: 8),
|
|
Text('Corre o ficheiro FIX_COMPLETO_V3.sql no Supabase SQL Editor.',
|
|
style: TextStyle(color: Color(0xFFAAAAAA), fontSize: 12, height: 1.5)),
|
|
]),
|
|
),
|
|
const SizedBox(height: 24),
|
|
// Botão com constraints explícitas — evita layout error
|
|
SizedBox(
|
|
width: double.infinity, height: 50,
|
|
child: ElevatedButton.icon(
|
|
onPressed: _load,
|
|
icon: const Icon(Icons.refresh),
|
|
label: const Text('Tentar novamente'),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: _blue,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
),
|
|
),
|
|
),
|
|
]),
|
|
);
|
|
|
|
Widget _buildForm() => SingleChildScrollView(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
|
|
// ── DADOS DA CRECHE ──────────────────────────────────────
|
|
_sec('🏫', 'Dados da Creche'),
|
|
const SizedBox(height: 14),
|
|
_field(_nameCtrl, 'Nome da Creche', Icons.business),
|
|
const SizedBox(height: 12),
|
|
_field(_addrCtrl, 'Endereço completo', Icons.location_city),
|
|
const SizedBox(height: 12),
|
|
_field(_slogCtrl, 'Slogan', Icons.format_quote),
|
|
const SizedBox(height: 28),
|
|
|
|
// ── GEOFENCE ─────────────────────────────────────────────
|
|
_sec('📍', 'Geofence — Área de acesso'),
|
|
const SizedBox(height: 8),
|
|
Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: _blue.withOpacity(0.06), borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(color: _blue.withOpacity(0.2))),
|
|
child: const Text(
|
|
'Define a área onde os funcionários podem fazer login.\nDeixa em branco para desactivar o geofence.',
|
|
style: TextStyle(color: Color(0xFF888888), fontSize: 12, height: 1.5)),
|
|
),
|
|
const SizedBox(height: 14),
|
|
Row(children: [
|
|
Expanded(child: _field(_latCtrl, 'Latitude', Icons.explore, type: TextInputType.number)),
|
|
const SizedBox(width: 12),
|
|
Expanded(child: _field(_lngCtrl, 'Longitude', Icons.explore, type: TextInputType.number)),
|
|
]),
|
|
const SizedBox(height: 12),
|
|
_field(_radCtrl, 'Raio em metros (ex: 150)', Icons.radar, type: TextInputType.number),
|
|
const SizedBox(height: 28),
|
|
|
|
// ── IPs PERMITIDOS ────────────────────────────────────────
|
|
_sec('🔒', 'IPs Permitidos'),
|
|
const SizedBox(height: 8),
|
|
const Text('Restringe o login a IPs específicos. Deixa vazio para não restringir.',
|
|
style: TextStyle(color: Color(0xFF888888), fontSize: 12)),
|
|
const SizedBox(height: 12),
|
|
if (_ips.isNotEmpty) ...[
|
|
Wrap(
|
|
spacing: 8, runSpacing: 8,
|
|
children: _ips.map((ip) => Chip(
|
|
label: Text(ip, style: const TextStyle(color: Colors.white, fontSize: 12)),
|
|
backgroundColor: const Color(0xFF1C2233),
|
|
side: BorderSide(color: _blue.withOpacity(0.4)),
|
|
deleteIcon: const Icon(Icons.close, size: 14, color: _red),
|
|
onDeleted: () => setState(() => _ips.remove(ip)),
|
|
)).toList(),
|
|
),
|
|
const SizedBox(height: 12),
|
|
],
|
|
// Row com TextField + botão — usando IntrinsicHeight para evitar layout issues
|
|
Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _ipCtrl,
|
|
style: const TextStyle(color: Colors.white, fontSize: 14),
|
|
onSubmitted: (_) => _addIp(),
|
|
decoration: InputDecoration(
|
|
hintText: 'Ex: 192.168.1.1',
|
|
hintStyle: const TextStyle(color: Color(0xFF555577), fontSize: 13),
|
|
prefixIcon: const Icon(Icons.lan, color: _blue, size: 20),
|
|
filled: true, fillColor: _card,
|
|
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10),
|
|
borderSide: BorderSide(color: Colors.white.withOpacity(0.1))),
|
|
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10),
|
|
borderSide: const BorderSide(color: _blue)),
|
|
contentPadding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
// SizedBox explícito evita o bug w=Infinity no ElevatedButton dentro de Row
|
|
SizedBox(
|
|
height: 50, width: 80,
|
|
child: ElevatedButton(
|
|
onPressed: _addIp,
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: _blue, padding: EdgeInsets.zero,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
|
),
|
|
child: const Text('+ Add', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
|
|
),
|
|
),
|
|
]),
|
|
const SizedBox(height: 36),
|
|
|
|
// ── BOTÃO GUARDAR ─────────────────────────────────────────
|
|
// SizedBox com width explícita previne BoxConstraints(w=Infinity)
|
|
SizedBox(
|
|
width: double.infinity, height: 54,
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(colors: _saving
|
|
? [const Color(0xFF1A3A4A), const Color(0xFF1A3A4A)]
|
|
: [_blue, const Color(0xFF0288D1)]),
|
|
borderRadius: BorderRadius.circular(14),
|
|
boxShadow: _saving ? [] : [BoxShadow(color: _blue.withOpacity(0.25), blurRadius: 16, offset: const Offset(0, 6))],
|
|
),
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(14),
|
|
onTap: _saving ? null : _save,
|
|
child: Center(child: _saving
|
|
? const SizedBox(height: 22, width: 22, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2.5))
|
|
: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
|
Icon(Icons.save_outlined, color: Colors.white, size: 20),
|
|
SizedBox(width: 10),
|
|
Text('Guardar Configurações', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16)),
|
|
]),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
)]),
|
|
);
|
|
|
|
Widget _sec(String icon, String title) => Padding(
|
|
padding: const EdgeInsets.only(bottom: 4),
|
|
child: Row(children: [
|
|
Text(icon, style: const TextStyle(fontSize: 20)),
|
|
const SizedBox(width: 10),
|
|
Text(title, style: const TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.bold)),
|
|
]),
|
|
);
|
|
|
|
Widget _field(TextEditingController c, String label, IconData icon, {TextInputType type = TextInputType.text}) =>
|
|
TextField(
|
|
controller: c, keyboardType: type,
|
|
style: const TextStyle(color: Colors.white, fontSize: 14),
|
|
decoration: InputDecoration(
|
|
labelText: label,
|
|
labelStyle: const TextStyle(color: Color(0xFF888888), fontSize: 13),
|
|
prefixIcon: Icon(icon, color: _blue, size: 20),
|
|
filled: true, fillColor: _card,
|
|
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide(color: Colors.white.withOpacity(0.1))),
|
|
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12),
|
|
borderSide: const BorderSide(color: _blue, width: 1.5)),
|
|
),
|
|
);
|
|
}
|