fixed things and icon

This commit is contained in:
2026-08-22 16:09:05 +02:00
parent e87292dda7
commit a7e4c5f083
49 changed files with 626 additions and 160 deletions
+12
View File
@@ -0,0 +1,12 @@
import '../models/school.dart';
/// Scuola unica dell'app (YogaSoul).
/// Il DB non ha il concetto di "scuola": questo oggetto serve solo alle
/// pagine che mostrano nome/indirizzo. Definito qui una volta sola così
/// login e splash (e chiunque altro) usano lo stesso dato.
const School kYogaSoulSchool = School(
id: 1,
name: 'YogaSoul',
logo: null,
addressFull: 'via Valassina 62/B Seregno - Sala Contesto Yoga',
);
+6 -1
View File
@@ -4,7 +4,12 @@ class School {
final String? logo;
final String? addressFull;
School({required this.id, required this.name, this.logo, this.addressFull});
const School({
required this.id,
required this.name,
this.logo,
this.addressFull,
});
factory School.fromJson(Map<String, dynamic> j) {
return School(
+6 -10
View File
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import '../services/vanguard_api.dart';
import '../services/auth_storage.dart';
import '../models/school.dart';
import '../config/app_school.dart';
import 'home_page.dart';
class LoginPage extends StatefulWidget {
@@ -20,15 +22,6 @@ class _LoginPageState extends State<LoginPage> {
String error = '';
bool _obscurePassword = true;
// Scuola unica (YogaSoul). Il DB non ha il concetto di scuola:
// questo oggetto serve solo a soddisfare le pagine che mostrano nome/indirizzo.
static final School _yogaSoulSchool = School(
id: 1,
name: 'YogaSoul',
logo: null,
addressFull: 'via Valassina 62/B Seregno - Sala Contesto Yoga',
);
@override
void dispose() {
emailController.dispose();
@@ -48,6 +41,9 @@ class _LoginPageState extends State<LoginPage> {
password: passwordController.text.trim(),
);
// Se "ricordami" è attivo, salva il nuovo token in cassaforte.
await AuthStorage.instance.saveTokenIfRemember(token);
if (!mounted) return;
// Bypass SelectSchoolPage: scuola unica, si va dritti alla Home.
@@ -56,7 +52,7 @@ class _LoginPageState extends State<LoginPage> {
MaterialPageRoute(
builder: (_) => HomePage(
token: token,
school: _yogaSoulSchool,
school: kYogaSoulSchool,
userFirstName: null,
),
),
+237
View File
@@ -1 +1,238 @@
import 'package:flutter/material.dart';
import '../models/school.dart';
import '../services/auth_storage.dart';
import '../widgets/yogibook_background.dart';
class SettingsPage extends StatefulWidget {
final String token;
final School school;
final String? userFirstName;
const SettingsPage({
super.key,
required this.token,
required this.school,
this.userFirstName,
});
@override
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
static const Color kGreen = Color(0xFF10B981);
final _auth = AuthStorage.instance;
bool _loading = true;
bool _rememberMe = false;
bool _biometric = false;
bool _biometricAvailableOnDevice = false;
// evita doppi tap durante il salvataggio
bool _busy = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final remember = await _auth.isRememberMeEnabled();
final bio = await _auth.isBiometricEnabled();
final canBio = await _auth.canUseBiometrics();
if (!mounted) return;
setState(() {
_rememberMe = remember;
_biometric = bio;
_biometricAvailableOnDevice = canBio;
_loading = false;
});
}
void _snack(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
}
// "Ricordami": se acceso salva SUBITO il token corrente in cassaforte,
// così funziona già da questa sessione (non solo dal prossimo login).
Future<void> _onRememberChanged(bool value) async {
if (_busy) return;
setState(() => _busy = true);
try {
await _auth.setRememberMe(value);
if (value) {
// salva il token che questa pagina ha in mano
await _auth.saveTokenIfRemember(widget.token);
}
// spegnere ricordami spegne anche la biometria (dipendenza)
final bioNow = await _auth.isBiometricEnabled();
if (!mounted) return;
setState(() {
_rememberMe = value;
_biometric = bioNow;
});
_snack(
value ? 'Accesso rapido attivato.' : 'Accesso rapido disattivato.',
);
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _onBiometricChanged(bool value) async {
if (_busy) return;
// non attivabile senza impronte registrate / hardware
if (value && !_biometricAvailableOnDevice) {
_snack(
'Nessuna impronta o volto registrati sul dispositivo. '
'Configurali nelle impostazioni del telefono, poi riprova.',
);
return;
}
setState(() => _busy = true);
try {
await _auth.setBiometric(value);
if (!mounted) return;
setState(() => _biometric = value);
_snack(
value
? 'Sblocco biometrico attivato.'
: 'Sblocco biometrico disattivato.',
);
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
centerTitle: true,
title: const Text(
'Impostazioni',
style: TextStyle(fontWeight: FontWeight.w800, fontSize: 18),
),
),
body: YogibookBackground(
child: SafeArea(
top: false,
child: _loading
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
_sectionTitle('Accesso'),
const SizedBox(height: 10),
_accessCard(),
const SizedBox(height: 16),
_infoNote(),
],
),
),
),
);
}
Widget _sectionTitle(String t) => Text(
t,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w900),
);
Widget _accessCard() {
// la biometria è attivabile solo se "ricordami" è ON
final biometricEnabled = _rememberMe;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
boxShadow: const [
BoxShadow(
blurRadius: 18,
color: Color(0x12000000),
offset: Offset(0, 10),
),
],
),
child: Column(
children: [
SwitchListTile(
activeColor: kGreen,
value: _rememberMe,
onChanged: _busy ? null : _onRememberChanged,
secondary: const Icon(Icons.vpn_key_rounded),
title: const Text(
'Ricordami',
style: TextStyle(fontWeight: FontWeight.w800),
),
subtitle: const Text(
'Resta connesso su questo dispositivo senza reinserire la password.',
),
),
const Divider(height: 1),
SwitchListTile(
activeColor: kGreen,
value: _biometric && biometricEnabled,
onChanged: (_busy || !biometricEnabled)
? null
: _onBiometricChanged,
secondary: Icon(
Icons.fingerprint_rounded,
color: biometricEnabled ? null : Colors.black26,
),
title: Text(
'Sblocco con impronta / volto',
style: TextStyle(
fontWeight: FontWeight.w800,
color: biometricEnabled ? null : Colors.black38,
),
),
subtitle: Text(
biometricEnabled
? 'Chiedi la biometria all\'avvio per accedere.'
: 'Attiva prima "Ricordami" per usare lo sblocco biometrico.',
style: TextStyle(color: biometricEnabled ? null : Colors.black38),
),
),
],
),
);
}
Widget _infoNote() {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFFF6F6FB),
borderRadius: BorderRadius.circular(14),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.info_outline, size: 20, color: Colors.black45),
const SizedBox(width: 10),
Expanded(
child: Text(
_biometricAvailableOnDevice
? 'Il token di accesso è salvato in forma cifrata sul dispositivo. '
'Con lo sblocco biometrico attivo, viene usato solo dopo il riconoscimento.'
: 'Questo dispositivo non ha impronta o volto configurati: '
'lo sblocco biometrico non è disponibile, ma "Ricordami" funziona lo stesso.',
style: const TextStyle(fontSize: 12, color: Colors.black54),
),
),
],
),
);
}
}
+62 -15
View File
@@ -1,7 +1,10 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../config/app_school.dart';
import '../services/auth_storage.dart';
import 'login_page.dart';
import 'home_page.dart';
class SplashPage extends StatefulWidget {
const SplashPage({super.key});
@@ -17,16 +20,19 @@ class _SplashPageState extends State<SplashPage> {
Timer? _timer;
int _seconds = 5; // countdown iniziale
// Evita doppie navigazioni: countdown, tap e sblocco possono
// altrimenti innescare più push.
bool _navigated = false;
@override
void initState() {
super.initState();
// countdown 5 → 0, poi vai al login
_timer = Timer.periodic(const Duration(seconds: 1), (t) {
if (!mounted) return;
setState(() => _seconds--);
if (_seconds <= 0) {
t.cancel();
_goToLogin();
_finishSplash();
}
});
}
@@ -37,8 +43,45 @@ class _SplashPageState extends State<SplashPage> {
super.dispose();
}
// Fine dello splash: prova lo sblocco, poi instrada.
Future<void> _finishSplash() async {
if (_navigated) return;
final outcome = await AuthStorage.instance.tryUnlockAtStartup();
if (!mounted || _navigated) return;
switch (outcome.result) {
case UnlockResult.success:
_navigated = true;
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => HomePage(
token: outcome.token!,
school: kYogaSoulSchool,
userFirstName: null,
),
),
);
break;
case UnlockResult.noSavedLogin:
case UnlockResult.biometricFailed:
case UnlockResult.biometricUnavailable:
_goToLogin();
break;
}
}
// Tap sullo schermo: salta il countdown ma passa comunque dallo sblocco.
void _onTapSkip() {
if (_navigated) return;
_timer?.cancel();
_finishSplash();
}
void _goToLogin() {
if (!mounted) return;
if (_navigated || !mounted) return;
_navigated = true;
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const LoginPage()),
@@ -48,9 +91,8 @@ class _SplashPageState extends State<SplashPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
// tocca lo schermo per saltare subito al login
body: GestureDetector(
onTap: _goToLogin,
onTap: _onTapSkip,
child: Stack(
fit: StackFit.expand,
children: [
@@ -69,7 +111,7 @@ class _SplashPageState extends State<SplashPage> {
// Logo YogaSoul
Image.asset(
'assets/images/yogasoul_logo.png',
height: 90,
height: 72,
fit: BoxFit.contain,
),
@@ -82,13 +124,11 @@ class _SplashPageState extends State<SplashPage> {
vertical: 16,
),
decoration: BoxDecoration(
// velo bianco semitrasparente: stacca il testo dal cielo
color: Colors.white.withOpacity(0.42),
borderRadius: BorderRadius.circular(20),
),
child: Column(
children: [
// Titolo riga 1 (scuro)
const Text(
'Il tuo spazio.',
textAlign: TextAlign.center,
@@ -99,7 +139,6 @@ class _SplashPageState extends State<SplashPage> {
color: Color(0xFF1F1F1F),
),
),
// Titolo riga 2 (rosso bordeaux)
const Text(
'La tua energia.',
textAlign: TextAlign.center,
@@ -113,7 +152,6 @@ class _SplashPageState extends State<SplashPage> {
const SizedBox(height: 14),
// Sottotitolo
const Text(
'Ti diamo il benvenuto in YogiBook,\n'
'l’app della scuola YogaSoul — Seregno.\n'
@@ -141,13 +179,22 @@ class _SplashPageState extends State<SplashPage> {
bottom: 30,
child: Column(
children: [
Image.asset(
'assets/images/yogibook_logo.png',
height: 40,
fit: BoxFit.contain,
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.75),
borderRadius: BorderRadius.circular(16),
),
child: Image.asset(
'assets/images/yogibook_logo.png',
height: 40,
fit: BoxFit.contain,
),
),
const SizedBox(height: 14),
// Countdown numerico dentro un cerchio
Container(
width: 40,
height: 40,
+166
View File
@@ -1 +1,167 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:local_auth/local_auth.dart';
import 'package:local_auth/error_codes.dart' as auth_error;
import 'package:flutter/services.dart';
/// Esito dello sblocco all'avvio.
enum UnlockResult {
/// Nessun login salvato: mostra la schermata di login normale.
noSavedLogin,
/// Token disponibile e pronto all'uso: entra diretto.
success,
/// C'è un login salvato ma la biometria è fallita/annullata:
/// resta sul login, l'utente può accedere con password.
biometricFailed,
/// Biometria richiesta ma non disponibile (niente impronte / hardware):
/// fallback a login con password (scelta B).
biometricUnavailable,
}
/// Custode unico di token + preferenze di accesso (ricordami + biometria).
///
/// Non è un provider: lo si interroga in momenti precisi
/// (avvio nello splash, login, logout). Vedi nota architetturale nel piano.
class AuthStorage {
AuthStorage._();
static final AuthStorage instance = AuthStorage._();
// Su Android forza l'uso di EncryptedSharedPreferences (più robusto).
static const _secure = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
final LocalAuthentication _localAuth = LocalAuthentication();
// Chiavi interne della cassaforte.
static const _kToken = 'auth_token';
static const _kRemember = 'pref_remember_me';
static const _kBiometric = 'pref_biometric';
// ---------------------------------------------------------------------------
// PREFERENZE
// ---------------------------------------------------------------------------
/// "Ricordami" attivo? (default: false)
Future<bool> isRememberMeEnabled() async {
return (await _secure.read(key: _kRemember)) == 'true';
}
/// Biometria attiva? (default: false)
Future<bool> isBiometricEnabled() async {
return (await _secure.read(key: _kBiometric)) == 'true';
}
/// Imposta "ricordami". Se lo spegni, cancella anche il token salvato:
/// senza ricordami non ha senso tenere un token in cassaforte.
Future<void> setRememberMe(bool value) async {
await _secure.write(key: _kRemember, value: value ? 'true' : 'false');
if (!value) {
await _secure.delete(key: _kToken);
// Biometria senza token salvato non ha nulla da proteggere: spegnila.
await _secure.write(key: _kBiometric, value: 'false');
}
}
/// Imposta la biometria. Ha senso solo se "ricordami" è attivo.
Future<void> setBiometric(bool value) async {
await _secure.write(key: _kBiometric, value: value ? 'true' : 'false');
}
// ---------------------------------------------------------------------------
// TOKEN
// ---------------------------------------------------------------------------
/// Salva il token SOLO se "ricordami" è attivo. Chiamato dopo il login.
Future<void> saveTokenIfRemember(String token) async {
if (await isRememberMeEnabled()) {
await _secure.write(key: _kToken, value: token);
}
}
/// Cancella tutto: token + preferenze. Chiamato al logout.
Future<void> clear() async {
await _secure.delete(key: _kToken);
await _secure.delete(key: _kRemember);
await _secure.delete(key: _kBiometric);
}
/// Lettura grezza del token (senza biometria). Uso interno.
Future<String?> _readToken() => _secure.read(key: _kToken);
// ---------------------------------------------------------------------------
// BIOMETRIA
// ---------------------------------------------------------------------------
/// Il dispositivo può fare biometria E ha almeno un metodo registrato?
Future<bool> canUseBiometrics() async {
try {
final supported = await _localAuth.isDeviceSupported();
if (!supported) return false;
final canCheck = await _localAuth.canCheckBiometrics;
if (!canCheck) return false;
final enrolled = await _localAuth.getAvailableBiometrics();
return enrolled.isNotEmpty;
} on PlatformException {
return false;
}
}
/// Chiede l'impronta/volto. true = passata, false = fallita/annullata.
Future<bool> _promptBiometric() async {
try {
return await _localAuth.authenticate(
localizedReason: 'Sblocca YogiBook per accedere al tuo account',
options: const AuthenticationOptions(
biometricOnly: true, // niente fallback al PIN del telefono (scelta B)
stickyAuth: true, // regge se l'app va in background durante il prompt
),
);
} on PlatformException catch (e) {
// Casi tipici: nessuna impronta registrata, hardware assente, troppi tentativi.
if (e.code == auth_error.notAvailable ||
e.code == auth_error.notEnrolled ||
e.code == auth_error.lockedOut ||
e.code == auth_error.permanentlyLockedOut) {
return false;
}
return false;
}
}
// ---------------------------------------------------------------------------
// SBLOCCO ALL'AVVIO (chiamato dallo splash)
// ---------------------------------------------------------------------------
/// Decide cosa fare all'avvio dell'app.
/// Restituisce l'esito; il token (se disponibile) è in [tokenOut].
Future<({UnlockResult result, String? token})> tryUnlockAtStartup() async {
// 1. Ricordami spento o nessun token → login normale.
if (!await isRememberMeEnabled()) {
return (result: UnlockResult.noSavedLogin, token: null);
}
final token = await _readToken();
if (token == null || token.isEmpty) {
return (result: UnlockResult.noSavedLogin, token: null);
}
// 2. Biometria spenta → entra diretto col token salvato.
if (!await isBiometricEnabled()) {
return (result: UnlockResult.success, token: token);
}
// 3. Biometria accesa ma non disponibile → fallback a password (scelta B).
if (!await canUseBiometrics()) {
return (result: UnlockResult.biometricUnavailable, token: null);
}
// 4. Biometria accesa e disponibile → chiedi l'impronta.
final ok = await _promptBiometric();
if (ok) {
return (result: UnlockResult.success, token: token);
}
return (result: UnlockResult.biometricFailed, token: null);
}
}
+19
View File
@@ -9,6 +9,7 @@ import '../screens/medical_certificates_page.dart';
import '../screens/lessons_page.dart';
import '../screens/orders_detail_page.dart';
import '../screens/account_page.dart';
import '../screens/settings_page.dart';
import 'package:url_launcher/url_launcher.dart';
class AppDrawer extends StatelessWidget {
@@ -166,6 +167,24 @@ class AppDrawer extends StatelessWidget {
},
),
ListTile(
leading: const Icon(Icons.settings_rounded),
title: const Text('Impostazioni'),
onTap: () {
Navigator.of(context).pop();
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => SettingsPage(
token: token,
school: school,
userFirstName: userFirstName,
),
),
);
},
),
const Divider(height: 1),
ListTile(