Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c80535eb97 | |||
| a7e4c5f083 | |||
| e87292dda7 | |||
| 770c89ca3f | |||
| 64eade33f3 |
@@ -3,19 +3,14 @@ plugins {
|
||||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
// Firebase Cloud Messaging: legge android/app/google-services.json.
|
||||
id("com.google.gms.google-services")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.yogibook_app"
|
||||
namespace = "it.yogasoul.yogibook"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
// Richiesto da flutter_local_notifications (notifiche mostrate quando
|
||||
// l'app e' aperta).
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
@@ -26,7 +21,7 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.example.yogibook_app"
|
||||
applicationId = "it.yogasoul.yogibook"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
@@ -47,8 +42,3 @@ android {
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Libreria di supporto richiesta dal desugaring (flutter_local_notifications).
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||
}
|
||||
|
||||
@@ -4,9 +4,4 @@
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
|
||||
<!-- Solo per lo sviluppo: il backend locale (Docker) risponde in HTTP,
|
||||
e da Android 9 il traffico in chiaro e' bloccato di default.
|
||||
Questo file vale solo per la build di debug: la release resta in HTTPS. -->
|
||||
<application android:usesCleartextTraffic="true" />
|
||||
</manifest>
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Notifiche push: da Android 13 (API 33) il permesso va chiesto a runtime.
|
||||
Lo fa PushService.registerAfterLogin() dopo il login. -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
<application
|
||||
android:label="yogibook_app"
|
||||
android:label="YogiBook"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
package com.example.yogibook_app
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
@@ -0,0 +1,5 @@
|
||||
package it.yogasoul.yogibook
|
||||
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
|
||||
class MainActivity : FlutterFragmentActivity()
|
||||
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 122 KiB |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_foreground"
|
||||
android:inset="16%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 36 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
@@ -21,8 +21,6 @@ plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
// Necessario per Firebase (legge android/app/google-services.json).
|
||||
id("com.google.gms.google-services") version "4.4.2" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
||||
|
After Width: | Height: | Size: 433 KiB |
|
After Width: | Height: | Size: 371 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 433 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 129 KiB |
@@ -368,7 +368,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.yogibookApp;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = it.yogasoul.yogibook;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -384,7 +384,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.yogibookApp.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = it.yogasoul.yogibook.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -401,7 +401,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.yogibookApp.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = it.yogasoul.yogibook.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@@ -416,7 +416,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.yogibookApp.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = it.yogasoul.yogibook.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@@ -427,7 +427,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
@@ -484,7 +484,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
@@ -547,7 +547,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.yogibookApp;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = it.yogasoul.yogibook;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -569,7 +569,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.yogibookApp;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = it.yogasoul.yogibook;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
||||
@@ -1,122 +1 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "83.5x83.5",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "1024x1024",
|
||||
"idiom" : "ios-marketing",
|
||||
"filename" : "Icon-App-1024x1024@1x.png",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 430 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 29 KiB |
@@ -28,6 +28,8 @@
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>Usiamo Face ID per accedere rapidamente e in sicurezza al tuo account YogiBook.</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import '../models/school.dart';
|
||||
|
||||
/// Scuola unica dell'app (YogaSoul).
|
||||
/// Il DB non ha il concetto di "scuola": questo oggetto serve solo alle
|
||||
/// pagine che mostrano nome/indirizzo. Definito qui una volta sola così
|
||||
/// login e splash (e chiunque altro) usano lo stesso dato.
|
||||
const School kYogaSoulSchool = School(
|
||||
id: 1,
|
||||
name: 'YogaSoul',
|
||||
logo: null,
|
||||
addressFull: 'via Valassina 62/B Seregno - Sala Contesto Yoga',
|
||||
);
|
||||
@@ -3,16 +3,12 @@ import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'screens/login_page.dart';
|
||||
import 'services/push_service.dart';
|
||||
import 'screens/splash_page.dart';
|
||||
import 'state/app_state.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await initializeDateFormatting('it_IT');
|
||||
|
||||
await PushService.initFirebase();
|
||||
await PushService.enableForegroundNotifications();
|
||||
runApp(
|
||||
ChangeNotifierProvider(create: (_) => AppState(), child: const MyApp()),
|
||||
);
|
||||
@@ -36,7 +32,7 @@ class MyApp extends StatelessWidget {
|
||||
supportedLocales: const [Locale('it', 'IT'), Locale('en', 'US')],
|
||||
locale: const Locale('it', 'IT'),
|
||||
|
||||
home: const LoginPage(),
|
||||
home: const SplashPage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,12 @@ class School {
|
||||
final String? logo;
|
||||
final String? addressFull;
|
||||
|
||||
School({required this.id, required this.name, this.logo, this.addressFull});
|
||||
const School({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.logo,
|
||||
this.addressFull,
|
||||
});
|
||||
|
||||
factory School.fromJson(Map<String, dynamic> j) {
|
||||
return School(
|
||||
|
||||
@@ -3,9 +3,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import '../models/school.dart';
|
||||
import '../services/push_api.dart';
|
||||
import '../services/vanguard_api.dart';
|
||||
import '../widgets/yogibook_background.dart';
|
||||
import '../widgets/app_bottom_nav.dart';
|
||||
|
||||
class AccountPage extends StatefulWidget {
|
||||
final String token;
|
||||
@@ -42,54 +42,12 @@ class _AccountPageState extends State<AccountPage> {
|
||||
bool _obscurePw = true;
|
||||
bool _obscurePw2 = true;
|
||||
|
||||
bool? _pushEnabled;
|
||||
bool _savingPush = false;
|
||||
|
||||
final _picker = ImagePicker();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadProfile();
|
||||
_loadPushPreference();
|
||||
}
|
||||
|
||||
Future<void> _loadPushPreference() async {
|
||||
try {
|
||||
final attive = await PushApi.fetchPreference(token: widget.token);
|
||||
if (!mounted) return;
|
||||
setState(() => _pushEnabled = attive);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _pushEnabled = null);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _togglePush(bool valore) async {
|
||||
final precedente = _pushEnabled;
|
||||
|
||||
setState(() {
|
||||
_pushEnabled = valore;
|
||||
_savingPush = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final salvato = await PushApi.updatePreference(
|
||||
token: widget.token,
|
||||
enabled: valore,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _pushEnabled = salvato);
|
||||
_snack(
|
||||
salvato ? 'Notifiche attivate.' : 'Notifiche disattivate.',
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _pushEnabled = precedente);
|
||||
_snack('Errore nel salvataggio: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _savingPush = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -281,6 +239,12 @@ class _AccountPageState extends State<AccountPage> {
|
||||
style: TextStyle(fontWeight: FontWeight.w800, fontSize: 18),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: AppBottomNav(
|
||||
currentIndex: 2,
|
||||
token: widget.token,
|
||||
school: widget.school,
|
||||
userFirstName: widget.userFirstName,
|
||||
),
|
||||
body: YogibookBackground(
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
@@ -295,8 +259,6 @@ class _AccountPageState extends State<AccountPage> {
|
||||
const SizedBox(height: 20),
|
||||
_profileCard(),
|
||||
const SizedBox(height: 16),
|
||||
_notificationsCard(),
|
||||
const SizedBox(height: 16),
|
||||
_passwordCard(),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
@@ -454,47 +416,6 @@ class _AccountPageState extends State<AccountPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _notificationsCard() {
|
||||
if (_pushEnabled == null) {
|
||||
return _card(
|
||||
title: 'Notifiche',
|
||||
children: const [
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
SizedBox(width: 12),
|
||||
Expanded(child: Text('Carico le impostazioni...')),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return _card(
|
||||
title: 'Notifiche',
|
||||
children: [
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
activeThumbColor: kGreen,
|
||||
value: _pushEnabled!,
|
||||
onChanged: _savingPush ? null : _togglePush,
|
||||
title: const Text(
|
||||
'Notifiche push',
|
||||
style: TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
subtitle: const Text(
|
||||
'Promemoria delle lezioni del giorno, scadenza abbonamento e '
|
||||
'certificato medico.',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _passwordCard() {
|
||||
return _card(
|
||||
title: 'Cambia password',
|
||||
|
||||
@@ -35,17 +35,38 @@ 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;
|
||||
|
||||
// Nome caricato da getMe (widget.userFirstName è spesso null,
|
||||
// es. arrivando dallo splash con login persistente).
|
||||
String? _loadedFirstName;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
zenQuote = _pickZenQuote();
|
||||
_loadName();
|
||||
}
|
||||
|
||||
Future<void> _loadName() async {
|
||||
try {
|
||||
final me = await VanguardApi.getMe(token: widget.token);
|
||||
final first = (me['first_name'] ?? '').toString().trim();
|
||||
if (!mounted) return;
|
||||
if (first.isNotEmpty) {
|
||||
setState(() => _loadedFirstName = first);
|
||||
}
|
||||
} catch (_) {
|
||||
// silenzioso: se fallisce restiamo su "Ciao" senza nome
|
||||
}
|
||||
}
|
||||
|
||||
String get _name {
|
||||
final loaded = (_loadedFirstName ?? '').trim();
|
||||
if (loaded.isNotEmpty) return loaded;
|
||||
return (widget.userFirstName ?? '').trim();
|
||||
}
|
||||
|
||||
String get _name => (widget.userFirstName ?? '').trim();
|
||||
String get _avatarLetter => _name.isNotEmpty ? _name[0].toUpperCase() : 'U';
|
||||
|
||||
String get _schoolAddress => (widget.school.addressFull ?? '').trim();
|
||||
@@ -224,44 +245,6 @@ class _HomePageState extends State<HomePage> {
|
||||
);
|
||||
}
|
||||
|
||||
void _handleBottomNav(int i) {
|
||||
setState(() => bottomIndex = i);
|
||||
|
||||
if (i == 0) return;
|
||||
if (i == 1) {
|
||||
_goLessons();
|
||||
return;
|
||||
}
|
||||
if (i == 2) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => AccountPage(
|
||||
token: widget.token,
|
||||
school: widget.school,
|
||||
userFirstName: widget.userFirstName,
|
||||
),
|
||||
),
|
||||
).then((_) {
|
||||
if (mounted) setState(() => bottomIndex = 0);
|
||||
});
|
||||
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(
|
||||
@@ -299,8 +282,10 @@ class _HomePageState extends State<HomePage> {
|
||||
),
|
||||
|
||||
bottomNavigationBar: AppBottomNav(
|
||||
currentIndex: bottomIndex,
|
||||
onTap: _handleBottomNav,
|
||||
currentIndex: 0,
|
||||
token: widget.token,
|
||||
school: widget.school,
|
||||
userFirstName: widget.userFirstName,
|
||||
),
|
||||
|
||||
body: YogibookBackground(
|
||||
@@ -378,16 +363,27 @@ class _HomePageState extends State<HomePage> {
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => OrdersDetailPage(token: widget.token),
|
||||
builder: (_) => OrdersDetailPage(
|
||||
token: widget.token,
|
||||
school: widget.school,
|
||||
userFirstName: widget.userFirstName,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_HomeTile(
|
||||
icon: Icons.person_rounded,
|
||||
title: 'Account',
|
||||
subtitle: 'Profilo e impostazioni (TODO)',
|
||||
onTap: () => ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('TODO: Account')),
|
||||
subtitle: 'Profilo e impostazioni',
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => AccountPage(
|
||||
token: widget.token,
|
||||
school: widget.school,
|
||||
userFirstName: widget.userFirstName,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'home_page.dart';
|
||||
import 'meditation_page.dart';
|
||||
import '../widgets/app_drawer.dart';
|
||||
import '../widgets/app_bottom_nav.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../widgets/yogibook_background.dart';
|
||||
import 'reschedule_page.dart';
|
||||
import 'orders_page.dart';
|
||||
@@ -37,8 +38,6 @@ class _LessonsPageState extends State<LessonsPage> {
|
||||
LessonsSummary? summary;
|
||||
List<Lesson> lessons = [];
|
||||
|
||||
int bottomIndex = 1;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -104,48 +103,6 @@ class _LessonsPageState extends State<LessonsPage> {
|
||||
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(
|
||||
@@ -195,8 +152,10 @@ class _LessonsPageState extends State<LessonsPage> {
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: AppBottomNav(
|
||||
currentIndex: bottomIndex,
|
||||
onTap: _handleBottomNav,
|
||||
currentIndex: 1,
|
||||
token: widget.token,
|
||||
school: widget.school,
|
||||
userFirstName: widget.userFirstName,
|
||||
),
|
||||
|
||||
body: YogibookBackground(
|
||||
@@ -221,40 +180,83 @@ class _LessonsPageState extends State<LessonsPage> {
|
||||
),
|
||||
),
|
||||
|
||||
// Bottone "Programma lezioni" (solo se ci sono ticket residui)
|
||||
if (summary != null && summary!.toSchedule > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF10B981),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.add, color: Colors.white),
|
||||
label: Text(
|
||||
'Programma lezioni (${summary!.toSchedule} disponibili)',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
onPressed: () async {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => OrdersPage(token: widget.token),
|
||||
// Bottoni azione: Programma (condizionale) + Acquista (sempre)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
if (summary != null && summary!.toSchedule > 0) ...[
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF4F46E5),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
);
|
||||
_load();
|
||||
},
|
||||
icon: const Icon(Icons.add, color: Colors.white),
|
||||
label: Text(
|
||||
'Programma (${summary!.toSchedule})',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
onPressed: () async {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => OrdersPage(token: widget.token),
|
||||
),
|
||||
);
|
||||
_load();
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFF59E0B),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
icon: const Icon(
|
||||
Icons.shopping_bag_outlined,
|
||||
color: Colors.white,
|
||||
),
|
||||
label: const Text(
|
||||
'Acquista pacchetti',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
onPressed: () async {
|
||||
final uri = Uri.parse(
|
||||
'https://yogasoul.it/abbonamenti-prezzi-yoga-seregno/',
|
||||
);
|
||||
if (!await launchUrl(
|
||||
uri,
|
||||
mode: LaunchMode.externalApplication,
|
||||
)) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Impossibile aprire il link'),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: loading
|
||||
@@ -388,47 +390,53 @@ class _SummaryStrip extends StatelessWidget {
|
||||
_SummaryItem('Perse', summary.lost, const Color(0xFFFBC7C4)),
|
||||
];
|
||||
|
||||
return SizedBox(
|
||||
height: 76,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||
itemBuilder: (_, i) {
|
||||
final it = items[i];
|
||||
return Container(
|
||||
width: 96,
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: it.color,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'${it.value}',
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w900,
|
||||
// 3 colonne, spaziatura tra i box
|
||||
const spacing = 8.0;
|
||||
const columns = 3;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final boxWidth =
|
||||
(constraints.maxWidth - spacing * (columns - 1)) / columns;
|
||||
|
||||
return Wrap(
|
||||
spacing: spacing,
|
||||
runSpacing: spacing,
|
||||
children: items.map((it) {
|
||||
return Container(
|
||||
width: boxWidth,
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: it.color,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'${it.value}',
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
it.label,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
it.label,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/push_service.dart';
|
||||
import '../services/vanguard_api.dart';
|
||||
import '../services/auth_storage.dart';
|
||||
import '../models/school.dart';
|
||||
import '../config/app_school.dart';
|
||||
import 'home_page.dart';
|
||||
|
||||
class LoginPage extends StatefulWidget {
|
||||
@@ -21,15 +20,7 @@ class _LoginPageState extends State<LoginPage> {
|
||||
// ---- UI state
|
||||
bool loading = false;
|
||||
String error = '';
|
||||
|
||||
// Scuola unica (YogaSoul). Il DB non ha il concetto di scuola:
|
||||
// questo oggetto serve solo a soddisfare le pagine che mostrano nome/indirizzo.
|
||||
static final School _yogaSoulSchool = School(
|
||||
id: 1,
|
||||
name: 'YogaSoul',
|
||||
logo: null,
|
||||
addressFull: 'via Valassina 62/B Seregno - Sala Contesto Yoga',
|
||||
);
|
||||
bool _obscurePassword = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -50,7 +41,8 @@ class _LoginPageState extends State<LoginPage> {
|
||||
password: passwordController.text.trim(),
|
||||
);
|
||||
|
||||
unawaited(PushService.registerAfterLogin(token));
|
||||
// Se "ricordami" è attivo, salva il nuovo token in cassaforte.
|
||||
await AuthStorage.instance.saveTokenIfRemember(token);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -60,13 +52,27 @@ class _LoginPageState extends State<LoginPage> {
|
||||
MaterialPageRoute(
|
||||
builder: (_) => HomePage(
|
||||
token: token,
|
||||
school: _yogaSoulSchool,
|
||||
school: kYogaSoulSchool,
|
||||
userFirstName: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
setState(() => error = 'Errore login: $e');
|
||||
final msg = e.toString();
|
||||
String friendly;
|
||||
if (msg.contains('401') || msg.contains('403')) {
|
||||
friendly = 'Email o password non corretti.';
|
||||
} else if (msg.contains('SocketException') ||
|
||||
msg.contains('Failed host lookup') ||
|
||||
msg.contains('Connection')) {
|
||||
friendly = 'Nessuna connessione. Controlla la rete e riprova.';
|
||||
} else if (msg.contains('timed out') ||
|
||||
msg.contains('TimeoutException')) {
|
||||
friendly = 'Il server non risponde. Riprova tra poco.';
|
||||
} else {
|
||||
friendly = 'Accesso non riuscito. Riprova.';
|
||||
}
|
||||
setState(() => error = friendly);
|
||||
} finally {
|
||||
if (mounted) setState(() => loading = false);
|
||||
}
|
||||
@@ -180,44 +186,44 @@ class _LoginPageState extends State<LoginPage> {
|
||||
controller: emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration: InputDecoration(
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Email / Username',
|
||||
border: const OutlineInputBorder(),
|
||||
enabled: !loading,
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
TextField(
|
||||
controller: passwordController,
|
||||
obscureText: true,
|
||||
obscureText: _obscurePassword,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => loading ? null : doLogin(),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Password',
|
||||
border: const OutlineInputBorder(),
|
||||
enabled: !loading,
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility
|
||||
: Icons.visibility_off,
|
||||
),
|
||||
onPressed: loading
|
||||
? null
|
||||
: () => setState(
|
||||
() => _obscurePassword = !_obscurePassword,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
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'),
|
||||
),
|
||||
],
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: loading ? null : forgotPassword,
|
||||
child: const Text('Recupero password'),
|
||||
),
|
||||
),
|
||||
|
||||
if (error.isNotEmpty) ...[
|
||||
|
||||
@@ -50,9 +50,6 @@ class _MedicalCertificatesPageState extends State<MedicalCertificatesPage> {
|
||||
DateTime? expiryDate;
|
||||
bool uploading = false;
|
||||
|
||||
// ✅ questa pagina è "sezione account"
|
||||
int bottomIndex = 2;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -224,19 +221,10 @@ class _MedicalCertificatesPageState extends State<MedicalCertificatesPage> {
|
||||
}
|
||||
|
||||
Future<void> _openFileUrl(String url) async {
|
||||
// url tipico: "/userarea/certificate/xxx"
|
||||
// fix plural errato se presente
|
||||
var fixed = url.replaceFirst(
|
||||
'/userarea/certificates/',
|
||||
'/userarea/certificate/',
|
||||
);
|
||||
|
||||
// se non ha /public davanti, lo aggiungiamo
|
||||
if (!fixed.startsWith('/public/')) {
|
||||
fixed = '/public${fixed.startsWith('/') ? '' : '/'}$fixed';
|
||||
}
|
||||
|
||||
final abs = '${ApiConfig.scheme}://${ApiConfig.host}$fixed';
|
||||
// L'API restituisce già "/user/document/<file>".
|
||||
// Costruiamo l'URL assoluto senza manipolazioni.
|
||||
final path = url.startsWith('/') ? url : '/$url';
|
||||
final abs = '${ApiConfig.scheme}://${ApiConfig.host}$path';
|
||||
final uri = Uri.parse(abs);
|
||||
|
||||
final ok = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
@@ -286,54 +274,6 @@ class _MedicalCertificatesPageState extends State<MedicalCertificatesPage> {
|
||||
|
||||
bool _isPdfPath(String p) => p.toLowerCase().endsWith('.pdf');
|
||||
|
||||
Future<void> _goTo(Widget page) async {
|
||||
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) {
|
||||
// siamo già in "area account", non facciamo nulla (o TODO)
|
||||
return;
|
||||
}
|
||||
|
||||
if (i == 3) {
|
||||
_goTo(
|
||||
MeditationPage(
|
||||
token: widget.token,
|
||||
school: widget.school,
|
||||
userFirstName: widget.userFirstName,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -359,10 +299,11 @@ class _MedicalCertificatesPageState extends State<MedicalCertificatesPage> {
|
||||
],
|
||||
),
|
||||
|
||||
// ✅ Bottom nav standard (come MeditationPage)
|
||||
bottomNavigationBar: AppBottomNav(
|
||||
currentIndex: bottomIndex,
|
||||
onTap: _handleBottomNav,
|
||||
currentIndex: 2,
|
||||
token: widget.token,
|
||||
school: widget.school,
|
||||
userFirstName: widget.userFirstName,
|
||||
),
|
||||
|
||||
body: YogibookBackground(
|
||||
@@ -438,7 +379,6 @@ class _MedicalCertificatesPageState extends State<MedicalCertificatesPage> {
|
||||
final filename = (c['filename'] ?? '').toString();
|
||||
final uploadedAt = (c['uploaded_at'] ?? '').toString();
|
||||
final expiry = (c['expiry_date'] ?? '').toString();
|
||||
final notes = (c['notes'] ?? '').toString();
|
||||
final fileUrl = (c['file_url'] ?? '').toString();
|
||||
|
||||
return Card(
|
||||
@@ -539,11 +479,6 @@ class _MedicalCertificatesPageState extends State<MedicalCertificatesPage> {
|
||||
],
|
||||
),
|
||||
|
||||
if (notes.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(notes, style: const TextStyle(fontSize: 12)),
|
||||
],
|
||||
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
@@ -677,17 +612,6 @@ class _UploadCard extends StatelessWidget {
|
||||
label: Text(expiryLabel),
|
||||
),
|
||||
|
||||
const SizedBox(height: 10),
|
||||
|
||||
TextField(
|
||||
controller: notesController,
|
||||
minLines: 2,
|
||||
maxLines: 4,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Note (opzionale)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
SizedBox(
|
||||
|
||||
@@ -64,8 +64,6 @@ class _MeditationPageState extends State<MeditationPage>
|
||||
late final AnimationController _pulseCtrl;
|
||||
late final AnimationController _squareCtrl;
|
||||
|
||||
int bottomIndex = 3; // Meditazione
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -256,42 +254,6 @@ class _MeditationPageState extends State<MeditationPage>
|
||||
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>(
|
||||
@@ -340,31 +302,15 @@ class _MeditationPageState extends State<MeditationPage>
|
||||
),
|
||||
],
|
||||
),
|
||||
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',
|
||||
),
|
||||
],
|
||||
bottomNavigationBar: AppBottomNav(
|
||||
currentIndex: 3,
|
||||
token: widget.token,
|
||||
school: widget.school,
|
||||
userFirstName: widget.userFirstName,
|
||||
onBeforeLeave: () async {
|
||||
_pause();
|
||||
await _stopMusic();
|
||||
},
|
||||
),
|
||||
body: YogibookBackground(
|
||||
child: SafeArea(
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../models/lesson.dart';
|
||||
import '../models/school.dart';
|
||||
import '../services/lessons_api.dart';
|
||||
import '../widgets/yogibook_background.dart';
|
||||
import '../widgets/app_bottom_nav.dart';
|
||||
|
||||
/// Schermata "I miei ordini": lista ordini con conteggi.
|
||||
/// Toccando un ordine si apre il dettaglio (box + lezioni).
|
||||
class OrdersDetailPage extends StatefulWidget {
|
||||
final String token;
|
||||
final School school;
|
||||
final String? userFirstName;
|
||||
|
||||
const OrdersDetailPage({super.key, required this.token});
|
||||
const OrdersDetailPage({
|
||||
super.key,
|
||||
required this.token,
|
||||
required this.school,
|
||||
this.userFirstName,
|
||||
});
|
||||
|
||||
@override
|
||||
State<OrdersDetailPage> createState() => _OrdersDetailPageState();
|
||||
@@ -63,6 +72,12 @@ class _OrdersDetailPageState extends State<OrdersDetailPage> {
|
||||
style: TextStyle(fontWeight: FontWeight.w800, fontSize: 18),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: AppBottomNav(
|
||||
currentIndex: 1,
|
||||
token: widget.token,
|
||||
school: widget.school,
|
||||
userFirstName: widget.userFirstName,
|
||||
),
|
||||
body: YogibookBackground(
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
@@ -77,23 +92,90 @@ class _OrdersDetailPageState extends State<OrdersDetailPage> {
|
||||
itemCount: orders.length,
|
||||
itemBuilder: (_, i) {
|
||||
final o = orders[i];
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
o.serviceName.isEmpty
|
||||
? 'Ordine #${o.orderId}'
|
||||
: o.serviceName,
|
||||
style: const TextStyle(fontWeight: FontWeight.w800),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Ordine #${o.orderId} • ${o.tickets} lezioni\n'
|
||||
'Da programmare: ${o.toSchedule} • Scadenza: ${_fmtDate(o.expireOn)}'
|
||||
'${o.isExpired ? " (scaduto)" : ""}',
|
||||
),
|
||||
isThreeLine: true,
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 14),
|
||||
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: () => _openDetail(o),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 12, 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
o.serviceName.isEmpty
|
||||
? 'Ordine #${o.orderId}'
|
||||
: o.serviceName,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Ordine #${o.orderId} • ${o.tickets} lezioni',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.black54,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Da programmare: ${o.toSchedule}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.black54,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Riga riprogrammazioni + scadenza (chip)
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
_MiniChip(
|
||||
icon: Icons.autorenew,
|
||||
label:
|
||||
'Riprog. ${o.reprogrammed}/${o.maxReschedule}',
|
||||
color: const Color(0xFF10B981),
|
||||
),
|
||||
_MiniChip(
|
||||
icon: Icons.event_busy,
|
||||
label:
|
||||
'Scad. ${_fmtDate(o.expireOn)}',
|
||||
color: o.isExpired
|
||||
? const Color(0xFFDC3545)
|
||||
: const Color(0xFFF59E0B),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const Icon(
|
||||
Icons.chevron_right,
|
||||
color: Colors.black38,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -227,6 +309,15 @@ class _OrderDetailSheet extends StatelessWidget {
|
||||
'${order.isExpired ? " (scaduto)" : ""}',
|
||||
style: const TextStyle(fontSize: 12, color: Colors.black54),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Riprogrammazioni usate: ${order.reprogrammed} / ${order.maxReschedule}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.black54,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Divider(height: 24),
|
||||
|
||||
const Text(
|
||||
@@ -320,3 +411,42 @@ class _OrderDetailSheet extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MiniChip extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Color color;
|
||||
|
||||
const _MiniChip({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: color.withValues(alpha: 0.5), width: 1),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: color),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,37 +173,10 @@ class _ReschedulePageState extends State<ReschedulePage> {
|
||||
itemCount: slots.length,
|
||||
itemBuilder: (_, i) {
|
||||
final s = slots[i];
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
s.className,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${_weekdayLabel(s.date)} ${s.date} • ${s.time}\n'
|
||||
'Posti liberi: ${s.freePlaces}/${s.maxCapacity}'
|
||||
'${s.alreadyBooked ? " • Sei già prenotato" : ""}',
|
||||
),
|
||||
isThreeLine: true,
|
||||
trailing: s.bookable
|
||||
? ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: green,
|
||||
),
|
||||
onPressed: () => _confirm(s),
|
||||
child: const Text('Scegli'),
|
||||
)
|
||||
: const Text(
|
||||
'Non disp.',
|
||||
style: TextStyle(
|
||||
color: Colors.black38,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
return _SlotCard(
|
||||
slot: s,
|
||||
weekday: _weekdayLabel(s.date),
|
||||
onChoose: () => _confirm(s),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -262,3 +235,137 @@ class _ReschedulePageState extends State<ReschedulePage> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _SlotCard extends StatelessWidget {
|
||||
final AvailableSlot slot;
|
||||
final String weekday;
|
||||
final VoidCallback onChoose;
|
||||
|
||||
const _SlotCard({
|
||||
required this.slot,
|
||||
required this.weekday,
|
||||
required this.onChoose,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const green = Color(0xFF10B981);
|
||||
const orange = Color(0xFFF59E0B);
|
||||
|
||||
// Determina lo stato dello slot
|
||||
final bool isBooked = slot.alreadyBooked;
|
||||
final bool isFull = !slot.bookable && !isBooked;
|
||||
final bool isSelectable = slot.bookable && !isBooked;
|
||||
|
||||
// Colori in base allo stato
|
||||
Color bgColor;
|
||||
Color borderColor;
|
||||
if (isBooked) {
|
||||
bgColor = const Color(0xFFFFF4E5); // arancio chiaro
|
||||
borderColor = orange;
|
||||
} else if (isFull) {
|
||||
bgColor = const Color(0xFFF3F3F5); // grigio chiaro
|
||||
borderColor = const Color(0xFFD9D9E0);
|
||||
} else {
|
||||
bgColor = Colors.white;
|
||||
borderColor = const Color(0x14000000);
|
||||
}
|
||||
|
||||
// Etichetta / badge a destra
|
||||
Widget trailing;
|
||||
if (isSelectable) {
|
||||
trailing = ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: green,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
onPressed: onChoose,
|
||||
child: const Text('Scegli'),
|
||||
);
|
||||
} else if (isBooked) {
|
||||
trailing = _StatusBadge(text: 'Già prenotato', color: orange);
|
||||
} else {
|
||||
trailing = _StatusBadge(
|
||||
text: 'Al completo',
|
||||
color: const Color(0xFF9AA0A6),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 12, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: borderColor, width: 1.5),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
slot.className,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'$weekday ${slot.date} • ${slot.time}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.black54,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Posti liberi: ${slot.freePlaces}/${slot.maxCapacity}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isFull ? orange : Colors.black45,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
trailing,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
final String text;
|
||||
final Color color;
|
||||
|
||||
const _StatusBadge({required this.text, required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: color, width: 1.5),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/school.dart';
|
||||
import '../services/auth_storage.dart';
|
||||
import '../widgets/yogibook_background.dart';
|
||||
|
||||
class SettingsPage extends StatefulWidget {
|
||||
final String token;
|
||||
final School school;
|
||||
final String? userFirstName;
|
||||
|
||||
const SettingsPage({
|
||||
super.key,
|
||||
required this.token,
|
||||
required this.school,
|
||||
this.userFirstName,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SettingsPage> createState() => _SettingsPageState();
|
||||
}
|
||||
|
||||
class _SettingsPageState extends State<SettingsPage> {
|
||||
static const Color kGreen = Color(0xFF10B981);
|
||||
|
||||
final _auth = AuthStorage.instance;
|
||||
|
||||
bool _loading = true;
|
||||
bool _rememberMe = false;
|
||||
bool _biometric = false;
|
||||
bool _biometricAvailableOnDevice = false;
|
||||
|
||||
// evita doppi tap durante il salvataggio
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final remember = await _auth.isRememberMeEnabled();
|
||||
final bio = await _auth.isBiometricEnabled();
|
||||
final canBio = await _auth.canUseBiometrics();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_rememberMe = remember;
|
||||
_biometric = bio;
|
||||
_biometricAvailableOnDevice = canBio;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _snack(String msg) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||
}
|
||||
|
||||
// "Ricordami": se acceso salva SUBITO il token corrente in cassaforte,
|
||||
// così funziona già da questa sessione (non solo dal prossimo login).
|
||||
Future<void> _onRememberChanged(bool value) async {
|
||||
if (_busy) return;
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await _auth.setRememberMe(value);
|
||||
if (value) {
|
||||
// salva il token che questa pagina ha in mano
|
||||
await _auth.saveTokenIfRemember(widget.token);
|
||||
}
|
||||
// spegnere ricordami spegne anche la biometria (dipendenza)
|
||||
final bioNow = await _auth.isBiometricEnabled();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_rememberMe = value;
|
||||
_biometric = bioNow;
|
||||
});
|
||||
_snack(
|
||||
value ? 'Accesso rapido attivato.' : 'Accesso rapido disattivato.',
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onBiometricChanged(bool value) async {
|
||||
if (_busy) return;
|
||||
|
||||
// non attivabile senza impronte registrate / hardware
|
||||
if (value && !_biometricAvailableOnDevice) {
|
||||
_snack(
|
||||
'Nessuna impronta o volto registrati sul dispositivo. '
|
||||
'Configurali nelle impostazioni del telefono, poi riprova.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await _auth.setBiometric(value);
|
||||
if (!mounted) return;
|
||||
setState(() => _biometric = value);
|
||||
_snack(
|
||||
value
|
||||
? 'Sblocco biometrico attivato.'
|
||||
: 'Sblocco biometrico disattivato.',
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
title: const Text(
|
||||
'Impostazioni',
|
||||
style: TextStyle(fontWeight: FontWeight.w800, fontSize: 18),
|
||||
),
|
||||
),
|
||||
body: YogibookBackground(
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
children: [
|
||||
_sectionTitle('Accesso'),
|
||||
const SizedBox(height: 10),
|
||||
_accessCard(),
|
||||
const SizedBox(height: 16),
|
||||
_infoNote(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionTitle(String t) => Text(
|
||||
t,
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w900),
|
||||
);
|
||||
|
||||
Widget _accessCard() {
|
||||
// la biometria è attivabile solo se "ricordami" è ON
|
||||
final biometricEnabled = _rememberMe;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
blurRadius: 18,
|
||||
color: Color(0x12000000),
|
||||
offset: Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
SwitchListTile(
|
||||
activeColor: kGreen,
|
||||
value: _rememberMe,
|
||||
onChanged: _busy ? null : _onRememberChanged,
|
||||
secondary: const Icon(Icons.vpn_key_rounded),
|
||||
title: const Text(
|
||||
'Ricordami',
|
||||
style: TextStyle(fontWeight: FontWeight.w800),
|
||||
),
|
||||
subtitle: const Text(
|
||||
'Resta connesso su questo dispositivo senza reinserire la password.',
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
SwitchListTile(
|
||||
activeColor: kGreen,
|
||||
value: _biometric && biometricEnabled,
|
||||
onChanged: (_busy || !biometricEnabled)
|
||||
? null
|
||||
: _onBiometricChanged,
|
||||
secondary: Icon(
|
||||
Icons.fingerprint_rounded,
|
||||
color: biometricEnabled ? null : Colors.black26,
|
||||
),
|
||||
title: Text(
|
||||
'Sblocco con impronta / volto',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
color: biometricEnabled ? null : Colors.black38,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
biometricEnabled
|
||||
? 'Chiedi la biometria all\'avvio per accedere.'
|
||||
: 'Attiva prima "Ricordami" per usare lo sblocco biometrico.',
|
||||
style: TextStyle(color: biometricEnabled ? null : Colors.black38),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _infoNote() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF6F6FB),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.info_outline, size: 20, color: Colors.black45),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_biometricAvailableOnDevice
|
||||
? 'Il token di accesso è salvato in forma cifrata sul dispositivo. '
|
||||
'Con lo sblocco biometrico attivo, viene usato solo dopo il riconoscimento.'
|
||||
: 'Questo dispositivo non ha impronta o volto configurati: '
|
||||
'lo sblocco biometrico non è disponibile, ma "Ricordami" funziona lo stesso.',
|
||||
style: const TextStyle(fontSize: 12, color: Colors.black54),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../config/app_school.dart';
|
||||
import '../services/auth_storage.dart';
|
||||
import 'login_page.dart';
|
||||
import 'home_page.dart';
|
||||
|
||||
class SplashPage extends StatefulWidget {
|
||||
const SplashPage({super.key});
|
||||
|
||||
@override
|
||||
State<SplashPage> createState() => _SplashPageState();
|
||||
}
|
||||
|
||||
class _SplashPageState extends State<SplashPage> {
|
||||
// rosso bordeaux ripreso dal cuore del logo YogaSoul
|
||||
static const Color kRed = Color(0xFFB01E28);
|
||||
|
||||
Timer? _timer;
|
||||
int _seconds = 5; // countdown iniziale
|
||||
|
||||
// Evita doppie navigazioni: countdown, tap e sblocco possono
|
||||
// altrimenti innescare più push.
|
||||
bool _navigated = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (t) {
|
||||
if (!mounted) return;
|
||||
setState(() => _seconds--);
|
||||
if (_seconds <= 0) {
|
||||
t.cancel();
|
||||
_finishSplash();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// Fine dello splash: prova lo sblocco, poi instrada.
|
||||
Future<void> _finishSplash() async {
|
||||
if (_navigated) return;
|
||||
|
||||
final outcome = await AuthStorage.instance.tryUnlockAtStartup();
|
||||
if (!mounted || _navigated) return;
|
||||
|
||||
switch (outcome.result) {
|
||||
case UnlockResult.success:
|
||||
_navigated = true;
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => HomePage(
|
||||
token: outcome.token!,
|
||||
school: kYogaSoulSchool,
|
||||
userFirstName: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
break;
|
||||
case UnlockResult.noSavedLogin:
|
||||
case UnlockResult.biometricFailed:
|
||||
case UnlockResult.biometricUnavailable:
|
||||
_goToLogin();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Tap sullo schermo: salta il countdown ma passa comunque dallo sblocco.
|
||||
void _onTapSkip() {
|
||||
if (_navigated) return;
|
||||
_timer?.cancel();
|
||||
_finishSplash();
|
||||
}
|
||||
|
||||
void _goToLogin() {
|
||||
if (_navigated || !mounted) return;
|
||||
_navigated = true;
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const LoginPage()),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: GestureDetector(
|
||||
onTap: _onTapSkip,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// ---- Foto di sfondo full-screen
|
||||
Image.asset('assets/images/splash_bg.png', fit: BoxFit.cover),
|
||||
|
||||
// ---- Contenuto (logo + testo) in alto
|
||||
SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 10, 24, 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Logo YogaSoul
|
||||
Image.asset(
|
||||
'assets/images/yogasoul_logo.png',
|
||||
height: 72,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
|
||||
const SizedBox(height: 26),
|
||||
|
||||
// ---- Blocco testo su velo bianco morbido per leggibilità
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 18,
|
||||
vertical: 16,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.42),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Text(
|
||||
'Il tuo spazio.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 30,
|
||||
height: 1.1,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Color(0xFF1F1F1F),
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
'La tua energia.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 30,
|
||||
height: 1.1,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: kRed,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 14),
|
||||
|
||||
const Text(
|
||||
'Ti diamo il benvenuto in YogiBook,\n'
|
||||
'l’app della scuola YogaSoul — Seregno.\n'
|
||||
'Ti accompagna nelle tue lezioni e nei tuoi pacchetti.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.4,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF2A2A2A),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ---- Logo YogiBook + countdown in basso
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 30,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.75),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Image.asset(
|
||||
'assets/images/yogibook_logo.png',
|
||||
height: 40,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.25),
|
||||
border: Border.all(color: Colors.white, width: 1.5),
|
||||
),
|
||||
child: Text(
|
||||
'$_seconds',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../services/teacher_api.dart';
|
||||
import '../widgets/yogibook_background.dart';
|
||||
|
||||
/// Aggiungi partecipante a una classe (vista insegnante).
|
||||
/// Flusso: cerca/crea utente -> scegli modalità -> conferma.
|
||||
/// Ritorna `true` (Navigator.pop) se l'inserimento è avvenuto.
|
||||
class TeacherAddParticipantPage extends StatefulWidget {
|
||||
final String token;
|
||||
final TeacherClass cls;
|
||||
|
||||
const TeacherAddParticipantPage({
|
||||
super.key,
|
||||
required this.token,
|
||||
required this.cls,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TeacherAddParticipantPage> createState() =>
|
||||
_TeacherAddParticipantPageState();
|
||||
}
|
||||
|
||||
class _TeacherAddParticipantPageState extends State<TeacherAddParticipantPage> {
|
||||
static const Color kGreen = Color(0xFF10B981);
|
||||
|
||||
final _firstCtrl = TextEditingController();
|
||||
final _lastCtrl = TextEditingController();
|
||||
final _emailCtrl = TextEditingController();
|
||||
|
||||
// ricerca
|
||||
List<TeacherUser> _results = [];
|
||||
bool _searching = false;
|
||||
|
||||
// utente selezionato (null = nuovo utente da creare)
|
||||
TeacherUser? _selectedUser;
|
||||
bool _isNewUser = false;
|
||||
|
||||
// ordini dell'utente selezionato
|
||||
List<TeacherOrder> _orders = [];
|
||||
bool _loadingOrders = false;
|
||||
|
||||
// modalità: 'scala' | 'omaggio' | 'nuovo'
|
||||
String _mode = 'omaggio';
|
||||
int? _selectedOrderId;
|
||||
DateTime _newExpiry = DateTime.now().add(const Duration(days: 90));
|
||||
|
||||
bool _submitting = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_firstCtrl.dispose();
|
||||
_lastCtrl.dispose();
|
||||
_emailCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _snack(String msg) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||
}
|
||||
|
||||
Future<void> _search() async {
|
||||
final f = _firstCtrl.text.trim();
|
||||
final l = _lastCtrl.text.trim();
|
||||
if (f.length < 2 && l.length < 2) {
|
||||
setState(() => _results = []);
|
||||
return;
|
||||
}
|
||||
setState(() => _searching = true);
|
||||
try {
|
||||
final r = await TeacherApi.searchUsers(
|
||||
token: widget.token,
|
||||
first: f,
|
||||
last: l,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _results = r);
|
||||
} catch (e) {
|
||||
_snack('$e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _searching = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _selectUser(TeacherUser u) async {
|
||||
setState(() {
|
||||
_selectedUser = u;
|
||||
_isNewUser = false;
|
||||
_results = [];
|
||||
_firstCtrl.text = u.firstName;
|
||||
_lastCtrl.text = u.lastName;
|
||||
_emailCtrl.text = u.email;
|
||||
_loadingOrders = true;
|
||||
_orders = [];
|
||||
});
|
||||
try {
|
||||
final o = await TeacherApi.userOrders(token: widget.token, userId: u.id);
|
||||
if (!mounted) return;
|
||||
// default modalità: scala se ha pacchetti con residui, altrimenti omaggio
|
||||
final usable = o.where((x) => x.remaining > 0).toList();
|
||||
setState(() {
|
||||
_orders = o;
|
||||
if (usable.isNotEmpty) {
|
||||
_mode = 'scala';
|
||||
_selectedOrderId = usable.first.idOrderBook;
|
||||
} else {
|
||||
_mode = 'omaggio';
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
_snack('$e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _loadingOrders = false);
|
||||
}
|
||||
}
|
||||
|
||||
// segna che vogliamo creare un nuovo utente coi dati digitati
|
||||
void _useAsNewUser() {
|
||||
final f = _firstCtrl.text.trim();
|
||||
final l = _lastCtrl.text.trim();
|
||||
if (f.isEmpty || l.isEmpty) {
|
||||
_snack('Inserisci nome e cognome del nuovo utente.');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_selectedUser = null;
|
||||
_isNewUser = true;
|
||||
_results = [];
|
||||
_orders = [];
|
||||
_mode = 'omaggio'; // nuovo utente non ha pacchetti
|
||||
});
|
||||
}
|
||||
|
||||
void _clearSelection() {
|
||||
setState(() {
|
||||
_selectedUser = null;
|
||||
_isNewUser = false;
|
||||
_orders = [];
|
||||
_selectedOrderId = null;
|
||||
_mode = 'omaggio';
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _confirm() async {
|
||||
// deve esserci un utente selezionato o un nuovo utente valido
|
||||
if (_selectedUser == null && !_isNewUser) {
|
||||
_snack('Seleziona un utente o creane uno nuovo.');
|
||||
return;
|
||||
}
|
||||
if (_mode == 'scala' && _selectedOrderId == null) {
|
||||
_snack('Seleziona un pacchetto da cui scalare.');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
await TeacherApi.addBooking(
|
||||
token: widget.token,
|
||||
scheduleId: widget.cls.scheduleId,
|
||||
serviceId: widget.cls.serviceId,
|
||||
bookingStart: widget.cls.dateTime,
|
||||
mode: _mode,
|
||||
userId: _selectedUser?.id ?? 0,
|
||||
name: _isNewUser ? _firstCtrl.text.trim() : '',
|
||||
surname: _isNewUser ? _lastCtrl.text.trim() : '',
|
||||
email: _emailCtrl.text.trim(),
|
||||
orderId: _mode == 'scala' ? _selectedOrderId : null,
|
||||
newExpiry: _mode == 'nuovo'
|
||||
? DateFormat('yyyy-MM-dd').format(_newExpiry)
|
||||
: null,
|
||||
);
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context, true);
|
||||
} catch (e) {
|
||||
_snack('$e');
|
||||
setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasSelection = _selectedUser != null || _isNewUser;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
title: const Text(
|
||||
'Aggiungi partecipante',
|
||||
style: TextStyle(fontWeight: FontWeight.w800, fontSize: 18),
|
||||
),
|
||||
),
|
||||
body: YogibookBackground(
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
children: [
|
||||
_classBanner(),
|
||||
const SizedBox(height: 14),
|
||||
_searchFields(),
|
||||
if (!hasSelection) ...[const SizedBox(height: 8), _resultsBox()],
|
||||
if (hasSelection) ...[
|
||||
const SizedBox(height: 14),
|
||||
_selectionCard(),
|
||||
const SizedBox(height: 14),
|
||||
_modeSection(),
|
||||
const SizedBox(height: 20),
|
||||
_confirmButton(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _classBanner() {
|
||||
final dt = DateTime.tryParse(widget.cls.dateTime);
|
||||
final when = dt == null
|
||||
? widget.cls.dateTime
|
||||
: DateFormat("EEE d MMM, HH:mm", 'it_IT').format(dt);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
blurRadius: 12,
|
||||
color: Color(0x0F000000),
|
||||
offset: Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.class_, color: kGreen),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.cls.className,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$when · ${widget.cls.bookedCount}/${widget.cls.maxCapacity} posti',
|
||||
style: const TextStyle(fontSize: 12, color: Colors.black54),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _searchFields() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
blurRadius: 12,
|
||||
color: Color(0x0F000000),
|
||||
offset: Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _firstCtrl,
|
||||
onChanged: (_) => _search(),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Nome',
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _lastCtrl,
|
||||
onChanged: (_) => _search(),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Cognome',
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: _emailCtrl,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Email (per nuovo utente)',
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _resultsBox() {
|
||||
if (_searching) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
final f = _firstCtrl.text.trim();
|
||||
final l = _lastCtrl.text.trim();
|
||||
if (f.length < 2 && l.length < 2) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(
|
||||
'Digita almeno 2 lettere per cercare.',
|
||||
style: TextStyle(color: Colors.black45),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
..._results.map(
|
||||
(u) => Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: kGreen,
|
||||
child: Text(
|
||||
((u.firstName.isNotEmpty ? u.firstName[0] : '') +
|
||||
(u.lastName.isNotEmpty ? u.lastName[0] : ''))
|
||||
.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
u.fullName,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
subtitle: Text(u.email),
|
||||
onTap: () => _selectUser(u),
|
||||
),
|
||||
),
|
||||
),
|
||||
// opzione "crea nuovo utente" con i dati digitati
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF3F0FF),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFFD9CFFB)),
|
||||
),
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.person_add, color: Color(0xFF4F46E5)),
|
||||
title: const Text(
|
||||
'Crea nuovo utente',
|
||||
style: TextStyle(fontWeight: FontWeight.w800),
|
||||
),
|
||||
subtitle: const Text('Usa nome, cognome ed email digitati sopra'),
|
||||
onTap: _useAsNewUser,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _selectionCard() {
|
||||
final title = _isNewUser
|
||||
? '${_firstCtrl.text.trim()} ${_lastCtrl.text.trim()} (nuovo)'
|
||||
: _selectedUser!.fullName;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE7F8F1),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: kGreen),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.check_circle, color: kGreen),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(fontWeight: FontWeight.w800),
|
||||
),
|
||||
),
|
||||
TextButton(onPressed: _clearSelection, child: const Text('Cambia')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _modeSection() {
|
||||
final usable = _orders.where((o) => o.remaining > 0).toList();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
blurRadius: 12,
|
||||
color: Color(0x0F000000),
|
||||
offset: Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Modalità',
|
||||
style: TextStyle(fontWeight: FontWeight.w900, fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
if (_loadingOrders)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else ...[
|
||||
// scala (solo se ci sono pacchetti usabili)
|
||||
RadioListTile<String>(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
activeColor: kGreen,
|
||||
value: 'scala',
|
||||
groupValue: _mode,
|
||||
onChanged: usable.isEmpty
|
||||
? null
|
||||
: (v) => setState(() => _mode = v!),
|
||||
title: Text(
|
||||
usable.isEmpty
|
||||
? 'Scala da pacchetto (nessuno disponibile)'
|
||||
: 'Scala da un pacchetto',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: usable.isEmpty ? Colors.black38 : Colors.black87,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_mode == 'scala' && usable.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 12, bottom: 8),
|
||||
child: Column(
|
||||
children: usable
|
||||
.map(
|
||||
(o) => RadioListTile<int>(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
activeColor: kGreen,
|
||||
value: o.idOrderBook,
|
||||
groupValue: _selectedOrderId,
|
||||
onChanged: (v) =>
|
||||
setState(() => _selectedOrderId = v),
|
||||
title: Text(
|
||||
o.serviceName,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${o.remaining}/${o.tickets} residui'
|
||||
'${o.expireOn != null ? ' · scade ${o.expireOn}' : ''}',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
// omaggio
|
||||
RadioListTile<String>(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
activeColor: kGreen,
|
||||
value: 'omaggio',
|
||||
groupValue: _mode,
|
||||
onChanged: (v) => setState(() => _mode = v!),
|
||||
title: const Text(
|
||||
'Aggiungi come omaggio',
|
||||
style: TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
subtitle: const Text(
|
||||
'Non consuma pacchetti, non spostabile',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
// nuovo ordine
|
||||
RadioListTile<String>(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
activeColor: kGreen,
|
||||
value: 'nuovo',
|
||||
groupValue: _mode,
|
||||
onChanged: (v) => setState(() => _mode = v!),
|
||||
title: const Text(
|
||||
'Crea nuovo ordine (1 lezione)',
|
||||
style: TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
if (_mode == 'nuovo')
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 12, top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.event, size: 18, color: Colors.black45),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Scadenza: ${DateFormat('dd/MM/yyyy').format(_newExpiry)}',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _newExpiry,
|
||||
firstDate: DateTime.now(),
|
||||
lastDate: DateTime.now().add(
|
||||
const Duration(days: 730),
|
||||
),
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() => _newExpiry = picked);
|
||||
}
|
||||
},
|
||||
child: const Text('Cambia'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _confirmButton() {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: kGreen,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
onPressed: _submitting ? null : _confirm,
|
||||
child: _submitting
|
||||
? const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'Aggiungi alla classe',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../services/teacher_api.dart';
|
||||
import '../widgets/yogibook_background.dart';
|
||||
import 'teacher_reprogram_page.dart';
|
||||
import 'teacher_add_participant_page.dart';
|
||||
|
||||
/// Dettaglio di una classe (vista insegnante).
|
||||
/// Fase 2a: elenco iscritti + segna persa / ripristina.
|
||||
class TeacherClassDetailPage extends StatefulWidget {
|
||||
final String token;
|
||||
final TeacherClass cls;
|
||||
|
||||
const TeacherClassDetailPage({
|
||||
super.key,
|
||||
required this.token,
|
||||
required this.cls,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TeacherClassDetailPage> createState() => _TeacherClassDetailPageState();
|
||||
}
|
||||
|
||||
class _TeacherClassDetailPageState extends State<TeacherClassDetailPage> {
|
||||
static const Color kGreen = Color(0xFF10B981);
|
||||
|
||||
// copia locale modificabile dei partecipanti (per aggiornare la UI)
|
||||
late List<TeacherParticipant> _participants;
|
||||
int _busyBookingId =
|
||||
0; // booking in aggiornamento (per disabilitare il tasto)
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_participants = List.of(widget.cls.participants);
|
||||
}
|
||||
|
||||
String _fmtDate(String iso) {
|
||||
final dt = DateTime.tryParse(iso);
|
||||
if (dt == null) return iso;
|
||||
final s = DateFormat("EEEE d MMMM yyyy", 'it_IT').format(dt);
|
||||
return s[0].toUpperCase() + s.substring(1);
|
||||
}
|
||||
|
||||
String _fmtTime(String iso) {
|
||||
final dt = DateTime.tryParse(iso);
|
||||
if (dt == null) return '';
|
||||
return DateFormat('HH:mm').format(dt);
|
||||
}
|
||||
|
||||
void _snack(String msg) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||
}
|
||||
|
||||
Future<void> _toggleLost(TeacherParticipant p) async {
|
||||
final markLost = !p.isLost;
|
||||
|
||||
// conferma solo quando si segna persa (azione visibile all'allievo)
|
||||
if (markLost) {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Segnare come persa?'),
|
||||
content: Text(
|
||||
'Vuoi segnare la lezione di ${p.fullName} come persa? '
|
||||
'Resterà registrata e non tornerà disponibile all\'allievo.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Annulla'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Segna persa'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true) return;
|
||||
}
|
||||
|
||||
setState(() => _busyBookingId = p.bookingId);
|
||||
try {
|
||||
await TeacherApi.setLost(
|
||||
token: widget.token,
|
||||
bookingId: p.bookingId,
|
||||
lost: markLost,
|
||||
);
|
||||
// aggiorna la copia locale sostituendo il partecipante
|
||||
final idx = _participants.indexWhere((x) => x.bookingId == p.bookingId);
|
||||
if (idx != -1) {
|
||||
setState(() {
|
||||
_participants[idx] = TeacherParticipant(
|
||||
bookingId: p.bookingId,
|
||||
fullName: p.fullName,
|
||||
isLost: markLost,
|
||||
);
|
||||
});
|
||||
}
|
||||
_snack(markLost ? 'Segnata come persa.' : 'Ripristinata.');
|
||||
} catch (e) {
|
||||
_snack('$e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _busyBookingId = 0);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteBooking(TeacherParticipant p) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Eliminare la prenotazione?'),
|
||||
content: Text(
|
||||
'Vuoi rimuovere ${p.fullName} da questa classe? '
|
||||
'Il posto tornerà disponibile all\'allievo. L\'azione non è reversibile.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Annulla'),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFC0392B),
|
||||
),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Elimina'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true) return;
|
||||
|
||||
setState(() => _busyBookingId = p.bookingId);
|
||||
try {
|
||||
await TeacherApi.deleteBooking(
|
||||
token: widget.token,
|
||||
bookingId: p.bookingId,
|
||||
);
|
||||
setState(() {
|
||||
_participants.removeWhere((x) => x.bookingId == p.bookingId);
|
||||
});
|
||||
_snack('Prenotazione eliminata.');
|
||||
} catch (e) {
|
||||
_snack('$e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _busyBookingId = 0);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _reprogram(TeacherParticipant p) async {
|
||||
final done = await Navigator.push<bool>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => TeacherReprogramPage(
|
||||
token: widget.token,
|
||||
bookingId: p.bookingId,
|
||||
participantName: p.fullName,
|
||||
),
|
||||
),
|
||||
);
|
||||
// Se riprogrammata, la prenotazione non è più in questa classe: rimuovila.
|
||||
if (done == true && mounted) {
|
||||
setState(() {
|
||||
_participants.removeWhere((x) => x.bookingId == p.bookingId);
|
||||
});
|
||||
_snack('${p.fullName} riprogrammata.');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final active = _participants.where((p) => !p.isLost).toList();
|
||||
final lost = _participants.where((p) => p.isLost).toList();
|
||||
final cls = widget.cls;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
title: const Text(
|
||||
'Dettaglio classe',
|
||||
style: TextStyle(fontWeight: FontWeight.w800, fontSize: 18),
|
||||
),
|
||||
),
|
||||
body: YogibookBackground(
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
children: [
|
||||
_headerCard(),
|
||||
const SizedBox(height: 18),
|
||||
_sectionLabel('Iscritti (${active.length})'),
|
||||
const SizedBox(height: 8),
|
||||
if (active.isEmpty)
|
||||
_emptyRow('Nessun iscritto attivo.')
|
||||
else
|
||||
...active.map((p) => _tile(p)),
|
||||
if (lost.isNotEmpty) ...[
|
||||
const SizedBox(height: 18),
|
||||
_sectionLabel('Lezioni perse (${lost.length})'),
|
||||
const SizedBox(height: 8),
|
||||
...lost.map((p) => _tile(p)),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: kGreen,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.person_add_alt_1, color: Colors.white),
|
||||
label: const Text(
|
||||
'Aggiungi partecipante',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
onPressed: _openAddParticipant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openAddParticipant() async {
|
||||
final added = await Navigator.push<bool>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
TeacherAddParticipantPage(token: widget.token, cls: widget.cls),
|
||||
),
|
||||
);
|
||||
if (added == true && mounted) {
|
||||
_snack(
|
||||
'Partecipante aggiunto. Aggiorna il pannello per vedere la lista.',
|
||||
);
|
||||
// torna al pannello classi, che ricaricando mostrerà i dati aggiornati
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _headerCard() {
|
||||
final cls = widget.cls;
|
||||
final isFull = cls.isFull;
|
||||
final fillColor = isFull
|
||||
? const Color(0xFFC0392B)
|
||||
: (cls.maxCapacity > 0 && cls.bookedCount >= cls.maxCapacity - 1)
|
||||
? const Color(0xFFE67E22)
|
||||
: const Color(0xFF1A7F52);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
blurRadius: 18,
|
||||
color: Color(0x12000000),
|
||||
offset: Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
cls.className,
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w900),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.event, size: 18, color: Colors.black45),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_fmtDate(cls.dateTime),
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF404040),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.schedule, size: 18, color: Colors.black45),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_fmtTime(cls.dateTime),
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF404040),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: fillColor.withOpacity(0.12),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.group, size: 16, color: fillColor),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'${cls.bookedCount} / ${cls.maxCapacity} posti',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: fillColor,
|
||||
),
|
||||
),
|
||||
if (isFull) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'· AL COMPLETO',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: fillColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionLabel(String t) => Text(
|
||||
t.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.black54,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
);
|
||||
|
||||
Widget _emptyRow(String msg) => Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Text(msg, style: const TextStyle(color: Colors.black45)),
|
||||
);
|
||||
|
||||
Color _avatarColor(String seed) {
|
||||
const palette = [
|
||||
Color(0xFF1EBF73),
|
||||
Color(0xFF2980B9),
|
||||
Color(0xFF8E44AD),
|
||||
Color(0xFFE67E22),
|
||||
Color(0xFF16A085),
|
||||
Color(0xFFC0392B),
|
||||
Color(0xFF2C3E50),
|
||||
Color(0xFFD35400),
|
||||
];
|
||||
int h = 0;
|
||||
for (final c in seed.codeUnits) {
|
||||
h = (h + c) % palette.length;
|
||||
}
|
||||
return palette[h];
|
||||
}
|
||||
|
||||
String _initials(String fullName) {
|
||||
final parts = fullName.trim().split(RegExp(r'\s+'));
|
||||
if (parts.isEmpty || parts.first.isEmpty) return '?';
|
||||
final a = parts.first[0];
|
||||
final b = parts.length > 1 ? parts.last[0] : '';
|
||||
return (a + b).toUpperCase();
|
||||
}
|
||||
|
||||
Widget _tile(TeacherParticipant p) {
|
||||
final lost = p.isLost;
|
||||
final busy = _busyBookingId == p.bookingId;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: lost ? Border.all(color: const Color(0xFFF3D6D2)) : null,
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
blurRadius: 10,
|
||||
color: Color(0x0A000000),
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: lost ? const Color(0xFF9AA5A6) : _avatarColor(p.fullName),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
_initials(p.fullName),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
p.fullName,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 15,
|
||||
color: lost ? Colors.black54 : Colors.black87,
|
||||
decoration: lost ? TextDecoration.lineThrough : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
// chip stato
|
||||
Container(
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: lost ? const Color(0xFF1F2937) : const Color(0xFFE3F2EC),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
lost ? 'Persa' : 'Presente',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: lost ? Colors.white : const Color(0xFF1A7F52),
|
||||
),
|
||||
),
|
||||
),
|
||||
// pulsanti azione
|
||||
busy
|
||||
? const SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () => _toggleLost(p),
|
||||
tooltip: lost ? 'Ripristina' : 'Segna persa',
|
||||
icon: Icon(
|
||||
lost ? Icons.undo_rounded : Icons.person_off_rounded,
|
||||
color: lost
|
||||
? const Color(0xFF1A7F52)
|
||||
: const Color(0xFFC0392B),
|
||||
),
|
||||
),
|
||||
if (!lost)
|
||||
IconButton(
|
||||
onPressed: () => _reprogram(p),
|
||||
tooltip: 'Riprogramma',
|
||||
icon: const Icon(
|
||||
Icons.event_repeat_rounded,
|
||||
color: Color(0xFF2980B9),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => _deleteBooking(p),
|
||||
tooltip: 'Elimina prenotazione',
|
||||
icon: const Icon(
|
||||
Icons.delete_outline_rounded,
|
||||
color: Colors.black45,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../models/school.dart';
|
||||
import '../services/teacher_api.dart';
|
||||
import '../widgets/yogibook_background.dart';
|
||||
import 'teacher_class_detail_page.dart';
|
||||
|
||||
/// Pannello Insegnante (staff: Admin/teacher).
|
||||
/// Fase 1: sola lettura. Classi del mese con partecipanti, focus su oggi.
|
||||
class TeacherPanelPage extends StatefulWidget {
|
||||
final String token;
|
||||
final School school;
|
||||
final String? userFirstName;
|
||||
|
||||
const TeacherPanelPage({
|
||||
super.key,
|
||||
required this.token,
|
||||
required this.school,
|
||||
this.userFirstName,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TeacherPanelPage> createState() => _TeacherPanelPageState();
|
||||
}
|
||||
|
||||
class _TeacherPanelPageState extends State<TeacherPanelPage> {
|
||||
static const Color kGreen = Color(0xFF10B981);
|
||||
static const Color kDarkGreen = Color(0xFF065F46);
|
||||
|
||||
bool loading = true;
|
||||
String error = '';
|
||||
|
||||
String currentMonth = DateFormat('yyyy-MM').format(DateTime.now());
|
||||
List<TeacherClass> classes = [];
|
||||
|
||||
// per l'auto-scroll a oggi
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final Map<int, GlobalKey> _dayKeys = {}; // index classe -> key
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
loading = true;
|
||||
error = '';
|
||||
});
|
||||
try {
|
||||
final data = await TeacherApi.fetchClasses(
|
||||
token: widget.token,
|
||||
month: currentMonth,
|
||||
);
|
||||
setState(() {
|
||||
classes = data;
|
||||
});
|
||||
// dopo il render, prova a scrollare a oggi (con un attimo di ritardo
|
||||
// per essere sicuri che le card siano state disposte)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
Future.delayed(const Duration(milliseconds: 250), _scrollToToday);
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => error = 'Errore: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
// Trova la prima classe con data >= oggi e scrolla lì.
|
||||
void _scrollToToday() {
|
||||
if (classes.isEmpty) return;
|
||||
final today = DateTime.now();
|
||||
int targetIndex = 0;
|
||||
for (int i = 0; i < classes.length; i++) {
|
||||
final dt = DateTime.tryParse(classes[i].dateTime);
|
||||
if (dt != null &&
|
||||
!dt.isBefore(DateTime(today.year, today.month, today.day))) {
|
||||
targetIndex = i;
|
||||
break;
|
||||
}
|
||||
targetIndex = i; // se tutte passate, resta sull'ultima
|
||||
}
|
||||
final key = _dayKeys[targetIndex];
|
||||
final ctx = key?.currentContext;
|
||||
if (ctx != null) {
|
||||
Scrollable.ensureVisible(
|
||||
ctx,
|
||||
duration: const Duration(milliseconds: 400),
|
||||
alignment: 0.1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _shiftMonth(String yyyyMm, int delta) {
|
||||
final dt = DateFormat('yyyy-MM').parse(yyyyMm);
|
||||
final shifted = DateTime(dt.year, dt.month + delta, 1);
|
||||
return DateFormat('yyyy-MM').format(shifted);
|
||||
}
|
||||
|
||||
void _goPrevMonth() {
|
||||
setState(() => currentMonth = _shiftMonth(currentMonth, -1));
|
||||
_load();
|
||||
}
|
||||
|
||||
void _goNextMonth() {
|
||||
setState(() => currentMonth = _shiftMonth(currentMonth, 1));
|
||||
_load();
|
||||
}
|
||||
|
||||
String _monthLabel(String yyyyMm) {
|
||||
final dt = DateFormat('yyyy-MM').parse(yyyyMm);
|
||||
return DateFormat('MMMM yyyy', 'it_IT').format(dt);
|
||||
}
|
||||
|
||||
String _weekdayLabel(String iso) {
|
||||
final dt = DateTime.tryParse(iso);
|
||||
if (dt == null) return '';
|
||||
final s = DateFormat('EEEE', 'it_IT').format(dt);
|
||||
return s[0].toUpperCase() + s.substring(1);
|
||||
}
|
||||
|
||||
String _dayNum(String iso) {
|
||||
final dt = DateTime.tryParse(iso);
|
||||
if (dt == null) return '--';
|
||||
return DateFormat('dd').format(dt);
|
||||
}
|
||||
|
||||
String _time(String iso) {
|
||||
final dt = DateTime.tryParse(iso);
|
||||
if (dt == null) return '';
|
||||
return DateFormat('HH:mm').format(dt);
|
||||
}
|
||||
|
||||
bool _isToday(String iso) {
|
||||
final dt = DateTime.tryParse(iso);
|
||||
if (dt == null) return false;
|
||||
final now = DateTime.now();
|
||||
return dt.year == now.year && dt.month == now.month && dt.day == now.day;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
title: const Text(
|
||||
'Pannello classi',
|
||||
style: TextStyle(fontWeight: FontWeight.w800, fontSize: 18),
|
||||
),
|
||||
),
|
||||
body: YogibookBackground(
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: _MonthPill(
|
||||
label: _monthLabel(currentMonth),
|
||||
onPrev: _goPrevMonth,
|
||||
onNext: _goNextMonth,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: error.isNotEmpty
|
||||
? _ErrorBox(message: error, onRetry: _load)
|
||||
: classes.isEmpty
|
||||
? const _EmptyState()
|
||||
: ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
|
||||
itemCount: classes.length,
|
||||
itemBuilder: (_, i) {
|
||||
final key = _dayKeys.putIfAbsent(
|
||||
i,
|
||||
() => GlobalKey(),
|
||||
);
|
||||
final c = classes[i];
|
||||
final dt = DateTime.tryParse(c.dateTime);
|
||||
final now = DateTime.now();
|
||||
final isPast =
|
||||
dt != null &&
|
||||
dt.isBefore(
|
||||
DateTime(now.year, now.month, now.day),
|
||||
);
|
||||
return Container(
|
||||
key: key,
|
||||
child: _ClassCard(
|
||||
cls: c,
|
||||
weekday: _weekdayLabel(c.dateTime),
|
||||
dayNum: _dayNum(c.dateTime),
|
||||
time: _time(c.dateTime),
|
||||
highlightToday: _isToday(c.dateTime),
|
||||
isPast: isPast,
|
||||
onTap: () async {
|
||||
final changed = await Navigator.push<bool>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => TeacherClassDetailPage(
|
||||
token: widget.token,
|
||||
cls: c,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (changed == true) _load();
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Card classe compatta: al tap apre la pagina dettaglio.
|
||||
class _ClassCard extends StatelessWidget {
|
||||
final TeacherClass cls;
|
||||
final String weekday;
|
||||
final String dayNum;
|
||||
final String time;
|
||||
final bool highlightToday;
|
||||
final bool isPast;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ClassCard({
|
||||
required this.cls,
|
||||
required this.weekday,
|
||||
required this.dayNum,
|
||||
required this.time,
|
||||
required this.highlightToday,
|
||||
required this.isPast,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
static const Color kGreen = Color(0xFF10B981);
|
||||
static const Color kDarkGreen = Color(0xFF065F46);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isFull = cls.isFull;
|
||||
final nearFull =
|
||||
!isFull &&
|
||||
cls.maxCapacity > 0 &&
|
||||
cls.bookedCount >= cls.maxCapacity - 1;
|
||||
|
||||
final fillColor = isFull
|
||||
? const Color(0xFFC0392B)
|
||||
: nearFull
|
||||
? const Color(0xFFE67E22)
|
||||
: const Color(0xFF1A7F52);
|
||||
|
||||
final card = Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: highlightToday
|
||||
? Border.all(color: kGreen, width: 2)
|
||||
: Border.all(color: const Color(0x11000000)),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
blurRadius: 14,
|
||||
color: Color(0x10000000),
|
||||
offset: Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// pannello data
|
||||
Container(
|
||||
width: 74,
|
||||
color: highlightToday ? kGreen : const Color(0xFF0F766E),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 14,
|
||||
horizontal: 6,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
dayNum,
|
||||
style: const TextStyle(
|
||||
fontSize: 26,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
weekday.length > 3 ? weekday.substring(0, 3) : weekday,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// corpo
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 10, 12),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
cls.className,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (highlightToday)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(left: 6),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: kGreen,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Text(
|
||||
'OGGI',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.schedule,
|
||||
size: 14,
|
||||
color: Colors.black45,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
time,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF404040),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Icon(Icons.group, size: 14, color: fillColor),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${cls.bookedCount}/${cls.maxCapacity}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: fillColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// chevron
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 10),
|
||||
child: Icon(Icons.chevron_right, color: Colors.black26),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return Opacity(
|
||||
opacity: isPast && !highlightToday ? 0.55 : 1.0,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: onTap,
|
||||
child: card,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ParticipantRow extends StatelessWidget {
|
||||
final TeacherParticipant p;
|
||||
const _ParticipantRow({required this.p});
|
||||
|
||||
// colore avatar deterministico dal nome (come adminpanel)
|
||||
Color _avatarColor(String seed) {
|
||||
const palette = [
|
||||
Color(0xFF1EBF73),
|
||||
Color(0xFF2980B9),
|
||||
Color(0xFF8E44AD),
|
||||
Color(0xFFE67E22),
|
||||
Color(0xFF16A085),
|
||||
Color(0xFFC0392B),
|
||||
Color(0xFF2C3E50),
|
||||
Color(0xFFD35400),
|
||||
];
|
||||
int h = 0;
|
||||
for (final c in seed.codeUnits) {
|
||||
h = (h + c) % palette.length;
|
||||
}
|
||||
return palette[h];
|
||||
}
|
||||
|
||||
String get _initials {
|
||||
final parts = p.fullName.trim().split(RegExp(r'\s+'));
|
||||
if (parts.isEmpty || parts.first.isEmpty) return '?';
|
||||
final a = parts.first[0];
|
||||
final b = parts.length > 1 ? parts.last[0] : '';
|
||||
return (a + b).toUpperCase();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final lost = p.isLost;
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: lost ? const Color(0xFFFBF1F0) : const Color(0xFFF8FAFC),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: lost ? const Color(0xFFF3D6D2) : const Color(0xFFEEF0F3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: lost ? const Color(0xFF7F8C8D) : _avatarColor(p.fullName),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
_initials,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
p.fullName,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 14),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: lost ? const Color(0xFF1F2937) : const Color(0xFFE3F2EC),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
lost ? 'Persa' : 'Prenotata',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: lost ? Colors.white : const Color(0xFF1A7F52),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MonthPill extends StatelessWidget {
|
||||
final String label;
|
||||
final VoidCallback onPrev;
|
||||
final VoidCallback onNext;
|
||||
|
||||
const _MonthPill({
|
||||
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,
|
||||
color: 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,
|
||||
color: green,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
const _EmptyState();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(18),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.event_busy, size: 64, color: Colors.black26),
|
||||
SizedBox(height: 14),
|
||||
Text(
|
||||
'Nessuna classe questo mese',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../services/teacher_api.dart';
|
||||
import '../widgets/yogibook_background.dart';
|
||||
|
||||
/// Scelta della classe destinazione per riprogrammare una prenotazione.
|
||||
/// Ritorna `true` (via Navigator.pop) se la riprogrammazione è avvenuta.
|
||||
class TeacherReprogramPage extends StatefulWidget {
|
||||
final String token;
|
||||
final int bookingId;
|
||||
final String participantName;
|
||||
|
||||
const TeacherReprogramPage({
|
||||
super.key,
|
||||
required this.token,
|
||||
required this.bookingId,
|
||||
required this.participantName,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TeacherReprogramPage> createState() => _TeacherReprogramPageState();
|
||||
}
|
||||
|
||||
class _TeacherReprogramPageState extends State<TeacherReprogramPage> {
|
||||
static const Color kGreen = Color(0xFF10B981);
|
||||
|
||||
bool _loading = true;
|
||||
String _error = '';
|
||||
List<TeacherClass> _classes = [];
|
||||
|
||||
int? _selectedScheduleId;
|
||||
bool _countAsReprogram = true; // default: conta come riprogrammazione
|
||||
bool _submitting = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = '';
|
||||
});
|
||||
try {
|
||||
final data = await TeacherApi.availableClasses(
|
||||
token: widget.token,
|
||||
bookingId: widget.bookingId,
|
||||
);
|
||||
setState(() => _classes = data);
|
||||
} catch (e) {
|
||||
setState(() => _error = 'Errore: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
String _fmt(String iso) {
|
||||
final dt = DateTime.tryParse(iso);
|
||||
if (dt == null) return iso;
|
||||
final s = DateFormat("EEE d MMM yyyy, HH:mm", 'it_IT').format(dt);
|
||||
return s[0].toUpperCase() + s.substring(1);
|
||||
}
|
||||
|
||||
void _snack(String msg) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||
}
|
||||
|
||||
Future<void> _confirm() async {
|
||||
if (_selectedScheduleId == null) {
|
||||
_snack('Seleziona una classe di destinazione.');
|
||||
return;
|
||||
}
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
await TeacherApi.reprogram(
|
||||
token: widget.token,
|
||||
bookingId: widget.bookingId,
|
||||
scheduleId: _selectedScheduleId!,
|
||||
countAsReprogram: _countAsReprogram,
|
||||
);
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context, true); // segnala successo al chiamante
|
||||
} catch (e) {
|
||||
_snack('$e');
|
||||
setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
title: const Text(
|
||||
'Riprogramma',
|
||||
style: TextStyle(fontWeight: FontWeight.w800, fontSize: 18),
|
||||
),
|
||||
),
|
||||
body: YogibookBackground(
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error.isNotEmpty
|
||||
? _errorBox()
|
||||
: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: _headerInfo(),
|
||||
),
|
||||
Expanded(
|
||||
child: _classes.isEmpty
|
||||
? _emptyBox()
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
|
||||
itemCount: _classes.length,
|
||||
itemBuilder: (_, i) => _classTile(_classes[i]),
|
||||
),
|
||||
),
|
||||
_bottomBar(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _headerInfo() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
blurRadius: 12,
|
||||
color: Color(0x0F000000),
|
||||
offset: Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.person, size: 20, color: Colors.black45),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Sposta la lezione di ${widget.participantName}',
|
||||
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _classTile(TeacherClass c) {
|
||||
final selected = _selectedScheduleId == c.scheduleId;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _selectedScheduleId = c.scheduleId),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: selected ? kGreen : const Color(0x14000000),
|
||||
width: selected ? 2 : 1,
|
||||
),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
blurRadius: 10,
|
||||
color: Color(0x0A000000),
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
selected
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
color: selected ? kGreen : Colors.black26,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
c.className,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 15,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_fmt(c.dateTime),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF606060),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${c.bookedCount}/${c.maxCapacity}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Color(0xFF1A7F52),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _bottomBar() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 16),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
blurRadius: 16,
|
||||
color: Color(0x14000000),
|
||||
offset: Offset(0, -6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
activeColor: kGreen,
|
||||
value: _countAsReprogram,
|
||||
onChanged: _submitting
|
||||
? null
|
||||
: (v) => setState(() => _countAsReprogram = v),
|
||||
title: const Text(
|
||||
'Conta come riprogrammazione',
|
||||
style: TextStyle(fontWeight: FontWeight.w700, fontSize: 14),
|
||||
),
|
||||
subtitle: const Text(
|
||||
'Se attivo, incrementa il contatore riprogrammazioni dell\'ordine.',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: kGreen,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
onPressed: _submitting ? null : _confirm,
|
||||
child: _submitting
|
||||
? const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'Conferma riprogrammazione',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _emptyBox() {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.event_busy, size: 56, color: Colors.black26),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'Nessuna classe disponibile',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
Text(
|
||||
'Non ci sono classi future con posti liberi\nper questo allievo.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.black45),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _errorBox() {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 56, color: Colors.redAccent),
|
||||
const SizedBox(height: 10),
|
||||
Text(_error, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(onPressed: _load, child: const Text('Riprova')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
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),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:local_auth/local_auth.dart';
|
||||
import 'package:local_auth/error_codes.dart' as auth_error;
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Esito dello sblocco all'avvio.
|
||||
enum UnlockResult {
|
||||
/// Nessun login salvato: mostra la schermata di login normale.
|
||||
noSavedLogin,
|
||||
|
||||
/// Token disponibile e pronto all'uso: entra diretto.
|
||||
success,
|
||||
|
||||
/// C'è un login salvato ma la biometria è fallita/annullata:
|
||||
/// resta sul login, l'utente può accedere con password.
|
||||
biometricFailed,
|
||||
|
||||
/// Biometria richiesta ma non disponibile (niente impronte / hardware):
|
||||
/// fallback a login con password (scelta B).
|
||||
biometricUnavailable,
|
||||
}
|
||||
|
||||
/// Custode unico di token + preferenze di accesso (ricordami + biometria).
|
||||
///
|
||||
/// Non è un provider: lo si interroga in momenti precisi
|
||||
/// (avvio nello splash, login, logout). Vedi nota architetturale nel piano.
|
||||
class AuthStorage {
|
||||
AuthStorage._();
|
||||
static final AuthStorage instance = AuthStorage._();
|
||||
|
||||
// Su Android forza l'uso di EncryptedSharedPreferences (più robusto).
|
||||
static const _secure = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
);
|
||||
|
||||
final LocalAuthentication _localAuth = LocalAuthentication();
|
||||
|
||||
// Chiavi interne della cassaforte.
|
||||
static const _kToken = 'auth_token';
|
||||
static const _kRemember = 'pref_remember_me';
|
||||
static const _kBiometric = 'pref_biometric';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PREFERENZE
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// "Ricordami" attivo? (default: false)
|
||||
Future<bool> isRememberMeEnabled() async {
|
||||
return (await _secure.read(key: _kRemember)) == 'true';
|
||||
}
|
||||
|
||||
/// Biometria attiva? (default: false)
|
||||
Future<bool> isBiometricEnabled() async {
|
||||
return (await _secure.read(key: _kBiometric)) == 'true';
|
||||
}
|
||||
|
||||
/// Imposta "ricordami". Se lo spegni, cancella anche il token salvato:
|
||||
/// senza ricordami non ha senso tenere un token in cassaforte.
|
||||
Future<void> setRememberMe(bool value) async {
|
||||
await _secure.write(key: _kRemember, value: value ? 'true' : 'false');
|
||||
if (!value) {
|
||||
await _secure.delete(key: _kToken);
|
||||
// Biometria senza token salvato non ha nulla da proteggere: spegnila.
|
||||
await _secure.write(key: _kBiometric, value: 'false');
|
||||
}
|
||||
}
|
||||
|
||||
/// Imposta la biometria. Ha senso solo se "ricordami" è attivo.
|
||||
Future<void> setBiometric(bool value) async {
|
||||
await _secure.write(key: _kBiometric, value: value ? 'true' : 'false');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TOKEN
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Salva il token SOLO se "ricordami" è attivo. Chiamato dopo il login.
|
||||
Future<void> saveTokenIfRemember(String token) async {
|
||||
if (await isRememberMeEnabled()) {
|
||||
await _secure.write(key: _kToken, value: token);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancella tutto: token + preferenze. Chiamato al logout.
|
||||
Future<void> clear() async {
|
||||
await _secure.delete(key: _kToken);
|
||||
await _secure.delete(key: _kRemember);
|
||||
await _secure.delete(key: _kBiometric);
|
||||
}
|
||||
|
||||
/// Lettura grezza del token (senza biometria). Uso interno.
|
||||
Future<String?> _readToken() => _secure.read(key: _kToken);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BIOMETRIA
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Il dispositivo può fare biometria E ha almeno un metodo registrato?
|
||||
Future<bool> canUseBiometrics() async {
|
||||
try {
|
||||
final supported = await _localAuth.isDeviceSupported();
|
||||
if (!supported) return false;
|
||||
final canCheck = await _localAuth.canCheckBiometrics;
|
||||
if (!canCheck) return false;
|
||||
final enrolled = await _localAuth.getAvailableBiometrics();
|
||||
return enrolled.isNotEmpty;
|
||||
} on PlatformException {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Chiede l'impronta/volto. true = passata, false = fallita/annullata.
|
||||
Future<bool> _promptBiometric() async {
|
||||
try {
|
||||
return await _localAuth.authenticate(
|
||||
localizedReason: 'Sblocca YogiBook per accedere al tuo account',
|
||||
options: const AuthenticationOptions(
|
||||
biometricOnly: true, // niente fallback al PIN del telefono (scelta B)
|
||||
stickyAuth: true, // regge se l'app va in background durante il prompt
|
||||
),
|
||||
);
|
||||
} on PlatformException catch (e) {
|
||||
// Casi tipici: nessuna impronta registrata, hardware assente, troppi tentativi.
|
||||
if (e.code == auth_error.notAvailable ||
|
||||
e.code == auth_error.notEnrolled ||
|
||||
e.code == auth_error.lockedOut ||
|
||||
e.code == auth_error.permanentlyLockedOut) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SBLOCCO ALL'AVVIO (chiamato dallo splash)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Decide cosa fare all'avvio dell'app.
|
||||
/// Restituisce l'esito; il token (se disponibile) è in [tokenOut].
|
||||
Future<({UnlockResult result, String? token})> tryUnlockAtStartup() async {
|
||||
// 1. Ricordami spento o nessun token → login normale.
|
||||
if (!await isRememberMeEnabled()) {
|
||||
return (result: UnlockResult.noSavedLogin, token: null);
|
||||
}
|
||||
final token = await _readToken();
|
||||
if (token == null || token.isEmpty) {
|
||||
return (result: UnlockResult.noSavedLogin, token: null);
|
||||
}
|
||||
|
||||
// 2. Biometria spenta → entra diretto col token salvato.
|
||||
if (!await isBiometricEnabled()) {
|
||||
return (result: UnlockResult.success, token: token);
|
||||
}
|
||||
|
||||
// 3. Biometria accesa ma non disponibile → fallback a password (scelta B).
|
||||
if (!await canUseBiometrics()) {
|
||||
return (result: UnlockResult.biometricUnavailable, token: null);
|
||||
}
|
||||
|
||||
// 4. Biometria accesa e disponibile → chiedi l'impronta.
|
||||
final ok = await _promptBiometric();
|
||||
if (ok) {
|
||||
return (result: UnlockResult.success, token: token);
|
||||
}
|
||||
return (result: UnlockResult.biometricFailed, token: null);
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,9 @@ import 'package:mime/mime.dart';
|
||||
import '../config/api_config.dart';
|
||||
|
||||
class MedicalCertificatesApi {
|
||||
static Uri _u(String path, [Map<String, String>? q]) =>
|
||||
Uri.parse('${ApiConfig.phpApiBase}/$path').replace(queryParameters: q);
|
||||
static Uri _u(String path, [Map<String, String>? q]) => Uri.parse(
|
||||
'${ApiConfig.laravelApiBase}/$path',
|
||||
).replace(queryParameters: q);
|
||||
|
||||
static Future<List<Map<String, dynamic>>> list({
|
||||
required String token,
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../config/api_config.dart';
|
||||
|
||||
class PushApi {
|
||||
static Map<String, String> _headers(String token) => {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
};
|
||||
|
||||
static Future<String> registerDevice({
|
||||
required String token,
|
||||
required String deviceToken,
|
||||
required String platform,
|
||||
String? appVersion,
|
||||
}) async {
|
||||
final uri = Uri.parse('${ApiConfig.laravelApiBase}/register_device.php');
|
||||
|
||||
final res = await http.post(
|
||||
uri,
|
||||
headers: _headers(token),
|
||||
body: jsonEncode({
|
||||
'device_token': deviceToken,
|
||||
'platform': platform,
|
||||
if (appVersion != null) 'app_version': appVersion,
|
||||
}),
|
||||
);
|
||||
|
||||
final data = _decode(res.body, res.statusCode);
|
||||
|
||||
return (data['push_enabled'] ?? 'Y').toString();
|
||||
}
|
||||
|
||||
static Future<void> unregisterDevice({
|
||||
required String token,
|
||||
required String deviceToken,
|
||||
}) async {
|
||||
final uri = Uri.parse('${ApiConfig.laravelApiBase}/register_device.php');
|
||||
|
||||
final res = await http.delete(
|
||||
uri,
|
||||
headers: _headers(token),
|
||||
body: jsonEncode({'device_token': deviceToken}),
|
||||
);
|
||||
|
||||
_decode(res.body, res.statusCode);
|
||||
}
|
||||
|
||||
static Future<bool> fetchPreference({required String token}) async {
|
||||
final uri = Uri.parse('${ApiConfig.laravelApiBase}/push_preference.php');
|
||||
|
||||
final res = await http.get(uri, headers: _headers(token));
|
||||
final data = _decode(res.body, res.statusCode);
|
||||
|
||||
return (data['push_enabled'] ?? 'Y').toString().toUpperCase() == 'Y';
|
||||
}
|
||||
|
||||
static Future<bool> updatePreference({
|
||||
required String token,
|
||||
required bool enabled,
|
||||
}) async {
|
||||
final uri = Uri.parse('${ApiConfig.laravelApiBase}/push_preference.php');
|
||||
|
||||
final res = await http.post(
|
||||
uri,
|
||||
headers: _headers(token),
|
||||
body: jsonEncode({'enabled': enabled}),
|
||||
);
|
||||
|
||||
final data = _decode(res.body, res.statusCode);
|
||||
|
||||
return (data['push_enabled'] ?? 'Y').toString().toUpperCase() == 'Y';
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _decode(String body, int statusCode) {
|
||||
Map<String, dynamic> data;
|
||||
|
||||
try {
|
||||
data = jsonDecode(body) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
throw Exception('Risposta non valida ($statusCode): $body');
|
||||
}
|
||||
|
||||
if (statusCode != 200 || data['success'] != true) {
|
||||
throw Exception(data['error'] ?? data['message'] ?? 'Errore notifiche');
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
import 'push_api.dart';
|
||||
|
||||
class PushService {
|
||||
static String? _deviceToken;
|
||||
|
||||
static bool _firebaseReady = false;
|
||||
|
||||
static final FlutterLocalNotificationsPlugin _locali =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
|
||||
static const AndroidNotificationChannel _canale = AndroidNotificationChannel(
|
||||
'yogibook_notifiche',
|
||||
'Notifiche YogiBook',
|
||||
description: 'Promemoria delle lezioni, scadenze abbonamento e certificato',
|
||||
importance: Importance.high,
|
||||
);
|
||||
|
||||
static Future<void> initFirebase() async {
|
||||
if (_firebaseReady) return;
|
||||
|
||||
try {
|
||||
await Firebase.initializeApp();
|
||||
FirebaseMessaging.onBackgroundMessage(_backgroundHandler);
|
||||
await _preparaNotificheLocali();
|
||||
_firebaseReady = true;
|
||||
} catch (e) {
|
||||
debugPrint('Firebase non inizializzato: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _preparaNotificheLocali() async {
|
||||
const impostazioni = InitializationSettings(
|
||||
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
|
||||
iOS: DarwinInitializationSettings(
|
||||
requestAlertPermission: false,
|
||||
requestBadgePermission: false,
|
||||
requestSoundPermission: false,
|
||||
),
|
||||
);
|
||||
|
||||
await _locali.initialize(settings: impostazioni);
|
||||
|
||||
await _locali
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.createNotificationChannel(_canale);
|
||||
|
||||
FirebaseMessaging.onMessage.listen(_mostraInPrimoPiano);
|
||||
}
|
||||
|
||||
static Future<void> _mostraInPrimoPiano(RemoteMessage messaggio) async {
|
||||
if (kIsWeb || !Platform.isAndroid) return;
|
||||
|
||||
final notifica = messaggio.notification;
|
||||
if (notifica == null) return;
|
||||
|
||||
await _locali.show(
|
||||
id: notifica.hashCode,
|
||||
title: notifica.title,
|
||||
body: notifica.body,
|
||||
notificationDetails: NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
_canale.id,
|
||||
_canale.name,
|
||||
channelDescription: _canale.description,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> registerAfterLogin(String authToken) async {
|
||||
if (!_firebaseReady) return;
|
||||
|
||||
try {
|
||||
final messaging = FirebaseMessaging.instance;
|
||||
|
||||
final settings = await messaging.requestPermission();
|
||||
|
||||
if (settings.authorizationStatus == AuthorizationStatus.denied) {
|
||||
debugPrint('Permesso notifiche negato dall\'utente.');
|
||||
return;
|
||||
}
|
||||
|
||||
final deviceToken = await messaging.getToken();
|
||||
if (deviceToken == null) return;
|
||||
|
||||
_deviceToken = deviceToken;
|
||||
|
||||
await PushApi.registerDevice(
|
||||
token: authToken,
|
||||
deviceToken: deviceToken,
|
||||
platform: _platformName(),
|
||||
);
|
||||
|
||||
messaging.onTokenRefresh.listen((nuovo) async {
|
||||
_deviceToken = nuovo;
|
||||
try {
|
||||
await PushApi.registerDevice(
|
||||
token: authToken,
|
||||
deviceToken: nuovo,
|
||||
platform: _platformName(),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Aggiornamento token fallito: $e');
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Registrazione push fallita: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> unregisterOnLogout(String authToken) async {
|
||||
final deviceToken = _deviceToken;
|
||||
if (deviceToken == null) return;
|
||||
|
||||
try {
|
||||
await PushApi.unregisterDevice(
|
||||
token: authToken,
|
||||
deviceToken: deviceToken,
|
||||
);
|
||||
_deviceToken = null;
|
||||
} catch (e) {
|
||||
debugPrint('Disattivazione dispositivo fallita: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> enableForegroundNotifications() async {
|
||||
if (!_firebaseReady) return;
|
||||
|
||||
try {
|
||||
await FirebaseMessaging.instance
|
||||
.setForegroundNotificationPresentationOptions(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Impostazione notifiche in foreground fallita: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static String _platformName() {
|
||||
if (kIsWeb) return 'web';
|
||||
return Platform.isIOS ? 'ios' : 'android';
|
||||
}
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> _backgroundHandler(RemoteMessage message) async {
|
||||
debugPrint('Notifica in background: ${message.messageId}');
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../config/api_config.dart';
|
||||
|
||||
/// Ruolo dell'utente autenticato, letto da api_me_role.php.
|
||||
/// Serve a decidere se mostrare le funzioni insegnante (staff = Admin/teacher).
|
||||
class UserRole {
|
||||
final int roleId;
|
||||
final String roleName;
|
||||
final bool isStaff;
|
||||
|
||||
const UserRole({
|
||||
required this.roleId,
|
||||
required this.roleName,
|
||||
required this.isStaff,
|
||||
});
|
||||
|
||||
factory UserRole.fromMap(Map<String, dynamic> m) => UserRole(
|
||||
roleId: (m['role_id'] as num?)?.toInt() ?? 0,
|
||||
roleName: (m['role_name'] ?? '').toString(),
|
||||
isStaff: m['is_staff'] == true,
|
||||
);
|
||||
|
||||
/// Fallback sicuro: utente normale, nessun accesso staff.
|
||||
static const UserRole guest = UserRole(
|
||||
roleId: 0,
|
||||
roleName: '',
|
||||
isStaff: false,
|
||||
);
|
||||
}
|
||||
|
||||
class RoleService {
|
||||
static Uri _u(String path, [Map<String, String>? q]) => Uri.parse(
|
||||
'${ApiConfig.laravelApiBase}/$path',
|
||||
).replace(queryParameters: q);
|
||||
|
||||
static Future<UserRole> fetchMyRole({required String token}) async {
|
||||
final res = await http.get(
|
||||
_u('api_me_role.php'),
|
||||
headers: {'Accept': 'application/json', 'Authorization': 'Bearer $token'},
|
||||
);
|
||||
|
||||
if (res.statusCode != 200) {
|
||||
throw Exception('Role failed (${res.statusCode}): ${res.body}');
|
||||
}
|
||||
|
||||
final data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
if (data['success'] != true) {
|
||||
throw Exception(data['message'] ?? 'Role error');
|
||||
}
|
||||
|
||||
return UserRole.fromMap(data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../config/api_config.dart';
|
||||
|
||||
/// Un partecipante a una classe (vista insegnante).
|
||||
class TeacherParticipant {
|
||||
final int bookingId;
|
||||
final String fullName;
|
||||
final bool isLost;
|
||||
|
||||
const TeacherParticipant({
|
||||
required this.bookingId,
|
||||
required this.fullName,
|
||||
required this.isLost,
|
||||
});
|
||||
|
||||
factory TeacherParticipant.fromMap(Map<String, dynamic> m) =>
|
||||
TeacherParticipant(
|
||||
bookingId: (m['booking_id'] as num?)?.toInt() ?? 0,
|
||||
fullName: (m['full_name'] ?? '').toString().trim(),
|
||||
isLost: m['is_lost'] == true,
|
||||
);
|
||||
}
|
||||
|
||||
/// Una classe programmata con i suoi partecipanti.
|
||||
class TeacherClass {
|
||||
final int scheduleId;
|
||||
final int serviceId;
|
||||
final String className;
|
||||
final String? colorClass;
|
||||
final String dateTime; // "YYYY-MM-DD HH:MM:SS"
|
||||
final int maxCapacity;
|
||||
final int bookedCount;
|
||||
final bool isFull;
|
||||
final List<TeacherParticipant> participants;
|
||||
|
||||
const TeacherClass({
|
||||
required this.scheduleId,
|
||||
required this.serviceId,
|
||||
required this.className,
|
||||
required this.colorClass,
|
||||
required this.dateTime,
|
||||
required this.maxCapacity,
|
||||
required this.bookedCount,
|
||||
required this.isFull,
|
||||
required this.participants,
|
||||
});
|
||||
|
||||
factory TeacherClass.fromMap(Map<String, dynamic> m) => TeacherClass(
|
||||
scheduleId: (m['schedule_id'] as num?)?.toInt() ?? 0,
|
||||
serviceId: (m['service_id'] as num?)?.toInt() ?? 0,
|
||||
className: (m['class_name'] ?? '').toString(),
|
||||
colorClass: (m['color_class'])?.toString(),
|
||||
dateTime: (m['date_time'] ?? '').toString(),
|
||||
maxCapacity: (m['max_capacity'] as num?)?.toInt() ?? 0,
|
||||
bookedCount: (m['booked_count'] as num?)?.toInt() ?? 0,
|
||||
isFull: m['is_full'] == true,
|
||||
participants: ((m['participants'] as List?) ?? [])
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(TeacherParticipant.fromMap)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Utente trovato nella ricerca (per aggiungi partecipante).
|
||||
class TeacherUser {
|
||||
final int id;
|
||||
final String firstName;
|
||||
final String lastName;
|
||||
final String email;
|
||||
|
||||
const TeacherUser({
|
||||
required this.id,
|
||||
required this.firstName,
|
||||
required this.lastName,
|
||||
required this.email,
|
||||
});
|
||||
|
||||
String get fullName => '$firstName $lastName'.trim();
|
||||
|
||||
factory TeacherUser.fromMap(Map<String, dynamic> m) => TeacherUser(
|
||||
id: (m['id'] as num?)?.toInt() ?? 0,
|
||||
firstName: (m['first_name'] ?? '').toString(),
|
||||
lastName: (m['last_name'] ?? '').toString(),
|
||||
email: (m['email'] ?? '').toString(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Ordine/pacchetto di un utente con residui.
|
||||
class TeacherOrder {
|
||||
final int idOrderBook;
|
||||
final String serviceName;
|
||||
final int tickets;
|
||||
final int used;
|
||||
final int remaining;
|
||||
final String? expireOn;
|
||||
|
||||
const TeacherOrder({
|
||||
required this.idOrderBook,
|
||||
required this.serviceName,
|
||||
required this.tickets,
|
||||
required this.used,
|
||||
required this.remaining,
|
||||
required this.expireOn,
|
||||
});
|
||||
|
||||
factory TeacherOrder.fromMap(Map<String, dynamic> m) => TeacherOrder(
|
||||
idOrderBook: (m['idorderbook'] as num?)?.toInt() ?? 0,
|
||||
serviceName: (m['service_name'] ?? '').toString(),
|
||||
tickets: (m['tickets'] as num?)?.toInt() ?? 0,
|
||||
used: (m['used'] as num?)?.toInt() ?? 0,
|
||||
remaining: (m['remaining'] as num?)?.toInt() ?? 0,
|
||||
expireOn: (m['expireon'])?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
class TeacherApi {
|
||||
static Map<String, String> _headers(String token) => {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
};
|
||||
|
||||
/// Classi di un mese (YYYY-MM) con partecipanti. Solo staff lato server.
|
||||
static Future<List<TeacherClass>> fetchClasses({
|
||||
required String token,
|
||||
required String month,
|
||||
}) async {
|
||||
final query = month.isNotEmpty ? '?month=$month' : '';
|
||||
final uri = Uri.parse(
|
||||
'${ApiConfig.laravelApiBase}/api_teacher_classes.php$query',
|
||||
);
|
||||
|
||||
final res = await http.get(uri, headers: _headers(token));
|
||||
|
||||
Map<String, dynamic> data;
|
||||
try {
|
||||
data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
throw Exception('RAW(${res.statusCode}): ${res.body}');
|
||||
}
|
||||
|
||||
if (res.statusCode != 200 || data['success'] != true) {
|
||||
throw Exception(data['error'] ?? data['message'] ?? 'Errore classi');
|
||||
}
|
||||
|
||||
return ((data['classes'] as List?) ?? [])
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(TeacherClass.fromMap)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Segna una prenotazione come persa (lost=true) o la ripristina (false).
|
||||
static Future<void> setLost({
|
||||
required String token,
|
||||
required int bookingId,
|
||||
required bool lost,
|
||||
}) async {
|
||||
final uri = Uri.parse(
|
||||
'${ApiConfig.laravelApiBase}/api_teacher_mark_lost.php',
|
||||
);
|
||||
|
||||
final res = await http.post(
|
||||
uri,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
body: 'booking_id=$bookingId&lost=${lost ? 'Y' : 'N'}',
|
||||
);
|
||||
|
||||
Map<String, dynamic> data;
|
||||
try {
|
||||
data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
throw Exception('RAW(${res.statusCode}): ${res.body}');
|
||||
}
|
||||
|
||||
if (res.statusCode != 200 || data['success'] != true) {
|
||||
throw Exception(
|
||||
data['error'] ?? data['message'] ?? 'Errore aggiornamento',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Elimina una prenotazione (libera il ticket all'allievo).
|
||||
static Future<void> deleteBooking({
|
||||
required String token,
|
||||
required int bookingId,
|
||||
}) async {
|
||||
final uri = Uri.parse(
|
||||
'${ApiConfig.laravelApiBase}/api_teacher_delete_booking.php',
|
||||
);
|
||||
|
||||
final res = await http.post(
|
||||
uri,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
body: 'booking_id=$bookingId',
|
||||
);
|
||||
|
||||
Map<String, dynamic> data;
|
||||
try {
|
||||
data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
throw Exception('RAW(${res.statusCode}): ${res.body}');
|
||||
}
|
||||
|
||||
if (res.statusCode != 200 || data['success'] != true) {
|
||||
throw Exception(
|
||||
data['error'] ?? data['message'] ?? 'Errore eliminazione',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Classi destinazione per riprogrammare una prenotazione
|
||||
/// (future, non piene, escluse quelle dove è già iscritto).
|
||||
static Future<List<TeacherClass>> availableClasses({
|
||||
required String token,
|
||||
required int bookingId,
|
||||
}) async {
|
||||
final uri = Uri.parse(
|
||||
'${ApiConfig.laravelApiBase}/api_teacher_available_classes.php?booking_id=$bookingId',
|
||||
);
|
||||
|
||||
final res = await http.get(
|
||||
uri,
|
||||
headers: {'Accept': 'application/json', 'Authorization': 'Bearer $token'},
|
||||
);
|
||||
|
||||
Map<String, dynamic> data;
|
||||
try {
|
||||
data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
throw Exception('RAW(${res.statusCode}): ${res.body}');
|
||||
}
|
||||
|
||||
if (res.statusCode != 200 || data['success'] != true) {
|
||||
throw Exception(data['error'] ?? data['message'] ?? 'Errore classi');
|
||||
}
|
||||
|
||||
return ((data['classes'] as List?) ?? [])
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(TeacherClass.fromMap)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Esegue la riprogrammazione su una nuova classe.
|
||||
static Future<void> reprogram({
|
||||
required String token,
|
||||
required int bookingId,
|
||||
required int scheduleId,
|
||||
required bool countAsReprogram,
|
||||
}) async {
|
||||
final uri = Uri.parse(
|
||||
'${ApiConfig.laravelApiBase}/api_teacher_reprogram.php',
|
||||
);
|
||||
|
||||
final res = await http.post(
|
||||
uri,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
body:
|
||||
'booking_id=$bookingId&schedule_id=$scheduleId'
|
||||
'&is_reprogrammed=${countAsReprogram ? 'Y' : 'N'}',
|
||||
);
|
||||
|
||||
Map<String, dynamic> data;
|
||||
try {
|
||||
data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
throw Exception('RAW(${res.statusCode}): ${res.body}');
|
||||
}
|
||||
|
||||
if (res.statusCode != 200 || data['success'] != true) {
|
||||
throw Exception(
|
||||
data['error'] ?? data['message'] ?? 'Errore riprogrammazione',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ricerca utenti per nome/cognome (min 2 caratteri in uno dei due).
|
||||
static Future<List<TeacherUser>> searchUsers({
|
||||
required String token,
|
||||
required String first,
|
||||
required String last,
|
||||
}) async {
|
||||
final uri = Uri.parse(
|
||||
'${ApiConfig.laravelApiBase}/api_teacher_search_users.php'
|
||||
'?first=${Uri.encodeQueryComponent(first)}'
|
||||
'&last=${Uri.encodeQueryComponent(last)}',
|
||||
);
|
||||
|
||||
final res = await http.get(uri, headers: _headers(token));
|
||||
|
||||
Map<String, dynamic> data;
|
||||
try {
|
||||
data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
throw Exception('RAW(${res.statusCode}): ${res.body}');
|
||||
}
|
||||
|
||||
if (res.statusCode != 200 || data['success'] != true) {
|
||||
throw Exception(data['error'] ?? data['message'] ?? 'Errore ricerca');
|
||||
}
|
||||
|
||||
return ((data['results'] as List?) ?? [])
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(TeacherUser.fromMap)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Ordini con residui di un utente.
|
||||
static Future<List<TeacherOrder>> userOrders({
|
||||
required String token,
|
||||
required int userId,
|
||||
}) async {
|
||||
final uri = Uri.parse(
|
||||
'${ApiConfig.laravelApiBase}/api_teacher_user_orders.php?user_id=$userId',
|
||||
);
|
||||
|
||||
final res = await http.get(uri, headers: _headers(token));
|
||||
|
||||
Map<String, dynamic> data;
|
||||
try {
|
||||
data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
throw Exception('RAW(${res.statusCode}): ${res.body}');
|
||||
}
|
||||
|
||||
if (res.statusCode != 200 || data['success'] != true) {
|
||||
throw Exception(data['error'] ?? data['message'] ?? 'Errore ordini');
|
||||
}
|
||||
|
||||
return ((data['orders'] as List?) ?? [])
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(TeacherOrder.fromMap)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Aggiunge un partecipante a una classe.
|
||||
/// [mode] = 'scala' | 'omaggio' | 'nuovo'.
|
||||
/// Per utente esistente passare [userId] > 0; per nuovo utente passare
|
||||
/// [userId] = 0 con name/surname (email opzionale).
|
||||
static Future<void> addBooking({
|
||||
required String token,
|
||||
required int scheduleId,
|
||||
required int serviceId,
|
||||
required String bookingStart,
|
||||
required String mode,
|
||||
int userId = 0,
|
||||
String name = '',
|
||||
String surname = '',
|
||||
String email = '',
|
||||
int? orderId,
|
||||
String? newExpiry,
|
||||
}) async {
|
||||
final uri = Uri.parse(
|
||||
'${ApiConfig.laravelApiBase}/api_teacher_add_booking.php',
|
||||
);
|
||||
|
||||
final body = <String, String>{
|
||||
'idserviceschedule': '$scheduleId',
|
||||
'idservice': '$serviceId',
|
||||
'bookingstart': bookingStart,
|
||||
'bkmode': mode,
|
||||
'user_id': '$userId',
|
||||
'name': name,
|
||||
'surname': surname,
|
||||
'email': email,
|
||||
if (orderId != null) 'idorder': '$orderId',
|
||||
if (newExpiry != null) 'new_expiry': newExpiry,
|
||||
};
|
||||
|
||||
final res = await http.post(
|
||||
uri,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
body: body.entries
|
||||
.map(
|
||||
(e) =>
|
||||
'${Uri.encodeQueryComponent(e.key)}='
|
||||
'${Uri.encodeQueryComponent(e.value)}',
|
||||
)
|
||||
.join('&'),
|
||||
);
|
||||
|
||||
Map<String, dynamic> data;
|
||||
try {
|
||||
data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
throw Exception('RAW(${res.statusCode}): ${res.body}');
|
||||
}
|
||||
|
||||
if (res.statusCode != 200 || data['success'] != true) {
|
||||
throw Exception(data['error'] ?? data['message'] ?? 'Errore inserimento');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,79 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/school.dart';
|
||||
import '../screens/home_page.dart';
|
||||
import '../screens/lessons_page.dart';
|
||||
import '../screens/account_page.dart';
|
||||
import '../screens/meditation_page.dart';
|
||||
|
||||
/// Bottom navigation centralizzata.
|
||||
/// Gestisce da sé la navigazione tra le 4 tab principali.
|
||||
/// Ogni pagina passa solo il proprio [currentIndex] e i dati utente.
|
||||
///
|
||||
/// [onBeforeLeave] è opzionale: le pagine che devono fare pulizia prima di
|
||||
/// uscire (es. Meditazione che ferma musica/timer) lo passano.
|
||||
class AppBottomNav extends StatelessWidget {
|
||||
final int currentIndex;
|
||||
final ValueChanged<int> onTap;
|
||||
final String token;
|
||||
final School school;
|
||||
final String? userFirstName;
|
||||
final Future<void> Function()? onBeforeLeave;
|
||||
|
||||
const AppBottomNav({
|
||||
super.key,
|
||||
required this.currentIndex,
|
||||
required this.onTap,
|
||||
required this.token,
|
||||
required this.school,
|
||||
this.userFirstName,
|
||||
this.onBeforeLeave,
|
||||
});
|
||||
|
||||
Future<void> _go(BuildContext context, int i) async {
|
||||
if (i == currentIndex) return;
|
||||
|
||||
// pulizia eventuale (musica/timer) prima di lasciare la pagina
|
||||
if (onBeforeLeave != null) {
|
||||
await onBeforeLeave!();
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
|
||||
Widget page;
|
||||
switch (i) {
|
||||
case 0:
|
||||
page = HomePage(
|
||||
token: token,
|
||||
school: school,
|
||||
userFirstName: userFirstName,
|
||||
);
|
||||
break;
|
||||
case 1:
|
||||
page = LessonsPage(
|
||||
token: token,
|
||||
school: school,
|
||||
userFirstName: userFirstName,
|
||||
);
|
||||
break;
|
||||
case 2:
|
||||
page = AccountPage(
|
||||
token: token,
|
||||
school: school,
|
||||
userFirstName: userFirstName,
|
||||
);
|
||||
break;
|
||||
case 3:
|
||||
page = MeditationPage(
|
||||
token: token,
|
||||
school: school,
|
||||
userFirstName: userFirstName,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => page));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BottomNavigationBar(
|
||||
@@ -18,7 +82,7 @@ class AppBottomNav extends StatelessWidget {
|
||||
selectedItemColor: const Color(0xFF10B981),
|
||||
unselectedItemColor: Colors.black54,
|
||||
currentIndex: currentIndex,
|
||||
onTap: onTap,
|
||||
onTap: (i) => _go(context, i),
|
||||
items: const [
|
||||
BottomNavigationBarItem(icon: Icon(Icons.home_rounded), label: 'Home'),
|
||||
BottomNavigationBarItem(
|
||||
|
||||
@@ -9,6 +9,10 @@ import '../screens/medical_certificates_page.dart';
|
||||
import '../screens/lessons_page.dart';
|
||||
import '../screens/orders_detail_page.dart';
|
||||
import '../screens/account_page.dart';
|
||||
import '../screens/settings_page.dart';
|
||||
import '../screens/teacher_panel_page.dart';
|
||||
import '../services/role_service.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class AppDrawer extends StatelessWidget {
|
||||
final String token;
|
||||
@@ -33,6 +37,16 @@ class AppDrawer extends StatelessWidget {
|
||||
return '${ApiConfig.scheme}://${ApiConfig.host}/public/userarea/$raw';
|
||||
}
|
||||
|
||||
Future<void> _openUrl(BuildContext context, String url) async {
|
||||
final uri = Uri.parse(url);
|
||||
final ok = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
if (!ok && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Impossibile aprire il link.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _logout(BuildContext context) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
@@ -83,6 +97,50 @@ class AppDrawer extends StatelessWidget {
|
||||
fallbackLetter: _avatarLetter,
|
||||
),
|
||||
|
||||
// Sezione insegnante: visibile solo a staff (Admin/teacher).
|
||||
FutureBuilder<UserRole>(
|
||||
future: RoleService.fetchMyRole(token: token),
|
||||
builder: (context, snap) {
|
||||
final isStaff = snap.data?.isStaff ?? false;
|
||||
if (!isStaff) return const SizedBox.shrink();
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 4),
|
||||
child: const Text(
|
||||
'INSEGNANTE',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Color(0xFF10B981),
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.calendar_month_rounded),
|
||||
title: const Text('Pannello classi'),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => TeacherPanelPage(
|
||||
token: token,
|
||||
school: school,
|
||||
userFirstName: userFirstName,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
ListTile(
|
||||
leading: const Icon(Icons.medical_information),
|
||||
title: const Text('Certificati medici'),
|
||||
@@ -127,7 +185,11 @@ class AppDrawer extends StatelessWidget {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => OrdersDetailPage(token: token),
|
||||
builder: (_) => OrdersDetailPage(
|
||||
token: token,
|
||||
school: school,
|
||||
userFirstName: userFirstName,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -151,6 +213,47 @@ class AppDrawer extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
|
||||
ListTile(
|
||||
leading: const Icon(Icons.settings_rounded),
|
||||
title: const Text('Impostazioni'),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => SettingsPage(
|
||||
token: token,
|
||||
school: school,
|
||||
userFirstName: userFirstName,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const Divider(height: 1),
|
||||
|
||||
ListTile(
|
||||
leading: const Icon(Icons.privacy_tip_outlined),
|
||||
title: const Text('Cookie e Privacy Policy'),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
_openUrl(context, 'https://yogasoul.it/cookie-policy/');
|
||||
},
|
||||
),
|
||||
|
||||
ListTile(
|
||||
leading: const Icon(Icons.description_outlined),
|
||||
title: const Text('Condizioni generali'),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
_openUrl(
|
||||
context,
|
||||
'https://yogasoul.it/condizioni-generali-di-vendita/',
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const Divider(height: 1),
|
||||
|
||||
ListTile(
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
#include <audioplayers_linux/audioplayers_linux_plugin.h>
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
@@ -17,6 +18,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
||||
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
audioplayers_linux
|
||||
file_selector_linux
|
||||
flutter_secure_storage_linux
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
|
||||
@@ -8,10 +8,9 @@ import Foundation
|
||||
import audioplayers_darwin
|
||||
import file_picker
|
||||
import file_selector_macos
|
||||
import firebase_core
|
||||
import firebase_messaging
|
||||
import flutter_local_notifications
|
||||
import flutter_secure_storage_macos
|
||||
import google_sign_in_ios
|
||||
import local_auth_darwin
|
||||
import path_provider_foundation
|
||||
import shared_preferences_foundation
|
||||
import url_launcher_macos
|
||||
@@ -20,10 +19,9 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin"))
|
||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
|
||||
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
_flutterfire_internals:
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _flutterfire_internals
|
||||
sha256: "6727cf2ced9b104abca9daa278380be2eca2b98ce33d4b46f11708e387dc6b4d"
|
||||
name: archive
|
||||
sha256: be169cf6ac481e052c4538715d88841d567150dfe1df38aaec76461a4e7b39f2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.76"
|
||||
version: "4.1.0"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -89,6 +89,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
change_app_package_name:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: change_app_package_name
|
||||
sha256: "8e43b754fe960426904d77ed4c62fa8c9834deaf6e293ae40963fa447482c4c5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.0"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -97,6 +105,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
checked_yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: checked_yaml
|
||||
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.4"
|
||||
cli_util:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cli_util
|
||||
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.2"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -137,14 +161,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.8"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.13"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -209,54 +225,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.3+5"
|
||||
firebase_core:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: firebase_core
|
||||
sha256: "9478ca6700c02d315c6aba37e206e612317f98bb4f335bb1cbd6e0ce67dcf764"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.13.0"
|
||||
firebase_core_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_core_platform_interface
|
||||
sha256: e28f9afdcb5b0f0a8ea74ea3b322f5a7592c81cb45dd9d189913bdae08a2089a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.1.0"
|
||||
firebase_core_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_core_web
|
||||
sha256: f471a288b0101a45567548322ac4a5ad31e3ecbf87a576dcc0e424e6a11e04ea
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.10.0"
|
||||
firebase_messaging:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: firebase_messaging
|
||||
sha256: "55cf1000dd72d229ebff3ce6a399bd35705a7675275478b31308afd90ac85751"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "16.5.0"
|
||||
firebase_messaging_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_messaging_platform_interface
|
||||
sha256: e5fbb62bd23a27c983825277877ac91fdfdb6bfb31c6e5399700963ae586e339
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.9.3"
|
||||
firebase_messaging_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_messaging_web
|
||||
sha256: "663d320e90a41fe57d651ec79df2bc831bf29e051bc0c2cd41faf3106eb412d9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.4"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -270,6 +238,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_launcher_icons:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_launcher_icons
|
||||
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.14.4"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -278,46 +254,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_local_notifications:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_local_notifications
|
||||
sha256: "1447ba911c60f2ba3f25dae1af151ec187162566b0f57e37771bf0b400f013ad"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "22.3.0"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_linux
|
||||
sha256: "9ca97e63776f29ab1b955725c09999fc2c150523269db150c39274f2a43c5a8b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.0.1"
|
||||
flutter_local_notifications_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_platform_interface
|
||||
sha256: "43c3761d916c9bd3d5c7ebbc44d82f4990329840c0c5d62ad5260cc1b5d399bd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.2.0"
|
||||
flutter_local_notifications_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_web
|
||||
sha256: "516afaf97a2d1e67a036c6617321b00d205d72f7a67b6eccf936cd565f985878"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
flutter_local_notifications_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_windows
|
||||
sha256: "6f43bdd03b171b7a90f22647506fea33e2bb12294b7c7c7a3d690e960a382945"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
flutter_localizations:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -331,6 +267,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.33"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.2.4"
|
||||
flutter_secure_storage_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_linux
|
||||
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.3"
|
||||
flutter_secure_storage_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_macos
|
||||
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
flutter_secure_storage_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_platform_interface
|
||||
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
flutter_secure_storage_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_web
|
||||
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
flutter_secure_storage_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_windows
|
||||
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -405,6 +389,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
image:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image
|
||||
sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.9.2"
|
||||
image_picker:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -477,6 +469,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.20.2"
|
||||
js:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: js
|
||||
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.7"
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: json_annotation
|
||||
sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.12.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -509,6 +517,46 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
local_auth:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: local_auth
|
||||
sha256: "434d854cf478f17f12ab29a76a02b3067f86a63a6d6c4eb8fbfdcfe4879c1b7b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
local_auth_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: local_auth_android
|
||||
sha256: a0bdfcc0607050a26ef5b31d6b4b254581c3d3ce3c1816ab4d4f4a9173e84467
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.56"
|
||||
local_auth_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: local_auth_darwin
|
||||
sha256: "699873970067a40ef2f2c09b4c72eb1cfef64224ef041b3df9fdc5c4c1f91f49"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.1"
|
||||
local_auth_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: local_auth_platform_interface
|
||||
sha256: f98b8e388588583d3f781f6806e4f4c9f9e189d898d27f0c249b93a1973dd122
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
local_auth_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: local_auth_windows
|
||||
sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.11"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -605,14 +653,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.2"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -629,6 +669,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
posix:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: posix
|
||||
sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.5.2"
|
||||
provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -750,18 +798,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
|
||||
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: timezone
|
||||
sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
version: "0.7.9"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -846,10 +886,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -882,14 +922,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
xml:
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
|
||||
name: yaml
|
||||
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.11.0 <4.0.0"
|
||||
flutter: ">=3.38.1"
|
||||
dart: ">=3.10.4 <4.0.0"
|
||||
flutter: ">=3.35.0"
|
||||
|
||||
@@ -44,13 +44,14 @@ dependencies:
|
||||
mime: ^2.0.0
|
||||
provider: ^6.1.5+1
|
||||
shared_preferences: ^2.2.3
|
||||
firebase_core: ^4.13.0
|
||||
firebase_messaging: ^16.5.0
|
||||
flutter_local_notifications: ^22.3.0
|
||||
flutter_secure_storage: ^9.2.2
|
||||
local_auth: ^2.3.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
change_app_package_name: ^1.5.0
|
||||
flutter_launcher_icons: ^0.14.4
|
||||
|
||||
# The "flutter_lints" package below contains a set of recommended lints to
|
||||
# encourage good coding practices. The lint set provided by the package is
|
||||
@@ -70,6 +71,8 @@ flutter:
|
||||
uses-material-design: true
|
||||
assets:
|
||||
- assets/images/yogibook_logo.png
|
||||
- assets/images/splash_bg.png
|
||||
- assets/images/yogasoul_logo.png
|
||||
- assets/audio/meditation.mp3
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
@@ -102,3 +105,13 @@ flutter:
|
||||
#
|
||||
# For details regarding fonts from package dependencies,
|
||||
# see https://flutter.dev/to/font-from-package
|
||||
|
||||
flutter_launcher_icons:
|
||||
android: true
|
||||
ios: true
|
||||
image_path: "assets/icon/app_icon.png"
|
||||
# Adaptive icon Android: sfondo bianco + soggetto in primo piano.
|
||||
adaptive_icon_background: "#FFFFFF"
|
||||
adaptive_icon_foreground: "assets/icon/app_icon.png"
|
||||
# iOS non ammette trasparenza: rimuove il canale alpha automaticamente.
|
||||
remove_alpha_ios: true
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
|
||||
#include <audioplayers_windows/audioplayers_windows_plugin.h>
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
#include <local_auth_windows/local_auth_plugin.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
@@ -16,8 +17,10 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin"));
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
LocalAuthPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("LocalAuthPlugin"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
audioplayers_windows
|
||||
file_selector_windows
|
||||
firebase_core
|
||||
flutter_secure_storage_windows
|
||||
local_auth_windows
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
flutter_local_notifications_windows
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
||||