diff --git a/lib/config/api_config.dart b/lib/config/api_config.dart index 555753e..c34716c 100644 --- a/lib/config/api_config.dart +++ b/lib/config/api_config.dart @@ -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'; } diff --git a/lib/models/lesson.dart b/lib/models/lesson.dart index ca78416..50bebd5 100644 --- a/lib/models/lesson.dart +++ b/lib/models/lesson.dart @@ -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 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 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 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 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 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 j) { + int n(dynamic v) => (v as num?)?.toInt() ?? 0; + final s = (j['summary'] as Map?)?.cast() ?? 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())) + .toList(), + ); + } +} diff --git a/lib/screens/account_page.dart b/lib/screens/account_page.dart new file mode 100644 index 0000000..1e55583 --- /dev/null +++ b/lib/screens/account_page.dart @@ -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 createState() => _AccountPageState(); +} + +class _AccountPageState extends State { + 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 _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 _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 _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 _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 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')), + ], + ), + ), + ); + } +} diff --git a/lib/screens/home_page.dart b/lib/screens/home_page.dart index 7e62fee..b112ea8 100644 --- a/lib/screens/home_page.dart +++ b/lib/screens/home_page.dart @@ -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 { 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 { _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, diff --git a/lib/screens/lessons_page.dart b/lib/screens/lessons_page.dart index 8e87bdb..074de06 100644 --- a/lib/screens/lessons_page.dart +++ b/lib/screens/lessons_page.dart @@ -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 { String error = ''; String currentMonth = DateFormat('yyyy-MM').format(DateTime.now()); - String? prevMonth; - String? nextMonth; - String? schoolAddress; + LessonsSummary? summary; List lessons = []; int bottomIndex = 1; @@ -52,22 +52,15 @@ class _LessonsPageState extends State { }); 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?)?['address_full'] - ?.toString(); - - final list = (data['lessons'] as List? ?? []) - .map((e) => Lesson.fromJson(e as Map)) - .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 { } } + // Navigazione mesi calcolata lato client (l'API non restituisce prev/next). + String _shiftMonth(String yyyyMm, int delta) { + final dt = DateFormat('yyyy-MM').parse(yyyyMm); + final shifted = DateTime(dt.year, dt.month + delta, 1); + return DateFormat('yyyy-MM').format(shifted); + } + + void _goPrevMonth() { + setState(() => currentMonth = _shiftMonth(currentMonth, -1)); + _load(); + } + + void _goNextMonth() { + setState(() => currentMonth = _shiftMonth(currentMonth, 1)); + _load(); + } + String _monthLabel(String yyyyMm) { final dt = DateFormat('yyyy-MM').parse(yyyyMm); return DateFormat('MMMM yyyy', 'it_IT').format(dt); @@ -194,41 +204,58 @@ class _LessonsPageState extends State { top: false, child: Column( children: [ - // Address small (optional) - if (schoolAddress != null && schoolAddress!.trim().isNotEmpty) + // Riepilogo conteggi (le card pastello della webapp) + if (summary != null) Padding( - padding: const EdgeInsets.fromLTRB(16, 6, 16, 6), - child: Text( - schoolAddress!, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 12, - color: Colors.black45, - fontWeight: FontWeight.w500, + padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), + child: _SummaryStrip(summary: summary!), + ), + + // Selettore mese + Padding( + padding: const EdgeInsets.fromLTRB(16, 6, 16, 4), + child: _MonthPillCompact( + label: _monthLabel(currentMonth), + onPrev: _goPrevMonth, + onNext: _goNextMonth, + ), + ), + + // Bottone "Programma lezioni" (solo se ci sono ticket residui) + if (summary != null && summary!.toSchedule > 0) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF10B981), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + icon: const Icon(Icons.add, color: Colors.white), + label: Text( + 'Programma lezioni (${summary!.toSchedule} disponibili)', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w800, + ), + ), + onPressed: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => OrdersPage(token: widget.token), + ), + ); + _load(); + }, ), ), ), - // Month selector only - Padding( - padding: const EdgeInsets.fromLTRB(16, 6, 16, 10), - child: _MonthPillCompact( - label: _monthLabel(currentMonth), - onPrev: prevMonth == null - ? null - : () { - setState(() => currentMonth = prevMonth!); - _load(); - }, - onNext: nextMonth == null - ? null - : () { - setState(() => currentMonth = nextMonth!); - _load(); - }, - ), - ), - Expanded( child: loading ? const Center(child: CircularProgressIndicator()) @@ -263,23 +290,72 @@ class _LessonsPageState extends State { 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( + 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 { } } -class _AppDrawer extends StatelessWidget { - final String token; - final VoidCallback onLogout; - - const _AppDrawer({required this.token, required this.onLogout}); +class _SummaryStrip extends StatelessWidget { + final LessonsSummary summary; + const _SummaryStrip({required this.summary}); @override Widget build(BuildContext context) { - return Drawer( - child: SafeArea( - child: ListView( - padding: EdgeInsets.zero, - children: [ - const DrawerHeader( - child: Align( - alignment: Alignment.bottomLeft, - child: Text( - 'Menu', - style: TextStyle(fontSize: 22, fontWeight: FontWeight.w900), - ), - ), - ), - ListTile( - leading: const Icon(Icons.swap_horiz), - title: const Text('Cambia scuola'), - onTap: () { - Navigator.of(context).pop(); // chiude drawer - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (_) => SelectSchoolPage(token: token), - ), - ); - }, - ), - const Divider(height: 1), + final items = <_SummaryItem>[ + _SummaryItem('Acquistate', summary.purchased, const Color(0xFFE2C4FB)), + _SummaryItem('Praticate', summary.practiced, const Color(0xFFC4E1FB)), + _SummaryItem('Prenotate', summary.booked, const Color(0xFFCDFBC4)), + _SummaryItem('Da confermare', summary.pending, const Color(0xFFFBFAC4)), + _SummaryItem( + 'Da programmare', + summary.toSchedule, + const Color(0xFFFBE4C4), + ), + _SummaryItem('Perse', summary.lost, const Color(0xFFFBC7C4)), + ]; - ListTile( - leading: const Icon(Icons.logout), - title: const Text('Logout'), - onTap: onLogout, + return SizedBox( + height: 76, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: items.length, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (_, i) { + final it = items[i]; + return Container( + width: 96, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: it.color, + borderRadius: BorderRadius.circular(12), ), - ], - ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '${it.value}', + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 2), + Text( + it.label, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + }, ), ); } } +class _SummaryItem { + final String label; + final int value; + final Color color; + _SummaryItem(this.label, this.value, this.color); +} + class _MonthPillCompact extends StatelessWidget { final String label; final VoidCallback? onPrev; @@ -421,8 +519,8 @@ class _LessonGreenCardCompact extends StatelessWidget { const green = Color(0xFF10B981); const darkGreen = Color(0xFF065F46); - final level = (lesson.level ?? '').trim(); - final time = _shortTime(lesson.startTime); + final time = _shortTime(lesson.time); + final canModify = lesson.canReschedule || lesson.canDelete; return Container( margin: const EdgeInsets.only(bottom: 12), @@ -441,7 +539,7 @@ class _LessonGreenCardCompact extends StatelessWidget { borderRadius: BorderRadius.circular(18), child: Row( children: [ - // left panel smaller + // left panel Container( width: 92, color: const Color(0xFFBFF3DE), @@ -503,7 +601,7 @@ class _LessonGreenCardCompact extends StatelessWidget { ), ), - // right body tighter + // right body Expanded( child: Container( padding: const EdgeInsets.fromLTRB(10, 10, 10, 10), @@ -525,52 +623,30 @@ class _LessonGreenCardCompact extends StatelessWidget { Row( children: [ const Icon( - Icons.meeting_room_outlined, + Icons.place_outlined, size: 16, color: Colors.black54, ), const SizedBox(width: 6), Expanded( child: Text( - (lesson.roomName ?? '').trim().isEmpty + lesson.location.trim().isEmpty ? 'Sala da definire' - : lesson.roomName!, + : lesson.location, style: const TextStyle( fontSize: 12, color: Color(0xFF404040), fontWeight: FontWeight.w600, ), - maxLines: 1, + maxLines: 2, overflow: TextOverflow.ellipsis, ), ), - if (level.isNotEmpty) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: const Color(0xFF10B981), - borderRadius: BorderRadius.circular(999), - ), - child: Text( - _capitalize(level), - style: const TextStyle( - fontWeight: FontWeight.w900, - fontSize: 10, - color: Colors.white, - ), - ), - ), - ], ], ), const SizedBox(height: 8), - // Buttons compact (stessa riga, più piccoli) Row( children: [ Expanded( @@ -622,10 +698,10 @@ class _LessonGreenCardCompact extends StatelessWidget { ], ), - if (!lesson.canModify) ...[ + if (!canModify) ...[ const SizedBox(height: 6), const Text( - 'Non modificabile (entro 24 ore)', + 'Non modificabile', style: TextStyle(fontSize: 11, color: Colors.black54), ), ], @@ -639,30 +715,7 @@ class _LessonGreenCardCompact extends StatelessWidget { ); } - static Widget _metaRowCompact(IconData icon, String text) { - return Row( - children: [ - Icon(icon, size: 16, color: Colors.black54), - const SizedBox(width: 6), - Expanded( - child: Text( - text, - style: const TextStyle( - fontSize: 12, - color: Color(0xFF404040), - fontWeight: FontWeight.w600, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ); - } - static String _shortTime(String t) => t.length >= 5 ? t.substring(0, 5) : t; - static String _capitalize(String s) => - s.isEmpty ? s : s[0].toUpperCase() + s.substring(1); } class _EmptyState extends StatelessWidget { diff --git a/lib/screens/login_page.dart b/lib/screens/login_page.dart index 300c164..c9aa2a3 100644 --- a/lib/screens/login_page.dart +++ b/lib/screens/login_page.dart @@ -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 { 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 { 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'); diff --git a/lib/screens/meditation_page.dart b/lib/screens/meditation_page.dart index f884419..9c1fcd7 100644 --- a/lib/screens/meditation_page.dart +++ b/lib/screens/meditation_page.dart @@ -139,11 +139,17 @@ class _MeditationPageState extends State if (running) return; // start music on user action (safer for iOS) - await _ensureMusicStarted(); + if (!_musicStarted) { + await _ensureMusicStarted(); + } else { + // già partita in precedenza: riprende dopo la pausa + try { + await _bgPlayer.resume(); + } catch (_) {} + } await _applyMusicVolume(); setState(() => running = true); - // start square animation _squareCtrl.repeat(); @@ -165,8 +171,11 @@ class _MeditationPageState extends State _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() { diff --git a/lib/screens/orders_detail_page.dart b/lib/screens/orders_detail_page.dart new file mode 100644 index 0000000..d427c20 --- /dev/null +++ b/lib/screens/orders_detail_page.dart @@ -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 createState() => _OrdersDetailPageState(); +} + +class _OrdersDetailPageState extends State { + bool loading = true; + String error = ''; + List orders = []; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _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), + ), + ], + ), + ); + } +} diff --git a/lib/screens/orders_page.dart b/lib/screens/orders_page.dart new file mode 100644 index 0000000..fe91144 --- /dev/null +++ b/lib/screens/orders_page.dart @@ -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 createState() => _OrdersPageState(); +} + +class _OrdersPageState extends State { + bool loading = true; + String error = ''; + List orders = []; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _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, + ), + ), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/screens/reschedule_page.dart b/lib/screens/reschedule_page.dart new file mode 100644 index 0000000..0a8426f --- /dev/null +++ b/lib/screens/reschedule_page.dart @@ -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 createState() => _ReschedulePageState(); +} + +class _ReschedulePageState extends State { + bool loading = true; + String error = ''; + String currentMonth = DateFormat('yyyy-MM').format(DateTime.now()); + List slots = []; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _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 _confirm(AvailableSlot s) async { + final azione = widget.isBooking ? 'prenotare' : 'spostare'; + final ok = await showDialog( + 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'))); + } + } +} diff --git a/lib/services/lessons_api.dart b/lib/services/lessons_api.dart index 8eb8f87..8f29c7a 100644 --- a/lib/services/lessons_api.dart +++ b/lib/services/lessons_api.dart @@ -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 data; + try { + data = jsonDecode(res.body) as Map; + } 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 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 data; + try { + data = jsonDecode(res.body) as Map; + } 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> 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())) + .toList(); + } + + /// Esegue la riprogrammazione di una lezione su un nuovo slot. + static Future 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 data; + try { + data = jsonDecode(res.body) as Map; + } 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> fetchOrders({required String token}) async { + final uri = Uri.parse('${ApiConfig.laravelApiBase}/orders.php'); + final res = await http.get(uri, headers: _headers(token)); + + Map data; + try { + data = jsonDecode(res.body) as Map; + } 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())) + .toList(); + } + + /// Prenota una nuova lezione da un ticket residuo. + static Future 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 data; + try { + data = jsonDecode(res.body) as Map; + } 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> fetchOrdersDetail({ + required String token, + }) async { + final uri = Uri.parse('${ApiConfig.laravelApiBase}/orders_detail.php'); + final res = await http.get(uri, headers: _headers(token)); + + Map data; + try { + data = jsonDecode(res.body) as Map; + } 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())) + .toList(); } } diff --git a/lib/services/vanguard_api.dart b/lib/services/vanguard_api.dart index 67b1b54..e0f89ed 100644 --- a/lib/services/vanguard_api.dart +++ b/lib/services/vanguard_api.dart @@ -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> 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; + final data = body['data']; + if (data is! Map) { + throw Exception('Formato risposta /me non valido.'); + } + return data; + } + + /// PATCH /me/details — aggiorna nome e cognome. + static Future> updateDetails({ + required String token, + String? firstName, + String? lastName, + }) async { + final url = Uri.parse('${ApiConfig.laravelApiBase}/me/details'); + + final payload = {}; + 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; + return (body['data'] as Map?) ?? {}; + } + + /// PATCH /me/details/auth — aggiorna la password (nuova + conferma). + static Future 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 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; + final data = body['data'] as Map?; + return data?['avatar']?.toString(); + } } diff --git a/lib/widgets/app_drawer.dart b/lib/widgets/app_drawer.dart index 9ad36b6..9587613 100644 --- a/lib/widgets/app_drawer.dart +++ b/lib/widgets/app_drawer.dart @@ -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( diff --git a/pubspec.lock b/pubspec.lock index 362d7f8..2f36b05 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: