fixed several page and teacher page creation

This commit is contained in:
2026-08-23 13:22:50 +02:00
parent a7e4c5f083
commit c80535eb97
42 changed files with 2623 additions and 1 deletions
+24 -1
View File
@@ -37,13 +37,36 @@ class _HomePageState extends State<HomePage> {
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<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 _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,
),
),
),
);
}
}
+514
View File
@@ -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,
),
),
],
),
],
),
);
}
}
+622
View File
@@ -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')),
],
),
),
);
}
}
+352
View File
@@ -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')),
],
),
),
);
}
}