first commit

This commit is contained in:
2025-12-27 20:46:49 +01:00
commit 8ab3df59d4
141 changed files with 8326 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class ApiConfig {
// Host handling:
// - Web: localhost
// - Android emulator: 10.0.2.2 (maps to host machine)
static String get host {
if (kIsWeb) return 'localhost';
if (defaultTargetPlatform == TargetPlatform.android) return '10.0.2.2';
return 'localhost';
}
// Laravel/Vanguard API (login, password remind, social)
static String get laravelApiBase => 'http://$host/yogiboook/public/api';
// Your custom PHP APIs under /public/userarea/api/
static String get phpApiBase => 'http://$host/yogiboook/public/userarea/api';
static const String deviceName = 'FlutterApp';
}
+34
View File
@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'screens/login_page.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await initializeDateFormatting('it_IT');
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Yogibook',
theme: ThemeData(useMaterial3: true),
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('it', 'IT'), Locale('en', 'US')],
locale: const Locale('it', 'IT'),
home: const LoginPage(),
);
}
}
+52
View File
@@ -0,0 +1,52 @@
class Lesson {
final int bookingId;
final String status;
final String date; // YYYY-MM-DD
final String startTime;
final String endTime;
final String? roomName;
final String className;
final String? level;
final int availableEntries;
final int availableRecoveries;
final bool canModify;
Lesson({
required this.bookingId,
required this.status,
required this.date,
required this.startTime,
required this.endTime,
required this.roomName,
required this.className,
required this.level,
required this.availableEntries,
required this.availableRecoveries,
required this.canModify,
});
factory Lesson.fromJson(Map<String, dynamic> j) {
final session = (j['session'] as Map<String, dynamic>? ?? {});
final cls = (j['class'] as Map<String, dynamic>? ?? {});
final wallet = (j['wallet'] as Map<String, dynamic>? ?? {});
return Lesson(
bookingId: (j['booking_id'] as num).toInt(),
status: (j['status'] ?? '').toString(),
date: (session['date'] ?? '').toString(),
startTime: (session['start_time'] ?? '').toString(),
endTime: (session['end_time'] ?? '').toString(),
roomName: session['room_name']?.toString(),
className: (cls['name'] ?? '').toString(),
level: cls['level']?.toString(),
availableEntries: (wallet['available_entries'] as num?)?.toInt() ?? 0,
availableRecoveries:
(wallet['available_recoveries'] as num?)?.toInt() ?? 0,
canModify: (j['can_modify'] == true),
);
}
}
+19
View File
@@ -0,0 +1,19 @@
class School {
final int id;
final String name;
final String? logo;
final String? addressFull;
School({required this.id, required this.name, this.logo, this.addressFull});
factory School.fromJson(Map<String, dynamic> j) {
return School(
id: (j['id'] as num).toInt(),
name: (j['name'] ?? '').toString(),
logo: (j['logo'] as String?)?.trim().isEmpty == true ? null : j['logo'],
addressFull: (j['address_full'] as String?)?.trim().isEmpty == true
? null
: j['address_full'],
);
}
}
+531
View File
@@ -0,0 +1,531 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../models/school.dart';
import 'lessons_page.dart';
import 'select_school_page.dart';
import 'meditation_page.dart';
import '../services/vanguard_api.dart';
import 'login_page.dart';
class HomePage extends StatefulWidget {
final String token;
final School school;
final String? userFirstName;
const HomePage({
super.key,
required this.token,
required this.school,
this.userFirstName,
});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
static const Color kBg = Color(0xFFF6F6FB);
static const Color kGreen = Color(0xFF10B981);
int bottomIndex = 0; // Home
late final String zenQuote;
@override
void initState() {
super.initState();
zenQuote = _pickZenQuote();
}
String get _name => (widget.userFirstName ?? '').trim();
String get _avatarLetter => _name.isNotEmpty ? _name[0].toUpperCase() : 'U';
String get _schoolAddress => (widget.school.addressFull ?? '').trim();
String _pickZenQuote() {
const quotes = <String>[
'Respira. Sei esattamente dove devi essere.',
'Un respiro alla volta, un passo alla volta.',
'La costanza è più forte della motivazione.',
'Lascia andare ciò che non serve.',
'La calma è una superpotenza.',
'Il corpo ascolta ciò che la mente ripete.',
'Presenza. Non perfezione.',
'Sii gentile con te stesso: è pratica.',
'Ogni pratica è un nuovo inizio.',
'Dove va il respiro, va lattenzione.',
'Inspira fiducia, espira paura.',
'La pace inizia dal respiro.',
'Scegli la lentezza, trovi chiarezza.',
'Anche il silenzio insegna.',
'Oggi basta esserci.',
'Il meglio è nel semplice.',
'Radicati, poi fiorisci.',
'Lascia che il respiro ti guidi.',
'Un minuto di presenza cambia la giornata.',
'La forza è morbida.',
'Il cambiamento nasce dallascolto.',
'Non correre: senti.',
'Ogni espirazione è un rilascio.',
'Ogni inspirazione è un dono.',
'La pratica è un ritorno a casa.',
'Dove c’è respiro, c’è spazio.',
'Trasforma la tensione in attenzione.',
'Sii saldo e leggero.',
'Sii stabile come una montagna.',
'Sii fluido come lacqua.',
'La quiete è una scelta.',
'Concediti tempo.',
'Concediti gentilezza.',
'La mente si calma nel corpo.',
'Il corpo ricorda la cura.',
'Fai pace con il tuo ritmo.',
'Non devi dimostrare nulla.',
'Lascia che sia abbastanza.',
'La gratitudine apre il cuore.',
'La disciplina crea libertà.',
'Piccoli gesti, grandi cambiamenti.',
'Ogni giorno ricomincia.',
'Sorridi al respiro.',
'La tua energia segue la tua attenzione.',
'Rallenta e osserva.',
'C’è forza nella dolcezza.',
'C’è bellezza nella pausa.',
'Quando dubiti, respira.',
'Lascia andare il giudizio.',
'Cerca lequilibrio, non il controllo.',
'Senti i piedi: sei qui.',
'Senti il cuore: sei vivo.',
'Accetta, poi agisci.',
'La presenza è il vero lusso.',
'Larmonia è un allenamento.',
'Nessuna postura, solo esperienza.',
'Ogni postura è un dialogo.',
'Ascolta prima di spingere.',
'La stabilità nasce dalla morbidezza.',
'Il respiro è il tuo ancoraggio.',
'La calma è contagiosa.',
'Lascia che il corpo si apra con pazienza.',
'Scegli pensieri che ti nutrono.',
'Scegli parole che ti rispettano.',
'Scegli un ritmo sostenibile.',
'Lentamente è un modo di arrivare.',
'Fidati del processo.',
'Ogni tremore è vita.',
'Ogni difficoltà è un insegnante.',
'Non sei in ritardo.',
'Non sei indietro: sei in cammino.',
'Sii curioso, non severo.',
'La pratica non si perde mai.',
'Oggi fai un passo gentile.',
'Respira nel punto che resiste.',
'Dove c’è rigidità, porta luce.',
'Il corpo si ammorbidisce quando ti fidi.',
'La chiarezza arriva quando rallenti.',
'La mente si quieta quando senti.',
'Sii presente al 1%. È già tanto.',
'Lequilibrio è micro-aggiustamento.',
'La semplicità è potenza.',
'Lascia spazio al respiro.',
'Lascia spazio allerrore.',
'La pratica è onestà.',
'La pratica è cura.',
'Senti il tuo centro.',
'Torna al corpo, torna al presente.',
'Ogni respiro è una nuova possibilità.',
'Ogni inspirazione è un inizio.',
'Ogni espirazione è una resa.',
'Non forzare: accompagna.',
'Quando ti perdi, torna al respiro.',
'Il respiro illumina la strada.',
'Il corpo è la tua casa.',
'La pace è già qui, ascoltala.',
'Dai priorità a ciò che ti fa bene.',
'Sii fedele alla tua pratica, non allidea.',
'La tua serenità è una scelta quotidiana.',
'Oggi scegli leggerezza.',
];
return quotes[Random().nextInt(quotes.length)];
}
Future<void> _openMaps() async {
final query = _schoolAddress.isNotEmpty
? _schoolAddress
: widget.school.name;
final uri = Uri.parse(
'https://www.google.com/maps/search/?api=1&query=${Uri.encodeComponent(query)}',
);
final ok = await launchUrl(uri, mode: LaunchMode.externalApplication);
if (!ok && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Impossibile aprire Mappe su questo dispositivo.'),
),
);
}
}
void _goLessons() {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => LessonsPage(
token: widget.token,
school: widget.school,
userFirstName: widget.userFirstName,
),
),
);
}
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
Navigator.of(context).pop();
try {
await VanguardApi.logout(token: widget.token);
} catch (_) {}
if (!mounted) return;
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const LoginPage()),
(route) => false,
);
}
void _handleBottomNav(int i) {
setState(() => bottomIndex = i);
if (i == 0) return;
if (i == 1) {
_goLessons();
return;
}
if (i == 2) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('TODO: vai ad Account')));
return;
}
if (i == 3) {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => MeditationPage(
token: widget.token,
school: widget.school,
userFirstName: widget.userFirstName,
),
),
);
return;
}
}
@override
Widget build(BuildContext context) {
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: () {
Navigator.of(context).pop();
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => 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(
'Home',
style: TextStyle(fontWeight: FontWeight.w800, fontSize: 18),
),
actions: [
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, // con 4 icone serve
backgroundColor: Colors.white,
selectedItemColor: const Color(0xFF10B981),
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: Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Ciao + nome
Text(
_name.isNotEmpty ? 'Ciao, $_name' : 'Ciao',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w900),
),
const SizedBox(height: 10),
// Nome scuola + indirizzo sotto
Text(
widget.school.name,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w900),
),
if (_schoolAddress.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
_schoolAddress,
style: const TextStyle(
fontSize: 12,
color: Colors.black54,
fontWeight: FontWeight.w600,
),
),
],
const SizedBox(height: 10),
// Pulsante mappe
OutlinedButton.icon(
onPressed: _openMaps,
icon: const Icon(Icons.directions, size: 18, color: kGreen),
label: const Text(
'Apri Mappe',
style: TextStyle(fontWeight: FontWeight.w800, color: kGreen),
),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: kGreen, width: 2),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
),
const SizedBox(height: 14),
// Tiles
_HomeTile(
icon: Icons.event_note_rounded,
title: 'Vedi lezioni',
subtitle: 'Prenotazioni e gestione lezioni',
onTap: _goLessons,
),
_HomeTile(
icon: Icons.shopping_bag_rounded,
title: 'Vedi ordini',
subtitle: 'Storico e stato ordini (TODO)',
onTap: () => ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('TODO: Ordini'))),
),
_HomeTile(
icon: Icons.person_rounded,
title: 'Account',
subtitle: 'Profilo e impostazioni (TODO)',
onTap: () => ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('TODO: Account'))),
),
const Spacer(),
// Logo + frase zen
Center(
child: Column(
children: [
Text(
'$zenQuote',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16,
height: 1.3,
color: Colors.black87,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 14),
Image.asset('assets/images/yogibook_logo.png', height: 78),
const SizedBox(height: 6),
],
),
),
],
),
),
);
}
}
class _HomeTile extends StatelessWidget {
static const Color kGreen = Color(0xFF10B981);
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
const _HomeTile({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
boxShadow: const [
BoxShadow(
blurRadius: 18,
color: Color(0x12000000),
offset: Offset(0, 10),
),
],
),
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 12),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: const Color(0xFFE7F8F1),
borderRadius: BorderRadius.circular(14),
),
child: Icon(icon, color: kGreen),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontWeight: FontWeight.w900,
fontSize: 15,
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: const TextStyle(
color: Colors.black54,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
const SizedBox(width: 10),
Container(
width: 34,
height: 34,
decoration: const BoxDecoration(
color: kGreen,
shape: BoxShape.circle,
),
child: const Icon(Icons.chevron_right, color: Colors.white),
),
],
),
),
),
);
}
}
+759
View File
@@ -0,0 +1,759 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../models/lesson.dart';
import '../models/school.dart';
import '../services/vanguard_api.dart';
import 'select_school_page.dart';
import 'home_page.dart';
import 'meditation_page.dart';
class LessonsPage extends StatefulWidget {
final String token;
final School school;
final String? userFirstName;
const LessonsPage({
super.key,
required this.token,
required this.school,
this.userFirstName,
});
@override
State<LessonsPage> createState() => _LessonsPageState();
}
class _LessonsPageState extends State<LessonsPage> {
bool loading = true;
String error = '';
String currentMonth = DateFormat('yyyy-MM').format(DateTime.now());
String? prevMonth;
String? nextMonth;
String? schoolAddress;
List<Lesson> lessons = [];
int bottomIndex = 1;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
loading = true;
error = '';
});
try {
final data = await VanguardApi.getMyLessons(
token: widget.token,
schoolId: widget.school.id,
month: currentMonth,
);
prevMonth = data['prev_month']?.toString();
nextMonth = data['next_month']?.toString();
schoolAddress = (data['school'] as Map<String, dynamic>?)?['address_full']
?.toString();
final list = (data['lessons'] as List<dynamic>? ?? [])
.map((e) => Lesson.fromJson(e as Map<String, dynamic>))
.toList();
setState(() => lessons = list);
} catch (e) {
setState(() => error = 'Errore: $e');
} finally {
setState(() => loading = false);
}
}
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);
}
String _dayNum(String ymd) {
final dt = DateFormat('yyyy-MM-dd').parse(ymd);
return DateFormat('dd').format(dt);
}
String get _name => (widget.userFirstName ?? '').trim();
String get _avatarLetter => _name.isNotEmpty ? _name[0].toUpperCase() : 'U';
void _handleBottomNav(int i) {
if (i == bottomIndex) return;
setState(() => bottomIndex = i);
if (i == 0) {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => HomePage(
token: widget.token,
school: widget.school,
userFirstName: widget.userFirstName,
),
),
);
return;
}
if (i == 1) return; // sei già su Lezioni
if (i == 2) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('TODO: vai ad Account')));
return;
}
if (i == 3) {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => MeditationPage(
token: widget.token,
school: widget.school,
userFirstName: widget.userFirstName,
),
),
);
return;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF6F6FB),
drawer: _AppDrawer(
token: widget.token,
onLogout: () {
Navigator.of(context).pop();
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Logout (TODO)')));
},
),
appBar: AppBar(
backgroundColor: const Color(0xFFF6F6FB),
elevation: 0,
centerTitle: true,
title: Column(
children: [
const Text(
'Le mie lezioni',
style: TextStyle(fontWeight: FontWeight.w800, fontSize: 18),
),
const SizedBox(height: 2),
Text(
widget.school.name,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.black54,
),
),
],
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 12),
child: CircleAvatar(
radius: 16,
backgroundColor: const Color(0xFF10B981),
child: Text(
_avatarLetter,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w900,
fontSize: 12,
),
),
),
),
],
),
bottomNavigationBar: BottomNavigationBar(
type: BottomNavigationBarType.fixed, // con 4 icone serve
backgroundColor: Colors.white,
selectedItemColor: const Color(0xFF10B981),
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: Column(
children: [
// Address small (optional)
if (schoolAddress != null && schoolAddress!.trim().isNotEmpty)
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,
),
),
),
// 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())
: error.isNotEmpty
? _ErrorBox(message: error, onRetry: _load)
: lessons.isEmpty
? _EmptyState(
onBrowse: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('TODO: vedi corsi')),
);
},
)
: ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
itemCount: lessons.length + 1,
itemBuilder: (_, i) {
if (i == lessons.length) {
return Padding(
padding: const EdgeInsets.only(top: 8, bottom: 6),
child: Center(
child: Image.asset(
'assets/images/yogibook_logo.png',
height: 56,
),
),
);
}
final l = lessons[i];
return _LessonGreenCardCompact(
lesson: l,
weekday: _weekdayLabel(l.date),
dayNum: _dayNum(l.date),
onReschedule: l.canModify
? () =>
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Riprogramma: API dopo'),
),
)
: null,
onCancel: l.canModify
? () =>
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Cancella: API dopo'),
),
)
: null,
);
},
),
),
],
),
),
);
}
}
class _AppDrawer extends StatelessWidget {
final String token;
final VoidCallback onLogout;
const _AppDrawer({required this.token, required this.onLogout});
@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),
ListTile(
leading: const Icon(Icons.logout),
title: const Text('Logout'),
onTap: onLogout,
),
],
),
),
);
}
}
class _MonthPillCompact extends StatelessWidget {
final String label;
final VoidCallback? onPrev;
final VoidCallback? onNext;
const _MonthPillCompact({
required this.label,
required this.onPrev,
required this.onNext,
});
@override
Widget build(BuildContext context) {
const green = Color(0xFF10B981);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: const [
BoxShadow(
blurRadius: 14,
color: Color(0x11000000),
offset: Offset(0, 8),
),
],
),
child: Row(
children: [
IconButton(
onPressed: onPrev,
icon: const Icon(Icons.chevron_left),
iconSize: 22,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
color: onPrev == null ? Colors.black26 : green,
),
Expanded(
child: Text(
label,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w900),
),
),
IconButton(
onPressed: onNext,
icon: const Icon(Icons.chevron_right),
iconSize: 22,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
color: onNext == null ? Colors.black26 : green,
),
],
),
);
}
}
class _LessonGreenCardCompact extends StatelessWidget {
final Lesson lesson;
final String weekday;
final String dayNum;
final VoidCallback? onReschedule;
final VoidCallback? onCancel;
const _LessonGreenCardCompact({
required this.lesson,
required this.weekday,
required this.dayNum,
required this.onReschedule,
required this.onCancel,
});
@override
Widget build(BuildContext context) {
const green = Color(0xFF10B981);
const darkGreen = Color(0xFF065F46);
final level = (lesson.level ?? '').trim();
final time = _shortTime(lesson.startTime);
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: const Color(0xFFE7F8F1),
borderRadius: BorderRadius.circular(18),
boxShadow: const [
BoxShadow(
blurRadius: 18,
color: Color(0x12000000),
offset: Offset(0, 10),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(18),
child: Row(
children: [
// left panel smaller
Container(
width: 92,
color: const Color(0xFFBFF3DE),
padding: const EdgeInsets.fromLTRB(10, 12, 10, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
dayNum,
style: const TextStyle(
fontSize: 28,
height: 1,
fontWeight: FontWeight.w900,
color: darkGreen,
),
),
const SizedBox(height: 4),
Text(
weekday,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w800,
color: darkGreen,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.6),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.schedule,
size: 14,
color: Colors.black54,
),
const SizedBox(width: 6),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
time,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w800,
),
),
),
],
),
),
],
),
),
// right body tighter
Expanded(
child: Container(
padding: const EdgeInsets.fromLTRB(10, 10, 10, 10),
color: const Color(0xFFE7F8F1),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
lesson.className,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w900,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
Row(
children: [
const Icon(
Icons.meeting_room_outlined,
size: 16,
color: Colors.black54,
),
const SizedBox(width: 6),
Expanded(
child: Text(
(lesson.roomName ?? '').trim().isEmpty
? 'Sala da definire'
: lesson.roomName!,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF404040),
fontWeight: FontWeight.w600,
),
maxLines: 1,
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(
child: SizedBox(
height: 30,
child: ElevatedButton(
onPressed: onReschedule,
style: ElevatedButton.styleFrom(
backgroundColor: green,
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text(
'Riprogramma',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w800,
),
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: SizedBox(
height: 30,
child: OutlinedButton(
onPressed: onCancel,
style: OutlinedButton.styleFrom(
foregroundColor: green,
side: const BorderSide(color: green, width: 2),
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text(
'Cancella',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w800,
),
),
),
),
),
],
),
if (!lesson.canModify) ...[
const SizedBox(height: 6),
const Text(
'Non modificabile (entro 24 ore)',
style: TextStyle(fontSize: 11, color: Colors.black54),
),
],
],
),
),
),
],
),
),
);
}
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 {
final VoidCallback onBrowse;
const _EmptyState({required this.onBrowse});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.event_busy, size: 64, color: Colors.black26),
const SizedBox(height: 14),
const Text(
'Nessuna lezione prenotata',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: Colors.black54,
),
),
const SizedBox(height: 6),
const Text(
'Le tue lezioni appariranno qui',
style: TextStyle(color: Colors.black45),
),
const SizedBox(height: 14),
ElevatedButton(
onPressed: onBrowse,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF10B981),
padding: const EdgeInsets.symmetric(
horizontal: 18,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
child: const Text('Vedi i corsi'),
),
],
),
),
);
}
}
class _ErrorBox extends StatelessWidget {
final String message;
final VoidCallback onRetry;
const _ErrorBox({required this.message, required this.onRetry});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 58, color: Colors.redAccent),
const SizedBox(height: 10),
Text(message, textAlign: TextAlign.center),
const SizedBox(height: 12),
ElevatedButton(onPressed: onRetry, child: const Text('Riprova')),
],
),
),
);
}
}
+295
View File
@@ -0,0 +1,295 @@
import 'package:flutter/material.dart';
import '../services/vanguard_api.dart';
import 'select_school_page.dart';
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
// ---- Controllers
final emailController = TextEditingController();
final passwordController = TextEditingController();
// ---- UI state
bool loading = false;
String error = '';
@override
void dispose() {
emailController.dispose();
passwordController.dispose();
super.dispose();
}
Future<void> doLogin() async {
setState(() {
loading = true;
error = '';
});
try {
final token = await VanguardApi.login(
username: emailController.text.trim(),
password: passwordController.text.trim(),
);
if (!mounted) return;
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => SelectSchoolPage(token: token)),
);
} catch (e) {
setState(() => error = 'Errore login: $e');
} finally {
if (mounted) setState(() => loading = false);
}
}
Future<void> forgotPassword() async {
final email = emailController.text.trim();
if (email.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Inserisci prima la tua email.')),
);
return;
}
setState(() {
loading = true;
error = '';
});
try {
await VanguardApi.requestPasswordResetEmail(email: email);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Email inviata! Controlla la posta.')),
);
} catch (e) {
setState(() => error = 'Errore reset password: $e');
} finally {
if (mounted) setState(() => loading = false);
}
}
@override
Widget build(BuildContext context) {
const kBg1 = Color(0xFFF4F6FA);
const kBg2 = Color(0xFFEFF7F3); // soft green wash
const kGreen = Color(0xFF10B981);
return Scaffold(
body: Stack(
children: [
// ---- Background gradient
Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [kBg1, kBg2, kBg1],
),
),
),
// ---- Decorative blobs
const _BgBlob(
color: Color(0x3310B981),
size: 280,
top: -70,
left: -90,
),
const _BgBlob(
color: Color(0x22F59E0B),
size: 230,
bottom: 80,
right: -70,
),
const _BgBlob(
color: Color(0x227C3AED),
size: 190,
bottom: -55,
left: 35,
),
// ---- Centered login card
Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
boxShadow: const [
BoxShadow(
blurRadius: 26,
color: Colors.black12,
offset: Offset(0, 14),
),
],
border: Border.all(color: Colors.black12, width: 1),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
'assets/images/yogibook_logo.png',
height: 74,
),
const SizedBox(height: 14),
const Text(
'Yogibook Login',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 18),
TextField(
controller: emailController,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
decoration: InputDecoration(
labelText: 'Email / Username',
border: const OutlineInputBorder(),
enabled: !loading,
),
),
const SizedBox(height: 12),
TextField(
controller: passwordController,
obscureText: true,
textInputAction: TextInputAction.done,
onSubmitted: (_) => loading ? null : doLogin(),
decoration: InputDecoration(
labelText: 'Password',
border: const OutlineInputBorder(),
enabled: !loading,
),
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: loading ? null : forgotPassword,
child: const Text('Recupero password'),
),
TextButton(
onPressed: () =>
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Registrazione: dopo.'),
),
),
child: const Text('Registrati'),
),
],
),
if (error.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
error,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.red,
fontWeight: FontWeight.w600,
),
),
],
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: kGreen,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
onPressed: loading ? null : doLogin,
child: loading
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text(
'LOGIN',
style: TextStyle(fontWeight: FontWeight.w900),
),
),
),
],
),
),
),
),
),
],
),
);
}
}
class _BgBlob extends StatelessWidget {
final Color color;
final double size;
final double? top;
final double? left;
final double? right;
final double? bottom;
const _BgBlob({
required this.color,
required this.size,
this.top,
this.left,
this.right,
this.bottom,
});
@override
Widget build(BuildContext context) {
return Positioned(
top: top,
left: left,
right: right,
bottom: bottom,
child: IgnorePointer(
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
blurRadius: 70,
color: color,
offset: const Offset(0, 18),
),
],
),
),
),
);
}
}
+689
View File
@@ -0,0 +1,689 @@
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<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(
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;
}
}
+507
View File
@@ -0,0 +1,507 @@
import 'package:flutter/material.dart';
import '../models/school.dart';
import '../services/vanguard_api.dart';
import 'lessons_page.dart';
import 'home_page.dart';
import 'meditation_page.dart';
import 'login_page.dart';
class SelectSchoolPage extends StatefulWidget {
final String token;
const SelectSchoolPage({super.key, required this.token});
@override
State<SelectSchoolPage> createState() => _SelectSchoolPageState();
}
class _SelectSchoolPageState extends State<SelectSchoolPage> {
static const Color kBg = Color(0xFFF6F6FB);
static const Color kGreen = Color(0xFF10B981);
bool loading = true;
String error = '';
String? firstName;
List<School> schools = [];
int bottomIndex = 1; // 0=Home, 1=Lezioni, 2=Account (qui siamo in "Lezioni")
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
loading = true;
error = '';
});
try {
final data = await VanguardApi.getUserSchools(token: widget.token);
final user = (data['user'] as Map<String, dynamic>? ?? {});
firstName = user['first_name']?.toString();
final list = (data['schools'] as List<dynamic>? ?? [])
.map((e) => School.fromJson(e as Map<String, dynamic>))
.toList();
setState(() => schools = list);
// Auto-select if API says so
final autoSelect = data['auto_select'] == true;
final selectedId = data['selected_school_id'];
if (autoSelect && selectedId != null && schools.isNotEmpty) {
final id = (selectedId as num).toInt();
final s = schools.firstWhere((x) => x.id == id);
_enterSchool(s);
}
} catch (e) {
setState(() => error = 'Errore: $e');
} finally {
setState(() => loading = false);
}
}
void _enterSchool(School s) {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) =>
HomePage(token: widget.token, school: s, userFirstName: firstName),
),
);
}
String get _name => (firstName ?? '').trim();
String get _avatarLetter => _name.isNotEmpty ? _name[0].toUpperCase() : 'U';
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 il drawer se aperto
Navigator.of(context).pop();
// chiama API logout (se fallisce usciamo lo stesso)
try {
await VanguardApi.logout(token: widget.token);
} catch (_) {}
if (!mounted) return;
// torna a Login e cancella tutta la stack
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const LoginPage()),
(route) => false,
);
}
void _handleBottomNav(int i) {
setState(() => bottomIndex = i);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Prima seleziona la scuola')));
}
@override
Widget build(BuildContext context) {
return Scaffold(
drawer: _AppDrawer(token: widget.token, onLogout: _logout),
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
centerTitle: true,
title: const Text(
'Cambia scuola',
style: TextStyle(fontWeight: FontWeight.w800, fontSize: 18),
),
actions: [
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, // con 4 icone serve
backgroundColor: Colors.white,
selectedItemColor: const Color(0xFF10B981),
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: Stack(
children: [
// --- SFONDO (gradient)
Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFFF4F6FA),
Color(0xFFEFF7F3),
Color(0xFFF4F6FA),
],
),
),
),
// --- BOLLE decorative
const _BgBlob(
color: Color(0x3310B981),
size: 280,
top: -70,
left: -90,
),
const _BgBlob(
color: Color(0x22F59E0B),
size: 230,
bottom: 80,
right: -70,
),
const _BgBlob(
color: Color(0x227C3AED),
size: 190,
bottom: -55,
left: 35,
),
// --- CONTENUTO della pagina (quello che avevi prima)
SafeArea(
top: false,
child: loading
? const Center(child: CircularProgressIndicator())
: error.isNotEmpty
? _ErrorBox(message: error, onRetry: _load)
: _SchoolsList(
firstName: firstName,
schools: schools,
onSelect: _enterSchool,
),
),
],
),
);
}
}
class _AppDrawer extends StatelessWidget {
final String token;
final VoidCallback onLogout;
const _AppDrawer({required this.token, required this.onLogout});
@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(), // sei già qui
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.logout),
title: const Text('Logout'),
onTap: onLogout,
),
],
),
),
);
}
}
class _SchoolsList extends StatelessWidget {
static const Color kGreen = Color(0xFF10B981);
final String? firstName;
final List<School> schools;
final void Function(School) onSelect;
const _SchoolsList({
required this.firstName,
required this.schools,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
final name = (firstName ?? '').trim();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// header compatto (stesso stile “soft” delle lezioni)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name.isNotEmpty
? 'Seleziona una scuola, $name'
: 'Seleziona una scuola',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: Color(0xFF2B2B2B),
),
),
const SizedBox(height: 6),
const Text(
'Scegli la scuola di yoga.',
style: TextStyle(
color: Colors.black45,
fontWeight: FontWeight.w600,
),
),
],
),
),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
itemCount: schools.length,
itemBuilder: (_, i) {
final s = schools[i];
return _SchoolCard(school: s, onTap: () => onSelect(s));
},
),
),
const SizedBox(height: 8),
Center(
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Image.asset('assets/images/yogibook_logo.png', height: 56),
),
),
],
);
}
}
class _SchoolCard extends StatelessWidget {
static const Color kGreen = Color(0xFF10B981);
final School school;
final VoidCallback onTap;
const _SchoolCard({required this.school, required this.onTap});
@override
Widget build(BuildContext context) {
final address = (school.addressFull ?? '').trim();
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
boxShadow: const [
BoxShadow(
blurRadius: 18,
color: Color(0x12000000),
offset: Offset(0, 10),
),
],
),
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 12),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: const Color(0xFFE7F8F1),
borderRadius: BorderRadius.circular(14),
),
child: const Icon(Icons.school, color: kGreen),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
school.name,
style: const TextStyle(
fontWeight: FontWeight.w900,
fontSize: 15,
color: Color(0xFF1F1F1F),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (address.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
address,
style: const TextStyle(
fontSize: 12,
color: Colors.black54,
fontWeight: FontWeight.w600,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
const SizedBox(width: 10),
Container(
width: 34,
height: 34,
decoration: const BoxDecoration(
color: kGreen,
shape: BoxShape.circle,
),
child: const Icon(Icons.chevron_right, color: Colors.white),
),
],
),
),
),
);
}
}
class _ErrorBox extends StatelessWidget {
final String message;
final VoidCallback onRetry;
const _ErrorBox({required this.message, required this.onRetry});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 58, color: Colors.redAccent),
const SizedBox(height: 10),
Text(message, textAlign: TextAlign.center),
const SizedBox(height: 12),
ElevatedButton(onPressed: onRetry, child: const Text('Riprova')),
],
),
),
);
}
}
class _BgBlob extends StatelessWidget {
final Color color;
final double size;
final double? top;
final double? left;
final double? right;
final double? bottom;
const _BgBlob({
required this.color,
required this.size,
this.top,
this.left,
this.right,
this.bottom,
});
@override
Widget build(BuildContext context) {
return Positioned(
top: top,
left: left,
right: right,
bottom: bottom,
child: IgnorePointer(
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
blurRadius: 70,
color: color,
offset: const Offset(0, 18),
),
],
),
),
),
);
}
}
+84
View File
@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'main.dart'; // per VanguardApi
class SelectSchoolPage extends StatefulWidget {
final String token;
const SelectSchoolPage({super.key, required this.token});
@override
State<SelectSchoolPage> createState() => _SelectSchoolPageState();
}
class _SelectSchoolPageState extends State<SelectSchoolPage> {
bool loading = true;
String error = '';
List schools = [];
@override
void initState() {
super.initState();
_loadSchools();
}
Future<void> _loadSchools() async {
try {
final data = await VanguardApi.getUserSchools(token: widget.token);
if (data['success'] != true) {
throw Exception(data['message'] ?? 'Errore sconosciuto');
}
final List list = data['schools'] ?? [];
// Auto select
if (data['auto_select'] == true && list.length == 1) {
_enterSchool(list.first);
return;
}
setState(() {
schools = list;
loading = false;
});
} catch (e) {
setState(() {
error = e.toString();
loading = false;
});
}
}
void _enterSchool(Map school) {
// per ora debug
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => SuccessPage(token: widget.token)),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Scegli la scuola')),
body: loading
? const Center(child: CircularProgressIndicator())
: error.isNotEmpty
? Center(child: Text(error))
: ListView.builder(
itemCount: schools.length,
itemBuilder: (context, i) {
final s = schools[i];
return ListTile(
leading: s['logo'] != null
? Image.network(s['logo'], width: 40)
: const Icon(Icons.school),
title: Text(s['name']),
subtitle: Text(s['address_full'] ?? ''),
onTap: () => _enterSchool(s),
);
},
),
);
}
}
+126
View File
@@ -0,0 +1,126 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../config/api_config.dart';
class VanguardApi {
static Map<String, String> authHeaders(String token) => {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
};
static Future<String> login({
required String username,
required String password,
}) async {
final url = Uri.parse('${ApiConfig.laravelApiBase}/login');
final res = await http.post(
url,
headers: const {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: jsonEncode({
'username': username,
'password': password,
'device_name': ApiConfig.deviceName,
}),
);
if (res.statusCode != 200) {
throw Exception('Login failed (${res.statusCode}): ${res.body}');
}
final data = jsonDecode(res.body) as Map<String, dynamic>;
final token = data['token'];
if (token == null || token.toString().isEmpty) {
throw Exception('Missing token in login response.');
}
return token.toString();
}
static Future<void> requestPasswordResetEmail({required String email}) async {
final url = Uri.parse('${ApiConfig.laravelApiBase}/password/remind');
final res = await http.post(
url,
headers: const {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: jsonEncode({'email': email}),
);
if (res.statusCode != 200) {
throw Exception(
'Password remind failed (${res.statusCode}): ${res.body}',
);
}
}
// ---------- Your custom APIs ----------
static Future<Map<String, dynamic>> getUserSchools({
required String token,
}) async {
final url = Uri.parse('${ApiConfig.phpApiBase}/api_user_schools.php');
final res = await http.get(
url,
headers: {'Accept': 'application/json', 'Authorization': 'Bearer $token'},
);
if (res.statusCode != 200) {
throw Exception('User schools failed (${res.statusCode}): ${res.body}');
}
final data = jsonDecode(res.body) as Map<String, dynamic>;
if (data['success'] != true) {
throw Exception(data['message'] ?? 'Unknown error (user schools).');
}
return data;
}
static Future<Map<String, dynamic>> getMyLessons({
required String token,
required int schoolId,
required String month, // YYYY-MM
}) async {
final url = Uri.parse(
'${ApiConfig.phpApiBase}/api_my_lessons.php',
).replace(queryParameters: {'school_id': '$schoolId', 'month': month});
final res = await http.get(
url,
headers: {'Accept': 'application/json', 'Authorization': 'Bearer $token'},
);
if (res.statusCode != 200) {
throw Exception('My lessons failed (${res.statusCode}): ${res.body}');
}
final data = jsonDecode(res.body) as Map<String, dynamic>;
if (data['success'] != true) {
throw Exception(data['message'] ?? 'Unknown error (my lessons).');
}
return data;
}
static Future<void> logout({required String token}) async {
// Vanguard / Laravel: quasi sempre POST /logout con Bearer token (Sanctum)
final url = Uri.parse('${ApiConfig.laravelApiBase}/logout');
final res = await http.post(url, headers: authHeaders(token));
// 200 o 204 ok. Alcuni backend rispondono 401 se token già scaduto:
// in app lo consideriamo comunque "logout riuscito".
if (res.statusCode == 200 ||
res.statusCode == 204 ||
res.statusCode == 401) {
return;
}
throw Exception('Logout failed (${res.statusCode}): ${res.body}');
}
}