668 lines
20 KiB
Dart
668 lines
20 KiB
Dart
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';
|
||
import '../widgets/app_drawer.dart';
|
||
import '../widgets/app_bottom_nav.dart';
|
||
import '../widgets/yogibook_background.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<MeditationPage> createState() => _MeditationPageState();
|
||
}
|
||
|
||
class _MeditationPageState extends State<MeditationPage>
|
||
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<void> _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<void> _applyMusicVolume() async {
|
||
try {
|
||
await _bgPlayer.setVolume(_musicMuted ? 0.0 : _musicVolume);
|
||
} catch (_) {}
|
||
}
|
||
|
||
Future<void> _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<void> _stopMusic() async {
|
||
try {
|
||
await _bgPlayer.stop();
|
||
} catch (_) {}
|
||
}
|
||
|
||
// ======= SESSION CONTROLS =======
|
||
Future<void> _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<void> _logout() async {
|
||
final ok = await showDialog<bool>(
|
||
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<void> _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<double>(
|
||
begin: 0.96,
|
||
end: 1.06,
|
||
).animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut));
|
||
|
||
return Scaffold(
|
||
drawer: AppDrawer(
|
||
token: widget.token,
|
||
school: widget.school,
|
||
userFirstName: widget.userFirstName,
|
||
),
|
||
|
||
appBar: AppBar(
|
||
backgroundColor: Colors.transparent,
|
||
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: YogibookBackground(
|
||
child: 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;
|
||
}
|
||
}
|