diff --git a/lib/main.dart b/lib/main.dart index c26f141..a16dc56 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,13 +1,17 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:intl/date_symbol_data_local.dart'; +import 'package:provider/provider.dart'; import 'screens/login_page.dart'; +import 'state/app_state.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); await initializeDateFormatting('it_IT'); - runApp(const MyApp()); + runApp( + ChangeNotifierProvider(create: (_) => AppState(), child: const MyApp()), + ); } class MyApp extends StatelessWidget { diff --git a/lib/models/school_settings.dart b/lib/models/school_settings.dart new file mode 100644 index 0000000..b61a785 --- /dev/null +++ b/lib/models/school_settings.dart @@ -0,0 +1,68 @@ +class SchoolSettings { + final int portalPurchasesEnabled; + final String allowedProductTypes; + final String paymentMethods; + final String currencyCode; + + final int enableNotifications; + final int allowFreezeGlobal; + final int freezeMaxDaysGlobal; + + final int autoPropagateOnPurchase; + final int allowFullAccessRebooking; + + final List paymentMethodsArray; + final List allowedProductTypesArray; + + const SchoolSettings({ + required this.portalPurchasesEnabled, + required this.allowedProductTypes, + required this.paymentMethods, + required this.currencyCode, + required this.enableNotifications, + required this.allowFreezeGlobal, + required this.freezeMaxDaysGlobal, + required this.autoPropagateOnPurchase, + required this.allowFullAccessRebooking, + required this.paymentMethodsArray, + required this.allowedProductTypesArray, + }); + + factory SchoolSettings.fromMap(Map m) { + int i(dynamic v) => (v is num) ? v.toInt() : int.tryParse('$v') ?? 0; + String s(dynamic v, [String def = '']) => (v ?? def).toString(); + + List list(dynamic v) { + if (v is List) return v.map((e) => e.toString()).toList(); + return []; + } + + return SchoolSettings( + portalPurchasesEnabled: i(m['portal_purchases_enabled']), + allowedProductTypes: s(m['allowed_product_types']), + paymentMethods: s(m['payment_methods']), + currencyCode: s(m['currency_code'], 'EUR'), + enableNotifications: i(m['enable_notifications']), + allowFreezeGlobal: i(m['allow_freeze_global']), + freezeMaxDaysGlobal: i(m['freeze_max_days_global']), + autoPropagateOnPurchase: i(m['auto_propagate_on_purchase']), + allowFullAccessRebooking: i(m['allow_full_access_rebooking']), + paymentMethodsArray: list(m['payment_methods_array']), + allowedProductTypesArray: list(m['allowed_product_types_array']), + ); + } + + Map toMap() => { + 'portal_purchases_enabled': portalPurchasesEnabled, + 'allowed_product_types': allowedProductTypes, + 'payment_methods': paymentMethods, + 'currency_code': currencyCode, + 'enable_notifications': enableNotifications, + 'allow_freeze_global': allowFreezeGlobal, + 'freeze_max_days_global': freezeMaxDaysGlobal, + 'auto_propagate_on_purchase': autoPropagateOnPurchase, + 'allow_full_access_rebooking': allowFullAccessRebooking, + 'payment_methods_array': paymentMethodsArray, + 'allowed_product_types_array': allowedProductTypesArray, + }; +} diff --git a/lib/models/user_settings.dart b/lib/models/user_settings.dart new file mode 100644 index 0000000..1d246ca --- /dev/null +++ b/lib/models/user_settings.dart @@ -0,0 +1,67 @@ +class UserSettings { + final int notifyEmail; + final int notifyWhatsapp; + final int notifyPush; + + final int notifyBookingConfirm; + final int notifyBookingCancel; + final int notifySessionCancel; + final int notifyPaymentReceipt; + final int notifyExpirationReminder; + + final int newsletterOptIn; + final int marketingOptIn; + + final String locale; + final String timezone; + + const UserSettings({ + required this.notifyEmail, + required this.notifyWhatsapp, + required this.notifyPush, + required this.notifyBookingConfirm, + required this.notifyBookingCancel, + required this.notifySessionCancel, + required this.notifyPaymentReceipt, + required this.notifyExpirationReminder, + required this.newsletterOptIn, + required this.marketingOptIn, + required this.locale, + required this.timezone, + }); + + factory UserSettings.fromMap(Map m) { + int i(dynamic v) => (v is num) ? v.toInt() : int.tryParse('$v') ?? 0; + String s(dynamic v, [String def = '']) => (v ?? def).toString(); + + return UserSettings( + notifyEmail: i(m['notify_email']), + notifyWhatsapp: i(m['notify_whatsapp']), + notifyPush: i(m['notify_push']), + notifyBookingConfirm: i(m['notify_booking_confirm']), + notifyBookingCancel: i(m['notify_booking_cancel']), + notifySessionCancel: i(m['notify_session_cancel']), + notifyPaymentReceipt: i(m['notify_payment_receipt']), + notifyExpirationReminder: i(m['notify_expiration_reminder']), + newsletterOptIn: i(m['newsletter_opt_in']), + marketingOptIn: i(m['marketing_opt_in']), + locale: s(m['locale'], 'it'), + timezone: s(m['timezone'], 'Europe/Rome'), + ); + } + + Map toMap() => { + 'notify_email': notifyEmail, + 'notify_whatsapp': notifyWhatsapp, + 'notify_push': notifyPush, + 'notify_booking_confirm': notifyBookingConfirm, + 'notify_booking_cancel': notifyBookingCancel, + 'notify_session_cancel': notifySessionCancel, + 'notify_payment_receipt': notifyPaymentReceipt, + 'notify_expiration_reminder': notifyExpirationReminder, + 'newsletter_opt_in': newsletterOptIn, + 'marketing_opt_in': marketingOptIn, + 'locale': locale, + 'timezone': timezone, + }; +} diff --git a/lib/screens/home_page.dart b/lib/screens/home_page.dart index 0cfcef6..7e62fee 100644 --- a/lib/screens/home_page.dart +++ b/lib/screens/home_page.dart @@ -9,6 +9,9 @@ import 'meditation_page.dart'; import '../services/vanguard_api.dart'; import 'login_page.dart'; import 'medical_certificates_page.dart'; +import '../widgets/app_drawer.dart'; +import '../widgets/app_bottom_nav.dart'; +import '../widgets/yogibook_background.dart'; class HomePage extends StatefulWidget { final String token; @@ -251,60 +254,14 @@ class _HomePageState extends State { @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: kBg, - drawer: Drawer( - child: SafeArea( - child: ListView( - padding: EdgeInsets.zero, - children: [ - const DrawerHeader( - child: Align( - alignment: Alignment.bottomLeft, - child: Text( - 'Menu', - style: TextStyle(fontSize: 22, fontWeight: FontWeight.w900), - ), - ), - ), - ListTile( - leading: const Icon(Icons.swap_horiz), - title: const Text('Cambia scuola'), - onTap: () { - Navigator.of(context).pop(); - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (_) => SelectSchoolPage(token: widget.token), - ), - ); - }, - ), - ListTile( - leading: const Icon(Icons.medical_information), - title: const Text('Certificati medici'), - onTap: () { - Navigator.of(context).pop(); // chiude drawer - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => - MedicalCertificatesPage(token: widget.token), - ), - ); - }, - ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.logout), - title: const Text('Logout'), - onTap: _logout, - ), - ], - ), - ), + drawer: AppDrawer( + token: widget.token, + school: widget.school, + userFirstName: widget.userFirstName, ), + appBar: AppBar( - backgroundColor: kBg, + backgroundColor: Colors.transparent, elevation: 0, centerTitle: true, title: const Text( @@ -329,128 +286,125 @@ class _HomePageState extends State { ), ], ), - bottomNavigationBar: BottomNavigationBar( - type: BottomNavigationBarType.fixed, // con 4 icone serve - backgroundColor: Colors.white, - selectedItemColor: const Color(0xFF10B981), - unselectedItemColor: Colors.black54, + + bottomNavigationBar: AppBottomNav( currentIndex: bottomIndex, onTap: _handleBottomNav, - items: const [ - BottomNavigationBarItem( - icon: Icon(Icons.home_rounded), - label: 'Home', - ), - BottomNavigationBarItem( - icon: Icon(Icons.event_note_rounded), - label: 'Lezioni', - ), - BottomNavigationBarItem( - icon: Icon(Icons.person_rounded), - label: 'Account', - ), - BottomNavigationBarItem( - icon: Icon(Icons.self_improvement_rounded), - label: 'Meditazione', - ), - ], ), - body: Padding( - padding: const EdgeInsets.fromLTRB(16, 10, 16, 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Ciao + nome - Text( - _name.isNotEmpty ? 'Ciao, $_name' : 'Ciao', - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w900), - ), - const SizedBox(height: 10), + body: YogibookBackground( + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, - // Nome scuola + indirizzo sotto - Text( - widget.school.name, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w900), - ), - if (_schoolAddress.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - _schoolAddress, - style: const TextStyle( - fontSize: 12, - color: Colors.black54, - fontWeight: FontWeight.w600, + children: [ + // Ciao + nome + Text( + _name.isNotEmpty ? 'Ciao, $_name' : 'Ciao', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w900, + ), ), - ), - ], - const SizedBox(height: 10), + const SizedBox(height: 10), - // Pulsante mappe - OutlinedButton.icon( - onPressed: _openMaps, - icon: const Icon(Icons.directions, size: 18, color: kGreen), - label: const Text( - 'Apri Mappe', - style: TextStyle(fontWeight: FontWeight.w800, color: kGreen), - ), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: kGreen, width: 2), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), + // Nome scuola + indirizzo sotto + Text( + widget.school.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w900, + ), ), - ), - ), - - const SizedBox(height: 14), - - // Tiles - _HomeTile( - icon: Icons.event_note_rounded, - title: 'Vedi lezioni', - subtitle: 'Prenotazioni e gestione lezioni', - onTap: _goLessons, - ), - _HomeTile( - icon: Icons.shopping_bag_rounded, - title: 'Vedi ordini', - subtitle: 'Storico e stato ordini (TODO)', - onTap: () => ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('TODO: Ordini'))), - ), - _HomeTile( - icon: Icons.person_rounded, - title: 'Account', - subtitle: 'Profilo e impostazioni (TODO)', - onTap: () => ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('TODO: Account'))), - ), - - const Spacer(), - - // Logo + frase zen - Center( - child: Column( - children: [ + if (_schoolAddress.isNotEmpty) ...[ + const SizedBox(height: 4), Text( - '“$zenQuote”', - textAlign: TextAlign.center, + _schoolAddress, style: const TextStyle( - fontSize: 16, - height: 1.3, - color: Colors.black87, - fontWeight: FontWeight.w700, + fontSize: 12, + color: Colors.black54, + fontWeight: FontWeight.w600, ), ), - const SizedBox(height: 14), - Image.asset('assets/images/yogibook_logo.png', height: 78), - const SizedBox(height: 6), ], - ), + const SizedBox(height: 10), + + // Pulsante mappe + OutlinedButton.icon( + onPressed: _openMaps, + icon: const Icon(Icons.directions, size: 18, color: kGreen), + label: const Text( + 'Apri Mappe', + style: TextStyle( + fontWeight: FontWeight.w800, + color: kGreen, + ), + ), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: kGreen, width: 2), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + ), + + const SizedBox(height: 14), + + // Tiles + _HomeTile( + icon: Icons.event_note_rounded, + title: 'Vedi lezioni', + subtitle: 'Prenotazioni e gestione lezioni', + onTap: _goLessons, + ), + _HomeTile( + icon: Icons.shopping_bag_rounded, + title: 'Vedi ordini', + subtitle: 'Storico e stato ordini (TODO)', + onTap: () => ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('TODO: Ordini'))), + ), + _HomeTile( + icon: Icons.person_rounded, + title: 'Account', + subtitle: 'Profilo e impostazioni (TODO)', + onTap: () => ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('TODO: Account')), + ), + ), + + const Spacer(), + + // Logo + frase zen + Center( + child: Column( + children: [ + Text( + '“$zenQuote”', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16, + height: 1.3, + color: Colors.black87, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 14), + Image.asset( + 'assets/images/yogibook_logo.png', + height: 78, + ), + const SizedBox(height: 6), + ], + ), + ), + ], ), - ], + ), ), ), ); diff --git a/lib/screens/lessons_page.dart b/lib/screens/lessons_page.dart index 7786c47..8e87bdb 100644 --- a/lib/screens/lessons_page.dart +++ b/lib/screens/lessons_page.dart @@ -6,6 +6,9 @@ import '../services/vanguard_api.dart'; import 'select_school_page.dart'; import 'home_page.dart'; import 'meditation_page.dart'; +import '../widgets/app_drawer.dart'; +import '../widgets/app_bottom_nav.dart'; +import '../widgets/yogibook_background.dart'; class LessonsPage extends StatefulWidget { final String token; @@ -136,18 +139,14 @@ class _LessonsPageState extends State { @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: const Color(0xFFF6F6FB), - drawer: _AppDrawer( + drawer: AppDrawer( token: widget.token, - onLogout: () { - Navigator.of(context).pop(); - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Logout (TODO)'))); - }, + school: widget.school, + userFirstName: widget.userFirstName, ), + appBar: AppBar( - backgroundColor: const Color(0xFFF6F6FB), + backgroundColor: Colors.transparent, elevation: 0, centerTitle: true, title: Column( @@ -185,127 +184,109 @@ class _LessonsPageState extends State { ), ], ), - bottomNavigationBar: BottomNavigationBar( - type: BottomNavigationBarType.fixed, // con 4 icone serve - backgroundColor: Colors.white, - selectedItemColor: const Color(0xFF10B981), - unselectedItemColor: Colors.black54, + bottomNavigationBar: AppBottomNav( currentIndex: bottomIndex, onTap: _handleBottomNav, - items: const [ - BottomNavigationBarItem( - icon: Icon(Icons.home_rounded), - label: 'Home', - ), - BottomNavigationBarItem( - icon: Icon(Icons.event_note_rounded), - label: 'Lezioni', - ), - BottomNavigationBarItem( - icon: Icon(Icons.person_rounded), - label: 'Account', - ), - BottomNavigationBarItem( - icon: Icon(Icons.self_improvement_rounded), - label: 'Meditazione', - ), - ], ), - body: SafeArea( - top: false, - child: Column( - children: [ - // Address small (optional) - if (schoolAddress != null && schoolAddress!.trim().isNotEmpty) - Padding( - padding: const EdgeInsets.fromLTRB(16, 6, 16, 6), - child: Text( - schoolAddress!, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 12, - color: Colors.black45, - fontWeight: FontWeight.w500, + body: YogibookBackground( + child: SafeArea( + top: false, + child: Column( + children: [ + // Address small (optional) + if (schoolAddress != null && schoolAddress!.trim().isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 6, 16, 6), + child: Text( + schoolAddress!, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 12, + color: Colors.black45, + fontWeight: FontWeight.w500, + ), ), ), + + // Month selector only + Padding( + padding: const EdgeInsets.fromLTRB(16, 6, 16, 10), + child: _MonthPillCompact( + label: _monthLabel(currentMonth), + onPrev: prevMonth == null + ? null + : () { + setState(() => currentMonth = prevMonth!); + _load(); + }, + onNext: nextMonth == null + ? null + : () { + setState(() => currentMonth = nextMonth!); + _load(); + }, + ), ), - // Month selector only - Padding( - padding: const EdgeInsets.fromLTRB(16, 6, 16, 10), - child: _MonthPillCompact( - label: _monthLabel(currentMonth), - onPrev: prevMonth == null - ? null - : () { - setState(() => currentMonth = prevMonth!); - _load(); - }, - onNext: nextMonth == null - ? null - : () { - setState(() => currentMonth = nextMonth!); - _load(); - }, - ), - ), - - Expanded( - child: loading - ? const Center(child: CircularProgressIndicator()) - : error.isNotEmpty - ? _ErrorBox(message: error, onRetry: _load) - : lessons.isEmpty - ? _EmptyState( - onBrowse: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('TODO: vedi corsi')), - ); - }, - ) - : ListView.builder( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - itemCount: lessons.length + 1, - itemBuilder: (_, i) { - if (i == lessons.length) { - return Padding( - padding: const EdgeInsets.only(top: 8, bottom: 6), - child: Center( - child: Image.asset( - 'assets/images/yogibook_logo.png', - height: 56, - ), - ), + Expanded( + child: loading + ? const Center(child: CircularProgressIndicator()) + : error.isNotEmpty + ? _ErrorBox(message: error, onRetry: _load) + : lessons.isEmpty + ? _EmptyState( + onBrowse: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('TODO: vedi corsi')), ); - } + }, + ) + : ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + itemCount: lessons.length + 1, + itemBuilder: (_, i) { + if (i == lessons.length) { + return Padding( + padding: const EdgeInsets.only(top: 8, bottom: 6), + child: Center( + child: Image.asset( + 'assets/images/yogibook_logo.png', + height: 56, + ), + ), + ); + } - final l = lessons[i]; - return _LessonGreenCardCompact( - lesson: l, - weekday: _weekdayLabel(l.date), - dayNum: _dayNum(l.date), - onReschedule: l.canModify - ? () => - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Riprogramma: API dopo'), - ), - ) - : null, - onCancel: l.canModify - ? () => - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Cancella: API dopo'), - ), - ) - : null, - ); - }, - ), - ), - ], + final l = lessons[i]; + return _LessonGreenCardCompact( + lesson: l, + weekday: _weekdayLabel(l.date), + dayNum: _dayNum(l.date), + onReschedule: l.canModify + ? () => ScaffoldMessenger.of(context) + .showSnackBar( + const SnackBar( + content: Text( + 'Riprogramma: API dopo', + ), + ), + ) + : null, + onCancel: l.canModify + ? () => ScaffoldMessenger.of(context) + .showSnackBar( + const SnackBar( + content: Text('Cancella: API dopo'), + ), + ) + : null, + ); + }, + ), + ), + ], + ), ), ), ); diff --git a/lib/screens/medical_certificates_page.dart b/lib/screens/medical_certificates_page.dart index defd71e..e6862ce 100644 --- a/lib/screens/medical_certificates_page.dart +++ b/lib/screens/medical_certificates_page.dart @@ -1,3 +1,4 @@ +// lib/screens/medical_certificates_page.dart import 'dart:io'; import 'package:flutter/material.dart'; @@ -5,13 +6,29 @@ import 'package:image_picker/image_picker.dart'; import 'package:file_picker/file_picker.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../models/school.dart'; import '../services/medical_certificates_api.dart'; import '../config/api_config.dart'; +import '../widgets/app_drawer.dart'; +import '../widgets/app_bottom_nav.dart'; + +import 'home_page.dart'; +import 'lessons_page.dart'; +import 'meditation_page.dart'; +import '../widgets/yogibook_background.dart'; + class MedicalCertificatesPage extends StatefulWidget { final String token; + final School school; + final String? userFirstName; - const MedicalCertificatesPage({super.key, required this.token}); + const MedicalCertificatesPage({ + super.key, + required this.token, + required this.school, + this.userFirstName, + }); @override State createState() => @@ -19,6 +36,8 @@ class MedicalCertificatesPage extends StatefulWidget { } class _MedicalCertificatesPageState extends State { + static const Color kBg = Color(0xFFF6F6FB); + bool loading = true; String error = ''; List> certs = []; @@ -31,6 +50,9 @@ class _MedicalCertificatesPageState extends State { DateTime? expiryDate; bool uploading = false; + // ✅ questa pagina è "sezione account" + int bottomIndex = 2; + @override void initState() { super.initState(); @@ -60,7 +82,6 @@ class _MedicalCertificatesPageState extends State { } Future _pickFromChooser() async { - // 1 solo bottone -> bottom sheet scelta final choice = await showModalBottomSheet( context: context, showDragHandle: true, @@ -123,7 +144,6 @@ class _MedicalCertificatesPageState extends State { setState(() { pickedFile = f; pickedLabel = label; - // reset campi per nuovo upload docNameCtrl.text = 'certificato'; notesCtrl.clear(); expiryDate = null; @@ -182,7 +202,6 @@ class _MedicalCertificatesPageState extends State { notes: notesCtrl.text, ); - // reset form setState(() { pickedFile = null; pickedLabel = ''; @@ -205,10 +224,21 @@ class _MedicalCertificatesPageState extends State { } Future _openFileUrl(String url) async { - // url arriva tipo "/userarea/certificate/xxx" - // per aprirlo serve url assoluto - final abs = '${ApiConfig.scheme}://${ApiConfig.host}$url'; + // url tipico: "/userarea/certificate/xxx" + // fix plural errato se presente + var fixed = url.replaceFirst( + '/userarea/certificates/', + '/userarea/certificate/', + ); + + // se non ha /public davanti, lo aggiungiamo + if (!fixed.startsWith('/public/')) { + fixed = '/public${fixed.startsWith('/') ? '' : '/'}$fixed'; + } + + final abs = '${ApiConfig.scheme}://${ApiConfig.host}$fixed'; final uri = Uri.parse(abs); + final ok = await launchUrl(uri, mode: LaunchMode.externalApplication); if (!ok && mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -256,10 +286,67 @@ class _MedicalCertificatesPageState extends State { bool _isPdfPath(String p) => p.toLowerCase().endsWith('.pdf'); + Future _goTo(Widget page) async { + if (!mounted) return; + Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => page)); + } + + void _handleBottomNav(int i) { + if (i == bottomIndex) return; + setState(() => bottomIndex = i); + + if (i == 0) { + _goTo( + HomePage( + token: widget.token, + school: widget.school, + userFirstName: widget.userFirstName, + ), + ); + return; + } + + if (i == 1) { + _goTo( + LessonsPage( + token: widget.token, + school: widget.school, + userFirstName: widget.userFirstName, + ), + ); + return; + } + + if (i == 2) { + // siamo già in "area account", non facciamo nulla (o TODO) + return; + } + + if (i == 3) { + _goTo( + MeditationPage( + token: widget.token, + school: widget.school, + userFirstName: widget.userFirstName, + ), + ); + return; + } + } + @override Widget build(BuildContext context) { return Scaffold( + // ✅ Drawer standard (come MeditationPage) + drawer: AppDrawer( + token: widget.token, + school: widget.school, + userFirstName: widget.userFirstName, + ), + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, title: const Text( 'Certificati medici', style: TextStyle(fontWeight: FontWeight.w900), @@ -271,206 +358,223 @@ class _MedicalCertificatesPageState extends State { ), ], ), - body: RefreshIndicator( - onRefresh: _reload, - child: ListView( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 22), - children: [ - // 1 SOLO bottone in alto - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: uploading ? null : _pickFromChooser, - icon: const Icon(Icons.cloud_upload), - label: const Text('Carica certificato'), - ), - ), - const SizedBox(height: 12), + // ✅ Bottom nav standard (come MeditationPage) + bottomNavigationBar: AppBottomNav( + currentIndex: bottomIndex, + onTap: _handleBottomNav, + ), - // Preview + form (solo se file selezionato) - if (pickedFile != null) ...[ - _UploadCard( - filename: pickedLabel.isNotEmpty - ? pickedLabel - : pickedFile!.path.split(Platform.pathSeparator).last, - isImage: _isImagePath(pickedFile!.path), - isPdf: _isPdfPath(pickedFile!.path), - file: pickedFile!, - documentNameController: docNameCtrl, - notesController: notesCtrl, - expiryDate: expiryDate, - onPickExpiry: _pickExpiry, - onUpload: uploading ? null : _doUpload, - uploading: uploading, - expiryLabel: expiryDate == null - ? 'Seleziona data' - : _fmtDDMMYYYY(expiryDate!), - ), - const SizedBox(height: 14), - ], - - if (error.isNotEmpty) ...[ - Text(error, style: const TextStyle(color: Colors.red)), - const SizedBox(height: 10), - ], - - Text( - 'Certificati caricati (${certs.length})', - style: const TextStyle(fontWeight: FontWeight.w900, fontSize: 16), - ), - const SizedBox(height: 10), - - if (loading) - const Padding( - padding: EdgeInsets.only(top: 30), - child: Center(child: CircularProgressIndicator()), - ) - else if (certs.isEmpty) - const Padding( - padding: EdgeInsets.only(top: 26), - child: Center(child: Text('Nessun certificato caricato')), - ) - else - ...certs.map((c) { - final expired = c['is_expired'] == true; - final docName = (c['document_name'] ?? '').toString(); - final filename = (c['filename'] ?? '').toString(); - final uploadedAt = (c['uploaded_at'] ?? '').toString(); - final expiry = (c['expiry_date'] ?? '').toString(); - final notes = (c['notes'] ?? '').toString(); - final fileUrl = (c['file_url'] ?? '').toString(); - - return Card( - elevation: 0, - color: expired ? const Color(0xFFFFEBEE) : Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + body: YogibookBackground( + child: SafeArea( + top: false, + child: RefreshIndicator( + onRefresh: _reload, + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 22), + children: [ + // 1 SOLO bottone in alto + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: uploading ? null : _pickFromChooser, + icon: const Icon(Icons.cloud_upload), + label: const Text('Carica certificato'), ), - child: Padding( - padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + ), + + const SizedBox(height: 12), + + // Preview + form (solo se file selezionato) + if (pickedFile != null) ...[ + _UploadCard( + filename: pickedLabel.isNotEmpty + ? pickedLabel + : pickedFile!.path.split(Platform.pathSeparator).last, + isImage: _isImagePath(pickedFile!.path), + isPdf: _isPdfPath(pickedFile!.path), + file: pickedFile!, + documentNameController: docNameCtrl, + notesController: notesCtrl, + expiryDate: expiryDate, + onPickExpiry: _pickExpiry, + onUpload: uploading ? null : _doUpload, + uploading: uploading, + expiryLabel: expiryDate == null + ? 'Seleziona data' + : _fmtDDMMYYYY(expiryDate!), + ), + const SizedBox(height: 14), + ], + + if (error.isNotEmpty) ...[ + Text(error, style: const TextStyle(color: Colors.red)), + const SizedBox(height: 10), + ], + + Text( + 'Certificati caricati (${certs.length})', + style: const TextStyle( + fontWeight: FontWeight.w900, + fontSize: 16, + ), + ), + const SizedBox(height: 10), + + if (loading) + const Padding( + padding: EdgeInsets.only(top: 30), + child: Center(child: CircularProgressIndicator()), + ) + else if (certs.isEmpty) + const Padding( + padding: EdgeInsets.only(top: 26), + child: Center(child: Text('Nessun certificato caricato')), + ) + else + ...certs.map((c) { + final expired = c['is_expired'] == true; + final docName = (c['document_name'] ?? '').toString(); + final filename = (c['filename'] ?? '').toString(); + final uploadedAt = (c['uploaded_at'] ?? '').toString(); + final expiry = (c['expiry_date'] ?? '').toString(); + final notes = (c['notes'] ?? '').toString(); + final fileUrl = (c['file_url'] ?? '').toString(); + + return Card( + elevation: 0, + color: expired ? const Color(0xFFFFEBEE) : Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Text( - docName.isNotEmpty ? docName : 'certificato', - style: const TextStyle( - fontWeight: FontWeight.w900, - fontSize: 15, - ), - ), - ), - if (expired) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - decoration: BoxDecoration( - color: const Color(0xFFDC3545), - borderRadius: BorderRadius.circular(999), - ), - child: const Text( - 'SCADUTO', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.w900, - fontSize: 11, + Row( + children: [ + Expanded( + child: Text( + docName.isNotEmpty + ? docName + : 'certificato', + style: const TextStyle( + fontWeight: FontWeight.w900, + fontSize: 15, + ), ), ), - ), - ], - ), - const SizedBox(height: 6), - Text( - filename, - style: const TextStyle( - color: Colors.black54, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - const SizedBox(height: 10), - - Row( - children: [ - const Icon( - Icons.event, - size: 16, - color: Colors.black54, + if (expired) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: const Color(0xFFDC3545), + borderRadius: BorderRadius.circular(999), + ), + child: const Text( + 'SCADUTO', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w900, + fontSize: 11, + ), + ), + ), + ], ), - const SizedBox(width: 6), - Expanded( - child: Text( - 'Caricato: $uploadedAt', - style: const TextStyle( + const SizedBox(height: 6), + Text( + filename, + style: const TextStyle( + color: Colors.black54, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + const SizedBox(height: 10), + + Row( + children: [ + const Icon( + Icons.event, + size: 16, color: Colors.black54, - fontSize: 12, ), - ), - ), - ], - ), - const SizedBox(height: 6), - Row( - children: [ - const Icon( - Icons.timer, - size: 16, - color: Colors.black54, - ), - const SizedBox(width: 6), - Expanded( - child: Text( - 'Scadenza: ${expiry.isEmpty ? '—' : expiry}', - style: TextStyle( - color: expired - ? const Color(0xFFDC3545) - : Colors.black54, - fontSize: 12, + const SizedBox(width: 6), + Expanded( + child: Text( + 'Caricato: $uploadedAt', + style: const TextStyle( + color: Colors.black54, + fontSize: 12, + ), + ), ), - ), + ], + ), + const SizedBox(height: 6), + Row( + children: [ + const Icon( + Icons.timer, + size: 16, + color: Colors.black54, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + 'Scadenza: ${expiry.isEmpty ? '—' : expiry}', + style: TextStyle( + color: expired + ? const Color(0xFFDC3545) + : Colors.black54, + fontSize: 12, + ), + ), + ), + ], + ), + + if (notes.trim().isNotEmpty) ...[ + const SizedBox(height: 10), + Text(notes, style: const TextStyle(fontSize: 12)), + ], + + const SizedBox(height: 10), + Row( + children: [ + TextButton.icon( + onPressed: () => _openFileUrl(fileUrl), + icon: const Icon(Icons.open_in_new), + label: const Text('Apri'), + ), + const Spacer(), + TextButton.icon( + onPressed: () => + _deleteCert((c['id'] as num).toInt()), + icon: const Icon( + Icons.delete_outline, + color: Colors.red, + ), + label: const Text( + 'Elimina', + style: TextStyle(color: Colors.red), + ), + ), + ], ), ], ), - - if (notes.trim().isNotEmpty) ...[ - const SizedBox(height: 10), - Text(notes, style: const TextStyle(fontSize: 12)), - ], - - const SizedBox(height: 10), - Row( - children: [ - TextButton.icon( - onPressed: () => _openFileUrl(fileUrl), - icon: const Icon(Icons.open_in_new), - label: const Text('Apri'), - ), - const Spacer(), - TextButton.icon( - onPressed: () => - _deleteCert((c['id'] as num).toInt()), - icon: const Icon( - Icons.delete_outline, - color: Colors.red, - ), - label: const Text( - 'Elimina', - style: TextStyle(color: Colors.red), - ), - ), - ], - ), - ], - ), - ), - ); - }), - ], + ), + ); + }), + ], + ), + ), ), ), ); @@ -567,17 +671,12 @@ class _UploadCard extends StatelessWidget { ), const SizedBox(height: 10), - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: onPickExpiry, - icon: const Icon(Icons.date_range), - label: Text(expiryLabel), - ), - ), - ], + OutlinedButton.icon( + onPressed: onPickExpiry, + icon: const Icon(Icons.date_range), + label: Text(expiryLabel), ), + const SizedBox(height: 10), TextField( diff --git a/lib/screens/meditation_page.dart b/lib/screens/meditation_page.dart index 01caa0c..f884419 100644 --- a/lib/screens/meditation_page.dart +++ b/lib/screens/meditation_page.dart @@ -8,6 +8,9 @@ import 'home_page.dart'; import 'lessons_page.dart'; import 'select_school_page.dart'; import 'login_page.dart'; +import '../widgets/app_drawer.dart'; +import '../widgets/app_bottom_nav.dart'; +import '../widgets/yogibook_background.dart'; class MeditationPage extends StatefulWidget { final String token; @@ -288,41 +291,14 @@ class _MeditationPageState extends State ).animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut)); return Scaffold( - backgroundColor: kBg, - drawer: Drawer( - child: SafeArea( - child: ListView( - padding: EdgeInsets.zero, - children: [ - const DrawerHeader( - child: Align( - alignment: Alignment.bottomLeft, - child: Text( - 'Menu', - style: TextStyle(fontSize: 22, fontWeight: FontWeight.w900), - ), - ), - ), - ListTile( - leading: const Icon(Icons.swap_horiz), - title: const Text('Cambia scuola'), - onTap: () async { - Navigator.of(context).pop(); // chiude drawer - await _goTo(SelectSchoolPage(token: widget.token)); - }, - ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.logout), - title: const Text('Logout'), - onTap: _logout, - ), - ], - ), - ), + drawer: AppDrawer( + token: widget.token, + school: widget.school, + userFirstName: widget.userFirstName, ), + appBar: AppBar( - backgroundColor: kBg, + backgroundColor: Colors.transparent, elevation: 0, centerTitle: true, title: const Text( @@ -381,203 +357,205 @@ class _MeditationPageState extends State ), ], ), - body: SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 10, 16, 16), - child: Column( - children: [ - // Top card with progress - Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB(14, 12, 14, 14), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(18), - boxShadow: const [ - BoxShadow( - blurRadius: 18, - color: Color(0x12000000), - offset: Offset(0, 10), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Respiro consapevole', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w900, + body: YogibookBackground( + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 16), + child: Column( + children: [ + // Top card with progress + Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(14, 12, 14, 14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + boxShadow: const [ + BoxShadow( + blurRadius: 18, + color: Color(0x12000000), + offset: Offset(0, 10), ), - ), - const SizedBox(height: 8), - ClipRRect( - borderRadius: BorderRadius.circular(999), - child: LinearProgressIndicator( - minHeight: 8, - value: _progress, - backgroundColor: const Color(0xFFE9E9EF), - valueColor: const AlwaysStoppedAnimation( - Color(0xFFF59E0B), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Respiro consapevole', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w900, ), ), - ), - ], + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + minHeight: 8, + value: _progress, + backgroundColor: const Color(0xFFE9E9EF), + valueColor: const AlwaysStoppedAnimation( + Color(0xFFF59E0B), + ), + ), + ), + ], + ), ), - ), - const SizedBox(height: 20), + const SizedBox(height: 20), - // Square + countdown - Expanded( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.only(top: 8, bottom: 8), - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AnimatedBuilder( - animation: Listenable.merge([ - _squareCtrl, - bubbleScale, - ]), - builder: (_, __) { - final scale = bubbleScale.value; + // Square + countdown + Expanded( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.only(top: 8, bottom: 8), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedBuilder( + animation: Listenable.merge([ + _squareCtrl, + bubbleScale, + ]), + builder: (_, __) { + final scale = bubbleScale.value; - return Transform.scale( - scale: scale, - child: CustomPaint( - painter: _SquareBreathPainter( - t: _squareCtrl.value, - color: kGreen, - ), - child: SizedBox( - width: 240, - height: 240, - child: Center( - child: Text( - _breathLabel, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w800, - fontSize: 18, + return Transform.scale( + scale: scale, + child: CustomPaint( + painter: _SquareBreathPainter( + t: _squareCtrl.value, + color: kGreen, + ), + child: SizedBox( + width: 240, + height: 240, + child: Center( + child: Text( + _breathLabel, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w800, + fontSize: 18, + ), ), ), ), ), - ), - ); - }, - ), - - const SizedBox(height: 24), - - Text( - _mmss(remainingSeconds), - style: const TextStyle( - fontSize: 44, - fontWeight: FontWeight.w900, - color: Color(0xFF1F1F1F), + ); + }, ), - ), - const SizedBox(height: 6), - const Text( - 'Tempo rimanente', - style: TextStyle( - color: Colors.black45, - fontWeight: FontWeight.w600, + + const SizedBox(height: 24), + + Text( + _mmss(remainingSeconds), + style: const TextStyle( + fontSize: 44, + fontWeight: FontWeight.w900, + color: Color(0xFF1F1F1F), + ), ), - ), - - const SizedBox(height: 18), - - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox( - height: 44, - child: ElevatedButton.icon( - onPressed: running ? _pause : _start, - style: ElevatedButton.styleFrom( - backgroundColor: kGreen, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - ), - icon: Icon( - running ? Icons.pause : Icons.play_arrow, - ), - label: Text(running ? 'Pausa' : 'Avvia'), - ), + const SizedBox(height: 6), + const Text( + 'Tempo rimanente', + style: TextStyle( + color: Colors.black45, + fontWeight: FontWeight.w600, ), - const SizedBox(width: 12), - SizedBox( - height: 44, - child: OutlinedButton( - onPressed: _reset, - style: OutlinedButton.styleFrom( - side: const BorderSide( - color: kGreen, - width: 2, + ), + + const SizedBox(height: 18), + + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + height: 44, + child: ElevatedButton.icon( + onPressed: running ? _pause : _start, + style: ElevatedButton.styleFrom( + backgroundColor: kGreen, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), + icon: Icon( + running ? Icons.pause : Icons.play_arrow, ), + label: Text(running ? 'Pausa' : 'Avvia'), ), - child: const Text( - 'Reset', - style: TextStyle( - color: Colors.black87, - fontWeight: FontWeight.w800, + ), + const SizedBox(width: 12), + SizedBox( + height: 44, + child: OutlinedButton( + onPressed: _reset, + style: OutlinedButton.styleFrom( + side: const BorderSide( + color: kGreen, + width: 2, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: const Text( + 'Reset', + style: TextStyle( + color: Colors.black87, + fontWeight: FontWeight.w800, + ), ), ), ), - ), - ], - ), - ], - ), - ), - ), - ), - ), - - SizedBox( - width: double.infinity, - height: 52, - child: ElevatedButton( - onPressed: () async { - _pause(); - await _stopMusic(); - if (!mounted) return; - - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (_) => HomePage( - token: widget.token, - school: widget.school, - userFirstName: widget.userFirstName, + ], + ), + ], ), ), - ); - }, - style: ElevatedButton.styleFrom( - backgroundColor: kGreen, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), ), ), - child: const Text( - 'TERMINA SESSIONE', - style: TextStyle(fontWeight: FontWeight.w900), + ), + + SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: () async { + _pause(); + await _stopMusic(); + if (!mounted) return; + + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => HomePage( + token: widget.token, + school: widget.school, + userFirstName: widget.userFirstName, + ), + ), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: kGreen, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: const Text( + 'TERMINA SESSIONE', + style: TextStyle(fontWeight: FontWeight.w900), + ), ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/screens/select_school_page.dart b/lib/screens/select_school_page.dart index c6fc866..843991b 100644 --- a/lib/screens/select_school_page.dart +++ b/lib/screens/select_school_page.dart @@ -1,10 +1,14 @@ import 'package:flutter/material.dart'; import '../models/school.dart'; import '../services/vanguard_api.dart'; -import 'lessons_page.dart'; import 'home_page.dart'; -import 'meditation_page.dart'; import 'login_page.dart'; +import 'package:provider/provider.dart'; +import '../state/app_state.dart'; +import '../widgets/yogibook_background.dart'; + +// TODO (step successivo): creeremo questi due file +// import '../services/settings_bootstrap.dart'; class SelectSchoolPage extends StatefulWidget { final String token; @@ -15,7 +19,6 @@ class SelectSchoolPage extends StatefulWidget { } class _SelectSchoolPageState extends State { - static const Color kBg = Color(0xFFF6F6FB); static const Color kGreen = Color(0xFF10B981); bool loading = true; @@ -23,8 +26,6 @@ class _SelectSchoolPageState extends State { String? firstName; List schools = []; - int bottomIndex = 1; // 0=Home, 1=Lezioni, 2=Account (qui siamo in "Lezioni") - @override void initState() { super.initState(); @@ -51,26 +52,50 @@ class _SelectSchoolPageState extends State { // Auto-select if API says so final autoSelect = data['auto_select'] == true; final selectedId = data['selected_school_id']; + if (autoSelect && selectedId != null && schools.isNotEmpty) { final id = (selectedId as num).toInt(); final s = schools.firstWhere((x) => x.id == id); - _enterSchool(s); + await _enterSchool(s); // ✅ ora async } } catch (e) { setState(() => error = 'Errore: $e'); } finally { - setState(() => loading = false); + if (mounted) setState(() => loading = false); } } - void _enterSchool(School s) { - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (_) => - HomePage(token: widget.token, school: s, userFirstName: firstName), - ), - ); + Future _enterSchool(School s) async { + setState(() { + loading = true; + error = ''; + }); + + try { + // ✅ CARICA settings (user + school) nello store globale AppState + await context.read().bootstrap( + token: widget.token, + school: s, + userFirstName: firstName, + ); + + if (!mounted) return; + + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => HomePage( + token: widget.token, + school: s, + userFirstName: firstName, + ), + ), + ); + } catch (e) { + if (mounted) setState(() => error = 'Errore caricando impostazioni: $e'); + } finally { + if (mounted) setState(() => loading = false); + } } String get _name => (firstName ?? '').trim(); @@ -97,31 +122,20 @@ class _SelectSchoolPageState extends State { if (ok != true) return; - // chiude il drawer se aperto Navigator.of(context).pop(); - // chiama API logout (se fallisce usciamo lo stesso) try { await VanguardApi.logout(token: widget.token); } catch (_) {} if (!mounted) return; - // torna a Login e cancella tutta la stack Navigator.of(context).pushAndRemoveUntil( MaterialPageRoute(builder: (_) => const LoginPage()), (route) => false, ); } - void _handleBottomNav(int i) { - setState(() => bottomIndex = i); - - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Prima seleziona la scuola'))); - } - @override Widget build(BuildContext context) { return Scaffold( @@ -153,84 +167,20 @@ class _SelectSchoolPageState extends State { ), ], ), - bottomNavigationBar: BottomNavigationBar( - type: BottomNavigationBarType.fixed, // con 4 icone serve - backgroundColor: Colors.white, - selectedItemColor: const Color(0xFF10B981), - unselectedItemColor: Colors.black54, - currentIndex: bottomIndex, - onTap: _handleBottomNav, - items: const [ - BottomNavigationBarItem( - icon: Icon(Icons.home_rounded), - label: 'Home', - ), - BottomNavigationBarItem( - icon: Icon(Icons.event_note_rounded), - label: 'Lezioni', - ), - BottomNavigationBarItem( - icon: Icon(Icons.person_rounded), - label: 'Account', - ), - BottomNavigationBarItem( - icon: Icon(Icons.self_improvement_rounded), - label: 'Meditazione', - ), - ], - ), - body: Stack( - children: [ - // --- SFONDO (gradient) - Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - Color(0xFFF4F6FA), - Color(0xFFEFF7F3), - Color(0xFFF4F6FA), - ], - ), - ), - ), - - // --- BOLLE decorative - const _BgBlob( - color: Color(0x3310B981), - size: 280, - top: -70, - left: -90, - ), - const _BgBlob( - color: Color(0x22F59E0B), - size: 230, - bottom: 80, - right: -70, - ), - const _BgBlob( - color: Color(0x227C3AED), - size: 190, - bottom: -55, - left: 35, - ), - - // --- CONTENUTO della pagina (quello che avevi prima) - SafeArea( - top: false, - child: loading - ? const Center(child: CircularProgressIndicator()) - : error.isNotEmpty - ? _ErrorBox(message: error, onRetry: _load) - : _SchoolsList( - firstName: firstName, - schools: schools, - onSelect: _enterSchool, - ), - ), - ], + body: YogibookBackground( + child: SafeArea( + top: false, + child: loading + ? const Center(child: CircularProgressIndicator()) + : error.isNotEmpty + ? _ErrorBox(message: error, onRetry: _load) + : _SchoolsList( + firstName: firstName, + schools: schools, + onSelect: (s) => _enterSchool(s), + ), + ), ), ); } @@ -261,7 +211,7 @@ class _AppDrawer extends StatelessWidget { ListTile( leading: const Icon(Icons.swap_horiz), title: const Text('Cambia scuola'), - onTap: () => Navigator.of(context).pop(), // sei già qui + onTap: () => Navigator.of(context).pop(), ), const Divider(height: 1), ListTile( @@ -296,7 +246,6 @@ class _SchoolsList extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // header compatto (stesso stile “soft” delle lezioni) Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 10), child: Column( @@ -334,6 +283,7 @@ class _SchoolsList extends StatelessWidget { }, ), ), + const SizedBox(height: 8), Center( child: Padding( diff --git a/lib/services/settings_api.dart b/lib/services/settings_api.dart new file mode 100644 index 0000000..512b38b --- /dev/null +++ b/lib/services/settings_api.dart @@ -0,0 +1,46 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; + +import '../config/api_config.dart'; +import '../models/user_settings.dart'; +import '../models/school_settings.dart'; + +class SettingsApi { + static Map _headers(String token) => { + 'Accept': 'application/json', + 'Authorization': 'Bearer $token', + }; + + static Future fetchUserSettings({required String token}) async { + final uri = Uri.parse('${ApiConfig.phpApiBase}/api_user_settings.php'); + final res = await http.get(uri, headers: _headers(token)); + + final data = jsonDecode(res.body) as Map; + if (res.statusCode != 200 || data['success'] != true) { + throw Exception(data['message'] ?? 'User settings error'); + } + + return UserSettings.fromMap( + (data['settings'] as Map).cast(), + ); + } + + static Future fetchSchoolSettings({ + required String token, + required int schoolId, + }) async { + final uri = Uri.parse( + '${ApiConfig.phpApiBase}/api_school_settings.php?school_id=$schoolId', + ); + final res = await http.get(uri, headers: _headers(token)); + + final data = jsonDecode(res.body) as Map; + if (res.statusCode != 200 || data['success'] != true) { + throw Exception(data['message'] ?? 'School settings error'); + } + + return SchoolSettings.fromMap( + (data['settings'] as Map).cast(), + ); + } +} diff --git a/lib/state/app_settings_store.dart b/lib/state/app_settings_store.dart new file mode 100644 index 0000000..1580080 --- /dev/null +++ b/lib/state/app_settings_store.dart @@ -0,0 +1,56 @@ +import 'package:flutter/foundation.dart'; + +import '../models/user_settings.dart'; +import '../models/school_settings.dart'; +import '../services/settings_api.dart'; + +class AppSettingsStore extends ChangeNotifier { + bool loading = false; + String error = ''; + + int? schoolId; + + UserSettings? userSettings; + SchoolSettings? schoolSettings; + + bool get isReady => userSettings != null && schoolSettings != null; + + Future loadForSchool({ + required String token, + required int schoolId, + }) async { + loading = true; + error = ''; + notifyListeners(); + + try { + // puoi farle in parallelo + final results = await Future.wait([ + SettingsApi.fetchUserSettings(token: token), + SettingsApi.fetchSchoolSettings(token: token, schoolId: schoolId), + ]); + + userSettings = results[0] as UserSettings; + schoolSettings = results[1] as SchoolSettings; + this.schoolId = schoolId; + } catch (e) { + error = e.toString(); + // se fallisce, meglio lasciare null per evitare dati “mezzi vecchi” + userSettings = null; + schoolSettings = null; + this.schoolId = null; + } finally { + loading = false; + notifyListeners(); + } + } + + void clear() { + loading = false; + error = ''; + schoolId = null; + userSettings = null; + schoolSettings = null; + notifyListeners(); + } +} diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart new file mode 100644 index 0000000..b529655 --- /dev/null +++ b/lib/state/app_state.dart @@ -0,0 +1,104 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/school.dart'; +import '../models/user_settings.dart'; +import '../models/school_settings.dart'; +import '../services/settings_api.dart'; + +class AppState extends ChangeNotifier { + String? token; + School? school; + String? userFirstName; + + UserSettings? userSettings; + SchoolSettings? schoolSettings; + + bool loadingSettings = false; + String settingsError = ''; + + // Call this after login + school selection (not in SelectSchoolPage UI itself) + Future bootstrap({ + required String token, + required School school, + String? userFirstName, + }) async { + this.token = token; + this.school = school; + this.userFirstName = userFirstName; + + loadingSettings = true; + settingsError = ''; + notifyListeners(); + + // Try cache first + await _loadCache(); + + try { + // Fetch both in parallel + final results = await Future.wait([ + SettingsApi.fetchUserSettings(token: token), + SettingsApi.fetchSchoolSettings(token: token, schoolId: school.id), + ]); + + userSettings = results[0] as UserSettings; + schoolSettings = results[1] as SchoolSettings; + + await _saveCache(); + } catch (e) { + settingsError = e.toString(); + // If cache exists, we keep it and don't crash the app + } finally { + loadingSettings = false; + notifyListeners(); + } + } + + // If you want manual refresh from any page + Future refreshSettings() async { + if (token == null || school == null) return; + await bootstrap( + token: token!, + school: school!, + userFirstName: userFirstName, + ); + } + + String get _cacheKey { + final sid = school?.id ?? 0; + // Cache per school (user is implicit in token/session) + return 'settings_cache_school_$sid'; + } + + Future _saveCache() async { + if (userSettings == null || schoolSettings == null) return; + final sp = await SharedPreferences.getInstance(); + + final payload = jsonEncode({ + 'user': userSettings!.toMap(), + 'school': schoolSettings!.toMap(), + }); + + await sp.setString(_cacheKey, payload); + } + + Future _loadCache() async { + final sp = await SharedPreferences.getInstance(); + final raw = sp.getString(_cacheKey); + if (raw == null) return; + + try { + final decoded = jsonDecode(raw) as Map; + userSettings = UserSettings.fromMap( + (decoded['user'] as Map).cast(), + ); + schoolSettings = SchoolSettings.fromMap( + (decoded['school'] as Map).cast(), + ); + } catch (_) { + // Ignore cache errors + } + } +} diff --git a/lib/widgets/app_bottom_nav.dart b/lib/widgets/app_bottom_nav.dart new file mode 100644 index 0000000..d40cf2e --- /dev/null +++ b/lib/widgets/app_bottom_nav.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +class AppBottomNav extends StatelessWidget { + final int currentIndex; + final ValueChanged onTap; + + const AppBottomNav({ + super.key, + required this.currentIndex, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return BottomNavigationBar( + type: BottomNavigationBarType.fixed, + backgroundColor: Colors.white, + selectedItemColor: const Color(0xFF10B981), + unselectedItemColor: Colors.black54, + currentIndex: currentIndex, + onTap: onTap, + items: const [ + BottomNavigationBarItem(icon: Icon(Icons.home_rounded), label: 'Home'), + BottomNavigationBarItem( + icon: Icon(Icons.event_note_rounded), + label: 'Lezioni', + ), + BottomNavigationBarItem( + icon: Icon(Icons.person_rounded), + label: 'Account', + ), + BottomNavigationBarItem( + icon: Icon(Icons.self_improvement_rounded), + label: 'Meditazione', + ), + ], + ); + } +} diff --git a/lib/widgets/app_drawer.dart b/lib/widgets/app_drawer.dart new file mode 100644 index 0000000..9ad36b6 --- /dev/null +++ b/lib/widgets/app_drawer.dart @@ -0,0 +1,271 @@ +import 'package:flutter/material.dart'; + +import '../models/school.dart'; +import '../services/vanguard_api.dart'; +import '../config/api_config.dart'; + +import '../screens/select_school_page.dart'; +import '../screens/login_page.dart'; +import '../screens/medical_certificates_page.dart'; + +class AppDrawer extends StatelessWidget { + final String token; + final School school; + final String? userFirstName; + + const AppDrawer({ + super.key, + required this.token, + required this.school, + this.userFirstName, + }); + + String get _name => (userFirstName ?? '').trim(); + String get _avatarLetter => _name.isNotEmpty ? _name[0].toUpperCase() : 'U'; + + String? get _schoolLogoUrl { + final raw = (school.logo ?? '').toString().trim(); + if (raw.isEmpty) return null; + + // base: https://app.yogibook.com/public/userarea/ + return '${ApiConfig.scheme}://${ApiConfig.host}/public/userarea/$raw'; + } + + Future _logout(BuildContext context) async { + final ok = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Conferma logout'), + content: const Text('Vuoi uscire dal tuo account?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Annulla'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Logout'), + ), + ], + ), + ); + + if (ok != true) return; + + Navigator.of(context).pop(); // chiude drawer + + try { + await VanguardApi.logout(token: token); + } catch (_) {} + + if (!context.mounted) return; + + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const LoginPage()), + (route) => false, + ); + } + + @override + Widget build(BuildContext context) { + final logoUrl = _schoolLogoUrl; + + return Drawer( + child: SafeArea( + child: ListView( + padding: EdgeInsets.zero, + children: [ + _DrawerHeaderWide( + schoolName: school.name, + logoUrl: logoUrl, + fallbackLetter: _avatarLetter, + ), + + ListTile( + leading: const Icon(Icons.swap_horiz), + title: const Text('Cambia scuola'), + onTap: () { + Navigator.of(context).pop(); + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => SelectSchoolPage(token: token), + ), + ); + }, + ), + + // ✅ solo nel drawer + ListTile( + leading: const Icon(Icons.medical_information), + title: const Text('Certificati medici'), + onTap: () { + Navigator.of(context).pop(); + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => MedicalCertificatesPage( + token: token, + school: school, + userFirstName: userFirstName, + ), + ), + ); + }, + ), + + const Divider(height: 1), + + ListTile( + leading: const Icon(Icons.logout), + title: const Text('Logout'), + onTap: () => _logout(context), + ), + ], + ), + ), + ); + } +} + +class _DrawerHeaderWide extends StatelessWidget { + final String schoolName; + final String? logoUrl; + final String fallbackLetter; + + const _DrawerHeaderWide({ + required this.schoolName, + required this.logoUrl, + required this.fallbackLetter, + }); + + @override + Widget build(BuildContext context) { + const green = Color(0xFF10B981); + + return Container( + // Header largo quanto il drawer + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFF4F6FA), Color(0xFFEFF7F3)], + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Menu', + style: TextStyle(fontSize: 22, fontWeight: FontWeight.w900), + ), + const SizedBox(height: 12), + + // ✅ BOX LOGO LARGO + PIÙ ALTO + IN RATIO (non taglia) + Container( + width: double.infinity, + height: 140, // <-- aumenta qui (es. 140/160/180) + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: const [ + BoxShadow( + blurRadius: 18, + color: Color(0x12000000), + offset: Offset(0, 10), + ), + ], + border: Border.all(color: const Color(0x14000000)), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: logoUrl == null + ? _LogoFallback(letter: fallbackLetter) + : Padding( + padding: const EdgeInsets.all(10), // respiro per logo + child: Image.network( + logoUrl!, + width: double.infinity, // ✅ solo larghezza “imposta” + fit: BoxFit.contain, // ✅ mantiene ratio, non taglia + errorBuilder: (_, __, ___) => + _LogoFallback(letter: fallbackLetter), + ), + ), + ), + ), + + const SizedBox(height: 10), + + // Nome scuola sotto + Text( + schoolName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.black87, + fontWeight: FontWeight.w900, + fontSize: 22, + ), + ), + + const SizedBox(height: 6), + + // badge piccolo opzionale, fa “design” + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: green.withOpacity(0.12), + borderRadius: BorderRadius.circular(999), + ), + child: const Text( + 'Scuola selezionata', + style: TextStyle( + color: green, + fontWeight: FontWeight.w900, + fontSize: 11, + ), + ), + ), + ], + ), + ); + } +} + +class _LogoFallback extends StatelessWidget { + final String letter; + const _LogoFallback({required this.letter}); + + @override + Widget build(BuildContext context) { + const green = Color(0xFF10B981); + + return Container( + width: double.infinity, + height: double.infinity, + alignment: Alignment.center, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFE7F8F1), Color(0xFFF4F6FA)], + ), + ), + child: Container( + width: 44, + height: 44, + decoration: const BoxDecoration(color: green, shape: BoxShape.circle), + alignment: Alignment.center, + child: Text( + letter, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w900, + fontSize: 16, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/yogibook_background.dart b/lib/widgets/yogibook_background.dart new file mode 100644 index 0000000..0586d9b --- /dev/null +++ b/lib/widgets/yogibook_background.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; + +class YogibookBackground extends StatelessWidget { + final Widget child; + + const YogibookBackground({super.key, required this.child}); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + // --- SFONDO (gradient) + Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFF4F6FA), Color(0xFFEFF7F3), Color(0xFFF4F6FA)], + ), + ), + ), + + // --- BOLLE decorative + const _BgBlob(color: Color(0x3310B981), size: 280, top: -70, left: -90), + const _BgBlob( + color: Color(0x22F59E0B), + size: 230, + bottom: 80, + right: -70, + ), + const _BgBlob( + color: Color(0x227C3AED), + size: 190, + bottom: -55, + left: 35, + ), + + // contenuto pagina + child, + ], + ); + } +} + +class _BgBlob extends StatelessWidget { + final Color color; + final double size; + final double? top; + final double? left; + final double? right; + final double? bottom; + + const _BgBlob({ + required this.color, + required this.size, + this.top, + this.left, + this.right, + this.bottom, + }); + + @override + Widget build(BuildContext context) { + return Positioned( + top: top, + left: left, + right: right, + bottom: bottom, + child: IgnorePointer( + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + blurRadius: 70, + color: color, + offset: const Offset(0, 18), + ), + ], + ), + ), + ), + ); + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 3d123a5..793a31b 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,6 +10,7 @@ import file_picker import file_selector_macos import google_sign_in_ios import path_provider_foundation +import shared_preferences_foundation import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { @@ -18,5 +19,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index cd5b2f5..362d7f8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -429,6 +429,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" path: dependency: transitive description: @@ -501,6 +509,70 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc" + url: "https://pub.dev" + source: hosted + version: "2.4.18" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 3ad283e..e746574 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,6 +42,8 @@ dependencies: file_picker: ^8.0.7 image_picker: ^1.1.2 mime: ^2.0.0 + provider: ^6.1.5+1 + shared_preferences: ^2.2.3 dev_dependencies: flutter_test: