fixed several page and teacher page creation
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 433 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 433 KiB |
|
Before Width: | Height: | Size: 856 KiB After Width: | Height: | Size: 430 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 29 KiB |
@@ -37,13 +37,36 @@ class _HomePageState extends State<HomePage> {
|
|||||||
|
|
||||||
late final String zenQuote;
|
late final String zenQuote;
|
||||||
|
|
||||||
|
// Nome caricato da getMe (widget.userFirstName è spesso null,
|
||||||
|
// es. arrivando dallo splash con login persistente).
|
||||||
|
String? _loadedFirstName;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
zenQuote = _pickZenQuote();
|
zenQuote = _pickZenQuote();
|
||||||
|
_loadName();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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 _avatarLetter => _name.isNotEmpty ? _name[0].toUpperCase() : 'U';
|
||||||
|
|
||||||
String get _schoolAddress => (widget.school.addressFull ?? '').trim();
|
String get _schoolAddress => (widget.school.addressFull ?? '').trim();
|
||||||
|
|||||||
@@ -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<TeacherAddParticipantPage> createState() =>
|
||||||
|
_TeacherAddParticipantPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TeacherAddParticipantPageState extends State<TeacherAddParticipantPage> {
|
||||||
|
static const Color kGreen = Color(0xFF10B981);
|
||||||
|
|
||||||
|
final _firstCtrl = TextEditingController();
|
||||||
|
final _lastCtrl = TextEditingController();
|
||||||
|
final _emailCtrl = TextEditingController();
|
||||||
|
|
||||||
|
// ricerca
|
||||||
|
List<TeacherUser> _results = [];
|
||||||
|
bool _searching = false;
|
||||||
|
|
||||||
|
// utente selezionato (null = nuovo utente da creare)
|
||||||
|
TeacherUser? _selectedUser;
|
||||||
|
bool _isNewUser = false;
|
||||||
|
|
||||||
|
// ordini dell'utente selezionato
|
||||||
|
List<TeacherOrder> _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<void> _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<void> _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<void> _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<String>(
|
||||||
|
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<int>(
|
||||||
|
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<String>(
|
||||||
|
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<String>(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TeacherClassDetailPage> createState() => _TeacherClassDetailPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TeacherClassDetailPageState extends State<TeacherClassDetailPage> {
|
||||||
|
static const Color kGreen = Color(0xFF10B981);
|
||||||
|
|
||||||
|
// copia locale modificabile dei partecipanti (per aggiornare la UI)
|
||||||
|
late List<TeacherParticipant> _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<void> _toggleLost(TeacherParticipant p) async {
|
||||||
|
final markLost = !p.isLost;
|
||||||
|
|
||||||
|
// conferma solo quando si segna persa (azione visibile all'allievo)
|
||||||
|
if (markLost) {
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
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<void> _deleteBooking(TeacherParticipant p) async {
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
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<void> _reprogram(TeacherParticipant p) async {
|
||||||
|
final done = await Navigator.push<bool>(
|
||||||
|
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<void> _openAddParticipant() async {
|
||||||
|
final added = await Navigator.push<bool>(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TeacherPanelPage> createState() => _TeacherPanelPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TeacherPanelPageState extends State<TeacherPanelPage> {
|
||||||
|
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<TeacherClass> classes = [];
|
||||||
|
|
||||||
|
// per l'auto-scroll a oggi
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
final Map<int, GlobalKey> _dayKeys = {}; // index classe -> key
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<bool>(
|
||||||
|
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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TeacherReprogramPage> createState() => _TeacherReprogramPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TeacherReprogramPageState extends State<TeacherReprogramPage> {
|
||||||
|
static const Color kGreen = Color(0xFF10B981);
|
||||||
|
|
||||||
|
bool _loading = true;
|
||||||
|
String _error = '';
|
||||||
|
List<TeacherClass> _classes = [];
|
||||||
|
|
||||||
|
int? _selectedScheduleId;
|
||||||
|
bool _countAsReprogram = true; // default: conta come riprogrammazione
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, dynamic> 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<String, String>? q]) => Uri.parse(
|
||||||
|
'${ApiConfig.laravelApiBase}/$path',
|
||||||
|
).replace(queryParameters: q);
|
||||||
|
|
||||||
|
static Future<UserRole> 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<String, dynamic>;
|
||||||
|
if (data['success'] != true) {
|
||||||
|
throw Exception(data['message'] ?? 'Role error');
|
||||||
|
}
|
||||||
|
|
||||||
|
return UserRole.fromMap(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, dynamic> 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<TeacherParticipant> 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<String, dynamic> 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<String, dynamic>>()
|
||||||
|
.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<String, dynamic> 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<String, dynamic> 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<String, String> _headers(String token) => {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Authorization': 'Bearer $token',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Classi di un mese (YYYY-MM) con partecipanti. Solo staff lato server.
|
||||||
|
static Future<List<TeacherClass>> 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<String, dynamic> data;
|
||||||
|
try {
|
||||||
|
data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
|
} 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<String, dynamic>>()
|
||||||
|
.map(TeacherClass.fromMap)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Segna una prenotazione come persa (lost=true) o la ripristina (false).
|
||||||
|
static Future<void> 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<String, dynamic> data;
|
||||||
|
try {
|
||||||
|
data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
|
} 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<void> 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<String, dynamic> data;
|
||||||
|
try {
|
||||||
|
data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
|
} 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<List<TeacherClass>> 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<String, dynamic> data;
|
||||||
|
try {
|
||||||
|
data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
|
} 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<String, dynamic>>()
|
||||||
|
.map(TeacherClass.fromMap)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Esegue la riprogrammazione su una nuova classe.
|
||||||
|
static Future<void> 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<String, dynamic> data;
|
||||||
|
try {
|
||||||
|
data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
|
} 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<List<TeacherUser>> 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<String, dynamic> data;
|
||||||
|
try {
|
||||||
|
data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
|
} 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<String, dynamic>>()
|
||||||
|
.map(TeacherUser.fromMap)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ordini con residui di un utente.
|
||||||
|
static Future<List<TeacherOrder>> 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<String, dynamic> data;
|
||||||
|
try {
|
||||||
|
data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
|
} 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<String, dynamic>>()
|
||||||
|
.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<void> 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 = <String, String>{
|
||||||
|
'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<String, dynamic> data;
|
||||||
|
try {
|
||||||
|
data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
|
} catch (_) {
|
||||||
|
throw Exception('RAW(${res.statusCode}): ${res.body}');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.statusCode != 200 || data['success'] != true) {
|
||||||
|
throw Exception(data['error'] ?? data['message'] ?? 'Errore inserimento');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ import '../screens/lessons_page.dart';
|
|||||||
import '../screens/orders_detail_page.dart';
|
import '../screens/orders_detail_page.dart';
|
||||||
import '../screens/account_page.dart';
|
import '../screens/account_page.dart';
|
||||||
import '../screens/settings_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';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
class AppDrawer extends StatelessWidget {
|
class AppDrawer extends StatelessWidget {
|
||||||
@@ -95,6 +97,50 @@ class AppDrawer extends StatelessWidget {
|
|||||||
fallbackLetter: _avatarLetter,
|
fallbackLetter: _avatarLetter,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Sezione insegnante: visibile solo a staff (Admin/teacher).
|
||||||
|
FutureBuilder<UserRole>(
|
||||||
|
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(
|
ListTile(
|
||||||
leading: const Icon(Icons.medical_information),
|
leading: const Icon(Icons.medical_information),
|
||||||
title: const Text('Certificati medici'),
|
title: const Text('Certificati medici'),
|
||||||
|
|||||||