diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png index 5a98149..8527ee9 100644 Binary files a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png index 09924fa..f8b3125 100644 Binary files a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png index a19d547..2027dc8 100644 Binary files a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png index 56bc085..c41494f 100644 Binary files a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png index 32667c7..6b21621 100644 Binary files a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 8da004c..f578071 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 54193dc..3e091db 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index ed00ccc..6fbb88b 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index 5bc74fe..1cb8035 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index ba5ee02..4d08474 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/assets/icon/app_icon.png b/assets/icon/app_icon.png index 7114ec2..0e8a690 100644 Binary files a/assets/icon/app_icon.png and b/assets/icon/app_icon.png differ diff --git a/assets/icon/old2app_icon.png b/assets/icon/old2app_icon.png new file mode 100644 index 0000000..7114ec2 Binary files /dev/null and b/assets/icon/old2app_icon.png differ diff --git a/assets/images/Progetto senza titolo (7).png b/assets/images/Progetto senza titolo (7).png new file mode 100644 index 0000000..0e8a690 Binary files /dev/null and b/assets/images/Progetto senza titolo (7).png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index e9383c9..ce98f46 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 24ff9ee..313b9cd 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 460ecfe..a37fb52 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index f557302..70d7c7f 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index 711031f..7974c51 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index d0d0ba4..b780899 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 398aa9f..84f7fa8 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 460ecfe..a37fb52 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index fd3d166..d689490 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index ea94c4d..c833983 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png index c564cd9..9cb2723 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png index e9b3504..f9c3841 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png index a7b00dc..1286f3c 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png index 3be12d0..12b392a 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index ea94c4d..c833983 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index 59852c1..9ad97d5 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png index 8da004c..f578071 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png index 5bc74fe..1cb8035 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index ff9a7e5..4504a17 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index a9d8012..8fdd454 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 2fd7a26..d28cd41 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/lib/screens/home_page.dart b/lib/screens/home_page.dart index 5f4e6de..376c322 100644 --- a/lib/screens/home_page.dart +++ b/lib/screens/home_page.dart @@ -37,13 +37,36 @@ class _HomePageState extends State { late final String zenQuote; + // Nome caricato da getMe (widget.userFirstName è spesso null, + // es. arrivando dallo splash con login persistente). + String? _loadedFirstName; + @override void initState() { super.initState(); zenQuote = _pickZenQuote(); + _loadName(); + } + + Future _loadName() async { + try { + final me = await VanguardApi.getMe(token: widget.token); + final first = (me['first_name'] ?? '').toString().trim(); + if (!mounted) return; + if (first.isNotEmpty) { + setState(() => _loadedFirstName = first); + } + } catch (_) { + // silenzioso: se fallisce restiamo su "Ciao" senza nome + } + } + + String get _name { + final loaded = (_loadedFirstName ?? '').trim(); + if (loaded.isNotEmpty) return loaded; + return (widget.userFirstName ?? '').trim(); } - String get _name => (widget.userFirstName ?? '').trim(); String get _avatarLetter => _name.isNotEmpty ? _name[0].toUpperCase() : 'U'; String get _schoolAddress => (widget.school.addressFull ?? '').trim(); diff --git a/lib/screens/teacher_add_participant_page.dart b/lib/screens/teacher_add_participant_page.dart new file mode 100644 index 0000000..e5e8cb2 --- /dev/null +++ b/lib/screens/teacher_add_participant_page.dart @@ -0,0 +1,599 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../services/teacher_api.dart'; +import '../widgets/yogibook_background.dart'; + +/// Aggiungi partecipante a una classe (vista insegnante). +/// Flusso: cerca/crea utente -> scegli modalità -> conferma. +/// Ritorna `true` (Navigator.pop) se l'inserimento è avvenuto. +class TeacherAddParticipantPage extends StatefulWidget { + final String token; + final TeacherClass cls; + + const TeacherAddParticipantPage({ + super.key, + required this.token, + required this.cls, + }); + + @override + State createState() => + _TeacherAddParticipantPageState(); +} + +class _TeacherAddParticipantPageState extends State { + static const Color kGreen = Color(0xFF10B981); + + final _firstCtrl = TextEditingController(); + final _lastCtrl = TextEditingController(); + final _emailCtrl = TextEditingController(); + + // ricerca + List _results = []; + bool _searching = false; + + // utente selezionato (null = nuovo utente da creare) + TeacherUser? _selectedUser; + bool _isNewUser = false; + + // ordini dell'utente selezionato + List _orders = []; + bool _loadingOrders = false; + + // modalità: 'scala' | 'omaggio' | 'nuovo' + String _mode = 'omaggio'; + int? _selectedOrderId; + DateTime _newExpiry = DateTime.now().add(const Duration(days: 90)); + + bool _submitting = false; + + @override + void dispose() { + _firstCtrl.dispose(); + _lastCtrl.dispose(); + _emailCtrl.dispose(); + super.dispose(); + } + + void _snack(String msg) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + } + + Future _search() async { + final f = _firstCtrl.text.trim(); + final l = _lastCtrl.text.trim(); + if (f.length < 2 && l.length < 2) { + setState(() => _results = []); + return; + } + setState(() => _searching = true); + try { + final r = await TeacherApi.searchUsers( + token: widget.token, + first: f, + last: l, + ); + if (!mounted) return; + setState(() => _results = r); + } catch (e) { + _snack('$e'); + } finally { + if (mounted) setState(() => _searching = false); + } + } + + Future _selectUser(TeacherUser u) async { + setState(() { + _selectedUser = u; + _isNewUser = false; + _results = []; + _firstCtrl.text = u.firstName; + _lastCtrl.text = u.lastName; + _emailCtrl.text = u.email; + _loadingOrders = true; + _orders = []; + }); + try { + final o = await TeacherApi.userOrders(token: widget.token, userId: u.id); + if (!mounted) return; + // default modalità: scala se ha pacchetti con residui, altrimenti omaggio + final usable = o.where((x) => x.remaining > 0).toList(); + setState(() { + _orders = o; + if (usable.isNotEmpty) { + _mode = 'scala'; + _selectedOrderId = usable.first.idOrderBook; + } else { + _mode = 'omaggio'; + } + }); + } catch (e) { + _snack('$e'); + } finally { + if (mounted) setState(() => _loadingOrders = false); + } + } + + // segna che vogliamo creare un nuovo utente coi dati digitati + void _useAsNewUser() { + final f = _firstCtrl.text.trim(); + final l = _lastCtrl.text.trim(); + if (f.isEmpty || l.isEmpty) { + _snack('Inserisci nome e cognome del nuovo utente.'); + return; + } + setState(() { + _selectedUser = null; + _isNewUser = true; + _results = []; + _orders = []; + _mode = 'omaggio'; // nuovo utente non ha pacchetti + }); + } + + void _clearSelection() { + setState(() { + _selectedUser = null; + _isNewUser = false; + _orders = []; + _selectedOrderId = null; + _mode = 'omaggio'; + }); + } + + Future _confirm() async { + // deve esserci un utente selezionato o un nuovo utente valido + if (_selectedUser == null && !_isNewUser) { + _snack('Seleziona un utente o creane uno nuovo.'); + return; + } + if (_mode == 'scala' && _selectedOrderId == null) { + _snack('Seleziona un pacchetto da cui scalare.'); + return; + } + + setState(() => _submitting = true); + try { + await TeacherApi.addBooking( + token: widget.token, + scheduleId: widget.cls.scheduleId, + serviceId: widget.cls.serviceId, + bookingStart: widget.cls.dateTime, + mode: _mode, + userId: _selectedUser?.id ?? 0, + name: _isNewUser ? _firstCtrl.text.trim() : '', + surname: _isNewUser ? _lastCtrl.text.trim() : '', + email: _emailCtrl.text.trim(), + orderId: _mode == 'scala' ? _selectedOrderId : null, + newExpiry: _mode == 'nuovo' + ? DateFormat('yyyy-MM-dd').format(_newExpiry) + : null, + ); + if (!mounted) return; + Navigator.pop(context, true); + } catch (e) { + _snack('$e'); + setState(() => _submitting = false); + } + } + + @override + Widget build(BuildContext context) { + final hasSelection = _selectedUser != null || _isNewUser; + + return Scaffold( + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + centerTitle: true, + title: const Text( + 'Aggiungi partecipante', + style: TextStyle(fontWeight: FontWeight.w800, fontSize: 18), + ), + ), + body: YogibookBackground( + child: SafeArea( + top: false, + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + children: [ + _classBanner(), + const SizedBox(height: 14), + _searchFields(), + if (!hasSelection) ...[const SizedBox(height: 8), _resultsBox()], + if (hasSelection) ...[ + const SizedBox(height: 14), + _selectionCard(), + const SizedBox(height: 14), + _modeSection(), + const SizedBox(height: 20), + _confirmButton(), + ], + ], + ), + ), + ), + ); + } + + Widget _classBanner() { + final dt = DateTime.tryParse(widget.cls.dateTime); + final when = dt == null + ? widget.cls.dateTime + : DateFormat("EEE d MMM, HH:mm", 'it_IT').format(dt); + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + boxShadow: const [ + BoxShadow( + blurRadius: 12, + color: Color(0x0F000000), + offset: Offset(0, 6), + ), + ], + ), + child: Row( + children: [ + const Icon(Icons.class_, color: kGreen), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.cls.className, + style: const TextStyle( + fontWeight: FontWeight.w800, + fontSize: 15, + ), + ), + Text( + '$when · ${widget.cls.bookedCount}/${widget.cls.maxCapacity} posti', + style: const TextStyle(fontSize: 12, color: Colors.black54), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _searchFields() { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + boxShadow: const [ + BoxShadow( + blurRadius: 12, + color: Color(0x0F000000), + offset: Offset(0, 6), + ), + ], + ), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: TextField( + controller: _firstCtrl, + onChanged: (_) => _search(), + decoration: const InputDecoration( + labelText: 'Nome', + isDense: true, + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: TextField( + controller: _lastCtrl, + onChanged: (_) => _search(), + decoration: const InputDecoration( + labelText: 'Cognome', + isDense: true, + border: OutlineInputBorder(), + ), + ), + ), + ], + ), + const SizedBox(height: 10), + TextField( + controller: _emailCtrl, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration( + labelText: 'Email (per nuovo utente)', + isDense: true, + border: OutlineInputBorder(), + ), + ), + ], + ), + ); + } + + Widget _resultsBox() { + if (_searching) { + return const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ); + } + final f = _firstCtrl.text.trim(); + final l = _lastCtrl.text.trim(); + if (f.length < 2 && l.length < 2) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: Text( + 'Digita almeno 2 lettere per cercare.', + style: TextStyle(color: Colors.black45), + ), + ); + } + + return Column( + children: [ + ..._results.map( + (u) => Container( + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: ListTile( + leading: CircleAvatar( + backgroundColor: kGreen, + child: Text( + ((u.firstName.isNotEmpty ? u.firstName[0] : '') + + (u.lastName.isNotEmpty ? u.lastName[0] : '')) + .toUpperCase(), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w800, + fontSize: 13, + ), + ), + ), + title: Text( + u.fullName, + style: const TextStyle(fontWeight: FontWeight.w700), + ), + subtitle: Text(u.email), + onTap: () => _selectUser(u), + ), + ), + ), + // opzione "crea nuovo utente" con i dati digitati + Container( + margin: const EdgeInsets.only(top: 4), + decoration: BoxDecoration( + color: const Color(0xFFF3F0FF), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFD9CFFB)), + ), + child: ListTile( + leading: const Icon(Icons.person_add, color: Color(0xFF4F46E5)), + title: const Text( + 'Crea nuovo utente', + style: TextStyle(fontWeight: FontWeight.w800), + ), + subtitle: const Text('Usa nome, cognome ed email digitati sopra'), + onTap: _useAsNewUser, + ), + ), + ], + ); + } + + Widget _selectionCard() { + final title = _isNewUser + ? '${_firstCtrl.text.trim()} ${_lastCtrl.text.trim()} (nuovo)' + : _selectedUser!.fullName; + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFE7F8F1), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: kGreen), + ), + child: Row( + children: [ + const Icon(Icons.check_circle, color: kGreen), + const SizedBox(width: 10), + Expanded( + child: Text( + title, + style: const TextStyle(fontWeight: FontWeight.w800), + ), + ), + TextButton(onPressed: _clearSelection, child: const Text('Cambia')), + ], + ), + ); + } + + Widget _modeSection() { + final usable = _orders.where((o) => o.remaining > 0).toList(); + + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + boxShadow: const [ + BoxShadow( + blurRadius: 12, + color: Color(0x0F000000), + offset: Offset(0, 6), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Modalità', + style: TextStyle(fontWeight: FontWeight.w900, fontSize: 14), + ), + const SizedBox(height: 6), + if (_loadingOrders) + const Padding( + padding: EdgeInsets.all(12), + child: Center(child: CircularProgressIndicator()), + ) + else ...[ + // scala (solo se ci sono pacchetti usabili) + RadioListTile( + contentPadding: EdgeInsets.zero, + activeColor: kGreen, + value: 'scala', + groupValue: _mode, + onChanged: usable.isEmpty + ? null + : (v) => setState(() => _mode = v!), + title: Text( + usable.isEmpty + ? 'Scala da pacchetto (nessuno disponibile)' + : 'Scala da un pacchetto', + style: TextStyle( + fontWeight: FontWeight.w700, + color: usable.isEmpty ? Colors.black38 : Colors.black87, + ), + ), + ), + if (_mode == 'scala' && usable.isNotEmpty) + Padding( + padding: const EdgeInsets.only(left: 12, bottom: 8), + child: Column( + children: usable + .map( + (o) => RadioListTile( + contentPadding: EdgeInsets.zero, + activeColor: kGreen, + value: o.idOrderBook, + groupValue: _selectedOrderId, + onChanged: (v) => + setState(() => _selectedOrderId = v), + title: Text( + o.serviceName, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + subtitle: Text( + '${o.remaining}/${o.tickets} residui' + '${o.expireOn != null ? ' · scade ${o.expireOn}' : ''}', + style: const TextStyle(fontSize: 12), + ), + ), + ) + .toList(), + ), + ), + // omaggio + RadioListTile( + contentPadding: EdgeInsets.zero, + activeColor: kGreen, + value: 'omaggio', + groupValue: _mode, + onChanged: (v) => setState(() => _mode = v!), + title: const Text( + 'Aggiungi come omaggio', + style: TextStyle(fontWeight: FontWeight.w700), + ), + subtitle: const Text( + 'Non consuma pacchetti, non spostabile', + style: TextStyle(fontSize: 12), + ), + ), + // nuovo ordine + RadioListTile( + contentPadding: EdgeInsets.zero, + activeColor: kGreen, + value: 'nuovo', + groupValue: _mode, + onChanged: (v) => setState(() => _mode = v!), + title: const Text( + 'Crea nuovo ordine (1 lezione)', + style: TextStyle(fontWeight: FontWeight.w700), + ), + ), + if (_mode == 'nuovo') + Padding( + padding: const EdgeInsets.only(left: 12, top: 4), + child: Row( + children: [ + const Icon(Icons.event, size: 18, color: Colors.black45), + const SizedBox(width: 8), + Text( + 'Scadenza: ${DateFormat('dd/MM/yyyy').format(_newExpiry)}', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + const Spacer(), + TextButton( + onPressed: () async { + final picked = await showDatePicker( + context: context, + initialDate: _newExpiry, + firstDate: DateTime.now(), + lastDate: DateTime.now().add( + const Duration(days: 730), + ), + ); + if (picked != null) { + setState(() => _newExpiry = picked); + } + }, + child: const Text('Cambia'), + ), + ], + ), + ), + ], + ], + ), + ); + } + + Widget _confirmButton() { + return SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: kGreen, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + onPressed: _submitting ? null : _confirm, + child: _submitting + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text( + 'Aggiungi alla classe', + style: TextStyle( + fontWeight: FontWeight.w900, + color: Colors.white, + ), + ), + ), + ); + } +} diff --git a/lib/screens/teacher_class_detail_page.dart b/lib/screens/teacher_class_detail_page.dart new file mode 100644 index 0000000..dccab34 --- /dev/null +++ b/lib/screens/teacher_class_detail_page.dart @@ -0,0 +1,514 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../services/teacher_api.dart'; +import '../widgets/yogibook_background.dart'; +import 'teacher_reprogram_page.dart'; +import 'teacher_add_participant_page.dart'; + +/// Dettaglio di una classe (vista insegnante). +/// Fase 2a: elenco iscritti + segna persa / ripristina. +class TeacherClassDetailPage extends StatefulWidget { + final String token; + final TeacherClass cls; + + const TeacherClassDetailPage({ + super.key, + required this.token, + required this.cls, + }); + + @override + State createState() => _TeacherClassDetailPageState(); +} + +class _TeacherClassDetailPageState extends State { + static const Color kGreen = Color(0xFF10B981); + + // copia locale modificabile dei partecipanti (per aggiornare la UI) + late List _participants; + int _busyBookingId = + 0; // booking in aggiornamento (per disabilitare il tasto) + + @override + void initState() { + super.initState(); + _participants = List.of(widget.cls.participants); + } + + String _fmtDate(String iso) { + final dt = DateTime.tryParse(iso); + if (dt == null) return iso; + final s = DateFormat("EEEE d MMMM yyyy", 'it_IT').format(dt); + return s[0].toUpperCase() + s.substring(1); + } + + String _fmtTime(String iso) { + final dt = DateTime.tryParse(iso); + if (dt == null) return ''; + return DateFormat('HH:mm').format(dt); + } + + void _snack(String msg) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + } + + Future _toggleLost(TeacherParticipant p) async { + final markLost = !p.isLost; + + // conferma solo quando si segna persa (azione visibile all'allievo) + if (markLost) { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Segnare come persa?'), + content: Text( + 'Vuoi segnare la lezione di ${p.fullName} come persa? ' + 'Resterà registrata e non tornerà disponibile all\'allievo.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Annulla'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Segna persa'), + ), + ], + ), + ); + if (ok != true) return; + } + + setState(() => _busyBookingId = p.bookingId); + try { + await TeacherApi.setLost( + token: widget.token, + bookingId: p.bookingId, + lost: markLost, + ); + // aggiorna la copia locale sostituendo il partecipante + final idx = _participants.indexWhere((x) => x.bookingId == p.bookingId); + if (idx != -1) { + setState(() { + _participants[idx] = TeacherParticipant( + bookingId: p.bookingId, + fullName: p.fullName, + isLost: markLost, + ); + }); + } + _snack(markLost ? 'Segnata come persa.' : 'Ripristinata.'); + } catch (e) { + _snack('$e'); + } finally { + if (mounted) setState(() => _busyBookingId = 0); + } + } + + Future _deleteBooking(TeacherParticipant p) async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Eliminare la prenotazione?'), + content: Text( + 'Vuoi rimuovere ${p.fullName} da questa classe? ' + 'Il posto tornerà disponibile all\'allievo. L\'azione non è reversibile.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Annulla'), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFC0392B), + ), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Elimina'), + ), + ], + ), + ); + if (ok != true) return; + + setState(() => _busyBookingId = p.bookingId); + try { + await TeacherApi.deleteBooking( + token: widget.token, + bookingId: p.bookingId, + ); + setState(() { + _participants.removeWhere((x) => x.bookingId == p.bookingId); + }); + _snack('Prenotazione eliminata.'); + } catch (e) { + _snack('$e'); + } finally { + if (mounted) setState(() => _busyBookingId = 0); + } + } + + Future _reprogram(TeacherParticipant p) async { + final done = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => TeacherReprogramPage( + token: widget.token, + bookingId: p.bookingId, + participantName: p.fullName, + ), + ), + ); + // Se riprogrammata, la prenotazione non è più in questa classe: rimuovila. + if (done == true && mounted) { + setState(() { + _participants.removeWhere((x) => x.bookingId == p.bookingId); + }); + _snack('${p.fullName} riprogrammata.'); + } + } + + @override + Widget build(BuildContext context) { + final active = _participants.where((p) => !p.isLost).toList(); + final lost = _participants.where((p) => p.isLost).toList(); + final cls = widget.cls; + + return Scaffold( + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + centerTitle: true, + title: const Text( + 'Dettaglio classe', + style: TextStyle(fontWeight: FontWeight.w800, fontSize: 18), + ), + ), + body: YogibookBackground( + child: SafeArea( + top: false, + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + children: [ + _headerCard(), + const SizedBox(height: 18), + _sectionLabel('Iscritti (${active.length})'), + const SizedBox(height: 8), + if (active.isEmpty) + _emptyRow('Nessun iscritto attivo.') + else + ...active.map((p) => _tile(p)), + if (lost.isNotEmpty) ...[ + const SizedBox(height: 18), + _sectionLabel('Lezioni perse (${lost.length})'), + const SizedBox(height: 8), + ...lost.map((p) => _tile(p)), + ], + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: kGreen, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + icon: const Icon(Icons.person_add_alt_1, color: Colors.white), + label: const Text( + 'Aggiungi partecipante', + style: TextStyle( + fontWeight: FontWeight.w900, + color: Colors.white, + ), + ), + onPressed: _openAddParticipant, + ), + ), + ], + ), + ), + ), + ); + } + + Future _openAddParticipant() async { + final added = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + TeacherAddParticipantPage(token: widget.token, cls: widget.cls), + ), + ); + if (added == true && mounted) { + _snack( + 'Partecipante aggiunto. Aggiorna il pannello per vedere la lista.', + ); + // torna al pannello classi, che ricaricando mostrerà i dati aggiornati + Navigator.pop(context, true); + } + } + + Widget _headerCard() { + final cls = widget.cls; + final isFull = cls.isFull; + final fillColor = isFull + ? const Color(0xFFC0392B) + : (cls.maxCapacity > 0 && cls.bookedCount >= cls.maxCapacity - 1) + ? const Color(0xFFE67E22) + : const Color(0xFF1A7F52); + + return Container( + padding: const EdgeInsets.all(18), + 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: [ + Text( + cls.className, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w900), + ), + const SizedBox(height: 10), + Row( + children: [ + const Icon(Icons.event, size: 18, color: Colors.black45), + const SizedBox(width: 8), + Expanded( + child: Text( + _fmtDate(cls.dateTime), + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Color(0xFF404040), + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Row( + children: [ + const Icon(Icons.schedule, size: 18, color: Colors.black45), + const SizedBox(width: 8), + Text( + _fmtTime(cls.dateTime), + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Color(0xFF404040), + ), + ), + ], + ), + const SizedBox(height: 14), + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: fillColor.withOpacity(0.12), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.group, size: 16, color: fillColor), + const SizedBox(width: 6), + Text( + '${cls.bookedCount} / ${cls.maxCapacity} posti', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w800, + color: fillColor, + ), + ), + if (isFull) ...[ + const SizedBox(width: 8), + Text( + '· AL COMPLETO', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w900, + color: fillColor, + ), + ), + ], + ], + ), + ), + ], + ), + ); + } + + Widget _sectionLabel(String t) => Text( + t.toUpperCase(), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w900, + color: Colors.black54, + letterSpacing: 0.5, + ), + ); + + Widget _emptyRow(String msg) => Container( + padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + ), + child: Text(msg, style: const TextStyle(color: Colors.black45)), + ); + + Color _avatarColor(String seed) { + const palette = [ + Color(0xFF1EBF73), + Color(0xFF2980B9), + Color(0xFF8E44AD), + Color(0xFFE67E22), + Color(0xFF16A085), + Color(0xFFC0392B), + Color(0xFF2C3E50), + Color(0xFFD35400), + ]; + int h = 0; + for (final c in seed.codeUnits) { + h = (h + c) % palette.length; + } + return palette[h]; + } + + String _initials(String fullName) { + final parts = fullName.trim().split(RegExp(r'\s+')); + if (parts.isEmpty || parts.first.isEmpty) return '?'; + final a = parts.first[0]; + final b = parts.length > 1 ? parts.last[0] : ''; + return (a + b).toUpperCase(); + } + + Widget _tile(TeacherParticipant p) { + final lost = p.isLost; + final busy = _busyBookingId == p.bookingId; + + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + border: lost ? Border.all(color: const Color(0xFFF3D6D2)) : null, + boxShadow: const [ + BoxShadow( + blurRadius: 10, + color: Color(0x0A000000), + offset: Offset(0, 4), + ), + ], + ), + child: Row( + children: [ + Container( + width: 42, + height: 42, + alignment: Alignment.center, + decoration: BoxDecoration( + color: lost ? const Color(0xFF9AA5A6) : _avatarColor(p.fullName), + shape: BoxShape.circle, + ), + child: Text( + _initials(p.fullName), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w800, + fontSize: 15, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + p.fullName, + style: TextStyle( + fontWeight: FontWeight.w700, + fontSize: 15, + color: lost ? Colors.black54 : Colors.black87, + decoration: lost ? TextDecoration.lineThrough : null, + ), + ), + ), + // chip stato + Container( + margin: const EdgeInsets.only(right: 8), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: lost ? const Color(0xFF1F2937) : const Color(0xFFE3F2EC), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + lost ? 'Persa' : 'Presente', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w800, + color: lost ? Colors.white : const Color(0xFF1A7F52), + ), + ), + ), + // pulsanti azione + busy + ? const SizedBox( + width: 36, + height: 36, + child: Padding( + padding: EdgeInsets.all(8), + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: () => _toggleLost(p), + tooltip: lost ? 'Ripristina' : 'Segna persa', + icon: Icon( + lost ? Icons.undo_rounded : Icons.person_off_rounded, + color: lost + ? const Color(0xFF1A7F52) + : const Color(0xFFC0392B), + ), + ), + if (!lost) + IconButton( + onPressed: () => _reprogram(p), + tooltip: 'Riprogramma', + icon: const Icon( + Icons.event_repeat_rounded, + color: Color(0xFF2980B9), + ), + ), + IconButton( + onPressed: () => _deleteBooking(p), + tooltip: 'Elimina prenotazione', + icon: const Icon( + Icons.delete_outline_rounded, + color: Colors.black45, + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/screens/teacher_panel_page.dart b/lib/screens/teacher_panel_page.dart new file mode 100644 index 0000000..6e1f682 --- /dev/null +++ b/lib/screens/teacher_panel_page.dart @@ -0,0 +1,622 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../models/school.dart'; +import '../services/teacher_api.dart'; +import '../widgets/yogibook_background.dart'; +import 'teacher_class_detail_page.dart'; + +/// Pannello Insegnante (staff: Admin/teacher). +/// Fase 1: sola lettura. Classi del mese con partecipanti, focus su oggi. +class TeacherPanelPage extends StatefulWidget { + final String token; + final School school; + final String? userFirstName; + + const TeacherPanelPage({ + super.key, + required this.token, + required this.school, + this.userFirstName, + }); + + @override + State createState() => _TeacherPanelPageState(); +} + +class _TeacherPanelPageState extends State { + static const Color kGreen = Color(0xFF10B981); + static const Color kDarkGreen = Color(0xFF065F46); + + bool loading = true; + String error = ''; + + String currentMonth = DateFormat('yyyy-MM').format(DateTime.now()); + List classes = []; + + // per l'auto-scroll a oggi + final ScrollController _scrollController = ScrollController(); + final Map _dayKeys = {}; // index classe -> key + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + Future _load() async { + setState(() { + loading = true; + error = ''; + }); + try { + final data = await TeacherApi.fetchClasses( + token: widget.token, + month: currentMonth, + ); + setState(() { + classes = data; + }); + // dopo il render, prova a scrollare a oggi (con un attimo di ritardo + // per essere sicuri che le card siano state disposte) + WidgetsBinding.instance.addPostFrameCallback((_) { + Future.delayed(const Duration(milliseconds: 250), _scrollToToday); + }); + } catch (e) { + setState(() => error = 'Errore: $e'); + } finally { + if (mounted) setState(() => loading = false); + } + } + + // Trova la prima classe con data >= oggi e scrolla lì. + void _scrollToToday() { + if (classes.isEmpty) return; + final today = DateTime.now(); + int targetIndex = 0; + for (int i = 0; i < classes.length; i++) { + final dt = DateTime.tryParse(classes[i].dateTime); + if (dt != null && + !dt.isBefore(DateTime(today.year, today.month, today.day))) { + targetIndex = i; + break; + } + targetIndex = i; // se tutte passate, resta sull'ultima + } + final key = _dayKeys[targetIndex]; + final ctx = key?.currentContext; + if (ctx != null) { + Scrollable.ensureVisible( + ctx, + duration: const Duration(milliseconds: 400), + alignment: 0.1, + ); + } + } + + String _shiftMonth(String yyyyMm, int delta) { + final dt = DateFormat('yyyy-MM').parse(yyyyMm); + final shifted = DateTime(dt.year, dt.month + delta, 1); + return DateFormat('yyyy-MM').format(shifted); + } + + void _goPrevMonth() { + setState(() => currentMonth = _shiftMonth(currentMonth, -1)); + _load(); + } + + void _goNextMonth() { + setState(() => currentMonth = _shiftMonth(currentMonth, 1)); + _load(); + } + + String _monthLabel(String yyyyMm) { + final dt = DateFormat('yyyy-MM').parse(yyyyMm); + return DateFormat('MMMM yyyy', 'it_IT').format(dt); + } + + String _weekdayLabel(String iso) { + final dt = DateTime.tryParse(iso); + if (dt == null) return ''; + final s = DateFormat('EEEE', 'it_IT').format(dt); + return s[0].toUpperCase() + s.substring(1); + } + + String _dayNum(String iso) { + final dt = DateTime.tryParse(iso); + if (dt == null) return '--'; + return DateFormat('dd').format(dt); + } + + String _time(String iso) { + final dt = DateTime.tryParse(iso); + if (dt == null) return ''; + return DateFormat('HH:mm').format(dt); + } + + bool _isToday(String iso) { + final dt = DateTime.tryParse(iso); + if (dt == null) return false; + final now = DateTime.now(); + return dt.year == now.year && dt.month == now.month && dt.day == now.day; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + centerTitle: true, + title: const Text( + 'Pannello classi', + style: TextStyle(fontWeight: FontWeight.w800, fontSize: 18), + ), + ), + body: YogibookBackground( + child: SafeArea( + top: false, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: _MonthPill( + label: _monthLabel(currentMonth), + onPrev: _goPrevMonth, + onNext: _goNextMonth, + ), + ), + Expanded( + child: loading + ? const Center(child: CircularProgressIndicator()) + : error.isNotEmpty + ? _ErrorBox(message: error, onRetry: _load) + : classes.isEmpty + ? const _EmptyState() + : ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.fromLTRB(16, 4, 16, 16), + itemCount: classes.length, + itemBuilder: (_, i) { + final key = _dayKeys.putIfAbsent( + i, + () => GlobalKey(), + ); + final c = classes[i]; + final dt = DateTime.tryParse(c.dateTime); + final now = DateTime.now(); + final isPast = + dt != null && + dt.isBefore( + DateTime(now.year, now.month, now.day), + ); + return Container( + key: key, + child: _ClassCard( + cls: c, + weekday: _weekdayLabel(c.dateTime), + dayNum: _dayNum(c.dateTime), + time: _time(c.dateTime), + highlightToday: _isToday(c.dateTime), + isPast: isPast, + onTap: () async { + final changed = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => TeacherClassDetailPage( + token: widget.token, + cls: c, + ), + ), + ); + if (changed == true) _load(); + }, + ), + ); + }, + ), + ), + ], + ), + ), + ), + ); + } +} + +// Card classe compatta: al tap apre la pagina dettaglio. +class _ClassCard extends StatelessWidget { + final TeacherClass cls; + final String weekday; + final String dayNum; + final String time; + final bool highlightToday; + final bool isPast; + final VoidCallback onTap; + + const _ClassCard({ + required this.cls, + required this.weekday, + required this.dayNum, + required this.time, + required this.highlightToday, + required this.isPast, + required this.onTap, + }); + + static const Color kGreen = Color(0xFF10B981); + static const Color kDarkGreen = Color(0xFF065F46); + + @override + Widget build(BuildContext context) { + final isFull = cls.isFull; + final nearFull = + !isFull && + cls.maxCapacity > 0 && + cls.bookedCount >= cls.maxCapacity - 1; + + final fillColor = isFull + ? const Color(0xFFC0392B) + : nearFull + ? const Color(0xFFE67E22) + : const Color(0xFF1A7F52); + + final card = Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: highlightToday + ? Border.all(color: kGreen, width: 2) + : Border.all(color: const Color(0x11000000)), + boxShadow: const [ + BoxShadow( + blurRadius: 14, + color: Color(0x10000000), + offset: Offset(0, 6), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // pannello data + Container( + width: 74, + color: highlightToday ? kGreen : const Color(0xFF0F766E), + padding: const EdgeInsets.symmetric( + vertical: 14, + horizontal: 6, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + dayNum, + style: const TextStyle( + fontSize: 26, + height: 1, + fontWeight: FontWeight.w900, + color: Colors.white, + ), + ), + const SizedBox(height: 2), + Text( + weekday.length > 3 ? weekday.substring(0, 3) : weekday, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w800, + color: Colors.white70, + ), + ), + ], + ), + ), + // corpo + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 10, 12), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + cls.className, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w900, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (highlightToday) + Container( + margin: const EdgeInsets.only(left: 6), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: kGreen, + borderRadius: BorderRadius.circular(20), + ), + child: const Text( + 'OGGI', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w900, + color: Colors.white, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Row( + children: [ + const Icon( + Icons.schedule, + size: 14, + color: Colors.black45, + ), + const SizedBox(width: 4), + Text( + time, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: Color(0xFF404040), + ), + ), + const SizedBox(width: 12), + Icon(Icons.group, size: 14, color: fillColor), + const SizedBox(width: 4), + Text( + '${cls.bookedCount}/${cls.maxCapacity}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w800, + color: fillColor, + ), + ), + ], + ), + ], + ), + ), + ), + // chevron + const Padding( + padding: EdgeInsets.only(right: 10), + child: Icon(Icons.chevron_right, color: Colors.black26), + ), + ], + ), + ), + ), + ); + + return Opacity( + opacity: isPast && !highlightToday ? 0.55 : 1.0, + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: onTap, + child: card, + ), + ), + ); + } +} + +class _ParticipantRow extends StatelessWidget { + final TeacherParticipant p; + const _ParticipantRow({required this.p}); + + // colore avatar deterministico dal nome (come adminpanel) + Color _avatarColor(String seed) { + const palette = [ + Color(0xFF1EBF73), + Color(0xFF2980B9), + Color(0xFF8E44AD), + Color(0xFFE67E22), + Color(0xFF16A085), + Color(0xFFC0392B), + Color(0xFF2C3E50), + Color(0xFFD35400), + ]; + int h = 0; + for (final c in seed.codeUnits) { + h = (h + c) % palette.length; + } + return palette[h]; + } + + String get _initials { + final parts = p.fullName.trim().split(RegExp(r'\s+')); + if (parts.isEmpty || parts.first.isEmpty) return '?'; + final a = parts.first[0]; + final b = parts.length > 1 ? parts.last[0] : ''; + return (a + b).toUpperCase(); + } + + @override + Widget build(BuildContext context) { + final lost = p.isLost; + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: lost ? const Color(0xFFFBF1F0) : const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: lost ? const Color(0xFFF3D6D2) : const Color(0xFFEEF0F3), + ), + ), + child: Row( + children: [ + Container( + width: 38, + height: 38, + alignment: Alignment.center, + decoration: BoxDecoration( + color: lost ? const Color(0xFF7F8C8D) : _avatarColor(p.fullName), + shape: BoxShape.circle, + ), + child: Text( + _initials, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w800, + fontSize: 14, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + p.fullName, + style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 14), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3), + decoration: BoxDecoration( + color: lost ? const Color(0xFF1F2937) : const Color(0xFFE3F2EC), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + lost ? 'Persa' : 'Prenotata', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w800, + color: lost ? Colors.white : const Color(0xFF1A7F52), + ), + ), + ), + ], + ), + ); + } +} + +class _MonthPill extends StatelessWidget { + final String label; + final VoidCallback onPrev; + final VoidCallback onNext; + + const _MonthPill({ + required this.label, + required this.onPrev, + required this.onNext, + }); + + @override + Widget build(BuildContext context) { + const green = Color(0xFF10B981); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: const [ + BoxShadow( + blurRadius: 14, + color: Color(0x11000000), + offset: Offset(0, 8), + ), + ], + ), + child: Row( + children: [ + IconButton( + onPressed: onPrev, + icon: const Icon(Icons.chevron_left), + iconSize: 22, + color: green, + ), + Expanded( + child: Text( + label, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w900), + ), + ), + IconButton( + onPressed: onNext, + icon: const Icon(Icons.chevron_right), + iconSize: 22, + color: green, + ), + ], + ), + ); + } +} + +class _EmptyState extends StatelessWidget { + const _EmptyState(); + + @override + Widget build(BuildContext context) { + return const Center( + child: Padding( + padding: EdgeInsets.all(18), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.event_busy, size: 64, color: Colors.black26), + SizedBox(height: 14), + Text( + 'Nessuna classe questo mese', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w800, + color: Colors.black54, + ), + ), + ], + ), + ), + ); + } +} + +class _ErrorBox extends StatelessWidget { + final String message; + final VoidCallback onRetry; + const _ErrorBox({required this.message, required this.onRetry}); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 58, color: Colors.redAccent), + const SizedBox(height: 10), + Text(message, textAlign: TextAlign.center), + const SizedBox(height: 12), + ElevatedButton(onPressed: onRetry, child: const Text('Riprova')), + ], + ), + ), + ); + } +} diff --git a/lib/screens/teacher_reprogram_page.dart b/lib/screens/teacher_reprogram_page.dart new file mode 100644 index 0000000..be7c126 --- /dev/null +++ b/lib/screens/teacher_reprogram_page.dart @@ -0,0 +1,352 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../services/teacher_api.dart'; +import '../widgets/yogibook_background.dart'; + +/// Scelta della classe destinazione per riprogrammare una prenotazione. +/// Ritorna `true` (via Navigator.pop) se la riprogrammazione è avvenuta. +class TeacherReprogramPage extends StatefulWidget { + final String token; + final int bookingId; + final String participantName; + + const TeacherReprogramPage({ + super.key, + required this.token, + required this.bookingId, + required this.participantName, + }); + + @override + State createState() => _TeacherReprogramPageState(); +} + +class _TeacherReprogramPageState extends State { + static const Color kGreen = Color(0xFF10B981); + + bool _loading = true; + String _error = ''; + List _classes = []; + + int? _selectedScheduleId; + bool _countAsReprogram = true; // default: conta come riprogrammazione + bool _submitting = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _loading = true; + _error = ''; + }); + try { + final data = await TeacherApi.availableClasses( + token: widget.token, + bookingId: widget.bookingId, + ); + setState(() => _classes = data); + } catch (e) { + setState(() => _error = 'Errore: $e'); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + String _fmt(String iso) { + final dt = DateTime.tryParse(iso); + if (dt == null) return iso; + final s = DateFormat("EEE d MMM yyyy, HH:mm", 'it_IT').format(dt); + return s[0].toUpperCase() + s.substring(1); + } + + void _snack(String msg) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + } + + Future _confirm() async { + if (_selectedScheduleId == null) { + _snack('Seleziona una classe di destinazione.'); + return; + } + setState(() => _submitting = true); + try { + await TeacherApi.reprogram( + token: widget.token, + bookingId: widget.bookingId, + scheduleId: _selectedScheduleId!, + countAsReprogram: _countAsReprogram, + ); + if (!mounted) return; + Navigator.pop(context, true); // segnala successo al chiamante + } catch (e) { + _snack('$e'); + setState(() => _submitting = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + centerTitle: true, + title: const Text( + 'Riprogramma', + style: TextStyle(fontWeight: FontWeight.w800, fontSize: 18), + ), + ), + body: YogibookBackground( + child: SafeArea( + top: false, + child: _loading + ? const Center(child: CircularProgressIndicator()) + : _error.isNotEmpty + ? _errorBox() + : Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: _headerInfo(), + ), + Expanded( + child: _classes.isEmpty + ? _emptyBox() + : ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), + itemCount: _classes.length, + itemBuilder: (_, i) => _classTile(_classes[i]), + ), + ), + _bottomBar(), + ], + ), + ), + ), + ); + } + + Widget _headerInfo() { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + boxShadow: const [ + BoxShadow( + blurRadius: 12, + color: Color(0x0F000000), + offset: Offset(0, 6), + ), + ], + ), + child: Row( + children: [ + const Icon(Icons.person, size: 20, color: Colors.black45), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Sposta la lezione di ${widget.participantName}', + style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 14), + ), + ), + ], + ), + ); + } + + Widget _classTile(TeacherClass c) { + final selected = _selectedScheduleId == c.scheduleId; + return GestureDetector( + onTap: () => setState(() => _selectedScheduleId = c.scheduleId), + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: selected ? kGreen : const Color(0x14000000), + width: selected ? 2 : 1, + ), + boxShadow: const [ + BoxShadow( + blurRadius: 10, + color: Color(0x0A000000), + offset: Offset(0, 4), + ), + ], + ), + child: Row( + children: [ + Icon( + selected + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + color: selected ? kGreen : Colors.black26, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + c.className, + style: const TextStyle( + fontWeight: FontWeight.w800, + fontSize: 15, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + _fmt(c.dateTime), + style: const TextStyle( + fontSize: 12, + color: Color(0xFF606060), + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Text( + '${c.bookedCount}/${c.maxCapacity}', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w800, + color: Color(0xFF1A7F52), + ), + ), + ], + ), + ), + ); + } + + Widget _bottomBar() { + return Container( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 16), + decoration: const BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + blurRadius: 16, + color: Color(0x14000000), + offset: Offset(0, -6), + ), + ], + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SwitchListTile( + contentPadding: EdgeInsets.zero, + activeColor: kGreen, + value: _countAsReprogram, + onChanged: _submitting + ? null + : (v) => setState(() => _countAsReprogram = v), + title: const Text( + 'Conta come riprogrammazione', + style: TextStyle(fontWeight: FontWeight.w700, fontSize: 14), + ), + subtitle: const Text( + 'Se attivo, incrementa il contatore riprogrammazioni dell\'ordine.', + style: TextStyle(fontSize: 12), + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: kGreen, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + onPressed: _submitting ? null : _confirm, + child: _submitting + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text( + 'Conferma riprogrammazione', + style: TextStyle( + fontWeight: FontWeight.w900, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _emptyBox() { + return const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.event_busy, size: 56, color: Colors.black26), + SizedBox(height: 12), + Text( + 'Nessuna classe disponibile', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + color: Colors.black54, + ), + ), + SizedBox(height: 6), + Text( + 'Non ci sono classi future con posti liberi\nper questo allievo.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.black45), + ), + ], + ), + ), + ); + } + + Widget _errorBox() { + return Center( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 56, color: Colors.redAccent), + const SizedBox(height: 10), + Text(_error, textAlign: TextAlign.center), + const SizedBox(height: 12), + ElevatedButton(onPressed: _load, child: const Text('Riprova')), + ], + ), + ), + ); + } +} diff --git a/lib/services/role_service.dart b/lib/services/role_service.dart new file mode 100644 index 0000000..af1c43b --- /dev/null +++ b/lib/services/role_service.dart @@ -0,0 +1,56 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../config/api_config.dart'; + +/// Ruolo dell'utente autenticato, letto da api_me_role.php. +/// Serve a decidere se mostrare le funzioni insegnante (staff = Admin/teacher). +class UserRole { + final int roleId; + final String roleName; + final bool isStaff; + + const UserRole({ + required this.roleId, + required this.roleName, + required this.isStaff, + }); + + factory UserRole.fromMap(Map m) => UserRole( + roleId: (m['role_id'] as num?)?.toInt() ?? 0, + roleName: (m['role_name'] ?? '').toString(), + isStaff: m['is_staff'] == true, + ); + + /// Fallback sicuro: utente normale, nessun accesso staff. + static const UserRole guest = UserRole( + roleId: 0, + roleName: '', + isStaff: false, + ); +} + +class RoleService { + static Uri _u(String path, [Map? q]) => Uri.parse( + '${ApiConfig.laravelApiBase}/$path', + ).replace(queryParameters: q); + + static Future fetchMyRole({required String token}) async { + final res = await http.get( + _u('api_me_role.php'), + headers: {'Accept': 'application/json', 'Authorization': 'Bearer $token'}, + ); + + if (res.statusCode != 200) { + throw Exception('Role failed (${res.statusCode}): ${res.body}'); + } + + final data = jsonDecode(res.body) as Map; + if (data['success'] != true) { + throw Exception(data['message'] ?? 'Role error'); + } + + return UserRole.fromMap(data); + } +} diff --git a/lib/services/teacher_api.dart b/lib/services/teacher_api.dart new file mode 100644 index 0000000..aef2eab --- /dev/null +++ b/lib/services/teacher_api.dart @@ -0,0 +1,410 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../config/api_config.dart'; + +/// Un partecipante a una classe (vista insegnante). +class TeacherParticipant { + final int bookingId; + final String fullName; + final bool isLost; + + const TeacherParticipant({ + required this.bookingId, + required this.fullName, + required this.isLost, + }); + + factory TeacherParticipant.fromMap(Map m) => + TeacherParticipant( + bookingId: (m['booking_id'] as num?)?.toInt() ?? 0, + fullName: (m['full_name'] ?? '').toString().trim(), + isLost: m['is_lost'] == true, + ); +} + +/// Una classe programmata con i suoi partecipanti. +class TeacherClass { + final int scheduleId; + final int serviceId; + final String className; + final String? colorClass; + final String dateTime; // "YYYY-MM-DD HH:MM:SS" + final int maxCapacity; + final int bookedCount; + final bool isFull; + final List participants; + + const TeacherClass({ + required this.scheduleId, + required this.serviceId, + required this.className, + required this.colorClass, + required this.dateTime, + required this.maxCapacity, + required this.bookedCount, + required this.isFull, + required this.participants, + }); + + factory TeacherClass.fromMap(Map m) => TeacherClass( + scheduleId: (m['schedule_id'] as num?)?.toInt() ?? 0, + serviceId: (m['service_id'] as num?)?.toInt() ?? 0, + className: (m['class_name'] ?? '').toString(), + colorClass: (m['color_class'])?.toString(), + dateTime: (m['date_time'] ?? '').toString(), + maxCapacity: (m['max_capacity'] as num?)?.toInt() ?? 0, + bookedCount: (m['booked_count'] as num?)?.toInt() ?? 0, + isFull: m['is_full'] == true, + participants: ((m['participants'] as List?) ?? []) + .cast>() + .map(TeacherParticipant.fromMap) + .toList(), + ); +} + +/// Utente trovato nella ricerca (per aggiungi partecipante). +class TeacherUser { + final int id; + final String firstName; + final String lastName; + final String email; + + const TeacherUser({ + required this.id, + required this.firstName, + required this.lastName, + required this.email, + }); + + String get fullName => '$firstName $lastName'.trim(); + + factory TeacherUser.fromMap(Map m) => TeacherUser( + id: (m['id'] as num?)?.toInt() ?? 0, + firstName: (m['first_name'] ?? '').toString(), + lastName: (m['last_name'] ?? '').toString(), + email: (m['email'] ?? '').toString(), + ); +} + +/// Ordine/pacchetto di un utente con residui. +class TeacherOrder { + final int idOrderBook; + final String serviceName; + final int tickets; + final int used; + final int remaining; + final String? expireOn; + + const TeacherOrder({ + required this.idOrderBook, + required this.serviceName, + required this.tickets, + required this.used, + required this.remaining, + required this.expireOn, + }); + + factory TeacherOrder.fromMap(Map m) => TeacherOrder( + idOrderBook: (m['idorderbook'] as num?)?.toInt() ?? 0, + serviceName: (m['service_name'] ?? '').toString(), + tickets: (m['tickets'] as num?)?.toInt() ?? 0, + used: (m['used'] as num?)?.toInt() ?? 0, + remaining: (m['remaining'] as num?)?.toInt() ?? 0, + expireOn: (m['expireon'])?.toString(), + ); +} + +class TeacherApi { + static Map _headers(String token) => { + 'Accept': 'application/json', + 'Authorization': 'Bearer $token', + }; + + /// Classi di un mese (YYYY-MM) con partecipanti. Solo staff lato server. + static Future> fetchClasses({ + required String token, + required String month, + }) async { + final query = month.isNotEmpty ? '?month=$month' : ''; + final uri = Uri.parse( + '${ApiConfig.laravelApiBase}/api_teacher_classes.php$query', + ); + + final res = await http.get(uri, headers: _headers(token)); + + Map data; + try { + data = jsonDecode(res.body) as Map; + } catch (_) { + throw Exception('RAW(${res.statusCode}): ${res.body}'); + } + + if (res.statusCode != 200 || data['success'] != true) { + throw Exception(data['error'] ?? data['message'] ?? 'Errore classi'); + } + + return ((data['classes'] as List?) ?? []) + .cast>() + .map(TeacherClass.fromMap) + .toList(); + } + + /// Segna una prenotazione come persa (lost=true) o la ripristina (false). + static Future setLost({ + required String token, + required int bookingId, + required bool lost, + }) async { + final uri = Uri.parse( + '${ApiConfig.laravelApiBase}/api_teacher_mark_lost.php', + ); + + final res = await http.post( + uri, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': 'Bearer $token', + }, + body: 'booking_id=$bookingId&lost=${lost ? 'Y' : 'N'}', + ); + + Map data; + try { + data = jsonDecode(res.body) as Map; + } catch (_) { + throw Exception('RAW(${res.statusCode}): ${res.body}'); + } + + if (res.statusCode != 200 || data['success'] != true) { + throw Exception( + data['error'] ?? data['message'] ?? 'Errore aggiornamento', + ); + } + } + + /// Elimina una prenotazione (libera il ticket all'allievo). + static Future deleteBooking({ + required String token, + required int bookingId, + }) async { + final uri = Uri.parse( + '${ApiConfig.laravelApiBase}/api_teacher_delete_booking.php', + ); + + final res = await http.post( + uri, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': 'Bearer $token', + }, + body: 'booking_id=$bookingId', + ); + + Map data; + try { + data = jsonDecode(res.body) as Map; + } catch (_) { + throw Exception('RAW(${res.statusCode}): ${res.body}'); + } + + if (res.statusCode != 200 || data['success'] != true) { + throw Exception( + data['error'] ?? data['message'] ?? 'Errore eliminazione', + ); + } + } + + /// Classi destinazione per riprogrammare una prenotazione + /// (future, non piene, escluse quelle dove è già iscritto). + static Future> availableClasses({ + required String token, + required int bookingId, + }) async { + final uri = Uri.parse( + '${ApiConfig.laravelApiBase}/api_teacher_available_classes.php?booking_id=$bookingId', + ); + + final res = await http.get( + uri, + headers: {'Accept': 'application/json', 'Authorization': 'Bearer $token'}, + ); + + Map data; + try { + data = jsonDecode(res.body) as Map; + } catch (_) { + throw Exception('RAW(${res.statusCode}): ${res.body}'); + } + + if (res.statusCode != 200 || data['success'] != true) { + throw Exception(data['error'] ?? data['message'] ?? 'Errore classi'); + } + + return ((data['classes'] as List?) ?? []) + .cast>() + .map(TeacherClass.fromMap) + .toList(); + } + + /// Esegue la riprogrammazione su una nuova classe. + static Future reprogram({ + required String token, + required int bookingId, + required int scheduleId, + required bool countAsReprogram, + }) async { + final uri = Uri.parse( + '${ApiConfig.laravelApiBase}/api_teacher_reprogram.php', + ); + + final res = await http.post( + uri, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': 'Bearer $token', + }, + body: + 'booking_id=$bookingId&schedule_id=$scheduleId' + '&is_reprogrammed=${countAsReprogram ? 'Y' : 'N'}', + ); + + Map data; + try { + data = jsonDecode(res.body) as Map; + } catch (_) { + throw Exception('RAW(${res.statusCode}): ${res.body}'); + } + + if (res.statusCode != 200 || data['success'] != true) { + throw Exception( + data['error'] ?? data['message'] ?? 'Errore riprogrammazione', + ); + } + } + + /// Ricerca utenti per nome/cognome (min 2 caratteri in uno dei due). + static Future> searchUsers({ + required String token, + required String first, + required String last, + }) async { + final uri = Uri.parse( + '${ApiConfig.laravelApiBase}/api_teacher_search_users.php' + '?first=${Uri.encodeQueryComponent(first)}' + '&last=${Uri.encodeQueryComponent(last)}', + ); + + final res = await http.get(uri, headers: _headers(token)); + + Map data; + try { + data = jsonDecode(res.body) as Map; + } catch (_) { + throw Exception('RAW(${res.statusCode}): ${res.body}'); + } + + if (res.statusCode != 200 || data['success'] != true) { + throw Exception(data['error'] ?? data['message'] ?? 'Errore ricerca'); + } + + return ((data['results'] as List?) ?? []) + .cast>() + .map(TeacherUser.fromMap) + .toList(); + } + + /// Ordini con residui di un utente. + static Future> userOrders({ + required String token, + required int userId, + }) async { + final uri = Uri.parse( + '${ApiConfig.laravelApiBase}/api_teacher_user_orders.php?user_id=$userId', + ); + + final res = await http.get(uri, headers: _headers(token)); + + Map data; + try { + data = jsonDecode(res.body) as Map; + } catch (_) { + throw Exception('RAW(${res.statusCode}): ${res.body}'); + } + + if (res.statusCode != 200 || data['success'] != true) { + throw Exception(data['error'] ?? data['message'] ?? 'Errore ordini'); + } + + return ((data['orders'] as List?) ?? []) + .cast>() + .map(TeacherOrder.fromMap) + .toList(); + } + + /// Aggiunge un partecipante a una classe. + /// [mode] = 'scala' | 'omaggio' | 'nuovo'. + /// Per utente esistente passare [userId] > 0; per nuovo utente passare + /// [userId] = 0 con name/surname (email opzionale). + static Future addBooking({ + required String token, + required int scheduleId, + required int serviceId, + required String bookingStart, + required String mode, + int userId = 0, + String name = '', + String surname = '', + String email = '', + int? orderId, + String? newExpiry, + }) async { + final uri = Uri.parse( + '${ApiConfig.laravelApiBase}/api_teacher_add_booking.php', + ); + + final body = { + 'idserviceschedule': '$scheduleId', + 'idservice': '$serviceId', + 'bookingstart': bookingStart, + 'bkmode': mode, + 'user_id': '$userId', + 'name': name, + 'surname': surname, + 'email': email, + if (orderId != null) 'idorder': '$orderId', + if (newExpiry != null) 'new_expiry': newExpiry, + }; + + final res = await http.post( + uri, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': 'Bearer $token', + }, + body: body.entries + .map( + (e) => + '${Uri.encodeQueryComponent(e.key)}=' + '${Uri.encodeQueryComponent(e.value)}', + ) + .join('&'), + ); + + Map data; + try { + data = jsonDecode(res.body) as Map; + } catch (_) { + throw Exception('RAW(${res.statusCode}): ${res.body}'); + } + + if (res.statusCode != 200 || data['success'] != true) { + throw Exception(data['error'] ?? data['message'] ?? 'Errore inserimento'); + } + } +} diff --git a/lib/widgets/app_drawer.dart b/lib/widgets/app_drawer.dart index ef71e22..f81b88b 100644 --- a/lib/widgets/app_drawer.dart +++ b/lib/widgets/app_drawer.dart @@ -10,6 +10,8 @@ import '../screens/lessons_page.dart'; import '../screens/orders_detail_page.dart'; import '../screens/account_page.dart'; import '../screens/settings_page.dart'; +import '../screens/teacher_panel_page.dart'; +import '../services/role_service.dart'; import 'package:url_launcher/url_launcher.dart'; class AppDrawer extends StatelessWidget { @@ -95,6 +97,50 @@ class AppDrawer extends StatelessWidget { fallbackLetter: _avatarLetter, ), + // Sezione insegnante: visibile solo a staff (Admin/teacher). + FutureBuilder( + future: RoleService.fetchMyRole(token: token), + builder: (context, snap) { + final isStaff = snap.data?.isStaff ?? false; + if (!isStaff) return const SizedBox.shrink(); + return Column( + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(16, 10, 16, 4), + child: const Text( + 'INSEGNANTE', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w900, + color: Color(0xFF10B981), + letterSpacing: 0.5, + ), + ), + ), + ListTile( + leading: const Icon(Icons.calendar_month_rounded), + title: const Text('Pannello classi'), + onTap: () { + Navigator.of(context).pop(); + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => TeacherPanelPage( + token: token, + school: school, + userFirstName: userFirstName, + ), + ), + ); + }, + ), + const Divider(height: 1), + ], + ); + }, + ), + ListTile( leading: const Icon(Icons.medical_information), title: const Text('Certificati medici'),