YogiBook_App/lib/screens/meditation_page.dart

668 lines
20 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 laudio 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;
}
}