import 'dart:async'; import 'package:flutter/material.dart'; import 'package:audioplayers/audioplayers.dart'; import '../models/school.dart'; import '../services/vanguard_api.dart'; import 'home_page.dart'; import 'lessons_page.dart'; import 'select_school_page.dart'; import 'login_page.dart'; class MeditationPage extends StatefulWidget { final String token; final School school; final String? userFirstName; const MeditationPage({ super.key, required this.token, required this.school, this.userFirstName, }); @override State createState() => _MeditationPageState(); } class _MeditationPageState extends State with TickerProviderStateMixin { static const Color kBg = Color(0xFFF6F6FB); static const Color kGreen = Color(0xFF10B981); // ======= AUDIO ======= final AudioPlayer _bgPlayer = AudioPlayer(); bool _musicMuted = false; bool _musicStarted = false; static const double _musicVolume = 0.35; // Metti il tuo file qui (consigliato: asset locale) // 1) Metti il file in: assets/audio/meditation.mp3 // 2) Aggiungi in pubspec.yaml: // assets: // - assets/audio/meditation.mp3 static const String _musicAssetPath = 'audio/meditation.mp3'; // ======= TIMER ======= static const int totalSeconds = 10 * 60; // 10 min int remainingSeconds = totalSeconds; bool running = false; // ======= SQUARE BREATHING 4-4-4-4 ======= static const int inhaleSec = 4; static const int hold1Sec = 4; static const int exhaleSec = 4; static const int hold2Sec = 4; static const int cycleSec = inhaleSec + hold1Sec + exhaleSec + hold2Sec; Timer? _timer; // UI anims late final AnimationController _pulseCtrl; late final AnimationController _squareCtrl; int bottomIndex = 3; // Meditazione @override void initState() { super.initState(); _pulseCtrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 1800), )..repeat(reverse: true); _squareCtrl = AnimationController( vsync: this, duration: const Duration(seconds: cycleSec), ); // audio options _bgPlayer.setReleaseMode(ReleaseMode.loop); _bgPlayer.setVolume(_musicMuted ? 0.0 : _musicVolume); } @override void dispose() { _timer?.cancel(); _pulseCtrl.dispose(); _squareCtrl.dispose(); _bgPlayer.dispose(); super.dispose(); } // ======= AUDIO HELPERS ======= Future _ensureMusicStarted() async { if (_musicStarted) return; try { // audioplayers: AssetSource usa path relativo alla cartella assets/ await _bgPlayer.play( AssetSource(_musicAssetPath), volume: _musicMuted ? 0.0 : _musicVolume, ); _musicStarted = true; } catch (_) { // Se manca l'asset o errore audio, non blocchiamo la pagina. // (Puoi loggare se vuoi) } } Future _applyMusicVolume() async { try { await _bgPlayer.setVolume(_musicMuted ? 0.0 : _musicVolume); } catch (_) {} } Future _toggleMute() async { setState(() => _musicMuted = !_musicMuted); // se l’audio non è mai partito, lo facciamo partire al primo toggle o al primo start if (!_musicStarted) { await _ensureMusicStarted(); } else { await _applyMusicVolume(); } } Future _stopMusic() async { try { await _bgPlayer.stop(); } catch (_) {} } // ======= SESSION CONTROLS ======= Future _start() async { if (running) return; // start music on user action (safer for iOS) await _ensureMusicStarted(); await _applyMusicVolume(); setState(() => running = true); // start square animation _squareCtrl.repeat(); _timer ??= Timer.periodic(const Duration(seconds: 1), (_) { if (!mounted) return; if (remainingSeconds <= 0) { _pause(); return; } setState(() => remainingSeconds -= 1); }); } void _pause() { setState(() => running = false); _timer?.cancel(); _timer = null; _squareCtrl.stop(); // musica: la lasciamo andare ma mutabile; se vuoi che si fermi in pausa: // _bgPlayer.pause(); } void _reset() { _pause(); setState(() => remainingSeconds = totalSeconds); _squareCtrl.reset(); } // ======= LOGOUT ======= Future _logout() async { final ok = await showDialog( context: context, builder: (_) => AlertDialog( title: const Text('Conferma logout'), content: const Text('Vuoi uscire dal tuo account?'), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('Annulla'), ), ElevatedButton( onPressed: () => Navigator.pop(context, true), child: const Text('Logout'), ), ], ), ); if (ok != true) return; // chiude drawer (qui siamo nel drawer) Navigator.of(context).pop(); // stop music await _stopMusic(); try { await VanguardApi.logout(token: widget.token); } catch (_) { // anche se fallisce lato server, usciamo lo stesso } if (!mounted) return; Navigator.of(context).pushAndRemoveUntil( MaterialPageRoute(builder: (_) => const LoginPage()), (route) => false, ); } // ======= UI HELPERS ======= String get _breathLabel { final t = (_squareCtrl.value * cycleSec) % cycleSec; if (t < inhaleSec) return 'Inspira'; if (t < inhaleSec + hold1Sec) return 'Trattieni'; if (t < inhaleSec + hold1Sec + exhaleSec) return 'Espira'; return 'Trattieni'; } double get _progress { final done = totalSeconds - remainingSeconds; return done <= 0 ? 0 : (done / totalSeconds).clamp(0.0, 1.0); } String _mmss(int sec) { final m = (sec ~/ 60).toString().padLeft(2, '0'); final s = (sec % 60).toString().padLeft(2, '0'); return '$m:$s'; } String get _name => (widget.userFirstName ?? '').trim(); String get _avatarLetter => _name.isNotEmpty ? _name[0].toUpperCase() : 'U'; Future _goTo(Widget page) async { _pause(); await _stopMusic(); if (!mounted) return; Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => page)); } void _handleBottomNav(int i) { if (i == bottomIndex) return; setState(() => bottomIndex = i); if (i == 0) { _goTo( HomePage( token: widget.token, school: widget.school, userFirstName: widget.userFirstName, ), ); return; } if (i == 1) { _goTo( LessonsPage( token: widget.token, school: widget.school, userFirstName: widget.userFirstName, ), ); return; } if (i == 2) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('TODO: vai ad Account'))); return; } // i == 3 => già qui } @override Widget build(BuildContext context) { final bubbleScale = Tween( begin: 0.96, end: 1.06, ).animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut)); return Scaffold( backgroundColor: kBg, drawer: 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: () async { Navigator.of(context).pop(); // chiude drawer await _goTo(SelectSchoolPage(token: widget.token)); }, ), const Divider(height: 1), ListTile( leading: const Icon(Icons.logout), title: const Text('Logout'), onTap: _logout, ), ], ), ), ), appBar: AppBar( backgroundColor: kBg, elevation: 0, centerTitle: true, title: const Text( 'Meditazione', style: TextStyle(fontWeight: FontWeight.w800, fontSize: 18), ), actions: [ IconButton( tooltip: _musicMuted ? 'Audio OFF' : 'Audio ON', onPressed: _toggleMute, icon: Icon( _musicMuted ? Icons.volume_off_rounded : Icons.volume_up_rounded, color: Colors.black87, ), ), Padding( padding: const EdgeInsets.only(right: 12), child: CircleAvatar( radius: 16, backgroundColor: kGreen, child: Text( _avatarLetter, style: const TextStyle( color: Colors.white, fontWeight: FontWeight.w900, fontSize: 12, ), ), ), ), ], ), bottomNavigationBar: BottomNavigationBar( type: BottomNavigationBarType.fixed, backgroundColor: Colors.white, selectedItemColor: kGreen, unselectedItemColor: Colors.black54, currentIndex: bottomIndex, onTap: _handleBottomNav, items: const [ BottomNavigationBarItem( icon: Icon(Icons.home_rounded), label: 'Home', ), BottomNavigationBarItem( icon: Icon(Icons.event_note_rounded), label: 'Lezioni', ), BottomNavigationBarItem( icon: Icon(Icons.person_rounded), label: 'Account', ), BottomNavigationBarItem( icon: Icon(Icons.self_improvement_rounded), label: 'Meditazione', ), ], ), body: SafeArea( top: false, child: Padding( padding: const EdgeInsets.fromLTRB(16, 10, 16, 16), child: Column( children: [ // Top card with progress Container( width: double.infinity, padding: const EdgeInsets.fromLTRB(14, 12, 14, 14), 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: [ const Text( 'Respiro consapevole', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w900, ), ), const SizedBox(height: 8), ClipRRect( borderRadius: BorderRadius.circular(999), child: LinearProgressIndicator( minHeight: 8, value: _progress, backgroundColor: const Color(0xFFE9E9EF), valueColor: const AlwaysStoppedAnimation( Color(0xFFF59E0B), ), ), ), ], ), ), const SizedBox(height: 20), // Square + countdown Expanded( child: SingleChildScrollView( child: Padding( padding: const EdgeInsets.only(top: 8, bottom: 8), child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ AnimatedBuilder( animation: Listenable.merge([ _squareCtrl, bubbleScale, ]), builder: (_, __) { final scale = bubbleScale.value; return Transform.scale( scale: scale, child: CustomPaint( painter: _SquareBreathPainter( t: _squareCtrl.value, color: kGreen, ), child: SizedBox( width: 240, height: 240, child: Center( child: Text( _breathLabel, style: const TextStyle( color: Colors.white, fontWeight: FontWeight.w800, fontSize: 18, ), ), ), ), ), ); }, ), const SizedBox(height: 24), Text( _mmss(remainingSeconds), style: const TextStyle( fontSize: 44, fontWeight: FontWeight.w900, color: Color(0xFF1F1F1F), ), ), const SizedBox(height: 6), const Text( 'Tempo rimanente', style: TextStyle( color: Colors.black45, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 18), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ SizedBox( height: 44, child: ElevatedButton.icon( onPressed: running ? _pause : _start, style: ElevatedButton.styleFrom( backgroundColor: kGreen, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(14), ), ), icon: Icon( running ? Icons.pause : Icons.play_arrow, ), label: Text(running ? 'Pausa' : 'Avvia'), ), ), const SizedBox(width: 12), SizedBox( height: 44, child: OutlinedButton( onPressed: _reset, style: OutlinedButton.styleFrom( side: const BorderSide( color: kGreen, width: 2, ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(14), ), ), child: const Text( 'Reset', style: TextStyle( color: Colors.black87, fontWeight: FontWeight.w800, ), ), ), ), ], ), ], ), ), ), ), ), SizedBox( width: double.infinity, height: 52, child: ElevatedButton( onPressed: () async { _pause(); await _stopMusic(); if (!mounted) return; Navigator.pushReplacement( context, MaterialPageRoute( builder: (_) => HomePage( token: widget.token, school: widget.school, userFirstName: widget.userFirstName, ), ), ); }, style: ElevatedButton.styleFrom( backgroundColor: kGreen, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), ), child: const Text( 'TERMINA SESSIONE', style: TextStyle(fontWeight: FontWeight.w900), ), ), ), ], ), ), ), ); } } class _SquareBreathPainter extends CustomPainter { final double t; // 0..1 final Color color; _SquareBreathPainter({required this.t, required this.color}); @override void paint(Canvas canvas, Size size) { final pad = 18.0; final rect = Rect.fromLTWH( pad, pad, size.width - pad * 2, size.height - pad * 2, ); final rrect = RRect.fromRectAndRadius(rect, const Radius.circular(22)); // background layers final bg1 = Paint() ..color = color.withOpacity(0.20) ..style = PaintingStyle.fill; final bg2 = Paint() ..color = color.withOpacity(0.28) ..style = PaintingStyle.fill; final bg3 = Paint() ..color = color.withOpacity(1.0) ..style = PaintingStyle.fill; canvas.save(); canvas.translate(2, 4); canvas.drawRRect(rrect, bg1); canvas.restore(); canvas.save(); canvas.translate(-2, -2); canvas.drawRRect(rrect, bg2); canvas.restore(); canvas.drawRRect(rrect, bg3); // border final border = Paint() ..color = Colors.white.withOpacity(0.25) ..style = PaintingStyle.stroke ..strokeWidth = 2.2; canvas.drawRRect(rrect, border); // dot final p = _dotPositionOnSquare(rect, t); final dotOuter = Paint()..color = Colors.white.withOpacity(0.95); final dotInner = Paint()..color = color.withOpacity(1.0); canvas.drawCircle(p, 9.5, dotOuter); canvas.drawCircle(p, 6.2, dotInner); } Offset _dotPositionOnSquare(Rect rect, double t01) { const inhale = _MeditationPageState.inhaleSec; const hold1 = _MeditationPageState.hold1Sec; const exhale = _MeditationPageState.exhaleSec; const hold2 = _MeditationPageState.hold2Sec; const cycle = _MeditationPageState.cycleSec; final x0 = rect.left; final x1 = rect.right; final y0 = rect.bottom; final y1 = rect.top; final tt = (t01 * cycle) % cycle; double u; // bottom-left -> up (Inspira) if (tt < inhale) { u = tt / inhale; return Offset(x0, _lerp(y0, y1, u)); } // top-left -> right (Trattieni) if (tt < inhale + hold1) { u = (tt - inhale) / hold1; return Offset(_lerp(x0, x1, u), y1); } // top-right -> down (Espira) if (tt < inhale + hold1 + exhale) { u = (tt - inhale - hold1) / exhale; return Offset(x1, _lerp(y1, y0, u)); } // bottom-right -> left (Trattieni) u = (tt - inhale - hold1 - exhale) / hold2; return Offset(_lerp(x1, x0, u), y0); } double _lerp(double a, double b, double t) => a + (b - a) * t; @override bool shouldRepaint(covariant _SquareBreathPainter oldDelegate) { return oldDelegate.t != t || oldDelegate.color != color; } }