added pages to project
This commit is contained in:
@@ -5,7 +5,7 @@ class ApiConfig {
|
|||||||
static const bool useLive = true;
|
static const bool useLive = true;
|
||||||
|
|
||||||
/// Dominio produzione
|
/// Dominio produzione
|
||||||
static const String liveHost = 'app.yogiboook.com';
|
static const String liveHost = 'yogibook.yogasoul.it';
|
||||||
|
|
||||||
// Host handling DEV:
|
// Host handling DEV:
|
||||||
// - Web: localhost
|
// - Web: localhost
|
||||||
@@ -24,7 +24,7 @@ class ApiConfig {
|
|||||||
/// DEV: http://10.0.2.2/yogiboook/public/api
|
/// DEV: http://10.0.2.2/yogiboook/public/api
|
||||||
/// LIVE: https://app.yogiboook.com/public/api
|
/// LIVE: https://app.yogiboook.com/public/api
|
||||||
static String get laravelApiBase {
|
static String get laravelApiBase {
|
||||||
if (useLive) return '$scheme://$host/public/api';
|
if (useLive) return '$scheme://$host/api';
|
||||||
return '$scheme://$host/yogiboook/public/api';
|
return '$scheme://$host/yogiboook/public/api';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ class ApiConfig {
|
|||||||
/// DEV: http://10.0.2.2/yogiboook/public/userarea/api
|
/// DEV: http://10.0.2.2/yogiboook/public/userarea/api
|
||||||
/// LIVE: https://app.yogiboook.com/public/userarea/api
|
/// LIVE: https://app.yogiboook.com/public/userarea/api
|
||||||
static String get phpApiBase {
|
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';
|
return '$scheme://$host/yogiboook/public/userarea/api';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ class LessonsSummary {
|
|||||||
|
|
||||||
class Lesson {
|
class Lesson {
|
||||||
final int bookingId;
|
final int bookingId;
|
||||||
|
final int serviceId;
|
||||||
final String status;
|
final String status;
|
||||||
|
|
||||||
final String datetime; // "YYYY-MM-DD HH:MM:SS"
|
final String datetime; // "YYYY-MM-DD HH:MM:SS"
|
||||||
@@ -80,6 +81,7 @@ class Lesson {
|
|||||||
|
|
||||||
Lesson({
|
Lesson({
|
||||||
required this.bookingId,
|
required this.bookingId,
|
||||||
|
required this.serviceId,
|
||||||
required this.status,
|
required this.status,
|
||||||
required this.datetime,
|
required this.datetime,
|
||||||
required this.date,
|
required this.date,
|
||||||
@@ -96,6 +98,7 @@ class Lesson {
|
|||||||
factory Lesson.fromJson(Map<String, dynamic> j) {
|
factory Lesson.fromJson(Map<String, dynamic> j) {
|
||||||
return Lesson(
|
return Lesson(
|
||||||
bookingId: (j['booking_id'] as num?)?.toInt() ?? 0,
|
bookingId: (j['booking_id'] as num?)?.toInt() ?? 0,
|
||||||
|
serviceId: (j['service_id'] as num?)?.toInt() ?? 0,
|
||||||
status: (j['status'] ?? '').toString(),
|
status: (j['status'] ?? '').toString(),
|
||||||
datetime: (j['datetime'] ?? '').toString(),
|
datetime: (j['datetime'] ?? '').toString(),
|
||||||
date: (j['date'] ?? '').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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import 'medical_certificates_page.dart';
|
|||||||
import '../widgets/app_drawer.dart';
|
import '../widgets/app_drawer.dart';
|
||||||
import '../widgets/app_bottom_nav.dart';
|
import '../widgets/app_bottom_nav.dart';
|
||||||
import '../widgets/yogibook_background.dart';
|
import '../widgets/yogibook_background.dart';
|
||||||
|
import 'orders_detail_page.dart';
|
||||||
|
import 'account_page.dart';
|
||||||
|
|
||||||
class HomePage extends StatefulWidget {
|
class HomePage extends StatefulWidget {
|
||||||
final String token;
|
final String token;
|
||||||
@@ -231,9 +233,18 @@ class _HomePageState extends State<HomePage> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (i == 2) {
|
if (i == 2) {
|
||||||
ScaffoldMessenger.of(
|
Navigator.push(
|
||||||
context,
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (i == 3) {
|
if (i == 3) {
|
||||||
@@ -363,10 +374,13 @@ class _HomePageState extends State<HomePage> {
|
|||||||
_HomeTile(
|
_HomeTile(
|
||||||
icon: Icons.shopping_bag_rounded,
|
icon: Icons.shopping_bag_rounded,
|
||||||
title: 'Vedi ordini',
|
title: 'Vedi ordini',
|
||||||
subtitle: 'Storico e stato ordini (TODO)',
|
subtitle: 'Storico e stato dei tuoi ordini',
|
||||||
onTap: () => ScaffoldMessenger.of(
|
onTap: () => Navigator.push(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(const SnackBar(content: Text('TODO: Ordini'))),
|
MaterialPageRoute(
|
||||||
|
builder: (_) => OrdersDetailPage(token: widget.token),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_HomeTile(
|
_HomeTile(
|
||||||
icon: Icons.person_rounded,
|
icon: Icons.person_rounded,
|
||||||
|
|||||||
+195
-142
@@ -2,13 +2,15 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import '../models/lesson.dart';
|
import '../models/lesson.dart';
|
||||||
import '../models/school.dart';
|
import '../models/school.dart';
|
||||||
import '../services/vanguard_api.dart';
|
import '../services/lessons_api.dart';
|
||||||
import 'select_school_page.dart';
|
import 'select_school_page.dart';
|
||||||
import 'home_page.dart';
|
import 'home_page.dart';
|
||||||
import 'meditation_page.dart';
|
import 'meditation_page.dart';
|
||||||
import '../widgets/app_drawer.dart';
|
import '../widgets/app_drawer.dart';
|
||||||
import '../widgets/app_bottom_nav.dart';
|
import '../widgets/app_bottom_nav.dart';
|
||||||
import '../widgets/yogibook_background.dart';
|
import '../widgets/yogibook_background.dart';
|
||||||
|
import 'reschedule_page.dart';
|
||||||
|
import 'orders_page.dart';
|
||||||
|
|
||||||
class LessonsPage extends StatefulWidget {
|
class LessonsPage extends StatefulWidget {
|
||||||
final String token;
|
final String token;
|
||||||
@@ -31,10 +33,8 @@ class _LessonsPageState extends State<LessonsPage> {
|
|||||||
String error = '';
|
String error = '';
|
||||||
|
|
||||||
String currentMonth = DateFormat('yyyy-MM').format(DateTime.now());
|
String currentMonth = DateFormat('yyyy-MM').format(DateTime.now());
|
||||||
String? prevMonth;
|
|
||||||
String? nextMonth;
|
|
||||||
|
|
||||||
String? schoolAddress;
|
LessonsSummary? summary;
|
||||||
List<Lesson> lessons = [];
|
List<Lesson> lessons = [];
|
||||||
|
|
||||||
int bottomIndex = 1;
|
int bottomIndex = 1;
|
||||||
@@ -52,22 +52,15 @@ class _LessonsPageState extends State<LessonsPage> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final data = await VanguardApi.getMyLessons(
|
final data = await LessonsApi.fetchMyLessons(
|
||||||
token: widget.token,
|
token: widget.token,
|
||||||
schoolId: widget.school.id,
|
|
||||||
month: currentMonth,
|
month: currentMonth,
|
||||||
);
|
);
|
||||||
|
|
||||||
prevMonth = data['prev_month']?.toString();
|
setState(() {
|
||||||
nextMonth = data['next_month']?.toString();
|
summary = data.summary;
|
||||||
schoolAddress = (data['school'] as Map<String, dynamic>?)?['address_full']
|
lessons = data.lessons;
|
||||||
?.toString();
|
});
|
||||||
|
|
||||||
final list = (data['lessons'] as List<dynamic>? ?? [])
|
|
||||||
.map((e) => Lesson.fromJson(e as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
setState(() => lessons = list);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => error = 'Errore: $e');
|
setState(() => error = 'Errore: $e');
|
||||||
} finally {
|
} 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) {
|
String _monthLabel(String yyyyMm) {
|
||||||
final dt = DateFormat('yyyy-MM').parse(yyyyMm);
|
final dt = DateFormat('yyyy-MM').parse(yyyyMm);
|
||||||
return DateFormat('MMMM yyyy', 'it_IT').format(dt);
|
return DateFormat('MMMM yyyy', 'it_IT').format(dt);
|
||||||
@@ -194,40 +204,57 @@ class _LessonsPageState extends State<LessonsPage> {
|
|||||||
top: false,
|
top: false,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// Address small (optional)
|
// Riepilogo conteggi (le card pastello della webapp)
|
||||||
if (schoolAddress != null && schoolAddress!.trim().isNotEmpty)
|
if (summary != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 6),
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
|
||||||
child: Text(
|
child: _SummaryStrip(summary: summary!),
|
||||||
schoolAddress!,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Colors.black45,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Selettore mese
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 6, 16, 4),
|
||||||
|
child: _MonthPillCompact(
|
||||||
|
label: _monthLabel(currentMonth),
|
||||||
|
onPrev: _goPrevMonth,
|
||||||
|
onNext: _goNextMonth,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Month selector only
|
// Bottone "Programma lezioni" (solo se ci sono ticket residui)
|
||||||
|
if (summary != null && summary!.toSchedule > 0)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 10),
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||||
child: _MonthPillCompact(
|
child: SizedBox(
|
||||||
label: _monthLabel(currentMonth),
|
width: double.infinity,
|
||||||
onPrev: prevMonth == null
|
child: ElevatedButton.icon(
|
||||||
? null
|
style: ElevatedButton.styleFrom(
|
||||||
: () {
|
backgroundColor: const Color(0xFF10B981),
|
||||||
setState(() => currentMonth = prevMonth!);
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
_load();
|
shape: RoundedRectangleBorder(
|
||||||
},
|
borderRadius: BorderRadius.circular(14),
|
||||||
onNext: nextMonth == null
|
),
|
||||||
? null
|
),
|
||||||
: () {
|
icon: const Icon(Icons.add, color: Colors.white),
|
||||||
setState(() => currentMonth = nextMonth!);
|
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();
|
_load();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: loading
|
child: loading
|
||||||
@@ -263,23 +290,72 @@ class _LessonsPageState extends State<LessonsPage> {
|
|||||||
lesson: l,
|
lesson: l,
|
||||||
weekday: _weekdayLabel(l.date),
|
weekday: _weekdayLabel(l.date),
|
||||||
dayNum: _dayNum(l.date),
|
dayNum: _dayNum(l.date),
|
||||||
onReschedule: l.canModify
|
onReschedule: l.canReschedule
|
||||||
? () => ScaffoldMessenger.of(context)
|
? () async {
|
||||||
.showSnackBar(
|
await Navigator.push(
|
||||||
const SnackBar(
|
context,
|
||||||
content: Text(
|
MaterialPageRoute(
|
||||||
'Riprogramma: API dopo',
|
builder: (_) => ReschedulePage(
|
||||||
|
token: widget.token,
|
||||||
|
bookingId: l.bookingId,
|
||||||
|
serviceId: l.serviceId,
|
||||||
|
className: l.className,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
|
_load();
|
||||||
|
}
|
||||||
: null,
|
: null,
|
||||||
onCancel: l.canModify
|
onCancel: l.canDelete
|
||||||
? () => ScaffoldMessenger.of(context)
|
? () async {
|
||||||
.showSnackBar(
|
final conferma = await showDialog<bool>(
|
||||||
const SnackBar(
|
context: context,
|
||||||
content: Text('Cancella: API dopo'),
|
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,
|
: null,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -293,55 +369,77 @@ class _LessonsPageState extends State<LessonsPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppDrawer extends StatelessWidget {
|
class _SummaryStrip extends StatelessWidget {
|
||||||
final String token;
|
final LessonsSummary summary;
|
||||||
final VoidCallback onLogout;
|
const _SummaryStrip({required this.summary});
|
||||||
|
|
||||||
const _AppDrawer({required this.token, required this.onLogout});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Drawer(
|
final items = <_SummaryItem>[
|
||||||
child: SafeArea(
|
_SummaryItem('Acquistate', summary.purchased, const Color(0xFFE2C4FB)),
|
||||||
child: ListView(
|
_SummaryItem('Praticate', summary.practiced, const Color(0xFFC4E1FB)),
|
||||||
padding: EdgeInsets.zero,
|
_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)),
|
||||||
|
];
|
||||||
|
|
||||||
|
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: [
|
children: [
|
||||||
const DrawerHeader(
|
Text(
|
||||||
child: Align(
|
'${it.value}',
|
||||||
alignment: Alignment.bottomLeft,
|
style: const TextStyle(
|
||||||
child: Text(
|
fontSize: 22,
|
||||||
'Menu',
|
fontWeight: FontWeight.w900,
|
||||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w900),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
it.label,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
ListTile(
|
maxLines: 2,
|
||||||
leading: const Icon(Icons.swap_horiz),
|
overflow: TextOverflow.ellipsis,
|
||||||
title: const Text('Cambia scuola'),
|
),
|
||||||
onTap: () {
|
],
|
||||||
Navigator.of(context).pop(); // chiude drawer
|
|
||||||
Navigator.pushReplacement(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) => SelectSchoolPage(token: token),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
|
||||||
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.logout),
|
|
||||||
title: const Text('Logout'),
|
|
||||||
onTap: onLogout,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _SummaryItem {
|
||||||
|
final String label;
|
||||||
|
final int value;
|
||||||
|
final Color color;
|
||||||
|
_SummaryItem(this.label, this.value, this.color);
|
||||||
|
}
|
||||||
|
|
||||||
class _MonthPillCompact extends StatelessWidget {
|
class _MonthPillCompact extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
final VoidCallback? onPrev;
|
final VoidCallback? onPrev;
|
||||||
@@ -421,8 +519,8 @@ class _LessonGreenCardCompact extends StatelessWidget {
|
|||||||
const green = Color(0xFF10B981);
|
const green = Color(0xFF10B981);
|
||||||
const darkGreen = Color(0xFF065F46);
|
const darkGreen = Color(0xFF065F46);
|
||||||
|
|
||||||
final level = (lesson.level ?? '').trim();
|
final time = _shortTime(lesson.time);
|
||||||
final time = _shortTime(lesson.startTime);
|
final canModify = lesson.canReschedule || lesson.canDelete;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
@@ -441,7 +539,7 @@ class _LessonGreenCardCompact extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(18),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// left panel smaller
|
// left panel
|
||||||
Container(
|
Container(
|
||||||
width: 92,
|
width: 92,
|
||||||
color: const Color(0xFFBFF3DE),
|
color: const Color(0xFFBFF3DE),
|
||||||
@@ -503,7 +601,7 @@ class _LessonGreenCardCompact extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// right body tighter
|
// right body
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.fromLTRB(10, 10, 10, 10),
|
padding: const EdgeInsets.fromLTRB(10, 10, 10, 10),
|
||||||
@@ -525,52 +623,30 @@ class _LessonGreenCardCompact extends StatelessWidget {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
const Icon(
|
||||||
Icons.meeting_room_outlined,
|
Icons.place_outlined,
|
||||||
size: 16,
|
size: 16,
|
||||||
color: Colors.black54,
|
color: Colors.black54,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
(lesson.roomName ?? '').trim().isEmpty
|
lesson.location.trim().isEmpty
|
||||||
? 'Sala da definire'
|
? 'Sala da definire'
|
||||||
: lesson.roomName!,
|
: lesson.location,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: Color(0xFF404040),
|
color: Color(0xFF404040),
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
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),
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
// Buttons compact (stessa riga, più piccoli)
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -622,10 +698,10 @@ class _LessonGreenCardCompact extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
if (!lesson.canModify) ...[
|
if (!canModify) ...[
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
const Text(
|
const Text(
|
||||||
'Non modificabile (entro 24 ore)',
|
'Non modificabile',
|
||||||
style: TextStyle(fontSize: 11, color: Colors.black54),
|
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 _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 {
|
class _EmptyState extends StatelessWidget {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../services/vanguard_api.dart';
|
import '../services/vanguard_api.dart';
|
||||||
import 'select_school_page.dart';
|
import '../models/school.dart';
|
||||||
|
import 'home_page.dart';
|
||||||
|
|
||||||
class LoginPage extends StatefulWidget {
|
class LoginPage extends StatefulWidget {
|
||||||
const LoginPage({super.key});
|
const LoginPage({super.key});
|
||||||
@@ -18,6 +19,15 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
bool loading = false;
|
bool loading = false;
|
||||||
String error = '';
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
emailController.dispose();
|
emailController.dispose();
|
||||||
@@ -39,9 +49,16 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// Bypass SelectSchoolPage: scuola unica, si va dritti alla Home.
|
||||||
Navigator.pushReplacement(
|
Navigator.pushReplacement(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(builder: (_) => SelectSchoolPage(token: token)),
|
MaterialPageRoute(
|
||||||
|
builder: (_) => HomePage(
|
||||||
|
token: token,
|
||||||
|
school: _yogaSoulSchool,
|
||||||
|
userFirstName: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => error = 'Errore login: $e');
|
setState(() => error = 'Errore login: $e');
|
||||||
|
|||||||
@@ -139,11 +139,17 @@ class _MeditationPageState extends State<MeditationPage>
|
|||||||
if (running) return;
|
if (running) return;
|
||||||
|
|
||||||
// start music on user action (safer for iOS)
|
// start music on user action (safer for iOS)
|
||||||
|
if (!_musicStarted) {
|
||||||
await _ensureMusicStarted();
|
await _ensureMusicStarted();
|
||||||
|
} else {
|
||||||
|
// già partita in precedenza: riprende dopo la pausa
|
||||||
|
try {
|
||||||
|
await _bgPlayer.resume();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
await _applyMusicVolume();
|
await _applyMusicVolume();
|
||||||
|
|
||||||
setState(() => running = true);
|
setState(() => running = true);
|
||||||
|
|
||||||
// start square animation
|
// start square animation
|
||||||
_squareCtrl.repeat();
|
_squareCtrl.repeat();
|
||||||
|
|
||||||
@@ -165,8 +171,11 @@ class _MeditationPageState extends State<MeditationPage>
|
|||||||
_timer = null;
|
_timer = null;
|
||||||
|
|
||||||
_squareCtrl.stop();
|
_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() {
|
void _reset() {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,67 @@ class LessonsApi {
|
|||||||
String? month,
|
String? month,
|
||||||
}) async {
|
}) async {
|
||||||
final query = (month != null && month.isNotEmpty) ? '?month=$month' : '';
|
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));
|
final res = await http.get(uri, headers: _headers(token));
|
||||||
|
|
||||||
@@ -31,9 +91,118 @@ class LessonsApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (res.statusCode != 200 || data['success'] != true) {
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http_parser/http_parser.dart';
|
||||||
|
import 'package:mime/mime.dart';
|
||||||
import '../config/api_config.dart';
|
import '../config/api_config.dart';
|
||||||
|
|
||||||
class VanguardApi {
|
class VanguardApi {
|
||||||
@@ -123,4 +126,107 @@ class VanguardApi {
|
|||||||
|
|
||||||
throw Exception('Logout failed (${res.statusCode}): ${res.body}');
|
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
@@ -4,9 +4,11 @@ import '../models/school.dart';
|
|||||||
import '../services/vanguard_api.dart';
|
import '../services/vanguard_api.dart';
|
||||||
import '../config/api_config.dart';
|
import '../config/api_config.dart';
|
||||||
|
|
||||||
import '../screens/select_school_page.dart';
|
|
||||||
import '../screens/login_page.dart';
|
import '../screens/login_page.dart';
|
||||||
import '../screens/medical_certificates_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 {
|
class AppDrawer extends StatelessWidget {
|
||||||
final String token;
|
final String token;
|
||||||
@@ -81,21 +83,6 @@ class AppDrawer extends StatelessWidget {
|
|||||||
fallbackLetter: _avatarLetter,
|
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(
|
ListTile(
|
||||||
leading: const Icon(Icons.medical_information),
|
leading: const Icon(Icons.medical_information),
|
||||||
title: const Text('Certificati medici'),
|
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),
|
const Divider(height: 1),
|
||||||
|
|
||||||
ListTile(
|
ListTile(
|
||||||
|
|||||||
+8
-8
@@ -77,10 +77,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: characters
|
name: characters
|
||||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.1"
|
||||||
clock:
|
clock:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -401,18 +401,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.17"
|
version: "0.12.18"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.11.1"
|
version: "0.13.0"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -630,10 +630,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.7"
|
version: "0.7.9"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
Reference in New Issue
Block a user