fixed several page and teacher page creation
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../services/teacher_api.dart';
|
||||
import '../widgets/yogibook_background.dart';
|
||||
import 'teacher_reprogram_page.dart';
|
||||
import 'teacher_add_participant_page.dart';
|
||||
|
||||
/// Dettaglio di una classe (vista insegnante).
|
||||
/// Fase 2a: elenco iscritti + segna persa / ripristina.
|
||||
class TeacherClassDetailPage extends StatefulWidget {
|
||||
final String token;
|
||||
final TeacherClass cls;
|
||||
|
||||
const TeacherClassDetailPage({
|
||||
super.key,
|
||||
required this.token,
|
||||
required this.cls,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TeacherClassDetailPage> createState() => _TeacherClassDetailPageState();
|
||||
}
|
||||
|
||||
class _TeacherClassDetailPageState extends State<TeacherClassDetailPage> {
|
||||
static const Color kGreen = Color(0xFF10B981);
|
||||
|
||||
// copia locale modificabile dei partecipanti (per aggiornare la UI)
|
||||
late List<TeacherParticipant> _participants;
|
||||
int _busyBookingId =
|
||||
0; // booking in aggiornamento (per disabilitare il tasto)
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_participants = List.of(widget.cls.participants);
|
||||
}
|
||||
|
||||
String _fmtDate(String iso) {
|
||||
final dt = DateTime.tryParse(iso);
|
||||
if (dt == null) return iso;
|
||||
final s = DateFormat("EEEE d MMMM yyyy", 'it_IT').format(dt);
|
||||
return s[0].toUpperCase() + s.substring(1);
|
||||
}
|
||||
|
||||
String _fmtTime(String iso) {
|
||||
final dt = DateTime.tryParse(iso);
|
||||
if (dt == null) return '';
|
||||
return DateFormat('HH:mm').format(dt);
|
||||
}
|
||||
|
||||
void _snack(String msg) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||
}
|
||||
|
||||
Future<void> _toggleLost(TeacherParticipant p) async {
|
||||
final markLost = !p.isLost;
|
||||
|
||||
// conferma solo quando si segna persa (azione visibile all'allievo)
|
||||
if (markLost) {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Segnare come persa?'),
|
||||
content: Text(
|
||||
'Vuoi segnare la lezione di ${p.fullName} come persa? '
|
||||
'Resterà registrata e non tornerà disponibile all\'allievo.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Annulla'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Segna persa'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true) return;
|
||||
}
|
||||
|
||||
setState(() => _busyBookingId = p.bookingId);
|
||||
try {
|
||||
await TeacherApi.setLost(
|
||||
token: widget.token,
|
||||
bookingId: p.bookingId,
|
||||
lost: markLost,
|
||||
);
|
||||
// aggiorna la copia locale sostituendo il partecipante
|
||||
final idx = _participants.indexWhere((x) => x.bookingId == p.bookingId);
|
||||
if (idx != -1) {
|
||||
setState(() {
|
||||
_participants[idx] = TeacherParticipant(
|
||||
bookingId: p.bookingId,
|
||||
fullName: p.fullName,
|
||||
isLost: markLost,
|
||||
);
|
||||
});
|
||||
}
|
||||
_snack(markLost ? 'Segnata come persa.' : 'Ripristinata.');
|
||||
} catch (e) {
|
||||
_snack('$e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _busyBookingId = 0);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteBooking(TeacherParticipant p) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Eliminare la prenotazione?'),
|
||||
content: Text(
|
||||
'Vuoi rimuovere ${p.fullName} da questa classe? '
|
||||
'Il posto tornerà disponibile all\'allievo. L\'azione non è reversibile.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Annulla'),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFC0392B),
|
||||
),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Elimina'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true) return;
|
||||
|
||||
setState(() => _busyBookingId = p.bookingId);
|
||||
try {
|
||||
await TeacherApi.deleteBooking(
|
||||
token: widget.token,
|
||||
bookingId: p.bookingId,
|
||||
);
|
||||
setState(() {
|
||||
_participants.removeWhere((x) => x.bookingId == p.bookingId);
|
||||
});
|
||||
_snack('Prenotazione eliminata.');
|
||||
} catch (e) {
|
||||
_snack('$e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _busyBookingId = 0);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _reprogram(TeacherParticipant p) async {
|
||||
final done = await Navigator.push<bool>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => TeacherReprogramPage(
|
||||
token: widget.token,
|
||||
bookingId: p.bookingId,
|
||||
participantName: p.fullName,
|
||||
),
|
||||
),
|
||||
);
|
||||
// Se riprogrammata, la prenotazione non è più in questa classe: rimuovila.
|
||||
if (done == true && mounted) {
|
||||
setState(() {
|
||||
_participants.removeWhere((x) => x.bookingId == p.bookingId);
|
||||
});
|
||||
_snack('${p.fullName} riprogrammata.');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final active = _participants.where((p) => !p.isLost).toList();
|
||||
final lost = _participants.where((p) => p.isLost).toList();
|
||||
final cls = widget.cls;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
title: const Text(
|
||||
'Dettaglio classe',
|
||||
style: TextStyle(fontWeight: FontWeight.w800, fontSize: 18),
|
||||
),
|
||||
),
|
||||
body: YogibookBackground(
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
children: [
|
||||
_headerCard(),
|
||||
const SizedBox(height: 18),
|
||||
_sectionLabel('Iscritti (${active.length})'),
|
||||
const SizedBox(height: 8),
|
||||
if (active.isEmpty)
|
||||
_emptyRow('Nessun iscritto attivo.')
|
||||
else
|
||||
...active.map((p) => _tile(p)),
|
||||
if (lost.isNotEmpty) ...[
|
||||
const SizedBox(height: 18),
|
||||
_sectionLabel('Lezioni perse (${lost.length})'),
|
||||
const SizedBox(height: 8),
|
||||
...lost.map((p) => _tile(p)),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: kGreen,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.person_add_alt_1, color: Colors.white),
|
||||
label: const Text(
|
||||
'Aggiungi partecipante',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
onPressed: _openAddParticipant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openAddParticipant() async {
|
||||
final added = await Navigator.push<bool>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
TeacherAddParticipantPage(token: widget.token, cls: widget.cls),
|
||||
),
|
||||
);
|
||||
if (added == true && mounted) {
|
||||
_snack(
|
||||
'Partecipante aggiunto. Aggiorna il pannello per vedere la lista.',
|
||||
);
|
||||
// torna al pannello classi, che ricaricando mostrerà i dati aggiornati
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _headerCard() {
|
||||
final cls = widget.cls;
|
||||
final isFull = cls.isFull;
|
||||
final fillColor = isFull
|
||||
? const Color(0xFFC0392B)
|
||||
: (cls.maxCapacity > 0 && cls.bookedCount >= cls.maxCapacity - 1)
|
||||
? const Color(0xFFE67E22)
|
||||
: const Color(0xFF1A7F52);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
blurRadius: 18,
|
||||
color: Color(0x12000000),
|
||||
offset: Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
cls.className,
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w900),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.event, size: 18, color: Colors.black45),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_fmtDate(cls.dateTime),
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF404040),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.schedule, size: 18, color: Colors.black45),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_fmtTime(cls.dateTime),
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF404040),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: fillColor.withOpacity(0.12),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.group, size: 16, color: fillColor),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'${cls.bookedCount} / ${cls.maxCapacity} posti',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: fillColor,
|
||||
),
|
||||
),
|
||||
if (isFull) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'· AL COMPLETO',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: fillColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionLabel(String t) => Text(
|
||||
t.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.black54,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
);
|
||||
|
||||
Widget _emptyRow(String msg) => Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Text(msg, style: const TextStyle(color: Colors.black45)),
|
||||
);
|
||||
|
||||
Color _avatarColor(String seed) {
|
||||
const palette = [
|
||||
Color(0xFF1EBF73),
|
||||
Color(0xFF2980B9),
|
||||
Color(0xFF8E44AD),
|
||||
Color(0xFFE67E22),
|
||||
Color(0xFF16A085),
|
||||
Color(0xFFC0392B),
|
||||
Color(0xFF2C3E50),
|
||||
Color(0xFFD35400),
|
||||
];
|
||||
int h = 0;
|
||||
for (final c in seed.codeUnits) {
|
||||
h = (h + c) % palette.length;
|
||||
}
|
||||
return palette[h];
|
||||
}
|
||||
|
||||
String _initials(String fullName) {
|
||||
final parts = fullName.trim().split(RegExp(r'\s+'));
|
||||
if (parts.isEmpty || parts.first.isEmpty) return '?';
|
||||
final a = parts.first[0];
|
||||
final b = parts.length > 1 ? parts.last[0] : '';
|
||||
return (a + b).toUpperCase();
|
||||
}
|
||||
|
||||
Widget _tile(TeacherParticipant p) {
|
||||
final lost = p.isLost;
|
||||
final busy = _busyBookingId == p.bookingId;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: lost ? Border.all(color: const Color(0xFFF3D6D2)) : null,
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
blurRadius: 10,
|
||||
color: Color(0x0A000000),
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: lost ? const Color(0xFF9AA5A6) : _avatarColor(p.fullName),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
_initials(p.fullName),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
p.fullName,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 15,
|
||||
color: lost ? Colors.black54 : Colors.black87,
|
||||
decoration: lost ? TextDecoration.lineThrough : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
// chip stato
|
||||
Container(
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: lost ? const Color(0xFF1F2937) : const Color(0xFFE3F2EC),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
lost ? 'Persa' : 'Presente',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: lost ? Colors.white : const Color(0xFF1A7F52),
|
||||
),
|
||||
),
|
||||
),
|
||||
// pulsanti azione
|
||||
busy
|
||||
? const SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () => _toggleLost(p),
|
||||
tooltip: lost ? 'Ripristina' : 'Segna persa',
|
||||
icon: Icon(
|
||||
lost ? Icons.undo_rounded : Icons.person_off_rounded,
|
||||
color: lost
|
||||
? const Color(0xFF1A7F52)
|
||||
: const Color(0xFFC0392B),
|
||||
),
|
||||
),
|
||||
if (!lost)
|
||||
IconButton(
|
||||
onPressed: () => _reprogram(p),
|
||||
tooltip: 'Riprogramma',
|
||||
icon: const Icon(
|
||||
Icons.event_repeat_rounded,
|
||||
color: Color(0xFF2980B9),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => _deleteBooking(p),
|
||||
tooltip: 'Elimina prenotazione',
|
||||
icon: const Icon(
|
||||
Icons.delete_outline_rounded,
|
||||
color: Colors.black45,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../models/school.dart';
|
||||
import '../services/teacher_api.dart';
|
||||
import '../widgets/yogibook_background.dart';
|
||||
import 'teacher_class_detail_page.dart';
|
||||
|
||||
/// Pannello Insegnante (staff: Admin/teacher).
|
||||
/// Fase 1: sola lettura. Classi del mese con partecipanti, focus su oggi.
|
||||
class TeacherPanelPage extends StatefulWidget {
|
||||
final String token;
|
||||
final School school;
|
||||
final String? userFirstName;
|
||||
|
||||
const TeacherPanelPage({
|
||||
super.key,
|
||||
required this.token,
|
||||
required this.school,
|
||||
this.userFirstName,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TeacherPanelPage> createState() => _TeacherPanelPageState();
|
||||
}
|
||||
|
||||
class _TeacherPanelPageState extends State<TeacherPanelPage> {
|
||||
static const Color kGreen = Color(0xFF10B981);
|
||||
static const Color kDarkGreen = Color(0xFF065F46);
|
||||
|
||||
bool loading = true;
|
||||
String error = '';
|
||||
|
||||
String currentMonth = DateFormat('yyyy-MM').format(DateTime.now());
|
||||
List<TeacherClass> classes = [];
|
||||
|
||||
// per l'auto-scroll a oggi
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final Map<int, GlobalKey> _dayKeys = {}; // index classe -> key
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
loading = true;
|
||||
error = '';
|
||||
});
|
||||
try {
|
||||
final data = await TeacherApi.fetchClasses(
|
||||
token: widget.token,
|
||||
month: currentMonth,
|
||||
);
|
||||
setState(() {
|
||||
classes = data;
|
||||
});
|
||||
// dopo il render, prova a scrollare a oggi (con un attimo di ritardo
|
||||
// per essere sicuri che le card siano state disposte)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
Future.delayed(const Duration(milliseconds: 250), _scrollToToday);
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => error = 'Errore: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
// Trova la prima classe con data >= oggi e scrolla lì.
|
||||
void _scrollToToday() {
|
||||
if (classes.isEmpty) return;
|
||||
final today = DateTime.now();
|
||||
int targetIndex = 0;
|
||||
for (int i = 0; i < classes.length; i++) {
|
||||
final dt = DateTime.tryParse(classes[i].dateTime);
|
||||
if (dt != null &&
|
||||
!dt.isBefore(DateTime(today.year, today.month, today.day))) {
|
||||
targetIndex = i;
|
||||
break;
|
||||
}
|
||||
targetIndex = i; // se tutte passate, resta sull'ultima
|
||||
}
|
||||
final key = _dayKeys[targetIndex];
|
||||
final ctx = key?.currentContext;
|
||||
if (ctx != null) {
|
||||
Scrollable.ensureVisible(
|
||||
ctx,
|
||||
duration: const Duration(milliseconds: 400),
|
||||
alignment: 0.1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _shiftMonth(String yyyyMm, int delta) {
|
||||
final dt = DateFormat('yyyy-MM').parse(yyyyMm);
|
||||
final shifted = DateTime(dt.year, dt.month + delta, 1);
|
||||
return DateFormat('yyyy-MM').format(shifted);
|
||||
}
|
||||
|
||||
void _goPrevMonth() {
|
||||
setState(() => currentMonth = _shiftMonth(currentMonth, -1));
|
||||
_load();
|
||||
}
|
||||
|
||||
void _goNextMonth() {
|
||||
setState(() => currentMonth = _shiftMonth(currentMonth, 1));
|
||||
_load();
|
||||
}
|
||||
|
||||
String _monthLabel(String yyyyMm) {
|
||||
final dt = DateFormat('yyyy-MM').parse(yyyyMm);
|
||||
return DateFormat('MMMM yyyy', 'it_IT').format(dt);
|
||||
}
|
||||
|
||||
String _weekdayLabel(String iso) {
|
||||
final dt = DateTime.tryParse(iso);
|
||||
if (dt == null) return '';
|
||||
final s = DateFormat('EEEE', 'it_IT').format(dt);
|
||||
return s[0].toUpperCase() + s.substring(1);
|
||||
}
|
||||
|
||||
String _dayNum(String iso) {
|
||||
final dt = DateTime.tryParse(iso);
|
||||
if (dt == null) return '--';
|
||||
return DateFormat('dd').format(dt);
|
||||
}
|
||||
|
||||
String _time(String iso) {
|
||||
final dt = DateTime.tryParse(iso);
|
||||
if (dt == null) return '';
|
||||
return DateFormat('HH:mm').format(dt);
|
||||
}
|
||||
|
||||
bool _isToday(String iso) {
|
||||
final dt = DateTime.tryParse(iso);
|
||||
if (dt == null) return false;
|
||||
final now = DateTime.now();
|
||||
return dt.year == now.year && dt.month == now.month && dt.day == now.day;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
title: const Text(
|
||||
'Pannello classi',
|
||||
style: TextStyle(fontWeight: FontWeight.w800, fontSize: 18),
|
||||
),
|
||||
),
|
||||
body: YogibookBackground(
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: _MonthPill(
|
||||
label: _monthLabel(currentMonth),
|
||||
onPrev: _goPrevMonth,
|
||||
onNext: _goNextMonth,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: error.isNotEmpty
|
||||
? _ErrorBox(message: error, onRetry: _load)
|
||||
: classes.isEmpty
|
||||
? const _EmptyState()
|
||||
: ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
|
||||
itemCount: classes.length,
|
||||
itemBuilder: (_, i) {
|
||||
final key = _dayKeys.putIfAbsent(
|
||||
i,
|
||||
() => GlobalKey(),
|
||||
);
|
||||
final c = classes[i];
|
||||
final dt = DateTime.tryParse(c.dateTime);
|
||||
final now = DateTime.now();
|
||||
final isPast =
|
||||
dt != null &&
|
||||
dt.isBefore(
|
||||
DateTime(now.year, now.month, now.day),
|
||||
);
|
||||
return Container(
|
||||
key: key,
|
||||
child: _ClassCard(
|
||||
cls: c,
|
||||
weekday: _weekdayLabel(c.dateTime),
|
||||
dayNum: _dayNum(c.dateTime),
|
||||
time: _time(c.dateTime),
|
||||
highlightToday: _isToday(c.dateTime),
|
||||
isPast: isPast,
|
||||
onTap: () async {
|
||||
final changed = await Navigator.push<bool>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => TeacherClassDetailPage(
|
||||
token: widget.token,
|
||||
cls: c,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (changed == true) _load();
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Card classe compatta: al tap apre la pagina dettaglio.
|
||||
class _ClassCard extends StatelessWidget {
|
||||
final TeacherClass cls;
|
||||
final String weekday;
|
||||
final String dayNum;
|
||||
final String time;
|
||||
final bool highlightToday;
|
||||
final bool isPast;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ClassCard({
|
||||
required this.cls,
|
||||
required this.weekday,
|
||||
required this.dayNum,
|
||||
required this.time,
|
||||
required this.highlightToday,
|
||||
required this.isPast,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
static const Color kGreen = Color(0xFF10B981);
|
||||
static const Color kDarkGreen = Color(0xFF065F46);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isFull = cls.isFull;
|
||||
final nearFull =
|
||||
!isFull &&
|
||||
cls.maxCapacity > 0 &&
|
||||
cls.bookedCount >= cls.maxCapacity - 1;
|
||||
|
||||
final fillColor = isFull
|
||||
? const Color(0xFFC0392B)
|
||||
: nearFull
|
||||
? const Color(0xFFE67E22)
|
||||
: const Color(0xFF1A7F52);
|
||||
|
||||
final card = Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: highlightToday
|
||||
? Border.all(color: kGreen, width: 2)
|
||||
: Border.all(color: const Color(0x11000000)),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
blurRadius: 14,
|
||||
color: Color(0x10000000),
|
||||
offset: Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// pannello data
|
||||
Container(
|
||||
width: 74,
|
||||
color: highlightToday ? kGreen : const Color(0xFF0F766E),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 14,
|
||||
horizontal: 6,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
dayNum,
|
||||
style: const TextStyle(
|
||||
fontSize: 26,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
weekday.length > 3 ? weekday.substring(0, 3) : weekday,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// corpo
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 10, 12),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
cls.className,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (highlightToday)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(left: 6),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: kGreen,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Text(
|
||||
'OGGI',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.schedule,
|
||||
size: 14,
|
||||
color: Colors.black45,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
time,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF404040),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Icon(Icons.group, size: 14, color: fillColor),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${cls.bookedCount}/${cls.maxCapacity}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: fillColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// chevron
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 10),
|
||||
child: Icon(Icons.chevron_right, color: Colors.black26),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return Opacity(
|
||||
opacity: isPast && !highlightToday ? 0.55 : 1.0,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: onTap,
|
||||
child: card,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ParticipantRow extends StatelessWidget {
|
||||
final TeacherParticipant p;
|
||||
const _ParticipantRow({required this.p});
|
||||
|
||||
// colore avatar deterministico dal nome (come adminpanel)
|
||||
Color _avatarColor(String seed) {
|
||||
const palette = [
|
||||
Color(0xFF1EBF73),
|
||||
Color(0xFF2980B9),
|
||||
Color(0xFF8E44AD),
|
||||
Color(0xFFE67E22),
|
||||
Color(0xFF16A085),
|
||||
Color(0xFFC0392B),
|
||||
Color(0xFF2C3E50),
|
||||
Color(0xFFD35400),
|
||||
];
|
||||
int h = 0;
|
||||
for (final c in seed.codeUnits) {
|
||||
h = (h + c) % palette.length;
|
||||
}
|
||||
return palette[h];
|
||||
}
|
||||
|
||||
String get _initials {
|
||||
final parts = p.fullName.trim().split(RegExp(r'\s+'));
|
||||
if (parts.isEmpty || parts.first.isEmpty) return '?';
|
||||
final a = parts.first[0];
|
||||
final b = parts.length > 1 ? parts.last[0] : '';
|
||||
return (a + b).toUpperCase();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final lost = p.isLost;
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: lost ? const Color(0xFFFBF1F0) : const Color(0xFFF8FAFC),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: lost ? const Color(0xFFF3D6D2) : const Color(0xFFEEF0F3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: lost ? const Color(0xFF7F8C8D) : _avatarColor(p.fullName),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
_initials,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
p.fullName,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 14),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: lost ? const Color(0xFF1F2937) : const Color(0xFFE3F2EC),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
lost ? 'Persa' : 'Prenotata',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: lost ? Colors.white : const Color(0xFF1A7F52),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MonthPill extends StatelessWidget {
|
||||
final String label;
|
||||
final VoidCallback onPrev;
|
||||
final VoidCallback onNext;
|
||||
|
||||
const _MonthPill({
|
||||
required this.label,
|
||||
required this.onPrev,
|
||||
required this.onNext,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const green = Color(0xFF10B981);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
blurRadius: 14,
|
||||
color: Color(0x11000000),
|
||||
offset: Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: onPrev,
|
||||
icon: const Icon(Icons.chevron_left),
|
||||
iconSize: 22,
|
||||
color: green,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w900),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: onNext,
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
iconSize: 22,
|
||||
color: green,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
const _EmptyState();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(18),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.event_busy, size: 64, color: Colors.black26),
|
||||
SizedBox(height: 14),
|
||||
Text(
|
||||
'Nessuna classe questo mese',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorBox extends StatelessWidget {
|
||||
final String message;
|
||||
final VoidCallback onRetry;
|
||||
const _ErrorBox({required this.message, required this.onRetry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 58, color: Colors.redAccent),
|
||||
const SizedBox(height: 10),
|
||||
Text(message, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(onPressed: onRetry, child: const Text('Riprova')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../services/teacher_api.dart';
|
||||
import '../widgets/yogibook_background.dart';
|
||||
|
||||
/// Scelta della classe destinazione per riprogrammare una prenotazione.
|
||||
/// Ritorna `true` (via Navigator.pop) se la riprogrammazione è avvenuta.
|
||||
class TeacherReprogramPage extends StatefulWidget {
|
||||
final String token;
|
||||
final int bookingId;
|
||||
final String participantName;
|
||||
|
||||
const TeacherReprogramPage({
|
||||
super.key,
|
||||
required this.token,
|
||||
required this.bookingId,
|
||||
required this.participantName,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TeacherReprogramPage> createState() => _TeacherReprogramPageState();
|
||||
}
|
||||
|
||||
class _TeacherReprogramPageState extends State<TeacherReprogramPage> {
|
||||
static const Color kGreen = Color(0xFF10B981);
|
||||
|
||||
bool _loading = true;
|
||||
String _error = '';
|
||||
List<TeacherClass> _classes = [];
|
||||
|
||||
int? _selectedScheduleId;
|
||||
bool _countAsReprogram = true; // default: conta come riprogrammazione
|
||||
bool _submitting = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = '';
|
||||
});
|
||||
try {
|
||||
final data = await TeacherApi.availableClasses(
|
||||
token: widget.token,
|
||||
bookingId: widget.bookingId,
|
||||
);
|
||||
setState(() => _classes = data);
|
||||
} catch (e) {
|
||||
setState(() => _error = 'Errore: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
String _fmt(String iso) {
|
||||
final dt = DateTime.tryParse(iso);
|
||||
if (dt == null) return iso;
|
||||
final s = DateFormat("EEE d MMM yyyy, HH:mm", 'it_IT').format(dt);
|
||||
return s[0].toUpperCase() + s.substring(1);
|
||||
}
|
||||
|
||||
void _snack(String msg) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||
}
|
||||
|
||||
Future<void> _confirm() async {
|
||||
if (_selectedScheduleId == null) {
|
||||
_snack('Seleziona una classe di destinazione.');
|
||||
return;
|
||||
}
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
await TeacherApi.reprogram(
|
||||
token: widget.token,
|
||||
bookingId: widget.bookingId,
|
||||
scheduleId: _selectedScheduleId!,
|
||||
countAsReprogram: _countAsReprogram,
|
||||
);
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context, true); // segnala successo al chiamante
|
||||
} catch (e) {
|
||||
_snack('$e');
|
||||
setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
title: const Text(
|
||||
'Riprogramma',
|
||||
style: TextStyle(fontWeight: FontWeight.w800, fontSize: 18),
|
||||
),
|
||||
),
|
||||
body: YogibookBackground(
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error.isNotEmpty
|
||||
? _errorBox()
|
||||
: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: _headerInfo(),
|
||||
),
|
||||
Expanded(
|
||||
child: _classes.isEmpty
|
||||
? _emptyBox()
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
|
||||
itemCount: _classes.length,
|
||||
itemBuilder: (_, i) => _classTile(_classes[i]),
|
||||
),
|
||||
),
|
||||
_bottomBar(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _headerInfo() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
blurRadius: 12,
|
||||
color: Color(0x0F000000),
|
||||
offset: Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.person, size: 20, color: Colors.black45),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Sposta la lezione di ${widget.participantName}',
|
||||
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _classTile(TeacherClass c) {
|
||||
final selected = _selectedScheduleId == c.scheduleId;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _selectedScheduleId = c.scheduleId),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: selected ? kGreen : const Color(0x14000000),
|
||||
width: selected ? 2 : 1,
|
||||
),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
blurRadius: 10,
|
||||
color: Color(0x0A000000),
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
selected
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
color: selected ? kGreen : Colors.black26,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
c.className,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 15,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_fmt(c.dateTime),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF606060),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${c.bookedCount}/${c.maxCapacity}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Color(0xFF1A7F52),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _bottomBar() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 16),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
blurRadius: 16,
|
||||
color: Color(0x14000000),
|
||||
offset: Offset(0, -6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
activeColor: kGreen,
|
||||
value: _countAsReprogram,
|
||||
onChanged: _submitting
|
||||
? null
|
||||
: (v) => setState(() => _countAsReprogram = v),
|
||||
title: const Text(
|
||||
'Conta come riprogrammazione',
|
||||
style: TextStyle(fontWeight: FontWeight.w700, fontSize: 14),
|
||||
),
|
||||
subtitle: const Text(
|
||||
'Se attivo, incrementa il contatore riprogrammazioni dell\'ordine.',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: kGreen,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
onPressed: _submitting ? null : _confirm,
|
||||
child: _submitting
|
||||
? const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'Conferma riprogrammazione',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _emptyBox() {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.event_busy, size: 56, color: Colors.black26),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'Nessuna classe disponibile',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
Text(
|
||||
'Non ci sono classi future con posti liberi\nper questo allievo.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.black45),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _errorBox() {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 56, color: Colors.redAccent),
|
||||
const SizedBox(height: 10),
|
||||
Text(_error, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(onPressed: _load, child: const Text('Riprova')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../config/api_config.dart';
|
||||
|
||||
/// Ruolo dell'utente autenticato, letto da api_me_role.php.
|
||||
/// Serve a decidere se mostrare le funzioni insegnante (staff = Admin/teacher).
|
||||
class UserRole {
|
||||
final int roleId;
|
||||
final String roleName;
|
||||
final bool isStaff;
|
||||
|
||||
const UserRole({
|
||||
required this.roleId,
|
||||
required this.roleName,
|
||||
required this.isStaff,
|
||||
});
|
||||
|
||||
factory UserRole.fromMap(Map<String, dynamic> m) => UserRole(
|
||||
roleId: (m['role_id'] as num?)?.toInt() ?? 0,
|
||||
roleName: (m['role_name'] ?? '').toString(),
|
||||
isStaff: m['is_staff'] == true,
|
||||
);
|
||||
|
||||
/// Fallback sicuro: utente normale, nessun accesso staff.
|
||||
static const UserRole guest = UserRole(
|
||||
roleId: 0,
|
||||
roleName: '',
|
||||
isStaff: false,
|
||||
);
|
||||
}
|
||||
|
||||
class RoleService {
|
||||
static Uri _u(String path, [Map<String, String>? q]) => Uri.parse(
|
||||
'${ApiConfig.laravelApiBase}/$path',
|
||||
).replace(queryParameters: q);
|
||||
|
||||
static Future<UserRole> fetchMyRole({required String token}) async {
|
||||
final res = await http.get(
|
||||
_u('api_me_role.php'),
|
||||
headers: {'Accept': 'application/json', 'Authorization': 'Bearer $token'},
|
||||
);
|
||||
|
||||
if (res.statusCode != 200) {
|
||||
throw Exception('Role failed (${res.statusCode}): ${res.body}');
|
||||
}
|
||||
|
||||
final data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
if (data['success'] != true) {
|
||||
throw Exception(data['message'] ?? 'Role error');
|
||||
}
|
||||
|
||||
return UserRole.fromMap(data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../config/api_config.dart';
|
||||
|
||||
/// Un partecipante a una classe (vista insegnante).
|
||||
class TeacherParticipant {
|
||||
final int bookingId;
|
||||
final String fullName;
|
||||
final bool isLost;
|
||||
|
||||
const TeacherParticipant({
|
||||
required this.bookingId,
|
||||
required this.fullName,
|
||||
required this.isLost,
|
||||
});
|
||||
|
||||
factory TeacherParticipant.fromMap(Map<String, dynamic> m) =>
|
||||
TeacherParticipant(
|
||||
bookingId: (m['booking_id'] as num?)?.toInt() ?? 0,
|
||||
fullName: (m['full_name'] ?? '').toString().trim(),
|
||||
isLost: m['is_lost'] == true,
|
||||
);
|
||||
}
|
||||
|
||||
/// Una classe programmata con i suoi partecipanti.
|
||||
class TeacherClass {
|
||||
final int scheduleId;
|
||||
final int serviceId;
|
||||
final String className;
|
||||
final String? colorClass;
|
||||
final String dateTime; // "YYYY-MM-DD HH:MM:SS"
|
||||
final int maxCapacity;
|
||||
final int bookedCount;
|
||||
final bool isFull;
|
||||
final List<TeacherParticipant> participants;
|
||||
|
||||
const TeacherClass({
|
||||
required this.scheduleId,
|
||||
required this.serviceId,
|
||||
required this.className,
|
||||
required this.colorClass,
|
||||
required this.dateTime,
|
||||
required this.maxCapacity,
|
||||
required this.bookedCount,
|
||||
required this.isFull,
|
||||
required this.participants,
|
||||
});
|
||||
|
||||
factory TeacherClass.fromMap(Map<String, dynamic> m) => TeacherClass(
|
||||
scheduleId: (m['schedule_id'] as num?)?.toInt() ?? 0,
|
||||
serviceId: (m['service_id'] as num?)?.toInt() ?? 0,
|
||||
className: (m['class_name'] ?? '').toString(),
|
||||
colorClass: (m['color_class'])?.toString(),
|
||||
dateTime: (m['date_time'] ?? '').toString(),
|
||||
maxCapacity: (m['max_capacity'] as num?)?.toInt() ?? 0,
|
||||
bookedCount: (m['booked_count'] as num?)?.toInt() ?? 0,
|
||||
isFull: m['is_full'] == true,
|
||||
participants: ((m['participants'] as List?) ?? [])
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(TeacherParticipant.fromMap)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Utente trovato nella ricerca (per aggiungi partecipante).
|
||||
class TeacherUser {
|
||||
final int id;
|
||||
final String firstName;
|
||||
final String lastName;
|
||||
final String email;
|
||||
|
||||
const TeacherUser({
|
||||
required this.id,
|
||||
required this.firstName,
|
||||
required this.lastName,
|
||||
required this.email,
|
||||
});
|
||||
|
||||
String get fullName => '$firstName $lastName'.trim();
|
||||
|
||||
factory TeacherUser.fromMap(Map<String, dynamic> m) => TeacherUser(
|
||||
id: (m['id'] as num?)?.toInt() ?? 0,
|
||||
firstName: (m['first_name'] ?? '').toString(),
|
||||
lastName: (m['last_name'] ?? '').toString(),
|
||||
email: (m['email'] ?? '').toString(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Ordine/pacchetto di un utente con residui.
|
||||
class TeacherOrder {
|
||||
final int idOrderBook;
|
||||
final String serviceName;
|
||||
final int tickets;
|
||||
final int used;
|
||||
final int remaining;
|
||||
final String? expireOn;
|
||||
|
||||
const TeacherOrder({
|
||||
required this.idOrderBook,
|
||||
required this.serviceName,
|
||||
required this.tickets,
|
||||
required this.used,
|
||||
required this.remaining,
|
||||
required this.expireOn,
|
||||
});
|
||||
|
||||
factory TeacherOrder.fromMap(Map<String, dynamic> m) => TeacherOrder(
|
||||
idOrderBook: (m['idorderbook'] as num?)?.toInt() ?? 0,
|
||||
serviceName: (m['service_name'] ?? '').toString(),
|
||||
tickets: (m['tickets'] as num?)?.toInt() ?? 0,
|
||||
used: (m['used'] as num?)?.toInt() ?? 0,
|
||||
remaining: (m['remaining'] as num?)?.toInt() ?? 0,
|
||||
expireOn: (m['expireon'])?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
class TeacherApi {
|
||||
static Map<String, String> _headers(String token) => {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
};
|
||||
|
||||
/// Classi di un mese (YYYY-MM) con partecipanti. Solo staff lato server.
|
||||
static Future<List<TeacherClass>> fetchClasses({
|
||||
required String token,
|
||||
required String month,
|
||||
}) async {
|
||||
final query = month.isNotEmpty ? '?month=$month' : '';
|
||||
final uri = Uri.parse(
|
||||
'${ApiConfig.laravelApiBase}/api_teacher_classes.php$query',
|
||||
);
|
||||
|
||||
final res = await http.get(uri, headers: _headers(token));
|
||||
|
||||
Map<String, dynamic> data;
|
||||
try {
|
||||
data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
throw Exception('RAW(${res.statusCode}): ${res.body}');
|
||||
}
|
||||
|
||||
if (res.statusCode != 200 || data['success'] != true) {
|
||||
throw Exception(data['error'] ?? data['message'] ?? 'Errore classi');
|
||||
}
|
||||
|
||||
return ((data['classes'] as List?) ?? [])
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(TeacherClass.fromMap)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Segna una prenotazione come persa (lost=true) o la ripristina (false).
|
||||
static Future<void> setLost({
|
||||
required String token,
|
||||
required int bookingId,
|
||||
required bool lost,
|
||||
}) async {
|
||||
final uri = Uri.parse(
|
||||
'${ApiConfig.laravelApiBase}/api_teacher_mark_lost.php',
|
||||
);
|
||||
|
||||
final res = await http.post(
|
||||
uri,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
body: 'booking_id=$bookingId&lost=${lost ? 'Y' : 'N'}',
|
||||
);
|
||||
|
||||
Map<String, dynamic> data;
|
||||
try {
|
||||
data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
throw Exception('RAW(${res.statusCode}): ${res.body}');
|
||||
}
|
||||
|
||||
if (res.statusCode != 200 || data['success'] != true) {
|
||||
throw Exception(
|
||||
data['error'] ?? data['message'] ?? 'Errore aggiornamento',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Elimina una prenotazione (libera il ticket all'allievo).
|
||||
static Future<void> deleteBooking({
|
||||
required String token,
|
||||
required int bookingId,
|
||||
}) async {
|
||||
final uri = Uri.parse(
|
||||
'${ApiConfig.laravelApiBase}/api_teacher_delete_booking.php',
|
||||
);
|
||||
|
||||
final res = await http.post(
|
||||
uri,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
body: 'booking_id=$bookingId',
|
||||
);
|
||||
|
||||
Map<String, dynamic> data;
|
||||
try {
|
||||
data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
throw Exception('RAW(${res.statusCode}): ${res.body}');
|
||||
}
|
||||
|
||||
if (res.statusCode != 200 || data['success'] != true) {
|
||||
throw Exception(
|
||||
data['error'] ?? data['message'] ?? 'Errore eliminazione',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Classi destinazione per riprogrammare una prenotazione
|
||||
/// (future, non piene, escluse quelle dove è già iscritto).
|
||||
static Future<List<TeacherClass>> availableClasses({
|
||||
required String token,
|
||||
required int bookingId,
|
||||
}) async {
|
||||
final uri = Uri.parse(
|
||||
'${ApiConfig.laravelApiBase}/api_teacher_available_classes.php?booking_id=$bookingId',
|
||||
);
|
||||
|
||||
final res = await http.get(
|
||||
uri,
|
||||
headers: {'Accept': 'application/json', 'Authorization': 'Bearer $token'},
|
||||
);
|
||||
|
||||
Map<String, dynamic> data;
|
||||
try {
|
||||
data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
throw Exception('RAW(${res.statusCode}): ${res.body}');
|
||||
}
|
||||
|
||||
if (res.statusCode != 200 || data['success'] != true) {
|
||||
throw Exception(data['error'] ?? data['message'] ?? 'Errore classi');
|
||||
}
|
||||
|
||||
return ((data['classes'] as List?) ?? [])
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(TeacherClass.fromMap)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Esegue la riprogrammazione su una nuova classe.
|
||||
static Future<void> reprogram({
|
||||
required String token,
|
||||
required int bookingId,
|
||||
required int scheduleId,
|
||||
required bool countAsReprogram,
|
||||
}) async {
|
||||
final uri = Uri.parse(
|
||||
'${ApiConfig.laravelApiBase}/api_teacher_reprogram.php',
|
||||
);
|
||||
|
||||
final res = await http.post(
|
||||
uri,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
body:
|
||||
'booking_id=$bookingId&schedule_id=$scheduleId'
|
||||
'&is_reprogrammed=${countAsReprogram ? 'Y' : 'N'}',
|
||||
);
|
||||
|
||||
Map<String, dynamic> data;
|
||||
try {
|
||||
data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
throw Exception('RAW(${res.statusCode}): ${res.body}');
|
||||
}
|
||||
|
||||
if (res.statusCode != 200 || data['success'] != true) {
|
||||
throw Exception(
|
||||
data['error'] ?? data['message'] ?? 'Errore riprogrammazione',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ricerca utenti per nome/cognome (min 2 caratteri in uno dei due).
|
||||
static Future<List<TeacherUser>> searchUsers({
|
||||
required String token,
|
||||
required String first,
|
||||
required String last,
|
||||
}) async {
|
||||
final uri = Uri.parse(
|
||||
'${ApiConfig.laravelApiBase}/api_teacher_search_users.php'
|
||||
'?first=${Uri.encodeQueryComponent(first)}'
|
||||
'&last=${Uri.encodeQueryComponent(last)}',
|
||||
);
|
||||
|
||||
final res = await http.get(uri, headers: _headers(token));
|
||||
|
||||
Map<String, dynamic> data;
|
||||
try {
|
||||
data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
throw Exception('RAW(${res.statusCode}): ${res.body}');
|
||||
}
|
||||
|
||||
if (res.statusCode != 200 || data['success'] != true) {
|
||||
throw Exception(data['error'] ?? data['message'] ?? 'Errore ricerca');
|
||||
}
|
||||
|
||||
return ((data['results'] as List?) ?? [])
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(TeacherUser.fromMap)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Ordini con residui di un utente.
|
||||
static Future<List<TeacherOrder>> userOrders({
|
||||
required String token,
|
||||
required int userId,
|
||||
}) async {
|
||||
final uri = Uri.parse(
|
||||
'${ApiConfig.laravelApiBase}/api_teacher_user_orders.php?user_id=$userId',
|
||||
);
|
||||
|
||||
final res = await http.get(uri, headers: _headers(token));
|
||||
|
||||
Map<String, dynamic> data;
|
||||
try {
|
||||
data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
throw Exception('RAW(${res.statusCode}): ${res.body}');
|
||||
}
|
||||
|
||||
if (res.statusCode != 200 || data['success'] != true) {
|
||||
throw Exception(data['error'] ?? data['message'] ?? 'Errore ordini');
|
||||
}
|
||||
|
||||
return ((data['orders'] as List?) ?? [])
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(TeacherOrder.fromMap)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Aggiunge un partecipante a una classe.
|
||||
/// [mode] = 'scala' | 'omaggio' | 'nuovo'.
|
||||
/// Per utente esistente passare [userId] > 0; per nuovo utente passare
|
||||
/// [userId] = 0 con name/surname (email opzionale).
|
||||
static Future<void> addBooking({
|
||||
required String token,
|
||||
required int scheduleId,
|
||||
required int serviceId,
|
||||
required String bookingStart,
|
||||
required String mode,
|
||||
int userId = 0,
|
||||
String name = '',
|
||||
String surname = '',
|
||||
String email = '',
|
||||
int? orderId,
|
||||
String? newExpiry,
|
||||
}) async {
|
||||
final uri = Uri.parse(
|
||||
'${ApiConfig.laravelApiBase}/api_teacher_add_booking.php',
|
||||
);
|
||||
|
||||
final body = <String, String>{
|
||||
'idserviceschedule': '$scheduleId',
|
||||
'idservice': '$serviceId',
|
||||
'bookingstart': bookingStart,
|
||||
'bkmode': mode,
|
||||
'user_id': '$userId',
|
||||
'name': name,
|
||||
'surname': surname,
|
||||
'email': email,
|
||||
if (orderId != null) 'idorder': '$orderId',
|
||||
if (newExpiry != null) 'new_expiry': newExpiry,
|
||||
};
|
||||
|
||||
final res = await http.post(
|
||||
uri,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
body: body.entries
|
||||
.map(
|
||||
(e) =>
|
||||
'${Uri.encodeQueryComponent(e.key)}='
|
||||
'${Uri.encodeQueryComponent(e.value)}',
|
||||
)
|
||||
.join('&'),
|
||||
);
|
||||
|
||||
Map<String, dynamic> data;
|
||||
try {
|
||||
data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
throw Exception('RAW(${res.statusCode}): ${res.body}');
|
||||
}
|
||||
|
||||
if (res.statusCode != 200 || data['success'] != true) {
|
||||
throw Exception(data['error'] ?? data['message'] ?? 'Errore inserimento');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import '../screens/lessons_page.dart';
|
||||
import '../screens/orders_detail_page.dart';
|
||||
import '../screens/account_page.dart';
|
||||
import '../screens/settings_page.dart';
|
||||
import '../screens/teacher_panel_page.dart';
|
||||
import '../services/role_service.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class AppDrawer extends StatelessWidget {
|
||||
@@ -95,6 +97,50 @@ class AppDrawer extends StatelessWidget {
|
||||
fallbackLetter: _avatarLetter,
|
||||
),
|
||||
|
||||
// Sezione insegnante: visibile solo a staff (Admin/teacher).
|
||||
FutureBuilder<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(
|
||||
leading: const Icon(Icons.medical_information),
|
||||
title: const Text('Certificati medici'),
|
||||
|
||||
Reference in New Issue
Block a user