Compare commits
10 commits
361c43f3c1
...
e57da88272
| Author | SHA1 | Date | |
|---|---|---|---|
| e57da88272 | |||
| fe6a1c6ee0 | |||
| 9431c8aacc | |||
| cb77f5751e | |||
| 2746560be7 | |||
| d731c31200 | |||
| 6092692c54 | |||
| fdc258af28 | |||
| 529e1c1e88 | |||
| 27a2ef0630 |
197
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)
|
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:
|
||||||
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
|
|
||||||
|
|
||||||
For help getting started with Flutter development, view the
|
* Wiederholungen verursachen "Schaden" an Gegnern.
|
||||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
* Absolvierte Workouts gewähren Erfahrungspunkte (XP).
|
||||||
samples, guidance on mobile development, and a full API reference.
|
* 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.
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ android {
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
|
|
@ -38,4 +39,5 @@ flutter {
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("androidx.core:core-ktx:1.12.0")
|
implementation("androidx.core:core-ktx:1.12.0")
|
||||||
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,16 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" android:maxSdkVersion="32" />
|
||||||
|
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="streetlifting_rpg"
|
android:label="streetlifting_rpg"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/launcher_icon"
|
||||||
android:enableOnBackInvokedCallback="true">
|
android:enableOnBackInvokedCallback="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
|
|
@ -22,6 +29,17 @@
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
|
||||||
|
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
||||||
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
|
||||||
|
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
|
||||||
|
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
|
|
||||||
BIN
android/app/src/main/res/mipmap-hdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
assets/icon.png
Normal file
|
After Width: | Height: | Size: 670 KiB |
BIN
assets/icon.png~
Normal file
|
After Width: | Height: | Size: 863 KiB |
BIN
assets/icons/icon.png
Normal file
|
After Width: | Height: | Size: 6.4 MiB |
5
devtools_options.yaml
Normal file
|
|
@ -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
|
||||||
|
|
@ -362,7 +362,7 @@
|
||||||
"usernameShortError": "Name zu kurz",
|
"usernameShortError": "Name zu kurz",
|
||||||
"privacyPolicyTitle": "Datenschutzerklärung",
|
"privacyPolicyTitle": "Datenschutzerklärung",
|
||||||
"privacyPolicySectionResponsible": "Verantwortlicher",
|
"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",
|
"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)",
|
"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",
|
"profileDeleteAccountSubtitle": "Löscht Account und Daten dauerhaft",
|
||||||
"profileDeleteConfirmTitle": "Account löschen?",
|
"profileDeleteConfirmTitle": "Account löschen?",
|
||||||
"profileDeleteConfirmBody": "Bist du sicher? Alle Daten gehen verloren.",
|
"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",
|
"templateStrengthOnly": "Nur Stärke",
|
||||||
"templateStrengthOnlyDesc": "Hauptübungen + FSL. Pur & Schnell.",
|
"templateStrengthOnlyDesc": "Hauptübungen + FSL. Pur & Schnell.",
|
||||||
|
|
|
||||||
|
|
@ -362,7 +362,7 @@
|
||||||
"usernameShortError": "Name too short",
|
"usernameShortError": "Name too short",
|
||||||
"privacyPolicyTitle": "Privacy Policy",
|
"privacyPolicyTitle": "Privacy Policy",
|
||||||
"privacyPolicySectionResponsible": "Responsible Party",
|
"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",
|
"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)",
|
"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",
|
"profileDeleteAccountSubtitle": "Permanently delete your account and data",
|
||||||
"profileDeleteConfirmTitle": "Delete Account?",
|
"profileDeleteConfirmTitle": "Delete Account?",
|
||||||
"profileDeleteConfirmBody": "Are you sure you want to delete your account? All data will be lost forever.",
|
"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",
|
"templateStrengthOnly": "Strength Only",
|
||||||
"templateStrengthOnlyDesc": "Main Lifts + FSL. Pure & Fast.",
|
"templateStrengthOnlyDesc": "Main Lifts + FSL. Pure & Fast.",
|
||||||
|
|
|
||||||
|
|
@ -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<AppDatabase>((ref) => throw UnimplementedError());
|
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.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/local/app_database.dart';
|
||||||
import 'package:slrpg_app/src/shared/data/remote/api_client.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/shared/data/remote/pb_auth_store.dart';
|
||||||
|
import 'package:slrpg_app/src/core/utils/notification_service.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
// 1. Env laden
|
|
||||||
try {
|
try {
|
||||||
await dotenv.load(fileName: '.env');
|
await dotenv.load(fileName: '.env');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -90,15 +23,16 @@ void main() async {
|
||||||
DeviceOrientation.portraitDown,
|
DeviceOrientation.portraitDown,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
final notificationService = NotificationService();
|
||||||
|
await notificationService.init();
|
||||||
|
|
||||||
final database = AppDatabase();
|
final database = AppDatabase();
|
||||||
|
|
||||||
// 2. Auth Store erstellen UND laden (Warten!)
|
|
||||||
final authStore = PbAuthStore();
|
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(
|
runApp(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
|
|
@ -111,6 +45,5 @@ void main() async {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provider Definition für DB (falls noch nicht vorhanden)
|
|
||||||
final appDatabaseProvider =
|
final appDatabaseProvider =
|
||||||
Provider<AppDatabase>((ref) => throw UnimplementedError());
|
Provider<AppDatabase>((ref) => throw UnimplementedError());
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ class AssetPaths {
|
||||||
|
|
||||||
static const String audioBeepShort = 'audio/beep_short.ogg';
|
static const String audioBeepShort = 'audio/beep_short.ogg';
|
||||||
static const String audioBeepLong = 'audio/beep_long.ogg';
|
static const String audioBeepLong = 'audio/beep_long.ogg';
|
||||||
|
static const String appLogo = 'assets/icons/icon.png';
|
||||||
}
|
}
|
||||||
|
|
||||||
class PlateColors {
|
class PlateColors {
|
||||||
|
|
|
||||||
|
|
@ -226,6 +226,8 @@ class _SplashScreenState extends ConsumerState<SplashScreen> {
|
||||||
final userRepo = ref.read(userRepositoryProvider);
|
final userRepo = ref.read(userRepositoryProvider);
|
||||||
final user = await userRepo.getLocalUser();
|
final user = await userRepo.getLocalUser();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
context.go('/login');
|
context.go('/login');
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,11 @@ class ErrorHandler {
|
||||||
|
|
||||||
final e = error.toString();
|
final e = error.toString();
|
||||||
|
|
||||||
|
// Check for network errors first, but be more specific
|
||||||
if (e.contains('SocketException') ||
|
if (e.contains('SocketException') ||
|
||||||
e.contains('Connection refused') ||
|
e.contains('Connection refused') ||
|
||||||
e.contains('ClientException') ||
|
e.contains('HandshakeException') ||
|
||||||
e.contains('HandshakeException')) {
|
e.contains('Network is unreachable')) {
|
||||||
return l10n.errorNoInternet;
|
return l10n.errorNoInternet;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -24,11 +25,13 @@ class ErrorHandler {
|
||||||
return l10n.errorNotFound;
|
return l10n.errorNotFound;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PocketBase often returns 400 for multiple things
|
||||||
if (e.contains('400')) {
|
if (e.contains('400')) {
|
||||||
if (e.contains('validation_not_unique')) {
|
if (e.contains('validation_not_unique')) {
|
||||||
return l10n.errorEntryNotUnique;
|
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.errorAuthenticationFailed;
|
||||||
}
|
}
|
||||||
return l10n.errorIllegalRequest;
|
return l10n.errorIllegalRequest;
|
||||||
|
|
@ -38,17 +41,23 @@ class ErrorHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
static void showErrorSnackBar(BuildContext context, Object error) {
|
static void showErrorSnackBar(BuildContext context, Object error) {
|
||||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
scaffoldMessenger.hideCurrentSnackBar();
|
||||||
|
|
||||||
|
scaffoldMessenger.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(getReadableError(context, error)),
|
content: Text(getReadableError(context, error)),
|
||||||
backgroundColor: AppTheme.errorColor,
|
backgroundColor: AppTheme.errorColor,
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
duration: const Duration(seconds: 4),
|
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(
|
action: SnackBarAction(
|
||||||
label: 'OK',
|
label: 'OK',
|
||||||
textColor: Colors.white,
|
textColor: Colors.white,
|
||||||
onPressed: () => ScaffoldMessenger.of(context).hideCurrentSnackBar(),
|
onPressed: () {
|
||||||
|
scaffoldMessenger.hideCurrentSnackBar();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
197
lib/src/core/utils/notification_service.dart
Normal file
|
|
@ -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<NotificationService>((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<void> 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<void> 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<void> scheduleDailyTrainingReminder({
|
||||||
|
required List<String> 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<void> 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});
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:slrpg_app/l10n/app_localizations.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/utils/error_handler.dart';
|
||||||
|
import 'package:slrpg_app/src/core/constants/asset_paths.dart';
|
||||||
|
|
||||||
import '../../data/repositories/auth_repository.dart';
|
import '../../data/repositories/auth_repository.dart';
|
||||||
import '../../../../core/theme/app_theme.dart';
|
import '../../../../core/theme/app_theme.dart';
|
||||||
|
|
@ -57,29 +58,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
_errorMessage = _parseErrorMessage(e.toString(), l10n);
|
_errorMessage = ErrorHandler.getReadableError(context, e);
|
||||||
});
|
});
|
||||||
ErrorHandler.showErrorSnackBar(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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
@ -106,11 +93,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||||
children: [
|
children: [
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Container(
|
Container(
|
||||||
width: 100,
|
width: 120,
|
||||||
height: 100,
|
height: 220,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.primaryColor,
|
borderRadius: BorderRadius.circular(24),
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: AppTheme.primaryColor
|
color: AppTheme.primaryColor
|
||||||
|
|
@ -120,10 +106,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: ClipRRect(
|
||||||
Icons.fitness_center,
|
borderRadius: BorderRadius.circular(24),
|
||||||
size: 56,
|
child: Image.asset(
|
||||||
color: Colors.black,
|
AssetPaths.appLogo,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import '../../../gamification/presentation/widgets/avatar_editor.dart';
|
||||||
import '../../../gamification/presentation/widgets/avatar_renderer.dart';
|
import '../../../gamification/presentation/widgets/avatar_renderer.dart';
|
||||||
import '../../../gamification/domain/entities/item_catalog.dart';
|
import '../../../gamification/domain/entities/item_catalog.dart';
|
||||||
import '../../../../shared/domain/logic/wendler_calculator.dart';
|
import '../../../../shared/domain/logic/wendler_calculator.dart';
|
||||||
|
import '../../../../core/utils/notification_service.dart' as ns;
|
||||||
|
|
||||||
class ProfileScreen extends ConsumerStatefulWidget {
|
class ProfileScreen extends ConsumerStatefulWidget {
|
||||||
const ProfileScreen({super.key});
|
const ProfileScreen({super.key});
|
||||||
|
|
@ -400,10 +401,87 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
// Error handling...
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _updateNotificationSetting(String key, bool value) async {
|
||||||
|
if (_user == null) return;
|
||||||
|
|
||||||
|
final currentSettings =
|
||||||
|
Map<String, dynamic>.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<void> _updateTrainingDays(String day) async {
|
||||||
|
if (_user == null) return;
|
||||||
|
|
||||||
|
final currentDays = List<String>.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<String>.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<String>.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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
@ -513,6 +591,57 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
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,
|
Text(l10n.profileTrainingFocus,
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
|
|
|
||||||
|
|
@ -117,8 +117,11 @@ class _LoreCard extends StatelessWidget {
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Wrap(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
spacing: 12.0,
|
||||||
|
alignment: WrapAlignment.spaceBetween,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
runSpacing: 4,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
name.toUpperCase(),
|
name.toUpperCase(),
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import 'package:slrpg_app/src/shared/domain/entities/workout_set.dart';
|
||||||
|
|
||||||
import '../../../../core/constants/asset_paths.dart';
|
import '../../../../core/constants/asset_paths.dart';
|
||||||
import '../../../../core/theme/app_theme.dart';
|
import '../../../../core/theme/app_theme.dart';
|
||||||
|
import '../../../../core/utils/notification_service.dart';
|
||||||
import '../../../../shared/data/repositories/user_repository.dart';
|
import '../../../../shared/data/repositories/user_repository.dart';
|
||||||
import '../../../../shared/domain/logic/plate_calculator.dart';
|
import '../../../../shared/domain/logic/plate_calculator.dart';
|
||||||
import '../../../multiplayer/data/repositories/party_repository.dart';
|
import '../../../multiplayer/data/repositories/party_repository.dart';
|
||||||
|
|
@ -139,15 +140,31 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
final controller = ref.read(battleControllerProvider.notifier);
|
final controller = ref.read(battleControllerProvider.notifier);
|
||||||
if (controller.state.isResting) {
|
final battleState = ref.read(battleControllerProvider);
|
||||||
|
|
||||||
|
if (battleState.isResting) {
|
||||||
controller.tickRest();
|
controller.tickRest();
|
||||||
if (controller.state.restSeconds > 0) {
|
final newState = ref.read(battleControllerProvider);
|
||||||
|
|
||||||
|
if (newState.isResting) {
|
||||||
_runRestTimer();
|
_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() {
|
void _skipRest() {
|
||||||
ref.read(battleControllerProvider.notifier).skipRest();
|
ref.read(battleControllerProvider.notifier).skipRest();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12
lib/src/l10n/app_de.arb
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
12
lib/src/l10n/app_en.arb
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -13,7 +13,7 @@ class AppDatabase extends _$AppDatabase {
|
||||||
AppDatabase() : super(_openConnection());
|
AppDatabase() : super(_openConnection());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 3;
|
int get schemaVersion => 4;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
|
|
@ -24,6 +24,10 @@ class AppDatabase extends _$AppDatabase {
|
||||||
if (from < 3) {
|
if (from < 3) {
|
||||||
await m.addColumn(users, users.exerciseVariants);
|
await m.addColumn(users, users.exerciseVariants);
|
||||||
}
|
}
|
||||||
|
if (from < 4) {
|
||||||
|
await m.addColumn(users, users.trainingDays);
|
||||||
|
await m.addColumn(users, users.notificationSettings);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ class Users extends Table {
|
||||||
TextColumn get inventorySettings =>
|
TextColumn get inventorySettings =>
|
||||||
text().map(const MapConverter()).nullable()();
|
text().map(const MapConverter()).nullable()();
|
||||||
TextColumn get avatarConfig => 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()();
|
DateTimeColumn get lastSyncAt => dateTime().nullable()();
|
||||||
BoolColumn get isDirty => boolean().withDefault(const Constant(false))();
|
BoolColumn get isDirty => boolean().withDefault(const Constant(false))();
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,38 @@
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
||||||
import 'package:logger/logger.dart';
|
import 'package:logger/logger.dart';
|
||||||
import 'package:pocketbase/pocketbase.dart';
|
import 'package:pocketbase/pocketbase.dart';
|
||||||
|
|
||||||
import '../../../core/constants/app_constants.dart';
|
import '../../../core/constants/app_constants.dart';
|
||||||
import 'pb_auth_store.dart';
|
|
||||||
|
|
||||||
// final apiClientProvider = Provider<ApiClient>((ref) => ApiClient());
|
/// Provider for the [ApiClient] instance.
|
||||||
|
///
|
||||||
|
/// The provider must be overridden in the [ProviderScope] to provide a concrete implementation.
|
||||||
final apiClientProvider =
|
final apiClientProvider =
|
||||||
Provider<ApiClient>((ref) => throw UnimplementedError());
|
Provider<ApiClient>((ref) => throw UnimplementedError());
|
||||||
|
|
||||||
|
/// Client for communicating with the PocketBase backend.
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
late final PocketBase _pb;
|
late final PocketBase _pb;
|
||||||
// final PbAuthStore _authStore;
|
|
||||||
final Logger _logger;
|
final Logger _logger;
|
||||||
|
|
||||||
|
/// Underlying PocketBase instance.
|
||||||
PocketBase get pb => _pb;
|
PocketBase get pb => _pb;
|
||||||
|
|
||||||
|
/// Stream of authentication state changes.
|
||||||
Stream<AuthStoreEvent> get authStateChanges => _pb.authStore.onChange;
|
Stream<AuthStoreEvent> get authStateChanges => _pb.authStore.onChange;
|
||||||
|
|
||||||
|
/// Creates a new ApiClient.
|
||||||
ApiClient({
|
ApiClient({
|
||||||
required AuthStore authStore,
|
required AuthStore authStore,
|
||||||
Logger? logger,
|
Logger? logger,
|
||||||
}) : _logger = logger ?? Logger() {
|
}) : _logger = logger ?? Logger() {
|
||||||
_pb = PocketBase(
|
_pb = PocketBase(
|
||||||
AppConstants.apiBaseUrl,
|
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<T> _handleRequest<T>(Future<T> Function() request) async {
|
Future<T> _handleRequest<T>(Future<T> Function() request) async {
|
||||||
try {
|
try {
|
||||||
return await request();
|
return await request();
|
||||||
|
|
@ -67,6 +57,7 @@ class ApiClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Logs in a user using email and password.
|
||||||
Future<RecordAuth> login(String email, String password) async {
|
Future<RecordAuth> login(String email, String password) async {
|
||||||
try {
|
try {
|
||||||
final authData = await _pb.collection('users').authWithPassword(
|
final authData = await _pb.collection('users').authWithPassword(
|
||||||
|
|
@ -81,6 +72,7 @@ class ApiClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Registers a new user account.
|
||||||
Future<RecordAuth> register({
|
Future<RecordAuth> register({
|
||||||
required String email,
|
required String email,
|
||||||
required String username,
|
required String username,
|
||||||
|
|
@ -91,7 +83,7 @@ class ApiClient {
|
||||||
Map<String, dynamic>? avatarConfig,
|
Map<String, dynamic>? avatarConfig,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final user = await _pb.collection('users').create(body: {
|
await _pb.collection('users').create(body: {
|
||||||
'email': email,
|
'email': email,
|
||||||
'name': username,
|
'name': username,
|
||||||
'password': password,
|
'password': password,
|
||||||
|
|
@ -112,6 +104,7 @@ class ApiClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Requests a verification email for the given address.
|
||||||
Future<void> requestVerification(String email) async {
|
Future<void> requestVerification(String email) async {
|
||||||
try {
|
try {
|
||||||
await _pb.collection('users').requestVerification(email);
|
await _pb.collection('users').requestVerification(email);
|
||||||
|
|
@ -122,6 +115,7 @@ class ApiClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Forces a token refresh.
|
||||||
Future<void> refreshAuth() async {
|
Future<void> refreshAuth() async {
|
||||||
try {
|
try {
|
||||||
await _pb.collection('users').authRefresh();
|
await _pb.collection('users').authRefresh();
|
||||||
|
|
@ -133,10 +127,12 @@ class ApiClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clears the authentication store.
|
||||||
Future<void> logout() async {
|
Future<void> logout() async {
|
||||||
_pb.authStore.clear();
|
_pb.authStore.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Synchronizes local data with the server.
|
||||||
Future<Map<String, dynamic>> sync({
|
Future<Map<String, dynamic>> sync({
|
||||||
required String lastSyncTimestamp,
|
required String lastSyncTimestamp,
|
||||||
required Map<String, dynamic> pushData,
|
required Map<String, dynamic> pushData,
|
||||||
|
|
@ -154,6 +150,7 @@ class ApiClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates a new training cycle on the server.
|
||||||
Future<Map<String, dynamic>> createCycle(
|
Future<Map<String, dynamic>> createCycle(
|
||||||
Map<String, double> trainingMaxes) async {
|
Map<String, double> trainingMaxes) async {
|
||||||
return _handleRequest(() async {
|
return _handleRequest(() async {
|
||||||
|
|
@ -166,6 +163,7 @@ class ApiClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Marks a training cycle as finished on the server.
|
||||||
Future<Map<String, dynamic>> finishCycle(String cycleId) async {
|
Future<Map<String, dynamic>> finishCycle(String cycleId) async {
|
||||||
return _handleRequest(() async {
|
return _handleRequest(() async {
|
||||||
final result = await _pb.send(
|
final result = await _pb.send(
|
||||||
|
|
@ -177,6 +175,7 @@ class ApiClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Retrieves the current active cycle from the server.
|
||||||
Future<Map<String, dynamic>> getCurrentCycle() async {
|
Future<Map<String, dynamic>> getCurrentCycle() async {
|
||||||
return _handleRequest(() async {
|
return _handleRequest(() async {
|
||||||
final result = await _pb.send(ApiEndpoints.cycleCurrent, method: 'GET');
|
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<Map<String, dynamic>> getStatsHistory({
|
Future<Map<String, dynamic>> getStatsHistory({
|
||||||
required String exercise,
|
required String exercise,
|
||||||
required String range,
|
required String range,
|
||||||
|
|
@ -201,6 +201,7 @@ class ApiClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Retrieves a summary of all statistics.
|
||||||
Future<Map<String, dynamic>> getStatsSummary() async {
|
Future<Map<String, dynamic>> getStatsSummary() async {
|
||||||
return _handleRequest(() async {
|
return _handleRequest(() async {
|
||||||
final result = await _pb.send(ApiEndpoints.statsSummary, method: 'GET');
|
final result = await _pb.send(ApiEndpoints.statsSummary, method: 'GET');
|
||||||
|
|
@ -208,6 +209,7 @@ class ApiClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Updates the user's bodyweight on the server.
|
||||||
Future<void> updateBodyweight(double bodyweight) async {
|
Future<void> updateBodyweight(double bodyweight) async {
|
||||||
await _handleRequest(() async {
|
await _handleRequest(() async {
|
||||||
await _pb.send(
|
await _pb.send(
|
||||||
|
|
@ -218,6 +220,7 @@ class ApiClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Updates the user's inventory settings on the server.
|
||||||
Future<void> updateInventory(Map<String, dynamic> inventory) async {
|
Future<void> updateInventory(Map<String, dynamic> inventory) async {
|
||||||
await _handleRequest(() async {
|
await _handleRequest(() async {
|
||||||
await _pb.send(
|
await _pb.send(
|
||||||
|
|
@ -228,6 +231,7 @@ class ApiClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Updates the user's password.
|
||||||
Future<void> updatePassword({
|
Future<void> updatePassword({
|
||||||
required String userId,
|
required String userId,
|
||||||
required String oldPassword,
|
required String oldPassword,
|
||||||
|
|
@ -243,12 +247,14 @@ class ApiClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deletes the user's account.
|
||||||
Future<void> deleteAccount(String userId) async {
|
Future<void> deleteAccount(String userId) async {
|
||||||
await _handleRequest(() async {
|
await _handleRequest(() async {
|
||||||
await _pb.collection('users').delete(userId);
|
await _pb.collection('users').delete(userId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resets all training progress on the server.
|
||||||
Future<void> resetProgress() async {
|
Future<void> resetProgress() async {
|
||||||
await _handleRequest(() async {
|
await _handleRequest(() async {
|
||||||
await _pb.send(
|
await _pb.send(
|
||||||
|
|
@ -258,10 +264,12 @@ class ApiClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the current authentication token.
|
||||||
String? getToken() {
|
String? getToken() {
|
||||||
return _pb.authStore.token.isNotEmpty ? _pb.authStore.token : null;
|
return _pb.authStore.token.isNotEmpty ? _pb.authStore.token : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the current user's ID.
|
||||||
String? getUserId() {
|
String? getUserId() {
|
||||||
return _pb.authStore.record?.id;
|
return _pb.authStore.record?.id;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
78
pubspec.lock
|
|
@ -25,6 +25,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.1.11"
|
version: "0.1.11"
|
||||||
|
archive:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: archive
|
||||||
|
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.7"
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -289,6 +297,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
|
dbus:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dbus
|
||||||
|
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.12"
|
||||||
dio:
|
dio:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -398,6 +414,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.2.1"
|
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:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
|
|
@ -406,6 +430,30 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
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:
|
flutter_localizations:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|
@ -565,6 +613,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
|
image:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image
|
||||||
|
sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.7.2"
|
||||||
intl:
|
intl:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -718,7 +774,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: path
|
name: path
|
||||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||||
|
|
@ -809,10 +865,10 @@ packages:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: pocketbase
|
name: pocketbase
|
||||||
sha256: e6aca23d3181a23d367c6650fc4ebe6855bfba4d18b3746ceb1974fb152d8cf9
|
sha256: b94e63f00ce29c5f465e8122ae63cdf4e3c37f9b3dc6d04f0dfe6625bcdb8839
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.19.1"
|
version: "0.23.2"
|
||||||
pool:
|
pool:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -821,6 +877,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.2"
|
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:
|
pretty_dio_logger:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -1194,6 +1258,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.12"
|
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:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
15
pubspec.yaml
|
|
@ -13,6 +13,8 @@ dependencies:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
audioplayers: ^6.0.0
|
audioplayers: ^6.0.0
|
||||||
flutter_dotenv: ^5.1.0
|
flutter_dotenv: ^5.1.0
|
||||||
|
flutter_local_notifications: ^18.0.0
|
||||||
|
timezone: ^0.10.0
|
||||||
|
|
||||||
# State Management
|
# State Management
|
||||||
flutter_riverpod: ^3.1.0
|
flutter_riverpod: ^3.1.0
|
||||||
|
|
@ -24,11 +26,12 @@ dependencies:
|
||||||
sqlite3_flutter_libs: ^0.5.20
|
sqlite3_flutter_libs: ^0.5.20
|
||||||
path_provider: ^2.1.3
|
path_provider: ^2.1.3
|
||||||
share_plus: ^7.2.1
|
share_plus: ^7.2.1
|
||||||
|
path: ^1.9.0
|
||||||
|
|
||||||
# Networking
|
# Networking
|
||||||
dio: ^5.4.3+1
|
dio: ^5.4.3+1
|
||||||
pretty_dio_logger: ^1.3.1
|
pretty_dio_logger: ^1.3.1
|
||||||
pocketbase: ^0.19.0
|
pocketbase: ^0.23.2
|
||||||
|
|
||||||
# Storage
|
# Storage
|
||||||
flutter_secure_storage: ^10.0.0
|
flutter_secure_storage: ^10.0.0
|
||||||
|
|
@ -62,6 +65,7 @@ dev_dependencies:
|
||||||
drift_dev: ^2.16.0
|
drift_dev: ^2.16.0
|
||||||
freezed: ^3.2.3
|
freezed: ^3.2.3
|
||||||
json_serializable: ^6.8.0
|
json_serializable: ^6.8.0
|
||||||
|
flutter_launcher_icons: ^0.14.4
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
@ -75,6 +79,7 @@ flutter:
|
||||||
- assets/images/enemies/
|
- assets/images/enemies/
|
||||||
- assets/images/backgrounds/
|
- assets/images/backgrounds/
|
||||||
- assets/audio/
|
- assets/audio/
|
||||||
|
- assets/icons/
|
||||||
- .env
|
- .env
|
||||||
- .env.development
|
- .env.development
|
||||||
- .env.production
|
- .env.production
|
||||||
|
|
@ -83,3 +88,11 @@ flutter:
|
||||||
# - family: PixelFont
|
# - family: PixelFont
|
||||||
# fonts:
|
# fonts:
|
||||||
# - asset: assets/fonts/pixel.ttf
|
# - 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"
|
||||||
|
|
|
||||||