added pages to project

This commit is contained in:
2026-08-11 07:54:22 +02:00
parent 9c623f96c0
commit ab36215b7a
14 changed files with 2049 additions and 195 deletions
+3 -3
View File
@@ -5,7 +5,7 @@ class ApiConfig {
static const bool useLive = true;
/// Dominio produzione
static const String liveHost = 'app.yogiboook.com';
static const String liveHost = 'yogibook.yogasoul.it';
// Host handling DEV:
// - Web: localhost
@@ -24,7 +24,7 @@ class ApiConfig {
/// DEV: http://10.0.2.2/yogiboook/public/api
/// LIVE: https://app.yogiboook.com/public/api
static String get laravelApiBase {
if (useLive) return '$scheme://$host/public/api';
if (useLive) return '$scheme://$host/api';
return '$scheme://$host/yogiboook/public/api';
}
@@ -32,7 +32,7 @@ class ApiConfig {
/// DEV: http://10.0.2.2/yogiboook/public/userarea/api
/// LIVE: https://app.yogiboook.com/public/userarea/api
static String get phpApiBase {
if (useLive) return '$scheme://$host/public/userarea/api';
if (useLive) return '$scheme://$host/userarea/api';
return '$scheme://$host/yogiboook/public/userarea/api';
}
+204
View File
@@ -62,6 +62,7 @@ class LessonsSummary {
class Lesson {
final int bookingId;
final int serviceId;
final String status;
final String datetime; // "YYYY-MM-DD HH:MM:SS"
@@ -80,6 +81,7 @@ class Lesson {
Lesson({
required this.bookingId,
required this.serviceId,
required this.status,
required this.datetime,
required this.date,
@@ -96,6 +98,7 @@ class Lesson {
factory Lesson.fromJson(Map<String, dynamic> j) {
return Lesson(
bookingId: (j['booking_id'] as num?)?.toInt() ?? 0,
serviceId: (j['service_id'] as num?)?.toInt() ?? 0,
status: (j['status'] ?? '').toString(),
datetime: (j['datetime'] ?? '').toString(),
date: (j['date'] ?? '').toString(),
@@ -110,3 +113,204 @@ class Lesson {
);
}
}
/// Uno slot disponibile per riprogrammare o prenotare (da available_slots.php).
class AvailableSlot {
final int scheduleId;
final int serviceId;
final String datetime;
final String date;
final String time;
final String className;
final String color;
final String location;
final int maxCapacity;
final int bookedCount;
final int freePlaces;
final bool alreadyBooked;
final bool bookable;
AvailableSlot({
required this.scheduleId,
required this.serviceId,
required this.datetime,
required this.date,
required this.time,
required this.className,
required this.color,
required this.location,
required this.maxCapacity,
required this.bookedCount,
required this.freePlaces,
required this.alreadyBooked,
required this.bookable,
});
factory AvailableSlot.fromJson(Map<String, dynamic> j) {
int n(dynamic v) => (v as num?)?.toInt() ?? 0;
return AvailableSlot(
scheduleId: n(j['schedule_id']),
serviceId: n(j['service_id']),
datetime: (j['datetime'] ?? '').toString(),
date: (j['date'] ?? '').toString(),
time: (j['time'] ?? '').toString(),
className: (j['class_name'] ?? '').toString(),
color: (j['color'] ?? '#1ebf73').toString(),
location: (j['location'] ?? '').toString(),
maxCapacity: n(j['max_capacity']),
bookedCount: n(j['booked_count']),
freePlaces: n(j['free_places']),
alreadyBooked: j['already_booked'] == true,
bookable: j['bookable'] == true,
);
}
}
/// Un ordine/pacchetto con ticket residui (da orders.php).
class OrderPackage {
final int orderId;
final int serviceId;
final String serviceName;
final int tickets;
final int used;
final int remaining;
final String? expireOn;
final bool isExpired;
final bool bookable;
OrderPackage({
required this.orderId,
required this.serviceId,
required this.serviceName,
required this.tickets,
required this.used,
required this.remaining,
required this.expireOn,
required this.isExpired,
required this.bookable,
});
factory OrderPackage.fromJson(Map<String, dynamic> j) {
int n(dynamic v) => (v as num?)?.toInt() ?? 0;
return OrderPackage(
orderId: n(j['order_id']),
serviceId: n(j['service_id']),
serviceName: (j['service_name'] ?? '').toString(),
tickets: n(j['tickets']),
used: n(j['used']),
remaining: n(j['remaining']),
expireOn: j['expire_on']?.toString(),
isExpired: j['is_expired'] == true,
bookable: j['bookable'] == true,
);
}
}
/// Una lezione dentro un ordine (da orders_detail.php).
class OrderLesson {
final int bookingId;
final String? datetime;
final String className;
final String status; // completed / booked / lost / expired / pending
final bool isReprogrammed;
OrderLesson({
required this.bookingId,
required this.datetime,
required this.className,
required this.status,
required this.isReprogrammed,
});
factory OrderLesson.fromJson(Map<String, dynamic> j) {
return OrderLesson(
bookingId: (j['booking_id'] as num?)?.toInt() ?? 0,
datetime: j['datetime']?.toString(),
className: (j['class_name'] ?? '').toString(),
status: (j['status'] ?? '').toString(),
isReprogrammed: j['is_reprogrammed'] == true,
);
}
}
/// Un ordine completo con conteggi e lezioni (da orders_detail.php).
class OrderDetail {
final int orderId;
final int? orderNumber;
final int serviceId;
final String serviceName;
final String day;
final String time;
final String? orderDate;
final String? firstLessonDate;
final String? expireOn;
final bool isExpired;
final int maxReschedule;
final int reprogrammed;
final int tickets;
final int total;
final int completed;
final int lost;
final int expired;
final int booked;
final int pending;
final int toSchedule;
final List<OrderLesson> lessons;
OrderDetail({
required this.orderId,
required this.orderNumber,
required this.serviceId,
required this.serviceName,
required this.day,
required this.time,
required this.orderDate,
required this.firstLessonDate,
required this.expireOn,
required this.isExpired,
required this.maxReschedule,
required this.reprogrammed,
required this.tickets,
required this.total,
required this.completed,
required this.lost,
required this.expired,
required this.booked,
required this.pending,
required this.toSchedule,
required this.lessons,
});
factory OrderDetail.fromJson(Map<String, dynamic> j) {
int n(dynamic v) => (v as num?)?.toInt() ?? 0;
final s = (j['summary'] as Map?)?.cast<String, dynamic>() ?? const {};
final rawLessons = (j['lessons'] as List?) ?? const [];
return OrderDetail(
orderId: n(j['order_id']),
orderNumber: (j['order_number'] as num?)?.toInt(),
serviceId: n(j['service_id']),
serviceName: (j['service_name'] ?? '').toString(),
day: (j['day'] ?? '').toString(),
time: (j['time'] ?? '').toString(),
orderDate: j['order_date']?.toString(),
firstLessonDate: j['first_lesson_date']?.toString(),
expireOn: j['expire_on']?.toString(),
isExpired: j['is_expired'] == true,
maxReschedule: n(j['max_reschedule']),
reprogrammed: n(j['reprogrammed']),
tickets: n(j['tickets']),
total: n(s['total']),
completed: n(s['completed']),
lost: n(s['lost']),
expired: n(s['expired']),
booked: n(s['booked']),
pending: n(s['pending']),
toSchedule: n(s['to_schedule']),
lessons: rawLessons
.map((e) => OrderLesson.fromJson((e as Map).cast<String, dynamic>()))
.toList(),
);
}
}
+537
View File
@@ -0,0 +1,537 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import '../models/school.dart';
import '../services/vanguard_api.dart';
import '../widgets/yogibook_background.dart';
class AccountPage extends StatefulWidget {
final String token;
final School school;
final String? userFirstName;
const AccountPage({
super.key,
required this.token,
required this.school,
this.userFirstName,
});
@override
State<AccountPage> createState() => _AccountPageState();
}
class _AccountPageState extends State<AccountPage> {
static const Color kGreen = Color(0xFF10B981);
final _firstNameCtrl = TextEditingController();
final _lastNameCtrl = TextEditingController();
final _passwordCtrl = TextEditingController();
final _passwordConfirmCtrl = TextEditingController();
bool _loadingProfile = true;
bool _savingProfile = false;
bool _savingPassword = false;
bool _uploadingAvatar = false;
String _error = '';
String? _avatarUrl;
bool _obscurePw = true;
bool _obscurePw2 = true;
final _picker = ImagePicker();
@override
void initState() {
super.initState();
_loadProfile();
}
@override
void dispose() {
_firstNameCtrl.dispose();
_lastNameCtrl.dispose();
_passwordCtrl.dispose();
_passwordConfirmCtrl.dispose();
super.dispose();
}
String get _firstName => _firstNameCtrl.text.trim();
String get _avatarLetter =>
_firstName.isNotEmpty ? _firstName[0].toUpperCase() : 'U';
Future<void> _loadProfile() async {
setState(() {
_loadingProfile = true;
_error = '';
});
try {
final me = await VanguardApi.getMe(token: widget.token);
_firstNameCtrl.text = (me['first_name'] ?? '').toString();
_lastNameCtrl.text = (me['last_name'] ?? '').toString();
final av = (me['avatar'] ?? '').toString().trim();
_avatarUrl = av.isEmpty ? null : av;
} catch (e) {
_error = 'Impossibile caricare il profilo: $e';
} finally {
if (mounted) setState(() => _loadingProfile = false);
}
}
void _snack(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
}
// ---------- Salva nome/cognome ----------
Future<void> _saveProfile() async {
if (_firstName.isEmpty || _lastNameCtrl.text.trim().isEmpty) {
_snack('Nome e cognome non possono essere vuoti.');
return;
}
setState(() => _savingProfile = true);
try {
await VanguardApi.updateDetails(
token: widget.token,
firstName: _firstName,
lastName: _lastNameCtrl.text.trim(),
);
_snack('Profilo aggiornato.');
if (mounted) FocusScope.of(context).unfocus();
} catch (e) {
_snack('Errore aggiornamento profilo: $e');
} finally {
if (mounted) setState(() => _savingProfile = false);
}
}
// ---------- Cambia password ----------
Future<void> _savePassword() async {
final pw = _passwordCtrl.text;
final pw2 = _passwordConfirmCtrl.text;
if (pw.isEmpty || pw2.isEmpty) {
_snack('Compila entrambi i campi password.');
return;
}
if (pw.length < 6) {
_snack('La password deve avere almeno 6 caratteri.');
return;
}
if (pw != pw2) {
_snack('Le due password non coincidono.');
return;
}
setState(() => _savingPassword = true);
try {
await VanguardApi.updatePassword(
token: widget.token,
password: pw,
passwordConfirmation: pw2,
);
_passwordCtrl.clear();
_passwordConfirmCtrl.clear();
if (mounted) FocusScope.of(context).unfocus();
_snack('Password aggiornata.');
} catch (e) {
_snack('Errore cambio password: $e');
} finally {
if (mounted) setState(() => _savingPassword = false);
}
}
// ---------- Avatar ----------
Future<void> _pickAvatar(ImageSource source) async {
try {
final XFile? file = await _picker.pickImage(
source: source,
maxWidth: 1024,
maxHeight: 1024,
imageQuality: 85,
);
if (file == null) return;
setState(() => _uploadingAvatar = true);
final newUrl = await VanguardApi.uploadAvatar(
token: widget.token,
filePath: file.path,
);
setState(() {
// se il server ritorna l'URL usalo, così si aggiorna dalla rete;
// in fallback mostriamo il file locale appena scelto
_avatarUrl = newUrl ?? _avatarUrl;
_localAvatarPath = file.path;
});
_snack('Avatar aggiornato.');
} catch (e) {
_snack('Errore avatar: $e');
} finally {
if (mounted) setState(() => _uploadingAvatar = false);
}
}
String? _localAvatarPath;
void _showAvatarSheet() {
showModalBottomSheet(
context: context,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.black26,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 8),
ListTile(
leading: const Icon(Icons.photo_library_rounded, color: kGreen),
title: const Text('Scegli dalla galleria'),
onTap: () {
Navigator.pop(context);
_pickAvatar(ImageSource.gallery);
},
),
ListTile(
leading: const Icon(Icons.photo_camera_rounded, color: kGreen),
title: const Text('Scatta una foto'),
onTap: () {
Navigator.pop(context);
_pickAvatar(ImageSource.camera);
},
),
const SizedBox(height: 8),
],
),
),
);
}
ImageProvider? get _avatarImage {
if (_localAvatarPath != null) return FileImage(File(_localAvatarPath!));
if (_avatarUrl != null) return NetworkImage(_avatarUrl!);
return null;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
centerTitle: true,
title: const Text(
'Account',
style: TextStyle(fontWeight: FontWeight.w800, fontSize: 18),
),
),
body: YogibookBackground(
child: SafeArea(
top: false,
child: _loadingProfile
? const Center(child: CircularProgressIndicator())
: _error.isNotEmpty
? _ErrorRetry(message: _error, onRetry: _loadProfile)
: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
_avatarBlock(),
const SizedBox(height: 20),
_profileCard(),
const SizedBox(height: 16),
_passwordCard(),
const SizedBox(height: 8),
],
),
),
),
);
}
// ---------- UI blocks ----------
Widget _avatarBlock() {
return Center(
child: Column(
children: [
Stack(
children: [
Container(
width: 116,
height: 116,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xFFE7F8F1),
boxShadow: const [
BoxShadow(
blurRadius: 18,
color: Color(0x1A000000),
offset: Offset(0, 10),
),
],
border: Border.all(color: Colors.white, width: 3),
),
child: ClipOval(
child: _avatarImage != null
? Image(
image: _avatarImage!,
width: 116,
height: 116,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _avatarFallback(),
)
: _avatarFallback(),
),
),
Positioned(
right: 0,
bottom: 0,
child: GestureDetector(
onTap: _uploadingAvatar ? null : _showAvatarSheet,
child: Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: kGreen,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
),
child: _uploadingAvatar
? const Padding(
padding: EdgeInsets.all(9),
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(
Icons.photo_camera_rounded,
color: Colors.white,
size: 18,
),
),
),
),
],
),
const SizedBox(height: 10),
Text(
_firstName.isEmpty
? 'Il tuo profilo'
: '$_firstName ${_lastNameCtrl.text.trim()}',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w900),
),
],
),
);
}
Widget _avatarFallback() {
return Container(
width: 116,
height: 116,
alignment: Alignment.center,
color: kGreen,
child: Text(
_avatarLetter,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w900,
fontSize: 44,
),
),
);
}
Widget _card({required String title, required List<Widget> children}) {
return Container(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
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(
title,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w900),
),
const SizedBox(height: 14),
...children,
],
),
);
}
Widget _profileCard() {
return _card(
title: 'Profilo',
children: [
TextField(
controller: _firstNameCtrl,
textInputAction: TextInputAction.next,
decoration: _inputDecoration('Nome', Icons.person_outline),
),
const SizedBox(height: 12),
TextField(
controller: _lastNameCtrl,
textInputAction: TextInputAction.done,
decoration: _inputDecoration('Cognome', Icons.badge_outlined),
),
const SizedBox(height: 16),
_primaryButton(
label: 'Salva profilo',
loading: _savingProfile,
onPressed: _saveProfile,
),
],
);
}
Widget _passwordCard() {
return _card(
title: 'Cambia password',
children: [
TextField(
controller: _passwordCtrl,
obscureText: _obscurePw,
textInputAction: TextInputAction.next,
decoration: _inputDecoration('Nuova password', Icons.lock_outline)
.copyWith(
suffixIcon: IconButton(
icon: Icon(
_obscurePw ? Icons.visibility : Icons.visibility_off,
),
onPressed: () => setState(() => _obscurePw = !_obscurePw),
),
),
),
const SizedBox(height: 12),
TextField(
controller: _passwordConfirmCtrl,
obscureText: _obscurePw2,
textInputAction: TextInputAction.done,
decoration: _inputDecoration('Conferma password', Icons.lock_outline)
.copyWith(
suffixIcon: IconButton(
icon: Icon(
_obscurePw2 ? Icons.visibility : Icons.visibility_off,
),
onPressed: () => setState(() => _obscurePw2 = !_obscurePw2),
),
),
),
const SizedBox(height: 16),
_primaryButton(
label: 'Aggiorna password',
loading: _savingPassword,
onPressed: _savePassword,
),
],
);
}
InputDecoration _inputDecoration(String label, IconData icon) {
return InputDecoration(
labelText: label,
prefixIcon: Icon(icon, size: 20),
filled: true,
fillColor: const Color(0xFFF6F6FB),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: kGreen, width: 2),
),
);
}
Widget _primaryButton({
required String label,
required bool loading,
required VoidCallback onPressed,
}) {
return SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: kGreen,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
onPressed: loading ? null : onPressed,
child: loading
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(
label,
style: const TextStyle(
fontWeight: FontWeight.w900,
color: Colors.white,
),
),
),
);
}
}
class _ErrorRetry extends StatelessWidget {
final String message;
final VoidCallback onRetry;
const _ErrorRetry({required this.message, required this.onRetry});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.black38),
const SizedBox(height: 12),
Text(message, textAlign: TextAlign.center),
const SizedBox(height: 16),
ElevatedButton(onPressed: onRetry, child: const Text('Riprova')),
],
),
),
);
}
}
+19 -5
View File
@@ -12,6 +12,8 @@ import 'medical_certificates_page.dart';
import '../widgets/app_drawer.dart';
import '../widgets/app_bottom_nav.dart';
import '../widgets/yogibook_background.dart';
import 'orders_detail_page.dart';
import 'account_page.dart';
class HomePage extends StatefulWidget {
final String token;
@@ -231,9 +233,18 @@ class _HomePageState extends State<HomePage> {
return;
}
if (i == 2) {
ScaffoldMessenger.of(
Navigator.push(
context,
).showSnackBar(const SnackBar(content: Text('TODO: vai ad Account')));
MaterialPageRoute(
builder: (_) => AccountPage(
token: widget.token,
school: widget.school,
userFirstName: widget.userFirstName,
),
),
).then((_) {
if (mounted) setState(() => bottomIndex = 0);
});
return;
}
if (i == 3) {
@@ -363,10 +374,13 @@ class _HomePageState extends State<HomePage> {
_HomeTile(
icon: Icons.shopping_bag_rounded,
title: 'Vedi ordini',
subtitle: 'Storico e stato ordini (TODO)',
onTap: () => ScaffoldMessenger.of(
subtitle: 'Storico e stato dei tuoi ordini',
onTap: () => Navigator.push(
context,
).showSnackBar(const SnackBar(content: Text('TODO: Ordini'))),
MaterialPageRoute(
builder: (_) => OrdersDetailPage(token: widget.token),
),
),
),
_HomeTile(
icon: Icons.person_rounded,
+207 -154
View File
@@ -2,13 +2,15 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../models/lesson.dart';
import '../models/school.dart';
import '../services/vanguard_api.dart';
import '../services/lessons_api.dart';
import 'select_school_page.dart';
import 'home_page.dart';
import 'meditation_page.dart';
import '../widgets/app_drawer.dart';
import '../widgets/app_bottom_nav.dart';
import '../widgets/yogibook_background.dart';
import 'reschedule_page.dart';
import 'orders_page.dart';
class LessonsPage extends StatefulWidget {
final String token;
@@ -31,10 +33,8 @@ class _LessonsPageState extends State<LessonsPage> {
String error = '';
String currentMonth = DateFormat('yyyy-MM').format(DateTime.now());
String? prevMonth;
String? nextMonth;
String? schoolAddress;
LessonsSummary? summary;
List<Lesson> lessons = [];
int bottomIndex = 1;
@@ -52,22 +52,15 @@ class _LessonsPageState extends State<LessonsPage> {
});
try {
final data = await VanguardApi.getMyLessons(
final data = await LessonsApi.fetchMyLessons(
token: widget.token,
schoolId: widget.school.id,
month: currentMonth,
);
prevMonth = data['prev_month']?.toString();
nextMonth = data['next_month']?.toString();
schoolAddress = (data['school'] as Map<String, dynamic>?)?['address_full']
?.toString();
final list = (data['lessons'] as List<dynamic>? ?? [])
.map((e) => Lesson.fromJson(e as Map<String, dynamic>))
.toList();
setState(() => lessons = list);
setState(() {
summary = data.summary;
lessons = data.lessons;
});
} catch (e) {
setState(() => error = 'Errore: $e');
} finally {
@@ -75,6 +68,23 @@ class _LessonsPageState extends State<LessonsPage> {
}
}
// Navigazione mesi calcolata lato client (l'API non restituisce prev/next).
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);
@@ -194,41 +204,58 @@ class _LessonsPageState extends State<LessonsPage> {
top: false,
child: Column(
children: [
// Address small (optional)
if (schoolAddress != null && schoolAddress!.trim().isNotEmpty)
// Riepilogo conteggi (le card pastello della webapp)
if (summary != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 6),
child: Text(
schoolAddress!,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 12,
color: Colors.black45,
fontWeight: FontWeight.w500,
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: _SummaryStrip(summary: summary!),
),
// Selettore mese
Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 4),
child: _MonthPillCompact(
label: _monthLabel(currentMonth),
onPrev: _goPrevMonth,
onNext: _goNextMonth,
),
),
// Bottone "Programma lezioni" (solo se ci sono ticket residui)
if (summary != null && summary!.toSchedule > 0)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF10B981),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
icon: const Icon(Icons.add, color: Colors.white),
label: Text(
'Programma lezioni (${summary!.toSchedule} disponibili)',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w800,
),
),
onPressed: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => OrdersPage(token: widget.token),
),
);
_load();
},
),
),
),
// Month selector only
Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 10),
child: _MonthPillCompact(
label: _monthLabel(currentMonth),
onPrev: prevMonth == null
? null
: () {
setState(() => currentMonth = prevMonth!);
_load();
},
onNext: nextMonth == null
? null
: () {
setState(() => currentMonth = nextMonth!);
_load();
},
),
),
Expanded(
child: loading
? const Center(child: CircularProgressIndicator())
@@ -263,23 +290,72 @@ class _LessonsPageState extends State<LessonsPage> {
lesson: l,
weekday: _weekdayLabel(l.date),
dayNum: _dayNum(l.date),
onReschedule: l.canModify
? () => ScaffoldMessenger.of(context)
.showSnackBar(
const SnackBar(
content: Text(
'Riprogramma: API dopo',
),
onReschedule: l.canReschedule
? () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ReschedulePage(
token: widget.token,
bookingId: l.bookingId,
serviceId: l.serviceId,
className: l.className,
),
)
),
);
_load();
}
: null,
onCancel: l.canModify
? () => ScaffoldMessenger.of(context)
.showSnackBar(
const SnackBar(
content: Text('Cancella: API dopo'),
onCancel: l.canDelete
? () async {
final conferma = await showDialog<bool>(
context: context,
builder: (dialogCtx) => AlertDialog(
title: const Text(
'Cancellare la lezione?',
),
)
content: Text(
'Vuoi cancellare "${l.className}" del ${l.date}? '
'Ricordati di riprogrammarla entro la scadenza.',
),
actions: [
TextButton(
onPressed: () =>
Navigator.pop(dialogCtx, false),
child: const Text('Annulla'),
),
ElevatedButton(
onPressed: () =>
Navigator.pop(dialogCtx, true),
child: const Text('Cancella'),
),
],
),
);
if (conferma != true) return;
try {
await LessonsApi.cancelLesson(
token: widget.token,
bookingId: l.bookingId,
);
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(
const SnackBar(
content: Text('Lezione cancellata'),
),
);
_load();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(
SnackBar(content: Text('$e')),
);
}
}
: null,
);
},
@@ -293,55 +369,77 @@ class _LessonsPageState extends State<LessonsPage> {
}
}
class _AppDrawer extends StatelessWidget {
final String token;
final VoidCallback onLogout;
const _AppDrawer({required this.token, required this.onLogout});
class _SummaryStrip extends StatelessWidget {
final LessonsSummary summary;
const _SummaryStrip({required this.summary});
@override
Widget build(BuildContext context) {
return Drawer(
child: SafeArea(
child: ListView(
padding: EdgeInsets.zero,
children: [
const DrawerHeader(
child: Align(
alignment: Alignment.bottomLeft,
child: Text(
'Menu',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w900),
),
),
),
ListTile(
leading: const Icon(Icons.swap_horiz),
title: const Text('Cambia scuola'),
onTap: () {
Navigator.of(context).pop(); // chiude drawer
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => SelectSchoolPage(token: token),
),
);
},
),
const Divider(height: 1),
final items = <_SummaryItem>[
_SummaryItem('Acquistate', summary.purchased, const Color(0xFFE2C4FB)),
_SummaryItem('Praticate', summary.practiced, const Color(0xFFC4E1FB)),
_SummaryItem('Prenotate', summary.booked, const Color(0xFFCDFBC4)),
_SummaryItem('Da confermare', summary.pending, const Color(0xFFFBFAC4)),
_SummaryItem(
'Da programmare',
summary.toSchedule,
const Color(0xFFFBE4C4),
),
_SummaryItem('Perse', summary.lost, const Color(0xFFFBC7C4)),
];
ListTile(
leading: const Icon(Icons.logout),
title: const Text('Logout'),
onTap: onLogout,
return SizedBox(
height: 76,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: items.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (_, i) {
final it = items[i];
return Container(
width: 96,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: it.color,
borderRadius: BorderRadius.circular(12),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${it.value}',
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 2),
Text(
it.label,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
);
},
),
);
}
}
class _SummaryItem {
final String label;
final int value;
final Color color;
_SummaryItem(this.label, this.value, this.color);
}
class _MonthPillCompact extends StatelessWidget {
final String label;
final VoidCallback? onPrev;
@@ -421,8 +519,8 @@ class _LessonGreenCardCompact extends StatelessWidget {
const green = Color(0xFF10B981);
const darkGreen = Color(0xFF065F46);
final level = (lesson.level ?? '').trim();
final time = _shortTime(lesson.startTime);
final time = _shortTime(lesson.time);
final canModify = lesson.canReschedule || lesson.canDelete;
return Container(
margin: const EdgeInsets.only(bottom: 12),
@@ -441,7 +539,7 @@ class _LessonGreenCardCompact extends StatelessWidget {
borderRadius: BorderRadius.circular(18),
child: Row(
children: [
// left panel smaller
// left panel
Container(
width: 92,
color: const Color(0xFFBFF3DE),
@@ -503,7 +601,7 @@ class _LessonGreenCardCompact extends StatelessWidget {
),
),
// right body tighter
// right body
Expanded(
child: Container(
padding: const EdgeInsets.fromLTRB(10, 10, 10, 10),
@@ -525,52 +623,30 @@ class _LessonGreenCardCompact extends StatelessWidget {
Row(
children: [
const Icon(
Icons.meeting_room_outlined,
Icons.place_outlined,
size: 16,
color: Colors.black54,
),
const SizedBox(width: 6),
Expanded(
child: Text(
(lesson.roomName ?? '').trim().isEmpty
lesson.location.trim().isEmpty
? 'Sala da definire'
: lesson.roomName!,
: lesson.location,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF404040),
fontWeight: FontWeight.w600,
),
maxLines: 1,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
if (level.isNotEmpty) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: const Color(0xFF10B981),
borderRadius: BorderRadius.circular(999),
),
child: Text(
_capitalize(level),
style: const TextStyle(
fontWeight: FontWeight.w900,
fontSize: 10,
color: Colors.white,
),
),
),
],
],
),
const SizedBox(height: 8),
// Buttons compact (stessa riga, più piccoli)
Row(
children: [
Expanded(
@@ -622,10 +698,10 @@ class _LessonGreenCardCompact extends StatelessWidget {
],
),
if (!lesson.canModify) ...[
if (!canModify) ...[
const SizedBox(height: 6),
const Text(
'Non modificabile (entro 24 ore)',
'Non modificabile',
style: TextStyle(fontSize: 11, color: Colors.black54),
),
],
@@ -639,30 +715,7 @@ class _LessonGreenCardCompact extends StatelessWidget {
);
}
static Widget _metaRowCompact(IconData icon, String text) {
return Row(
children: [
Icon(icon, size: 16, color: Colors.black54),
const SizedBox(width: 6),
Expanded(
child: Text(
text,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF404040),
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
}
static String _shortTime(String t) => t.length >= 5 ? t.substring(0, 5) : t;
static String _capitalize(String s) =>
s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
}
class _EmptyState extends StatelessWidget {
+19 -2
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../services/vanguard_api.dart';
import 'select_school_page.dart';
import '../models/school.dart';
import 'home_page.dart';
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@@ -18,6 +19,15 @@ class _LoginPageState extends State<LoginPage> {
bool loading = false;
String error = '';
// Scuola unica (YogaSoul). Il DB non ha il concetto di scuola:
// questo oggetto serve solo a soddisfare le pagine che mostrano nome/indirizzo.
static final School _yogaSoulSchool = School(
id: 1,
name: 'YogaSoul',
logo: null,
addressFull: 'via Valassina 62/B Seregno - Sala Contesto Yoga',
);
@override
void dispose() {
emailController.dispose();
@@ -39,9 +49,16 @@ class _LoginPageState extends State<LoginPage> {
if (!mounted) return;
// Bypass SelectSchoolPage: scuola unica, si va dritti alla Home.
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => SelectSchoolPage(token: token)),
MaterialPageRoute(
builder: (_) => HomePage(
token: token,
school: _yogaSoulSchool,
userFirstName: null,
),
),
);
} catch (e) {
setState(() => error = 'Errore login: $e');
+13 -4
View File
@@ -139,11 +139,17 @@ class _MeditationPageState extends State<MeditationPage>
if (running) return;
// start music on user action (safer for iOS)
await _ensureMusicStarted();
if (!_musicStarted) {
await _ensureMusicStarted();
} else {
// già partita in precedenza: riprende dopo la pausa
try {
await _bgPlayer.resume();
} catch (_) {}
}
await _applyMusicVolume();
setState(() => running = true);
// start square animation
_squareCtrl.repeat();
@@ -165,8 +171,11 @@ class _MeditationPageState extends State<MeditationPage>
_timer = null;
_squareCtrl.stop();
// musica: la lasciamo andare ma mutabile; se vuoi che si fermi in pausa:
// _bgPlayer.pause();
// ferma la musica in pausa
try {
_bgPlayer.pause();
} catch (_) {}
}
void _reset() {
+322
View File
@@ -0,0 +1,322 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../models/lesson.dart';
import '../services/lessons_api.dart';
import '../widgets/yogibook_background.dart';
/// Schermata "I miei ordini": lista ordini con conteggi.
/// Toccando un ordine si apre il dettaglio (box + lezioni).
class OrdersDetailPage extends StatefulWidget {
final String token;
const OrdersDetailPage({super.key, required this.token});
@override
State<OrdersDetailPage> createState() => _OrdersDetailPageState();
}
class _OrdersDetailPageState extends State<OrdersDetailPage> {
bool loading = true;
String error = '';
List<OrderDetail> orders = [];
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
loading = true;
error = '';
});
try {
final data = await LessonsApi.fetchOrdersDetail(token: widget.token);
setState(() => orders = data);
} catch (e) {
setState(() => error = 'Errore: $e');
} finally {
setState(() => loading = false);
}
}
String _fmtDate(String? raw) {
if (raw == null || raw.isEmpty) return '-';
try {
final dt = DateTime.parse(raw);
return DateFormat('dd-MM-yyyy').format(dt);
} catch (_) {
return raw;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
centerTitle: true,
title: const Text(
'I miei ordini',
style: TextStyle(fontWeight: FontWeight.w800, fontSize: 18),
),
),
body: YogibookBackground(
child: SafeArea(
top: false,
child: loading
? const Center(child: CircularProgressIndicator())
: error.isNotEmpty
? Center(child: Text(error))
: orders.isEmpty
? const Center(child: Text('Nessun ordine trovato'))
: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: orders.length,
itemBuilder: (_, i) {
final o = orders[i];
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
title: Text(
o.serviceName.isEmpty
? 'Ordine #${o.orderId}'
: o.serviceName,
style: const TextStyle(fontWeight: FontWeight.w800),
),
subtitle: Text(
'Ordine #${o.orderId}${o.tickets} lezioni\n'
'Da programmare: ${o.toSchedule} • Scadenza: ${_fmtDate(o.expireOn)}'
'${o.isExpired ? " (scaduto)" : ""}',
),
isThreeLine: true,
trailing: const Icon(Icons.chevron_right),
onTap: () => _openDetail(o),
),
);
},
),
),
),
);
}
void _openDetail(OrderDetail o) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) => _OrderDetailSheet(order: o, fmtDate: _fmtDate),
);
}
}
class _OrderDetailSheet extends StatelessWidget {
final OrderDetail order;
final String Function(String?) fmtDate;
const _OrderDetailSheet({required this.order, required this.fmtDate});
String _statusLabel(String s) {
switch (s) {
case 'completed':
return 'Completata';
case 'booked':
return 'Programmata';
case 'lost':
return 'Persa';
case 'expired':
return 'Scaduta';
case 'pending':
return 'Da confermare';
default:
return s;
}
}
Color _statusColor(String s) {
switch (s) {
case 'completed':
return const Color(0xFF28A745);
case 'booked':
return const Color(0xFF007BFF);
case 'lost':
return const Color(0xFFDC3545);
case 'expired':
return const Color(0xFFFF8C00);
case 'pending':
return const Color(0xFFB8860B);
default:
return Colors.grey;
}
}
String _fmtDateTime(String? raw) {
if (raw == null || raw.isEmpty) return '-';
try {
final dt = DateTime.parse(raw);
return DateFormat('dd-MM-yyyy HH:mm').format(dt);
} catch (_) {
return raw;
}
}
@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.75,
maxChildSize: 0.95,
minChildSize: 0.5,
builder: (_, scrollCtrl) => Padding(
padding: const EdgeInsets.all(16),
child: ListView(
controller: scrollCtrl,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.black26,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 12),
Text(
'Ordine #${order.orderId}',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w900),
),
Text(
order.serviceName,
style: const TextStyle(color: Colors.black54),
),
const SizedBox(height: 12),
// Box conteggi
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_statBox('Totale', order.total, const Color(0xFFD1E7DD)),
_statBox('Praticate', order.completed, const Color(0xFFD4EDDA)),
_statBox('Perse', order.lost, const Color(0xFFF8D7DA)),
_statBox('Scadute', order.expired, const Color(0xFFFFF3CD)),
_statBox('Programmate', order.booked, const Color(0xFFCCE5FF)),
_statBox(
'Da confermare',
order.pending,
const Color(0xFFFDE7C4),
),
_statBox(
'Da programmare',
order.toSchedule,
const Color(0xFFE2D3F5),
),
],
),
const SizedBox(height: 8),
Text(
'Scadenza: ${fmtDate(order.expireOn)}'
'${order.isExpired ? " (scaduto)" : ""}',
style: const TextStyle(fontSize: 12, color: Colors.black54),
),
const Divider(height: 24),
const Text(
'Lezioni',
style: TextStyle(fontWeight: FontWeight.w800),
),
const SizedBox(height: 8),
if (order.lessons.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text('Nessuna lezione per questo ordine.'),
)
else
...order.lessons.map(
(l) => Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.className,
style: const TextStyle(
fontWeight: FontWeight.w700,
fontSize: 13,
),
),
Text(
_fmtDateTime(l.datetime),
style: const TextStyle(
fontSize: 12,
color: Colors.black54,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: _statusColor(l.status),
borderRadius: BorderRadius.circular(999),
),
child: Text(
_statusLabel(l.status),
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
),
],
),
),
),
const SizedBox(height: 20),
],
),
),
);
}
Widget _statBox(String label, int value, Color bg) {
return Container(
width: 100,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(10),
),
child: Column(
children: [
Text(
'$value',
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w900),
),
Text(
label,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600),
),
],
),
);
}
}
+122
View File
@@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
import '../models/lesson.dart';
import '../services/lessons_api.dart';
import '../widgets/yogibook_background.dart';
import 'reschedule_page.dart';
/// Schermata "Programma lezioni": mostra i pacchetti con ticket residui.
/// Scelto un pacchetto, apre la selezione slot in modalità prenotazione.
class OrdersPage extends StatefulWidget {
final String token;
const OrdersPage({super.key, required this.token});
@override
State<OrdersPage> createState() => _OrdersPageState();
}
class _OrdersPageState extends State<OrdersPage> {
bool loading = true;
String error = '';
List<OrderPackage> orders = [];
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
loading = true;
error = '';
});
try {
final data = await LessonsApi.fetchOrders(token: widget.token);
setState(() => orders = data);
} catch (e) {
setState(() => error = 'Errore: $e');
} finally {
setState(() => loading = false);
}
}
@override
Widget build(BuildContext context) {
const green = Color(0xFF10B981);
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
centerTitle: true,
title: const Text(
'Programma lezioni',
style: TextStyle(fontWeight: FontWeight.w800, fontSize: 18),
),
),
body: YogibookBackground(
child: SafeArea(
top: false,
child: loading
? const Center(child: CircularProgressIndicator())
: error.isNotEmpty
? Center(child: Text(error))
: orders.isEmpty
? const Center(child: Text('Nessun pacchetto disponibile'))
: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: orders.length,
itemBuilder: (_, i) {
final o = orders[i];
return Card(
margin: const EdgeInsets.only(bottom: 10),
child: ListTile(
title: Text(
o.serviceName.isEmpty
? 'Pacchetto #${o.orderId}'
: o.serviceName,
style: const TextStyle(fontWeight: FontWeight.w800),
),
subtitle: Text(
'Da prenotare: ${o.remaining} / ${o.tickets}'
'${o.expireOn != null ? "\nScadenza: ${o.expireOn}" : ""}'
'${o.isExpired ? " (scaduto)" : ""}',
),
isThreeLine: o.expireOn != null,
trailing: o.bookable
? ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: green,
),
onPressed: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ReschedulePage(
token: widget.token,
serviceId: o.serviceId,
className: o.serviceName,
orderId: o.orderId,
),
),
);
_load();
},
child: const Text('Prenota'),
)
: Text(
o.isExpired ? 'Scaduto' : 'Esaurito',
style: const TextStyle(
color: Colors.black38,
fontWeight: FontWeight.w700,
),
),
),
);
},
),
),
),
);
}
}
+264
View File
@@ -0,0 +1,264 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../models/lesson.dart';
import '../services/lessons_api.dart';
import '../widgets/yogibook_background.dart';
/// Schermata per scegliere uno slot.
/// Due modalità:
/// - RIPROGRAMMA: passa bookingId (+ serviceId della lezione)
/// - PRENOTA: passa orderId (+ serviceId dell'ordine)
class ReschedulePage extends StatefulWidget {
final String token;
final int serviceId;
final String className;
// Modalità riprogramma
final int? bookingId;
// Modalità prenota da ticket
final int? orderId;
const ReschedulePage({
super.key,
required this.token,
required this.serviceId,
required this.className,
this.bookingId,
this.orderId,
});
bool get isBooking => orderId != null;
@override
State<ReschedulePage> createState() => _ReschedulePageState();
}
class _ReschedulePageState extends State<ReschedulePage> {
bool loading = true;
String error = '';
String currentMonth = DateFormat('yyyy-MM').format(DateTime.now());
List<AvailableSlot> slots = [];
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
loading = true;
error = '';
});
try {
final data = await LessonsApi.fetchAvailableSlots(
token: widget.token,
serviceId: widget.serviceId,
month: currentMonth,
);
setState(() => slots = data);
} catch (e) {
setState(() => error = 'Errore: $e');
} finally {
setState(() => loading = false);
}
}
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);
}
String _monthLabel(String yyyyMm) {
final dt = DateFormat('yyyy-MM').parse(yyyyMm);
return DateFormat('MMMM yyyy', 'it_IT').format(dt);
}
String _weekdayLabel(String ymd) {
final dt = DateFormat('yyyy-MM-dd').parse(ymd);
final s = DateFormat('EEEE', 'it_IT').format(dt);
return s[0].toUpperCase() + s.substring(1);
}
@override
Widget build(BuildContext context) {
const green = Color(0xFF10B981);
final titolo = widget.isBooking ? 'Prenota lezione' : 'Riprogramma';
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
title: Column(
children: [
Text(
titolo,
style: const TextStyle(fontWeight: FontWeight.w800, fontSize: 18),
),
Text(
widget.className,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.black54,
),
),
],
),
centerTitle: true,
),
body: YogibookBackground(
child: SafeArea(
top: false,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 10),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
color: green,
onPressed: () {
setState(
() => currentMonth = _shiftMonth(currentMonth, -1),
);
_load();
},
),
Expanded(
child: Text(
_monthLabel(currentMonth),
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w900,
),
),
),
IconButton(
icon: const Icon(Icons.chevron_right),
color: green,
onPressed: () {
setState(
() => currentMonth = _shiftMonth(currentMonth, 1),
);
_load();
},
),
],
),
),
),
Expanded(
child: loading
? const Center(child: CircularProgressIndicator())
: error.isNotEmpty
? Center(child: Text(error))
: slots.isEmpty
? const Center(
child: Text('Nessuno slot disponibile questo mese'),
)
: ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
itemCount: slots.length,
itemBuilder: (_, i) {
final s = slots[i];
return Card(
margin: const EdgeInsets.only(bottom: 10),
child: ListTile(
title: Text(
s.className,
style: const TextStyle(
fontWeight: FontWeight.w800,
),
),
subtitle: Text(
'${_weekdayLabel(s.date)} ${s.date}${s.time}\n'
'Posti liberi: ${s.freePlaces}/${s.maxCapacity}'
'${s.alreadyBooked ? " • Sei già prenotato" : ""}',
),
isThreeLine: true,
trailing: s.bookable
? ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: green,
),
onPressed: () => _confirm(s),
child: const Text('Scegli'),
)
: const Text(
'Non disp.',
style: TextStyle(
color: Colors.black38,
fontWeight: FontWeight.w700,
),
),
),
);
},
),
),
],
),
),
),
);
}
Future<void> _confirm(AvailableSlot s) async {
final azione = widget.isBooking ? 'prenotare' : 'spostare';
final ok = await showDialog<bool>(
context: context,
builder: (dialogCtx) => AlertDialog(
title: Text('Confermi?'),
content: Text(
'Vuoi $azione "${s.className}" del ${s.date} alle ${s.time}?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogCtx, false),
child: const Text('Annulla'),
),
ElevatedButton(
onPressed: () => Navigator.pop(dialogCtx, true),
child: const Text('Conferma'),
),
],
),
);
if (ok != true) return;
try {
final String msg;
if (widget.isBooking) {
msg = await LessonsApi.bookFromTicket(
token: widget.token,
orderId: widget.orderId!,
newScheduleId: s.scheduleId,
);
} else {
msg = await LessonsApi.reschedule(
token: widget.token,
bookingId: widget.bookingId!,
newScheduleId: s.scheduleId,
);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
Navigator.pop(context);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e')));
}
}
}
+172 -3
View File
@@ -19,7 +19,67 @@ class LessonsApi {
String? month,
}) async {
final query = (month != null && month.isNotEmpty) ? '?month=$month' : '';
final uri = Uri.parse('${ApiConfig.phpApiBase}/my_lessons.php$query');
final uri = Uri.parse('${ApiConfig.laravelApiBase}/my_lessons.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 lezioni');
}
return LessonsResponse.fromJson(data);
}
/// Cancella una prenotazione dell'utente.
static Future<bool> cancelLesson({
required String token,
required int bookingId,
}) async {
final uri = Uri.parse('${ApiConfig.laravelApiBase}/cancel_lesson.php');
final res = await http.post(
uri,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
body: jsonEncode({'booking_id': bookingId}),
);
Map<String, dynamic> data;
try {
data = jsonDecode(res.body) as Map<String, dynamic>;
} catch (_) {
throw Exception('Risposta non valida dal server (${res.statusCode})');
}
if (res.statusCode == 200 && data['success'] == true) {
return true;
}
throw Exception(data['error'] ?? data['message'] ?? 'Errore cancellazione');
}
/// Elenca gli slot disponibili per riprogrammare/prenotare.
static Future<List<AvailableSlot>> fetchAvailableSlots({
required String token,
required int serviceId,
String? month,
}) async {
final monthQuery = (month != null && month.isNotEmpty)
? '&month=$month'
: '';
final uri = Uri.parse(
'${ApiConfig.laravelApiBase}/available_slots.php?service_id=$serviceId$monthQuery',
);
final res = await http.get(uri, headers: _headers(token));
@@ -31,9 +91,118 @@ class LessonsApi {
}
if (res.statusCode != 200 || data['success'] != true) {
throw Exception(data['error'] ?? data['message'] ?? 'Errore lezioni');
throw Exception(data['error'] ?? data['message'] ?? 'Errore slot');
}
return LessonsResponse.fromJson(data);
final raw = (data['slots'] as List?) ?? const [];
return raw
.map((e) => AvailableSlot.fromJson((e as Map).cast<String, dynamic>()))
.toList();
}
/// Esegue la riprogrammazione di una lezione su un nuovo slot.
static Future<String> reschedule({
required String token,
required int bookingId,
required int newScheduleId,
}) async {
final uri = Uri.parse('${ApiConfig.laravelApiBase}/reschedule.php');
final res = await http.post(
uri,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
body: jsonEncode({
'booking_id': bookingId,
'new_schedule_id': newScheduleId,
}),
);
Map<String, dynamic> data;
try {
data = jsonDecode(res.body) as Map<String, dynamic>;
} catch (_) {
throw Exception('Risposta non valida dal server (${res.statusCode})');
}
if (res.statusCode == 200 && data['success'] == true) {
return (data['message'] ?? 'Riprogrammazione richiesta').toString();
}
throw Exception(
data['error'] ?? data['message'] ?? 'Errore riprogrammazione',
);
}
/// Elenca gli ordini/pacchetti dell'utente con residui.
static Future<List<OrderPackage>> fetchOrders({required String token}) async {
final uri = Uri.parse('${ApiConfig.laravelApiBase}/orders.php');
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('Risposta non valida dal server (${res.statusCode})');
}
if (res.statusCode != 200 || data['success'] != true) {
throw Exception(data['error'] ?? data['message'] ?? 'Errore ordini');
}
final raw = (data['orders'] as List?) ?? const [];
return raw
.map((e) => OrderPackage.fromJson((e as Map).cast<String, dynamic>()))
.toList();
}
/// Prenota una nuova lezione da un ticket residuo.
static Future<String> bookFromTicket({
required String token,
required int orderId,
required int newScheduleId,
}) async {
final uri = Uri.parse('${ApiConfig.laravelApiBase}/book_from_ticket.php');
final res = await http.post(
uri,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
body: jsonEncode({'order_id': orderId, 'new_schedule_id': newScheduleId}),
);
Map<String, dynamic> data;
try {
data = jsonDecode(res.body) as Map<String, dynamic>;
} catch (_) {
throw Exception('Risposta non valida dal server (${res.statusCode})');
}
if (res.statusCode == 200 && data['success'] == true) {
return (data['message'] ?? 'Prenotazione richiesta').toString();
}
throw Exception(data['error'] ?? data['message'] ?? 'Errore prenotazione');
}
/// Elenca gli ordini dettagliati (con conteggi e lezioni).
static Future<List<OrderDetail>> fetchOrdersDetail({
required String token,
}) async {
final uri = Uri.parse('${ApiConfig.laravelApiBase}/orders_detail.php');
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('Risposta non valida dal server (${res.statusCode})');
}
if (res.statusCode != 200 || data['success'] != true) {
throw Exception(data['error'] ?? data['message'] ?? 'Errore ordini');
}
final raw = (data['orders'] as List?) ?? const [];
return raw
.map((e) => OrderDetail.fromJson((e as Map).cast<String, dynamic>()))
.toList();
}
}
+106
View File
@@ -1,5 +1,8 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:http_parser/http_parser.dart';
import 'package:mime/mime.dart';
import '../config/api_config.dart';
class VanguardApi {
@@ -123,4 +126,107 @@ class VanguardApi {
throw Exception('Logout failed (${res.statusCode}): ${res.body}');
}
// ---------- Profilo (Vanguard native) ----------
/// GET /me — dati profilo. Ritorna il contenuto di "data".
static Future<Map<String, dynamic>> getMe({required String token}) async {
final url = Uri.parse('${ApiConfig.laravelApiBase}/me');
final res = await http.get(url, headers: authHeaders(token));
if (res.statusCode != 200) {
throw Exception('Get profile failed (${res.statusCode}): ${res.body}');
}
final body = jsonDecode(res.body) as Map<String, dynamic>;
final data = body['data'];
if (data is! Map<String, dynamic>) {
throw Exception('Formato risposta /me non valido.');
}
return data;
}
/// PATCH /me/details — aggiorna nome e cognome.
static Future<Map<String, dynamic>> updateDetails({
required String token,
String? firstName,
String? lastName,
}) async {
final url = Uri.parse('${ApiConfig.laravelApiBase}/me/details');
final payload = <String, dynamic>{};
if (firstName != null) payload['first_name'] = firstName;
if (lastName != null) payload['last_name'] = lastName;
final res = await http.patch(
url,
headers: authHeaders(token),
body: jsonEncode(payload),
);
if (res.statusCode != 200) {
throw Exception('Update details failed (${res.statusCode}): ${res.body}');
}
final body = jsonDecode(res.body) as Map<String, dynamic>;
return (body['data'] as Map<String, dynamic>?) ?? {};
}
/// PATCH /me/details/auth — aggiorna la password (nuova + conferma).
static Future<void> updatePassword({
required String token,
required String password,
required String passwordConfirmation,
}) async {
final url = Uri.parse('${ApiConfig.laravelApiBase}/me/details/auth');
final res = await http.patch(
url,
headers: authHeaders(token),
body: jsonEncode({
'password': password,
'password_confirmation': passwordConfirmation,
}),
);
if (res.statusCode != 200) {
throw Exception(
'Update password failed (${res.statusCode}): ${res.body}',
);
}
}
/// POST /me/avatar — upload avatar (multipart). Ritorna l'URL avatar aggiornato.
static Future<String?> uploadAvatar({
required String token,
required String filePath,
}) async {
final url = Uri.parse('${ApiConfig.laravelApiBase}/me/avatar');
final mimeType = lookupMimeType(filePath) ?? 'image/jpeg';
final parts = mimeType.split('/');
final request = http.MultipartRequest('POST', url)
..headers['Accept'] = 'application/json'
..headers['Authorization'] = 'Bearer $token'
..files.add(
await http.MultipartFile.fromPath(
'file',
filePath,
contentType: MediaType(parts[0], parts[1]),
),
);
final streamed = await request.send();
final res = await http.Response.fromStream(streamed);
if (res.statusCode != 200) {
throw Exception('Upload avatar failed (${res.statusCode}): ${res.body}');
}
final body = jsonDecode(res.body) as Map<String, dynamic>;
final data = body['data'] as Map<String, dynamic>?;
return data?['avatar']?.toString();
}
}
+53 -16
View File
@@ -4,9 +4,11 @@ import '../models/school.dart';
import '../services/vanguard_api.dart';
import '../config/api_config.dart';
import '../screens/select_school_page.dart';
import '../screens/login_page.dart';
import '../screens/medical_certificates_page.dart';
import '../screens/lessons_page.dart';
import '../screens/orders_detail_page.dart';
import '../screens/account_page.dart';
class AppDrawer extends StatelessWidget {
final String token;
@@ -81,21 +83,6 @@ class AppDrawer extends StatelessWidget {
fallbackLetter: _avatarLetter,
),
ListTile(
leading: const Icon(Icons.swap_horiz),
title: const Text('Cambia scuola'),
onTap: () {
Navigator.of(context).pop();
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => SelectSchoolPage(token: token),
),
);
},
),
// ✅ solo nel drawer
ListTile(
leading: const Icon(Icons.medical_information),
title: const Text('Certificati medici'),
@@ -114,6 +101,56 @@ class AppDrawer extends StatelessWidget {
},
),
ListTile(
leading: const Icon(Icons.event_note_rounded),
title: const Text('Lezioni'),
onTap: () {
Navigator.of(context).pop();
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => LessonsPage(
token: token,
school: school,
userFirstName: userFirstName,
),
),
);
},
),
ListTile(
leading: const Icon(Icons.shopping_bag_rounded),
title: const Text('Ordini'),
onTap: () {
Navigator.of(context).pop();
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => OrdersDetailPage(token: token),
),
);
},
),
ListTile(
leading: const Icon(Icons.person_rounded),
title: const Text('Account'),
onTap: () {
Navigator.of(context).pop();
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AccountPage(
token: token,
school: school,
userFirstName: userFirstName,
),
),
);
},
),
const Divider(height: 1),
ListTile(