added pages to project
This commit is contained in:
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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_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,
|
||||
|
||||
+195
-142
@@ -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,40 +204,57 @@ 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,
|
||||
),
|
||||
),
|
||||
|
||||
// Month selector only
|
||||
// Bottone "Programma lezioni" (solo se ci sono ticket residui)
|
||||
if (summary != null && summary!.toSchedule > 0)
|
||||
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!);
|
||||
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();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: loading
|
||||
@@ -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,
|
||||
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)),
|
||||
];
|
||||
|
||||
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: [
|
||||
const DrawerHeader(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomLeft,
|
||||
child: Text(
|
||||
'Menu',
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w900),
|
||||
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,
|
||||
),
|
||||
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),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
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 {
|
||||
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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -139,11 +139,17 @@ class _MeditationPageState extends State<MeditationPage>
|
||||
if (running) return;
|
||||
|
||||
// start music on user action (safer for iOS)
|
||||
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() {
|
||||
|
||||
@@ -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,
|
||||
}) 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
+8
-8
@@ -77,10 +77,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
version: "1.4.1"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -401,18 +401,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.17"
|
||||
version: "0.12.18"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -630,10 +630,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7"
|
||||
version: "0.7.9"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
Reference in New Issue
Block a user