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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 433 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 433 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 856 KiB

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 29 KiB

+24 -1
View File
@@ -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,
),
),
),
);
}
}
+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')),
],
),
),
);
}
}
+56
View File
@@ -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);
}
}
+410
View File
@@ -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');
}
}
}
+46
View File
@@ -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'),