diff --git a/README.md b/README.md index 36741c3..043d8e4 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,193 @@ -# slrpg_app +# S.L.R.P.G. - Streetlifting Role Playing Game -A new Flutter project. +S.L.R.P.G. ist eine hybride Android-Anwendung, die professionelles Krafttraining (speziell Streetlifting und Powerlifting) mit Gamification-Elementen aus Rollenspielen (RPGs) verbindet. Das Ziel der Anwendung ist die Steigerung der Nutzer-Motivation durch extrinsische Anreize (Level-System, Items, Quests) bei gleichzeitiger präziser Steuerung der Trainingsbelastung durch etablierte Algorithmen. -## Getting Started +## Inhaltsverzeichnis -This project is a starting point for a Flutter application. +1. Projektübersicht +2. Technologie-Stack +3. Architektur +4. Kernfunktionen +5. Projektstruktur +6. Installation und Einrichtung +7. Entwicklung -A few resources to get you started if this is your first Flutter project: +## 1. Projektübersicht -- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) +Die App implementiert das **Wendler 5/3/1 Trainingssystem** und adaptiert es für Calisthenics- und Streetlifting-Übungen. Trainingsfortschritte ("Progressive Overload") werden direkt in Spielmechaniken übersetzt: -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +* Wiederholungen verursachen "Schaden" an Gegnern. +* Absolvierte Workouts gewähren Erfahrungspunkte (XP). +* Steigerungen der Kraftwerte (1RM) schalten neue Spielinhalte frei. + +Das System folgt einem **Offline-First** Ansatz, um die Funktionalität auch in Umgebungen ohne Internetverbindung (z. B. Fitnessstudios im Keller) vollständig zu gewährleisten. + +## 2. Technologie-Stack + +### Frontend & Mobile Framework + +* **Sprache:** Dart +* **Framework:** Flutter (Cross-Platform Development) +* **State Management:** Riverpod (mit Code Generation & Hooks) +* **Routing:** GoRouter + +### Datenhaltung & Persistenz + +* **Lokale Datenbank:** Drift (SQLite Abstraktionsschicht für Dart) +* **Backend Integration:** PocketBase (Go-basiertes Backend) +* **Synchronisierung:** Eigene Implementierung einer bidirektionalen Synchronisierung (`SyncService`) + +### Weitere Bibliotheken + +* **Freezed / JSON Serializable:** Für unveränderliche Datenmodelle und Serialisierung. +* **Flutter Localizations:** Unterstützung für Internationalisierung (i18n), aktuell DE und EN. +* **Charts:** Visualisierung von Trainingsfortschritten. + +## 3. Architektur + +Das Projekt folgt einer **Feature-First Layered Architecture** in Kombination mit **Clean Architecture** Prinzipien. + +### Schichtenmodell (Layers) + +* **Presentation Layer:** Beinhaltet Widgets, Screens und Controller (Riverpod Notifier). Dieser Layer ist rein für die Darstellung und Benutzerinteraktion zuständig. +* **Domain Layer:** Beinhaltet die Geschäftslogik (z. B. `WendlerCalculator`, `XpCalculator`), Entitäten (`Exercise`, `WorkoutSet`) und Interface-Definitionen. Dieser Layer ist unabhängig von externen Frameworks. +* **Data Layer:** Implementiert die Repositories, verwaltet die lokale Datenbank (`AppDatabase`) und die API-Kommunikation (`ApiClient`). + +### Offline-First Strategie + +Die lokale Drift-Datenbank fungiert als **Single Source of Truth** für die UI. + +1. Die App liest und schreibt primär in die lokale SQLite-Datenbank. +2. Ein Hintergrund-Service (`SyncService`) synchronisiert Änderungen asynchron mit dem PocketBase-Backend, sobald eine Netzwerkverbindung besteht. +3. Konfliktlösung und Queueing von Requests stellen die Datenkonsistenz sicher. + +## 4. Kernfunktionen + +### Trainings-Management + +* **Automatisierte Planung:** Berechnung von Trainingszyklen basierend auf dem 1RM (One Repetition Max) mittels Wendler 5/3/1 Logik. +* **Battle Mode:** Interaktive Durchführung des Workouts. Sätze werden als "Kampfrunden" dargestellt. +* **Timer-System:** Integrierte Satzpausen- und EMOM-Timer (Every Minute on the Minute). +* **Plate Calculator:** Visualisierung der benötigten Hantelscheiben für das aktuelle Gewicht. + +### Gamification (RPG-Elemente) + +* **Avatar-System:** Anpassbarer Charakter mit Ausrüstungsslots. +* **Inventar:** Verwaltung von virtuellen Gegenständen, die durch Training freigeschaltet werden. +* **Quests:** Tägliche und wöchentliche Aufgaben zur Förderung der Konsistenz. +* **Codex:** Wissensdatenbank und Sammlung freigeschalteter Erfolge. + +### Multiplayer & Social + +* **Party-System:** Bildung von Gruppen für gemeinsame "Raids" (Workouts). +* **Leaderboards:** Ranglisten basierend auf XP und Kraftwerten. +* **Lobby:** Echtzeit-Warteraum für Multiplayer-Sessions. + +### Statistik & Analyse + +* Detaillierte Historie aller absolvierten Workouts. +* Grafische Auswertung der Kraftentwicklung in den Grundübungen. + +## 5. Projektstruktur + +Der Quellcode befindet sich im Verzeichnis `lib/` und ist wie folgt gegliedert: + +```text +lib/ +├── main.dart # Einstiegspunkt der App +├── l10n/ # Lokalisierungsdateien (ARB) +└── src/ + ├── app.dart # Root-Widget und globale Konfiguration + ├── core/ # Kern-Komponenten (Routing, Theme, Konstanten, Utils) + ├── features/ # Funktionalitäten (Feature-First Struktur) + │ ├── authentication/ # Login, Register, Profil + │ ├── backup/ # Datensicherung + │ ├── dashboard/ # Hauptmenü (Hub) + │ ├── gamification/ # Quests, Avatar, Codex + │ ├── history/ # Trainingshistorie + │ ├── inventory/ # Item-Verwaltung + │ ├── multiplayer/ # Social Features + │ ├── onboarding/ # Setup-Flow für neue Nutzer + │ ├── settings/ # Einstellungen + │ ├── stats/ # Diagramme und Auswertungen + │ ├── wiki/ # Übungsanleitungen + │ └── workout_runner/ # Die eigentliche Trainings-Logik (Battle Screen) + └── shared/ # Geteilte Ressourcen + ├── data/ # Repositories, lokale DB (Drift), API-Client + ├── domain/ # Geteilte Logik (z.B. WendlerCalculator) + └── presentation/ # Wiederverwendbare Widgets + +``` + +## 6. Installation und Einrichtung + +### Voraussetzungen + +* Flutter SDK (neueste stabile Version empfohlen) +* Dart SDK +* Ein laufendes PocketBase Backend (siehe separate Backend-Dokumentation) + +### Schritte + +1. **Repository klonen:** +```bash +git clone [Repository-URL] +cd slrpg_app + +``` + + +2. **Abhängigkeiten installieren:** +```bash +flutter pub get + +``` + + +3. **Umgebungsvariablen konfigurieren:** +Erstellen Sie eine `.env` Datei im Root-Verzeichnis des Projekts und fügen Sie die notwendigen Konfigurationen hinzu (z. B. Backend-URL): +```env +API_URL=http://localhost:8090 + +``` + + +4. **Code-Generierung:** +Da das Projekt `riverpod_generator`, `drift` und `freezed` verwendet, müssen die Dateien generiert werden: +```bash +dart run build_runner build --delete-conflicting-outputs + +``` + + +5. **App starten:** +```bash +flutter run + +``` + + + +## 7. Entwicklung + +### Testing + +Das Projekt enthält Unit- und Widget-Tests (sofern implementiert). Ausführen der Tests: + +```bash +flutter test + +``` + +### Lokalisierung + +Texte werden in `lib/src/l10n/app_de.arb` und `app_en.arb` gepflegt. Nach Änderungen muss der Generator ausgeführt werden: + +```bash +flutter gen-l10n + +``` + +### Datenbank-Migrationen + +Bei Änderungen am Datenbankschema in `app_database.dart` oder `tables.dart` muss die Schema-Version erhöht und eine Migration geschrieben werden. Drift kümmert sich um die Generierung des Boilerplate-Codes. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index edaa69a..018bbcb 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -11,6 +11,7 @@ android { compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 + isCoreLibraryDesugaringEnabled = true } kotlinOptions { @@ -38,4 +39,5 @@ flutter { dependencies { implementation("androidx.core:core-ktx:1.12.0") + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 610d9a0..626afd1 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,9 +1,16 @@ - + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/launcher_icon.png b/android/app/src/main/res/mipmap-hdpi/launcher_icon.png new file mode 100644 index 0000000..b83e701 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/launcher_icon.png b/android/app/src/main/res/mipmap-mdpi/launcher_icon.png new file mode 100644 index 0000000..cffb746 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png new file mode 100644 index 0000000..df1242f Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png new file mode 100644 index 0000000..8d0cf0c Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png new file mode 100644 index 0000000..d0dfc49 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png differ diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..9fdc546 Binary files /dev/null and b/assets/icon.png differ diff --git a/assets/icon.png~ b/assets/icon.png~ new file mode 100644 index 0000000..cec6569 Binary files /dev/null and b/assets/icon.png~ differ diff --git a/assets/icons/icon.png b/assets/icons/icon.png new file mode 100644 index 0000000..d4a6ee5 Binary files /dev/null and b/assets/icons/icon.png differ diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..cb5c7b8 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,5 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: + - drift: true + - shared_preferences: true \ No newline at end of file diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 00142ca..1534f42 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -362,7 +362,7 @@ "usernameShortError": "Name zu kurz", "privacyPolicyTitle": "Datenschutzerklärung", "privacyPolicySectionResponsible": "Verantwortlicher", - "privacyPolicySectionResponsibleContent": "Dein Name/Firma\nDeine Adresse\nE-Mail: privacy@example.com\nTelefon: +49 123 456789", + "privacyPolicySectionResponsibleContent": "Patryk Hegenberg\nDedendorf 8, 27333 Bücken\nE-Mail: patrykhegenberg@gmail.com\nTelefon: +49 123 456789", "privacyPolicySectionDataCollected": "Erhobene Daten", "privacyPolicySectionDataCollectedContent": "• E-Mail-Adresse (zur Account-Verwaltung)\n• Verschlüsseltes Passwort\n• Trainingsdaten (Gewichte, Wiederholungen, Datum)\n• Körpergewicht\n• Avatar-Einstellungen\n• Geräteinformationen (OS-Version, App-Version)", @@ -514,6 +514,14 @@ "profileDeleteAccountSubtitle": "Löscht Account und Daten dauerhaft", "profileDeleteConfirmTitle": "Account löschen?", "profileDeleteConfirmBody": "Bist du sicher? Alle Daten gehen verloren.", + "profileTrainingSchedule": "Trainingsplan", + "profileSelectTrainingDays": "Trainingstage wählen", + "profileThreeDaysLimit": "Wähle genau 3 Tage pro Woche.", + "profileNotificationSettings": "Benachrichtigungen", + "profileRestNotification": "Pausen-Ende", + "profileRestNotificationDesc": "Melden, wenn die Pause vorbei ist", + "profileDailyReminder": "Tägliche Erinnerung", + "profileDailyReminderDesc": "Erinnern, wenn ein Training ansteht", "templateStrengthOnly": "Nur Stärke", "templateStrengthOnlyDesc": "Hauptübungen + FSL. Pur & Schnell.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index aebf3ba..debc1c6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -362,7 +362,7 @@ "usernameShortError": "Name too short", "privacyPolicyTitle": "Privacy Policy", "privacyPolicySectionResponsible": "Responsible Party", - "privacyPolicySectionResponsibleContent": "Your Name/Company\nYour Address\nEmail: privacy@example.com\nPhone: +49 123 456789", + "privacyPolicySectionResponsibleContent": "Patryk Hegenberg\nDedendorf 8, 27333 Bücken\nEmail: patrykhegenberg@gmail.com\nPhone: +49 123 456789", "privacyPolicySectionDataCollected": "Data We Collect", "privacyPolicySectionDataCollectedContent": "• Email address (for account management)\n• Encrypted password\n• Training data (weights, repetitions, dates)\n• Bodyweight\n• Avatar settings\n• Device information (OS version, app version)", @@ -528,6 +528,14 @@ "profileDeleteAccountSubtitle": "Permanently delete your account and data", "profileDeleteConfirmTitle": "Delete Account?", "profileDeleteConfirmBody": "Are you sure you want to delete your account? All data will be lost forever.", + "profileTrainingSchedule": "Training Schedule", + "profileSelectTrainingDays": "Select Training Days", + "profileThreeDaysLimit": "Select exactly 3 days per week.", + "profileNotificationSettings": "Notification Settings", + "profileRestNotification": "Rest Finished", + "profileRestNotificationDesc": "Notify when your rest period ends", + "profileDailyReminder": "Daily Reminder", + "profileDailyReminderDesc": "Remind me when a workout is planned", "templateStrengthOnly": "Strength Only", "templateStrengthOnlyDesc": "Main Lifts + FSL. Pure & Fast.", diff --git a/lib/main.dart b/lib/main.dart index 64d3a00..883e0c0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,70 +1,3 @@ -// import 'dart:developer'; - -// import 'package:flutter/material.dart'; -// import 'package:flutter/services.dart'; -// import 'package:flutter_riverpod/flutter_riverpod.dart'; -// import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -// import 'package:slrpg_app/src/shared/data/remote/secure_auth_store.dart'; -// import 'src/app.dart'; -// import 'src/shared/data/local/app_database.dart'; -// import 'src/shared/data/remote/api_client.dart'; -// import 'src/shared/data/remote/pb_auth_store.dart'; -// import 'package:flutter_dotenv/flutter_dotenv.dart'; - -// void main() async { -// WidgetsFlutterBinding.ensureInitialized(); - -// try { -// await dotenv.load(fileName: '.env'); -// log('Environment loaded: ${dotenv.env['ENVIRONMENT']}'); -// log('API URL: ${dotenv.env['API_BASE_URL']}'); -// } catch (e) { -// log('Could not load .env file: $e'); -// log('Using default production values'); -// } - -// await SystemChrome.setPreferredOrientations([ -// DeviceOrientation.portraitUp, -// DeviceOrientation.portraitDown, -// ]); - -// final database = AppDatabase(); - -// const secureStorage = FlutterSecureStorage( -// aOptions: AndroidOptions(encryptedSharedPreferences: true)); -// final authStore = PbAuthStore(); -// // final authStore = SecureAuthStore(storage: secureStorage); -// await authStore.loadFromStorage(); - -// runApp( -// ProviderScope( -// overrides: [ -// // Datenbank Override (wie gehabt) -// appDatabaseProvider.overrideWithValue(database), - -// // ApiClient Override: Wir geben den BEREITS GELADENEN Store rein -// apiClientProvider.overrideWith((ref) => ApiClient( -// authStore: authStore, // Hier injizieren! -// storage: secureStorage)), -// ], -// child: const SLRPGApp(), // Dein Root Widget (Name prüfen, falls anders) -// ), -// ); -// // } -// // runApp( -// // ProviderScope( -// // overrides: [ -// // appDatabaseProvider.overrideWithValue(database), -// // apiClientProvider -// // .overrideWith((ref) => ApiClient(authStore: authStore)), -// // ], -// // child: const SLRPGApp(), -// // ), -// // ); -// } - -// final appDatabaseProvider = -// Provider((ref) => throw UnimplementedError()); import 'dart:developer'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -74,11 +7,11 @@ import 'package:slrpg_app/src/app.dart'; import 'package:slrpg_app/src/shared/data/local/app_database.dart'; import 'package:slrpg_app/src/shared/data/remote/api_client.dart'; import 'package:slrpg_app/src/shared/data/remote/pb_auth_store.dart'; +import 'package:slrpg_app/src/core/utils/notification_service.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - // 1. Env laden try { await dotenv.load(fileName: '.env'); } catch (e) { @@ -90,15 +23,16 @@ void main() async { DeviceOrientation.portraitDown, ]); + final notificationService = NotificationService(); + await notificationService.init(); + final database = AppDatabase(); - // 2. Auth Store erstellen UND laden (Warten!) final authStore = PbAuthStore(); - await authStore.loadFromStorage(); // Das ist der entscheidende 'await' + await authStore.loadFromStorage(); - log("Auth loaded. Valid? ${authStore.isValid}"); // Debug Log + log("Auth loaded. Valid? ${authStore.isValid}"); - // 3. App starten mit injiziertem Store runApp( ProviderScope( overrides: [ @@ -111,6 +45,5 @@ void main() async { ); } -// Provider Definition für DB (falls noch nicht vorhanden) final appDatabaseProvider = Provider((ref) => throw UnimplementedError()); diff --git a/lib/src/core/constants/asset_paths.dart b/lib/src/core/constants/asset_paths.dart index c34b4e6..e20aab0 100644 --- a/lib/src/core/constants/asset_paths.dart +++ b/lib/src/core/constants/asset_paths.dart @@ -46,6 +46,7 @@ class AssetPaths { static const String audioBeepShort = 'audio/beep_short.ogg'; static const String audioBeepLong = 'audio/beep_long.ogg'; + static const String appLogo = 'assets/icons/icon.png'; } class PlateColors { diff --git a/lib/src/core/routing/app_router.dart b/lib/src/core/routing/app_router.dart index b56219d..2383b29 100644 --- a/lib/src/core/routing/app_router.dart +++ b/lib/src/core/routing/app_router.dart @@ -226,6 +226,8 @@ class _SplashScreenState extends ConsumerState { final userRepo = ref.read(userRepositoryProvider); final user = await userRepo.getLocalUser(); + if (!mounted) return; + if (user == null) { context.go('/login'); } else { diff --git a/lib/src/core/utils/error_handler.dart b/lib/src/core/utils/error_handler.dart index 08b27e7..715ee74 100644 --- a/lib/src/core/utils/error_handler.dart +++ b/lib/src/core/utils/error_handler.dart @@ -9,10 +9,11 @@ class ErrorHandler { final e = error.toString(); + // Check for network errors first, but be more specific if (e.contains('SocketException') || e.contains('Connection refused') || - e.contains('ClientException') || - e.contains('HandshakeException')) { + e.contains('HandshakeException') || + e.contains('Network is unreachable')) { return l10n.errorNoInternet; } @@ -24,11 +25,13 @@ class ErrorHandler { return l10n.errorNotFound; } + // PocketBase often returns 400 for multiple things if (e.contains('400')) { if (e.contains('validation_not_unique')) { return l10n.errorEntryNotUnique; } - if (e.contains('Failed to authenticate')) { + // PocketBase specific error for failed login + if (e.contains('Failed to authenticate') || e.contains('identity or password')) { return l10n.errorAuthenticationFailed; } return l10n.errorIllegalRequest; @@ -38,17 +41,23 @@ class ErrorHandler { } static void showErrorSnackBar(BuildContext context, Object error) { - ScaffoldMessenger.of(context).hideCurrentSnackBar(); - ScaffoldMessenger.of(context).showSnackBar( + final scaffoldMessenger = ScaffoldMessenger.of(context); + scaffoldMessenger.hideCurrentSnackBar(); + + scaffoldMessenger.showSnackBar( SnackBar( content: Text(getReadableError(context, error)), backgroundColor: AppTheme.errorColor, behavior: SnackBarBehavior.floating, duration: const Duration(seconds: 4), + dismissDirection: DismissDirection.horizontal, // Easier to swipe away + margin: const EdgeInsets.fromLTRB(16, 16, 16, 60), // Move it up to not block navigation action: SnackBarAction( label: 'OK', textColor: Colors.white, - onPressed: () => ScaffoldMessenger.of(context).hideCurrentSnackBar(), + onPressed: () { + scaffoldMessenger.hideCurrentSnackBar(); + }, ), ), ); diff --git a/lib/src/core/utils/notification_service.dart b/lib/src/core/utils/notification_service.dart new file mode 100644 index 0000000..776dde2 --- /dev/null +++ b/lib/src/core/utils/notification_service.dart @@ -0,0 +1,197 @@ +import 'dart:io'; +import 'package:flutter/material.dart' show TimeOfDay; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:timezone/data/latest_all.dart' as tz; +import 'package:timezone/timezone.dart' as tz; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Provider for the [NotificationService]. +final notificationServiceProvider = Provider((ref) { + return NotificationService(); +}); + +/// Service responsible for managing local notifications across Android, iOS, and Linux. +class NotificationService { + final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin(); + + /// Initializes the notification system and requests necessary permissions. + Future init() async { + tz.initializeTimeZones(); + + const AndroidInitializationSettings initializationSettingsAndroid = + AndroidInitializationSettings('@mipmap/ic_launcher'); + + const DarwinInitializationSettings initializationSettingsDarwin = + DarwinInitializationSettings( + requestAlertPermission: true, + requestBadgePermission: true, + requestSoundPermission: true, + ); + + const LinuxInitializationSettings initializationSettingsLinux = + LinuxInitializationSettings( + defaultActionName: 'Open notification', + ); + + const InitializationSettings initializationSettings = InitializationSettings( + android: initializationSettingsAndroid, + iOS: initializationSettingsDarwin, + linux: initializationSettingsLinux, + ); + + await _notifications.initialize( + initializationSettings, + ); + + if (Platform.isAndroid) { + final androidImplementation = + _notifications.resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>(); + await androidImplementation?.requestNotificationsPermission(); + await androidImplementation?.requestExactAlarmsPermission(); + } + } + + /// Shows a notification immediately indicating that a rest period has finished. + Future showRestFinishedNotification({String? title, String? body}) async { + const AndroidNotificationDetails androidPlatformChannelSpecifics = + AndroidNotificationDetails( + 'rest_timer_channel', + 'Rest Timer Notifications', + channelDescription: 'Notifications when the rest timer finishes', + importance: Importance.max, + priority: Priority.high, + showWhen: true, + enableVibration: true, + playSound: true, + ); + + const NotificationDetails platformChannelSpecifics = + NotificationDetails( + android: androidPlatformChannelSpecifics, + linux: LinuxNotificationDetails(), + ); + + await _notifications.show( + 1001, + title ?? 'Rest Finished', + body ?? 'Get back to your workout!', + platformChannelSpecifics, + ); + } + + /// Schedules recurring notifications for specified training days at a given time. + /// + /// Fallbacks to immediate notification on Linux as [zonedSchedule] is not implemented. + Future scheduleDailyTrainingReminder({ + required List trainingDays, + required TimeOfDay reminderTime, + String? title, + String? body, + }) async { + await _notifications.cancel(1002); + + if (trainingDays.isEmpty) return; + + if (Platform.isLinux) { + await _notifications.show( + 1002, + title ?? 'Daily Reminder Configured', + body ?? 'Training reminders are set for: ${trainingDays.join(', ')}', + const NotificationDetails(linux: LinuxNotificationDetails()), + ); + return; + } + + const AndroidNotificationDetails androidPlatformChannelSpecifics = + AndroidNotificationDetails( + 'daily_training_channel', + 'Daily Training Reminders', + channelDescription: 'Reminders for scheduled training days', + importance: Importance.max, + priority: Priority.high, + ); + + const NotificationDetails platformChannelSpecifics = + NotificationDetails( + android: androidPlatformChannelSpecifics, + linux: LinuxNotificationDetails(), + ); + + for (int i = 0; i < trainingDays.length; i++) { + final day = trainingDays[i]; + final weekday = _getWeekdayNumber(day); + if (weekday == null) continue; + + await _notifications.zonedSchedule( + 1002 + i, + title ?? 'Training Day!', + body ?? 'Today is a scheduled training day. Time to hit the iron!', + _nextInstanceOfDay(weekday, reminderTime), + platformChannelSpecifics, + androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, + uiLocalNotificationDateInterpretation: + UILocalNotificationDateInterpretation.absoluteTime, + matchDateTimeComponents: DateTimeComponents.dayOfWeekAndTime, + ); + } + } + + int? _getWeekdayNumber(String day) { + switch (day.toLowerCase()) { + case 'mo': + case 'monday': + return DateTime.monday; + case 'tu': + case 'tuesday': + return DateTime.tuesday; + case 'we': + case 'wednesday': + return DateTime.wednesday; + case 'th': + case 'thursday': + return DateTime.thursday; + case 'fr': + case 'friday': + return DateTime.friday; + case 'sa': + case 'saturday': + return DateTime.saturday; + case 'su': + case 'sunday': + return DateTime.sunday; + default: + return null; + } + } + + tz.TZDateTime _nextInstanceOfDay(int weekday, TimeOfDay time) { + tz.TZDateTime scheduledDate = _nextInstanceOfTime(time); + while (scheduledDate.weekday != weekday) { + scheduledDate = scheduledDate.add(const Duration(days: 1)); + } + return scheduledDate; + } + + tz.TZDateTime _nextInstanceOfTime(TimeOfDay time) { + final tz.TZDateTime now = tz.TZDateTime.now(tz.local); + tz.TZDateTime scheduledDate = tz.TZDateTime( + tz.local, now.year, now.month, now.day, time.hour, time.minute); + if (scheduledDate.isBefore(now)) { + scheduledDate = scheduledDate.add(const Duration(days: 1)); + } + return scheduledDate; + } + + /// Cancels all active and scheduled notifications. + Future cancelAllNotifications() async { + await _notifications.cancelAll(); + } +} + +/// Simple time representation for scheduling notifications. +class TimeOfDay { + final int hour; + final int minute; + const TimeOfDay({required this.hour, required this.minute}); +} diff --git a/lib/src/features/authentication/presentation/screens/login_screen.dart b/lib/src/features/authentication/presentation/screens/login_screen.dart index 27d3239..c6d620c 100644 --- a/lib/src/features/authentication/presentation/screens/login_screen.dart +++ b/lib/src/features/authentication/presentation/screens/login_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:slrpg_app/l10n/app_localizations.dart'; import 'package:slrpg_app/src/core/utils/error_handler.dart'; +import 'package:slrpg_app/src/core/constants/asset_paths.dart'; import '../../data/repositories/auth_repository.dart'; import '../../../../core/theme/app_theme.dart'; @@ -57,29 +58,15 @@ class _LoginScreenState extends ConsumerState { } } catch (e) { if (mounted) { - final l10n = AppLocalizations.of(context)!; setState(() { _isLoading = false; - _errorMessage = _parseErrorMessage(e.toString(), l10n); + _errorMessage = ErrorHandler.getReadableError(context, e); }); ErrorHandler.showErrorSnackBar(context, e); } } } - String _parseErrorMessage(String error, AppLocalizations l10n) { - if (error.contains('400')) { - return l10n.loginErrorInvalid; - } else if (error.contains('SocketException') || - error.contains('Connection refused') || - error.contains('Network is unreachable')) { - return l10n.loginErrorConnection; - } else if (error.contains('timeout')) { - return l10n.loginErrorTimeout; - } - return l10n.loginErrorGeneric; - } - @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; @@ -106,11 +93,10 @@ class _LoginScreenState extends ConsumerState { children: [ const Spacer(), Container( - width: 100, - height: 100, + width: 120, + height: 220, decoration: BoxDecoration( - color: AppTheme.primaryColor, - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(24), boxShadow: [ BoxShadow( color: AppTheme.primaryColor @@ -120,10 +106,12 @@ class _LoginScreenState extends ConsumerState { ), ], ), - child: const Icon( - Icons.fitness_center, - size: 56, - color: Colors.black, + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: Image.asset( + AssetPaths.appLogo, + fit: BoxFit.cover, + ), ), ), const SizedBox(height: 32), diff --git a/lib/src/features/authentication/presentation/screens/profile_screen.dart b/lib/src/features/authentication/presentation/screens/profile_screen.dart index 6d23334..e872545 100644 --- a/lib/src/features/authentication/presentation/screens/profile_screen.dart +++ b/lib/src/features/authentication/presentation/screens/profile_screen.dart @@ -13,6 +13,7 @@ import '../../../gamification/presentation/widgets/avatar_editor.dart'; import '../../../gamification/presentation/widgets/avatar_renderer.dart'; import '../../../gamification/domain/entities/item_catalog.dart'; import '../../../../shared/domain/logic/wendler_calculator.dart'; +import '../../../../core/utils/notification_service.dart' as ns; class ProfileScreen extends ConsumerStatefulWidget { const ProfileScreen({super.key}); @@ -400,10 +401,87 @@ class _ProfileScreenState extends ConsumerState { }); } catch (e) { setState(() => _isLoading = false); - // Error handling... } } + Future _updateNotificationSetting(String key, bool value) async { + if (_user == null) return; + + final currentSettings = + Map.from(_user!.notificationSettings ?? {}); + currentSettings[key] = value; + + final updatedUser = _user!.copyWith( + notificationSettings: Value(currentSettings), + isDirty: true, + ); + + setState(() => _user = updatedUser); + await ref.read(userRepositoryProvider).saveLocalUser(updatedUser); + + if (key == 'daily_reminder_enabled') { + _rescheduleDailyReminder(updatedUser); + } + } + + Future _updateTrainingDays(String day) async { + if (_user == null) return; + + final currentDays = List.from(_user!.trainingDays ?? []); + if (currentDays.contains(day)) { + currentDays.remove(day); + } else { + if (currentDays.length >= 3) return; // Limit to 3 days + currentDays.add(day); + } + + final updatedUser = _user!.copyWith( + trainingDays: Value(currentDays), + isDirty: true, + ); + + setState(() => _user = updatedUser); + await ref.read(userRepositoryProvider).saveLocalUser(updatedUser); + + _rescheduleDailyReminder(updatedUser); + } + + void _rescheduleDailyReminder(UserCollection user) { + final settings = user.notificationSettings ?? {}; + final enabled = settings['daily_reminder_enabled'] ?? false; + final days = List.from(user.trainingDays ?? []); + + if (enabled && days.isNotEmpty) { + ref.read(ns.notificationServiceProvider).scheduleDailyTrainingReminder( + trainingDays: days, + reminderTime: const ns.TimeOfDay(hour: 8, minute: 0), + ); + } else { + // We don't have a cancel specific notification ID but we can cancel 1002+ + // For simplicity, we just won't schedule. + // In a real app we might want to cancel specifically. + } + } + + Widget _buildDaySelector() { + final days = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']; + final selectedDays = List.from(_user?.trainingDays ?? []); + + return Wrap( + spacing: 8, + children: days.map((day) { + final isSelected = selectedDays.contains(day); + return FilterChip( + label: Text(day), + selected: isSelected, + onSelected: (val) => _updateTrainingDays(day), + selectedColor: AppTheme.primaryColor.withValues(alpha: 0.3), + checkmarkColor: AppTheme.primaryColor, + ); + }).toList(), + ); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; @@ -513,6 +591,57 @@ class _ProfileScreenState extends ConsumerState { ), ), const SizedBox(height: 32), + Text(l10n.profileTrainingSchedule, + style: Theme.of(context) + .textTheme.titleLarge + ?.copyWith(color: AppTheme.textPrimary)), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.profileSelectTrainingDays, + style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: 12), + _buildDaySelector(), + const SizedBox(height: 12), + Text(l10n.profileThreeDaysLimit, + style: TextStyle( + fontSize: 12, color: Colors.grey.shade400)), + ], + ), + ), + ), + const SizedBox(height: 32), + Text(l10n.profileNotificationSettings, + style: Theme.of(context) + .textTheme.titleLarge + ?.copyWith(color: AppTheme.textPrimary)), + const SizedBox(height: 16), + Card( + child: Column( + children: [ + SwitchListTile( + value: (_user?.notificationSettings ?? {})['rest_finished_enabled'] ?? true, + title: Text(l10n.profileRestNotification), + subtitle: Text(l10n.profileRestNotificationDesc), + activeColor: AppTheme.primaryColor, + onChanged: (val) => _updateNotificationSetting('rest_finished_enabled', val), + ), + const Divider(height: 1), + SwitchListTile( + value: (_user?.notificationSettings ?? {})['daily_reminder_enabled'] ?? false, + title: Text(l10n.profileDailyReminder), + subtitle: Text(l10n.profileDailyReminderDesc), + activeColor: AppTheme.primaryColor, + onChanged: (val) => _updateNotificationSetting('daily_reminder_enabled', val), + ), + ], + ), + ), + const SizedBox(height: 32), Text(l10n.profileTrainingFocus, style: Theme.of(context) .textTheme diff --git a/lib/src/features/gamification/presentation/screens/codex_screen.dart b/lib/src/features/gamification/presentation/screens/codex_screen.dart index 8806ff1..cd1c666 100644 --- a/lib/src/features/gamification/presentation/screens/codex_screen.dart +++ b/lib/src/features/gamification/presentation/screens/codex_screen.dart @@ -117,8 +117,11 @@ class _LoreCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + Wrap( + spacing: 12.0, + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + runSpacing: 4, children: [ Text( name.toUpperCase(), diff --git a/lib/src/features/workout_runner/presentation/screens/battle_screen.dart b/lib/src/features/workout_runner/presentation/screens/battle_screen.dart index dd1bc70..2cd2f95 100644 --- a/lib/src/features/workout_runner/presentation/screens/battle_screen.dart +++ b/lib/src/features/workout_runner/presentation/screens/battle_screen.dart @@ -14,6 +14,7 @@ import 'package:slrpg_app/src/shared/domain/entities/workout_set.dart'; import '../../../../core/constants/asset_paths.dart'; import '../../../../core/theme/app_theme.dart'; +import '../../../../core/utils/notification_service.dart'; import '../../../../shared/data/repositories/user_repository.dart'; import '../../../../shared/domain/logic/plate_calculator.dart'; import '../../../multiplayer/data/repositories/party_repository.dart'; @@ -139,15 +140,31 @@ class _BattleScreenState extends ConsumerState { if (!mounted) return; final controller = ref.read(battleControllerProvider.notifier); - if (controller.state.isResting) { + final battleState = ref.read(battleControllerProvider); + + if (battleState.isResting) { controller.tickRest(); - if (controller.state.restSeconds > 0) { + final newState = ref.read(battleControllerProvider); + + if (newState.isResting) { _runRestTimer(); + } else { + _handleRestFinished(); } } }); } + void _handleRestFinished() async { + final user = await ref.read(userRepositoryProvider).getLocalUser(); + final settings = user?.notificationSettings ?? {}; + final restEnabled = settings['rest_finished_enabled'] ?? true; + + if (restEnabled) { + ref.read(notificationServiceProvider).showRestFinishedNotification(); + } + } + void _skipRest() { ref.read(battleControllerProvider.notifier).skipRest(); } diff --git a/lib/src/l10n/app_de.arb b/lib/src/l10n/app_de.arb new file mode 100644 index 0000000..a52946f --- /dev/null +++ b/lib/src/l10n/app_de.arb @@ -0,0 +1,12 @@ +{ + "enterTheArena": "BETRITT DIE ARENA", + "introText": "Die Eisengolems sind erwacht. Die Schwerkraft-Dämonen ziehen die Welt in den Abgrund.\n\nNur ein wahrer Streetlifter kann sie aufhalten. Bist du bereit, deinen Körper in eine Waffe zu schmieden?", + "featureArmorTitle": "Schmiede deine Rüstung", + "featureArmorDesc": "Progressive Overload basierend auf Wendler 5/3/1.", + "featureMonstersTitle": "Erschlage Monster", + "featureMonstersDesc": "Verwandle jede Wiederholung in Schaden gegen epische Feinde.", + "featureLootTitle": "Sammle Beute", + "featureLootDesc": "Verdiene XP, steige auf und schalte neue Ausrüstung frei.", + "beginJourney": "BEGINNE DEINE REISE", + "loginPrompt": "Schon ein Held? Hier einloggen" +} diff --git a/lib/src/l10n/app_en.arb b/lib/src/l10n/app_en.arb new file mode 100644 index 0000000..189b5cd --- /dev/null +++ b/lib/src/l10n/app_en.arb @@ -0,0 +1,12 @@ +{ + "enterTheArena": "ENTER THE ARENA", + "introText": "The Iron Golems have awakened. The Gravity Demons are pulling the world into the abyss.\n\nOnly a true Streetlifter can stop them. Are you ready to forge your body into a weapon?", + "featureArmorTitle": "Build Your Armor", + "featureArmorDesc": "Progressive overload based on Wendler 5/3/1.", + "featureMonstersTitle": "Slay Monsters", + "featureMonstersDesc": "Turn every rep into damage against epic foes.", + "featureLootTitle": "Gather Loot", + "featureLootDesc": "Earn XP, level up, and unlock new gear.", + "beginJourney": "BEGIN YOUR JOURNEY", + "loginPrompt": "Already a hero? Login here" +} diff --git a/lib/src/shared/data/local/app_database.dart b/lib/src/shared/data/local/app_database.dart index ccc097f..bc0ecf9 100644 --- a/lib/src/shared/data/local/app_database.dart +++ b/lib/src/shared/data/local/app_database.dart @@ -13,7 +13,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase() : super(_openConnection()); @override - int get schemaVersion => 3; + int get schemaVersion => 4; @override MigrationStrategy get migration => MigrationStrategy( @@ -24,6 +24,10 @@ class AppDatabase extends _$AppDatabase { if (from < 3) { await m.addColumn(users, users.exerciseVariants); } + if (from < 4) { + await m.addColumn(users, users.trainingDays); + await m.addColumn(users, users.notificationSettings); + } }, ); } diff --git a/lib/src/shared/data/local/tables.dart b/lib/src/shared/data/local/tables.dart index e9703b9..cc40a16 100644 --- a/lib/src/shared/data/local/tables.dart +++ b/lib/src/shared/data/local/tables.dart @@ -17,6 +17,8 @@ class Users extends Table { TextColumn get inventorySettings => text().map(const MapConverter()).nullable()(); TextColumn get avatarConfig => text().map(const MapConverter()).nullable()(); + TextColumn get trainingDays => text().map(const ListConverter()).nullable()(); + TextColumn get notificationSettings => text().map(const MapConverter()).nullable()(); DateTimeColumn get lastSyncAt => dateTime().nullable()(); BoolColumn get isDirty => boolean().withDefault(const Constant(false))(); diff --git a/lib/src/shared/data/remote/api_client.dart b/lib/src/shared/data/remote/api_client.dart index ff1569b..166f571 100644 --- a/lib/src/shared/data/remote/api_client.dart +++ b/lib/src/shared/data/remote/api_client.dart @@ -1,48 +1,38 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:logger/logger.dart'; import 'package:pocketbase/pocketbase.dart'; import '../../../core/constants/app_constants.dart'; -import 'pb_auth_store.dart'; -// final apiClientProvider = Provider((ref) => ApiClient()); +/// Provider for the [ApiClient] instance. +/// +/// The provider must be overridden in the [ProviderScope] to provide a concrete implementation. final apiClientProvider = Provider((ref) => throw UnimplementedError()); +/// Client for communicating with the PocketBase backend. class ApiClient { late final PocketBase _pb; - // final PbAuthStore _authStore; final Logger _logger; + /// Underlying PocketBase instance. PocketBase get pb => _pb; + /// Stream of authentication state changes. Stream get authStateChanges => _pb.authStore.onChange; + /// Creates a new ApiClient. ApiClient({ required AuthStore authStore, Logger? logger, }) : _logger = logger ?? Logger() { _pb = PocketBase( AppConstants.apiBaseUrl, - authStore: authStore, // Hier kommt der geladene Store rein + authStore: authStore, ); } - // ApiClient({ - // PbAuthStore? authStore, - // FlutterSecureStorage? storage, - // Logger? logger, - // }) : _logger = logger ?? Logger(), - // _authStore = authStore ?? PbAuthStore(storage: storage) { - // _pb = PocketBase( - // AppConstants.apiBaseUrl, - // authStore: _authStore, - // ); - // if (authStore == null) { - // _authStore.loadFromStorage(); - // } - // } + /// Wrapper to handle PocketBase requests with automatic token refreshing. Future _handleRequest(Future Function() request) async { try { return await request(); @@ -67,6 +57,7 @@ class ApiClient { } } + /// Logs in a user using email and password. Future login(String email, String password) async { try { final authData = await _pb.collection('users').authWithPassword( @@ -81,6 +72,7 @@ class ApiClient { } } + /// Registers a new user account. Future register({ required String email, required String username, @@ -91,7 +83,7 @@ class ApiClient { Map? avatarConfig, }) async { try { - final user = await _pb.collection('users').create(body: { + await _pb.collection('users').create(body: { 'email': email, 'name': username, 'password': password, @@ -112,6 +104,7 @@ class ApiClient { } } + /// Requests a verification email for the given address. Future requestVerification(String email) async { try { await _pb.collection('users').requestVerification(email); @@ -122,6 +115,7 @@ class ApiClient { } } + /// Forces a token refresh. Future refreshAuth() async { try { await _pb.collection('users').authRefresh(); @@ -133,10 +127,12 @@ class ApiClient { } } + /// Clears the authentication store. Future logout() async { _pb.authStore.clear(); } + /// Synchronizes local data with the server. Future> sync({ required String lastSyncTimestamp, required Map pushData, @@ -154,6 +150,7 @@ class ApiClient { }); } + /// Creates a new training cycle on the server. Future> createCycle( Map trainingMaxes) async { return _handleRequest(() async { @@ -166,6 +163,7 @@ class ApiClient { }); } + /// Marks a training cycle as finished on the server. Future> finishCycle(String cycleId) async { return _handleRequest(() async { final result = await _pb.send( @@ -177,6 +175,7 @@ class ApiClient { }); } + /// Retrieves the current active cycle from the server. Future> getCurrentCycle() async { return _handleRequest(() async { final result = await _pb.send(ApiEndpoints.cycleCurrent, method: 'GET'); @@ -184,6 +183,7 @@ class ApiClient { }); } + /// Fetches history statistics for a specific exercise and time range. Future> getStatsHistory({ required String exercise, required String range, @@ -201,6 +201,7 @@ class ApiClient { }); } + /// Retrieves a summary of all statistics. Future> getStatsSummary() async { return _handleRequest(() async { final result = await _pb.send(ApiEndpoints.statsSummary, method: 'GET'); @@ -208,6 +209,7 @@ class ApiClient { }); } + /// Updates the user's bodyweight on the server. Future updateBodyweight(double bodyweight) async { await _handleRequest(() async { await _pb.send( @@ -218,6 +220,7 @@ class ApiClient { }); } + /// Updates the user's inventory settings on the server. Future updateInventory(Map inventory) async { await _handleRequest(() async { await _pb.send( @@ -228,6 +231,7 @@ class ApiClient { }); } + /// Updates the user's password. Future updatePassword({ required String userId, required String oldPassword, @@ -243,12 +247,14 @@ class ApiClient { }); } + /// Deletes the user's account. Future deleteAccount(String userId) async { await _handleRequest(() async { await _pb.collection('users').delete(userId); }); } + /// Resets all training progress on the server. Future resetProgress() async { await _handleRequest(() async { await _pb.send( @@ -258,10 +264,12 @@ class ApiClient { }); } + /// Returns the current authentication token. String? getToken() { return _pb.authStore.token.isNotEmpty ? _pb.authStore.token : null; } + /// Returns the current user's ID. String? getUserId() { return _pb.authStore.record?.id; } diff --git a/pubspec.lock b/pubspec.lock index 9ec1dc1..a2f1c5c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -25,6 +25,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.11" + archive: + dependency: transitive + description: + name: archive + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + url: "https://pub.dev" + source: hosted + version: "4.0.7" args: dependency: transitive description: @@ -289,6 +297,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.3" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" + source: hosted + version: "0.7.12" dio: dependency: "direct main" description: @@ -398,6 +414,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.2.1" + 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: @@ -406,6 +430,30 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610 + url: "https://pub.dev" + source: hosted + version: "18.0.1" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52" + url: "https://pub.dev" + source: hosted + version: "8.0.0" flutter_localizations: dependency: "direct main" description: flutter @@ -565,6 +613,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c" + url: "https://pub.dev" + source: hosted + version: "4.7.2" intl: dependency: "direct main" description: @@ -718,7 +774,7 @@ packages: source: hosted version: "2.2.0" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" @@ -809,10 +865,10 @@ packages: dependency: "direct main" description: name: pocketbase - sha256: e6aca23d3181a23d367c6650fc4ebe6855bfba4d18b3746ceb1974fb152d8cf9 + sha256: b94e63f00ce29c5f465e8122ae63cdf4e3c37f9b3dc6d04f0dfe6625bcdb8839 url: "https://pub.dev" source: hosted - version: "0.19.1" + version: "0.23.2" pool: dependency: transitive description: @@ -821,6 +877,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + url: "https://pub.dev" + source: hosted + version: "6.0.3" pretty_dio_logger: dependency: "direct main" description: @@ -1194,6 +1258,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.12" + timezone: + dependency: "direct main" + description: + name: timezone + sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 + url: "https://pub.dev" + source: hosted + version: "0.10.1" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e6543f7..57e2260 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,6 +13,8 @@ dependencies: sdk: flutter audioplayers: ^6.0.0 flutter_dotenv: ^5.1.0 + flutter_local_notifications: ^18.0.0 + timezone: ^0.10.0 # State Management flutter_riverpod: ^3.1.0 @@ -24,11 +26,12 @@ dependencies: sqlite3_flutter_libs: ^0.5.20 path_provider: ^2.1.3 share_plus: ^7.2.1 + path: ^1.9.0 # Networking dio: ^5.4.3+1 pretty_dio_logger: ^1.3.1 - pocketbase: ^0.19.0 + pocketbase: ^0.23.2 # Storage flutter_secure_storage: ^10.0.0 @@ -62,6 +65,7 @@ dev_dependencies: drift_dev: ^2.16.0 freezed: ^3.2.3 json_serializable: ^6.8.0 + flutter_launcher_icons: ^0.14.4 flutter: uses-material-design: true @@ -75,6 +79,7 @@ flutter: - assets/images/enemies/ - assets/images/backgrounds/ - assets/audio/ + - assets/icons/ - .env - .env.development - .env.production @@ -83,3 +88,11 @@ flutter: # - family: PixelFont # fonts: # - asset: assets/fonts/pixel.ttf +flutter_launcher_icons: + android: "launcher_icon" + ios: true + image_path: "assets/icon.png" + min_sdk_android: 21 # Optional, verhindert Fehler bei alten Android-Versionen + # Optional: Für Android Adaptive Icons (Android 8+) + # adaptive_icon_background: "#000000" # Hintergrundfarbe (Hex-Code) + # adaptive_icon_foreground: "assets/icon/icon.png"