diff --git a/.gitignore b/.gitignore
index 3820a95..84f4bd6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,3 +43,7 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
+
+.env
+.env.production
+.env.development
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 4428126..610d9a0 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -1,49 +1,5 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
\ No newline at end of file
+
diff --git a/assets/audio/beep_long.ogg b/assets/audio/beep_long.ogg
new file mode 100644
index 0000000..1fe6edf
Binary files /dev/null and b/assets/audio/beep_long.ogg differ
diff --git a/assets/audio/beep_short.ogg b/assets/audio/beep_short.ogg
new file mode 100644
index 0000000..73ef94a
Binary files /dev/null and b/assets/audio/beep_short.ogg differ
diff --git a/assets/images/backgrounds/commercial_gym.png b/assets/images/backgrounds/commercial_gym.png
new file mode 100644
index 0000000..baad337
Binary files /dev/null and b/assets/images/backgrounds/commercial_gym.png differ
diff --git a/assets/images/backgrounds/olympic_gym.png b/assets/images/backgrounds/olympic_gym.png
new file mode 100644
index 0000000..eae984f
Binary files /dev/null and b/assets/images/backgrounds/olympic_gym.png differ
diff --git a/assets/images/backgrounds/street_park_night.png b/assets/images/backgrounds/street_park_night.png
new file mode 100644
index 0000000..f6b4d5e
Binary files /dev/null and b/assets/images/backgrounds/street_park_night.png differ
diff --git a/l10n.yaml b/l10n.yaml
new file mode 100644
index 0000000..15338f2
--- /dev/null
+++ b/l10n.yaml
@@ -0,0 +1,3 @@
+arb-dir: lib/l10n
+template-arb-file: app_en.arb
+output-localization-file: app_localizations.dart
diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb
new file mode 100644
index 0000000..1318757
--- /dev/null
+++ b/lib/l10n/app_de.arb
@@ -0,0 +1,310 @@
+{
+ "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",
+
+ "loginWelcomeBack": "WILLKOMMEN ZURÜCK",
+ "loginSubtitle": "Zeit für das nächste Level",
+ "loginErrorInvalid": "Ungültige E-Mail oder Passwort",
+ "loginErrorConnection": "Keine Verbindung zum Server.\nBitte prüfe deine Internetverbindung.",
+ "loginErrorTimeout": "Zeitüberschreitung.\nBitte versuche es erneut.",
+ "loginErrorGeneric": "Login fehlgeschlagen. Bitte erneut versuchen.",
+ "emailLabel": "E-Mail",
+ "emailEmptyError": "Bitte gib deine E-Mail ein",
+ "emailInvalidError": "Bitte gib eine gültige E-Mail ein",
+ "passwordLabel": "Passwort",
+ "passwordEmptyError": "Bitte gib dein Passwort ein",
+ "passwordLengthError": "Passwort muss mindestens 8 Zeichen haben",
+ "loginButton": "ANMELDEN",
+ "loginNoAccount": "Kein Account? ",
+ "loginRegisterButton": "REGISTRIEREN",
+
+ "registerTitle": "KONTO ERSTELLEN",
+ "registerSubtitle": "Beginne deine Reise",
+ "registerEmailHelper": "Wird für den Login verwendet",
+ "continueButton": "WEITER",
+ "registerHaveAccount": "Bereits registriert? ",
+ "registerLoginButton": "LOGIN",
+
+ "hubNoActiveCycle": "Kein aktiver Zyklus",
+ "hubCreateCycle": "Neuen Zyklus starten",
+ "hubCycleLabel": "Zyklus",
+ "hubActiveLabel": "Aktiv",
+ "hubActiveYes": "Ja",
+ "navHistory": "Historie",
+ "navInventory": "Inventar",
+ "navStats": "Statistik",
+ "navCodex": "Kodex",
+ "missionBriefingTitle": "MISSION BRIEFING",
+ "missionBriefingBody": "Der Feind flieht! Wir haben ein 20-Minuten-Fenster, um ihn abzufangen.",
+ "missionBriefingDensity": "Kampfdichte: {sets} Sätze",
+ "@missionBriefingDensity": {
+ "placeholders": {
+ "sets": {
+ "type": "int"
+ }
+ }
+ },
+ "missionBriefingInterval": "Intervall: Alle {seconds} Sekunden",
+ "@missionBriefingInterval": {
+ "placeholders": {
+ "seconds": {
+ "type": "String"
+ }
+ }
+ },
+ "missionBriefingHardcore": "⚠️ HARDCORE MODUS",
+ "abortButton": "ABBRECHEN",
+ "engageButton": "ANGREIFEN",
+
+ "inventoryTitle": "Ausrüstung verwalten",
+ "saveButton": "SPEICHERN",
+ "inventoryBarbellWeight": "Hantelstangengewicht",
+ "inventoryPresets": "Schnellwahl",
+ "inventoryPresetHome": "Home Gym",
+ "inventoryPresetCommercial": "Fitnessstudio",
+ "inventoryPresetMinimal": "Minimal",
+ "inventoryPlates": "Verfügbare Scheiben",
+ "inventoryBands": "Widerstandsbänder (Hilfe)",
+ "saveChangesButton": "ÄNDERUNGEN SPEICHERN",
+ "inventoryUpdatedSuccess": "Inventar erfolgreich aktualisiert",
+ "inventorySaveError": "Fehler beim Speichern: {error}",
+ "@inventorySaveError": {
+ "placeholders": {
+ "error": {
+ "type": "String"
+ }
+ }
+ },
+
+ "statsTitle": "Statistik & Zyklen",
+ "statsProgressAnalysis": "Fortschrittsanalyse",
+ "statsCycleTitle": "ZYKLUS {number}",
+ "@statsCycleTitle": {
+ "placeholders": {
+ "number": {
+ "type": "int"
+ }
+ }
+ },
+ "statsCurrentTM": "Aktuelle Trainingsmaxima (TM)",
+ "statsFinishCycle": "ZYKLUS BEENDEN & LEVEL UP",
+ "statsCycleFinishedTitle": "Dungeon gesäubert!",
+ "statsCycleFinishedBody": "Du hast die Wächter dieses Zyklus besiegt. Doch tiefer im Dungeon warten stärkere Feinde...",
+ "statsTMIncreased": "Deine Trainingsmaxima wurden erhöht:",
+ "statsStalled": "STAGNIERT",
+ "statsEnterNextLevel": "NÄCHSTES LEVEL BETRETEN",
+
+ "historyTitle": "Quest Log",
+ "historyEmptyTitle": "Noch keine Quests abgeschlossen",
+ "historyEmptyBody": "Absolviere ein Training, um dein Journal zu füllen",
+ "historyUnknownWorkout": "Unbekanntes Training",
+
+ "battleWave": "WELLE {current} / {total}",
+ "@battleWave": {
+ "placeholders": {
+ "current": {"type": "int"},
+ "total": {"type": "int"}
+ }
+ },
+ "battleSet": "Satz {current} von {total}",
+ "@battleSet": {
+ "placeholders": {
+ "current": {"type": "int"},
+ "total": {"type": "int"}
+ }
+ },
+ "battleWeight": "GEWICHT",
+ "battleReps": "WH",
+ "battleAssistance": "UNTERSTÜTZUNG",
+ "battleCompleteSet": "SATZ ABSCHLIESSEN",
+ "battleRest": "PAUSE",
+ "battleSkipRest": "WEITER",
+ "battleUpNext": "NÄCHSTES: {exercise}",
+ "@battleUpNext": {
+ "placeholders": {
+ "exercise": {"type": "String"}
+ }
+ },
+ "battleRaidComplete": "RAID ERFOLGREICH!",
+ "battleBackToHub": "ZUR ZENTRALE",
+ "levelUpTitle": "LEVEL AUFSTIEG!",
+ "levelUpBody": "Du bist stärker geworden!",
+ "levelUpSubtitle": "Die Monster erzittern vor deiner neuen Macht.",
+ "battleAbandonTitle": "Raid abbrechen?",
+ "battleAbandonBody": "Dein Fortschritt wird nicht gespeichert.",
+ "cancelButton": "ABBRECHEN",
+ "abandonButton": "AUFGEBEN",
+ "amrapResultTitle": "🔥 AMRAP ERGEBNIS 🔥",
+ "amrapResultBody": "Alles gegeben! Wie viele waren es?",
+ "amrapConfirm": "ERGEBNIS BESTÄTIGEN",
+ "emomFinishedTitle": "MISSION ERFÜLLT",
+ "emomFinishedBody": "Die Zeit ist um. Hast du durchgehalten?",
+ "emomSetsCompleted": "SÄTZE ABGESCHLOSSEN",
+ "emomConfirm": "BESTÄTIGEN & BEENDEN",
+ "emomRepsPerRound": "Wiederholungen pro Runde",
+
+ "questTabDailies": "TÄGLICH",
+ "questTabJourney": "REISE",
+ "questEmptyDailies": "Keine täglichen Quests.\nKomm morgen wieder!",
+ "questEmptyJourney": "Deine Reise hat gerade erst begonnen.",
+
+ "setupProfileTitle": "Profil einrichten",
+ "bodyweightTitle": "Wie schwer bist du aktuell?",
+ "bodyweightSubtitle": "Wir benötigen dies zur Berechnung deiner Weighted Calisthenics Übungen",
+ "unitKg": "KG",
+ "unitLbs": "LBS",
+
+ "strengthTestTitle": "Stärke-Test",
+ "strengthTestSubtitle": "Kampf-Kalibrierung",
+ "strengthTestBody": "Wir müssen dein aktuelles Kraftlevel ermitteln, um die richtigen Monster zuzuweisen.",
+ "strengthLegs": "Beinkraft",
+ "strengthPull": "Zugkraft (Pull)",
+ "strengthPush": "Druckkraft (Push)",
+ "exerciseSquat": "Kniebeuge (Back Squat)",
+ "exercisePullup": "Weighted Pull-up",
+ "exerciseRow": "Pendlay Row",
+ "exerciseDip": "Weighted Dip",
+ "exerciseBench": "Bankdrücken",
+ "canDoOneRep": "Schaffst du 1 Rep?",
+ "isAssisted": "Mit Band-Hilfe?",
+ "addWeightLabel": "Zusatzgewicht (kg)",
+ "weightLabel": "Gewicht (kg)",
+ "bandAssistanceLabel": "Band-Hilfe (kg)",
+ "rowWeightLabel": "Ruder-Gewicht (kg)",
+ "repsLabel": "Wiederholungen",
+ "reps5rmLabel": "5RM Wiederholungen (meist 5)",
+ "est1rm": "Geschätztes 1RM",
+ "trainingMaxLabel": "Trainingsmaximum (90%)",
+ "adjustedWendler": "Angepasst: Wendler 5/3/1",
+ "tmExplanation": "Dein \"Trainingsmaximum\" (TM) ist deine Basis-Kampfkraft (90% vom 1RM). Bei Eigengewichtsübungen passen wir die Strategie an.",
+
+ "setupEquipmentTitle": "Ausrüstung Setup",
+ "setupInventoryTitle": "Ausrüstungsinventar",
+ "setupInventorySubtitle": "Sag uns, welche Ausrüstung du hast",
+ "setupBandsSubtitle": "Wähle Bänder für Pullup/Dip Unterstützung",
+ "nextStepButton": "NÄCHSTER SCHRITT",
+
+ "setupAvatarTitle": "Wähle deinen Helden",
+ "finishButton": "FERTIGSTELLEN",
+ "setupAvatarSubtitle": "So werden dich die Legenden in Erinnerung behalten.",
+ "secureAccountTitle": "Konto sichern",
+ "secureAccountBody": "Wähle ein starkes Passwort, um deinen Fortschritt zu schützen",
+ "confirmPasswordLabel": "Passwort bestätigen",
+ "passwordsDoNotMatch": "Passwörter stimmen nicht überein",
+ "confirmButton": "BESTÄTIGEN",
+
+ "guidePullupTitle": "Weighted Pull-Up",
+ "guidePullupLore": "Den Körper gegen die Schwerkraft zu ziehen, ist der ultimative Beweis für Oberkörperkraft.",
+ "guidePullupSteps": "Greife die Stange etwas weiter als schulterbreit (Obergriff)|Aktiviere den Core und ziehe die Schulterblätter nach unten/hinten|Ziehe dich hoch, bis das Kinn über der Stange ist|Lasse dich kontrolliert wieder ab",
+ "guidePullupMuscles": "Latissimus|Bizeps|Unterarme",
+ "guidePullupMistakes": "Schwingen (Kipping)|Halbe Wiederholungen|Schultern hochgezogen lassen",
+
+ "guideDipTitle": "Weighted Dip",
+ "guideDipLore": "Ein fundamentaler Druck-Move, um Mauern zu überwinden.",
+ "guideDipSteps": "Stütze dich auf die Barren, Arme gestreckt|Lehne dich leicht nach vorne für mehr Brust-Fokus|Senke den Körper ab, bis die Schultern unter den Ellbogen sind|Drücke dich explosiv zurück in die Ausgangsposition",
+ "guideDipMuscles": "Brust|Trizeps|Vordere Schulter",
+ "guideDipMistakes": "Zu wenig Tiefe|Ellbogen wandern zu weit nach außen",
+
+ "guideSquatTitle": "Low Bar Back Squat",
+ "guideSquatLore": "Die Mutter aller Schlachten. Trainiert den gesamten Körperpanzer.",
+ "guideSquatSteps": "Lege die Hantel auf dem hinteren Deltamuskel ab (nicht im Nacken)|Füße schulterbreit, Zehen leicht nach außen|Atme tief ein (Bracing) und schiebe die Hüfte nach hinten|Gehe in die Hocke (Hüfte unter Kniehöhe)|Drücke dich aus der Ferse/Mittelfuß wieder hoch",
+ "guideSquatMuscles": "Quadrizeps|Gesäß|Core|Rückenstrecker",
+ "guideSquatMistakes": "Knie fallen nach innen|Rücken rundet ein|Zu wenig Tiefe",
+
+ "guideBenchTitle": "Bench Press",
+ "guideBenchLore": "Der Standard-Test für reine Druckkraft.",
+ "guideBenchSteps": "Lege dich auf die Bank, Augen unter der Stange|Füße fest am Boden, leichter Bogen im Rücken (Brücke)|Senke die Hantel kontrolliert zur unteren Brust|Drücke die Hantel explosiv nach oben",
+ "guideBenchMuscles": "Brust|Trizeps|Vordere Schulter",
+ "guideBenchMistakes": "Ellbogen 90° abgespreizt (Verletzungsgefahr)|Hintern hebt ab",
+
+ "guideOhpTitle": "Overhead Press",
+ "guideOhpLore": "Ein Objekt über den Kopf zu stemmen erfordert pure Stabilität.",
+ "guideOhpSteps": "Stange liegt auf dem vorderen Schultermuskel|Fester Stand, Gesäß und Bauch maximal anspannen|Kopf leicht zurücknehmen, Stange vertikal nach oben drücken|Oben den Kopf \"durch das Fenster\" der Arme schieben",
+ "guideOhpMuscles": "Schultern|Trizeps|Core",
+ "guideOhpMistakes": "Hohlkreuz (Rücklage)|Beine helfen mit (Push Press)",
+
+ "guideRdlTitle": "Romanian Deadlift",
+ "guideRdlLore": "Baut die hintere Kette auf, essenziell für Stabilität.",
+ "guideRdlSteps": "Startposition stehend mit Hantel|Schiebe die Hüfte weit nach hinten, Beine bleiben fast gestreckt|Senke die Hantel am Bein entlang bis knapp unter das Knie|Spüre den Zug im Beinbeuger und richte dich wieder auf",
+ "guideRdlMuscles": "Beinbeuger (Hamstrings)|Gesäß|Unterer Rücken",
+ "guideRdlMistakes": "Rücken rundet ein|Hantel zu weit vom Körper weg",
+
+ "guideRowTitle": "Pendlay Row",
+ "guideRowLore": "Explosive Zugkraft vom Boden. Für einen starken Rücken.",
+ "guideRowSteps": "Oberkörper parallel zum Boden, Rücken gerade|Hantel liegt bei jeder Wiederholung tot auf dem Boden|Ziehe die Hantel explosiv zum unteren Brustbein|Kontrolliert ablegen, Spannung kurz lösen, neu ansetzen",
+ "guideRowMuscles": "Latissimus|Trapez|Hintere Schulter",
+ "guideRowMistakes": "Oberkörper richtet sich auf|Reißen mit Schwung",
+
+ "guideCurlTitle": "Barbell Curl",
+ "guideCurlLore": "Isolierte Kraft für den finalen Schlag.",
+ "guideCurlSteps": "Stehender Stand, Hantel im Untergriff|Ellbogen bleiben fixiert am Körper|Hantel zur Brust curlen, oben kurz halten|Langsam ablassen",
+ "guideCurlMuscles": "Bizeps",
+ "guideCurlMistakes": "Schwingen aus der Hüfte|Ellbogen wandern nach vorne",
+
+ "guideKbSwingTitle": "Kettlebell Swing",
+ "guideKbSwingLore": "Ballistische Kraft und Ausdauer. Die Hüfte ist der Motor.",
+ "guideKbSwingSteps": "Hüftbreiter Stand, KB vor dir am Boden|Hike-Pass: Ziehe die KB durch die Beine nach hinten|Hüfte explosiv strecken (Snap!), KB fliegt durch Hüftkraft auf Brusthöhe|KB kontrolliert zurückschwingen lassen",
+ "guideKbSwingMuscles": "Gesäß|Beinbeuger|Core|Ausdauer",
+ "guideKbSwingMistakes": "Kniebeuge statt Hüftbeuge|Arme heben das Gewicht",
+
+ "guideKbSnatchTitle": "Kettlebell Snatch",
+ "guideKbSnatchLore": "Der Zar der Kettlebell-Übungen. Totale Körperkontrolle.",
+ "guideKbSnatchSteps": "Starte wie beim Swing (Einarmig)|Hüftkraft beschleunigt die Kugel nach oben|Bei Kopfhöhe: Durchstoßen der Hand (\"Punch through\")|Sanftes Auffangen im Lockout über Kopf",
+ "guideKbSnatchMuscles": "Gesamter Körper|Schultern|Griffkraft",
+ "guideKbSnatchMistakes": "Kugel knallt auf den Unterarm|Zu wenig Hüftkraft",
+
+ "guideKbThrusterTitle": "Kettlebell Thruster",
+ "guideKbThrusterLore": "Eine brutale Kombination aus Squat und Press.",
+ "guideKbThrusterSteps": "KB in der Rack-Position (vor der Brust)|Tiefe Kniebeuge|Beim Aufstehen den Schwung nutzen, um KB über Kopf zu drücken|Zurück in die Rack-Position beim Absenken in den nächsten Squat",
+ "guideKbThrusterMuscles": "Beine|Schultern|Lunge (Cardio)",
+ "guideKbThrusterMistakes": "Pause zwischen Squat und Press|Rücken rundet im Squat",
+
+ "guideKbCleanPressTitle": "KB Clean & Press",
+ "guideKbCleanPressLore": "Zwei Bewegungen in Harmonie.",
+ "guideKbCleanPressSteps": "Clean: Ziehe die KB vom Boden in die Rack-Position vor der Brust|Press: Drücke sie strikt über den Kopf|Senke sie in Rack, dann zum Boden (oder Swing)",
+ "guideKbCleanPressMuscles": "Schultern|Rücken|Beine",
+ "guideKbCleanPressMistakes": "Clean knallt auf Arm|Hohlkreuz beim Press",
+
+ "guideFacePullTitle": "Band Face Pull",
+ "guideFacePullLore": "Schützt die Schultern vor dem Verschleiß des Kampfes.",
+ "guideFacePullSteps": "Band auf Kopfhöhe befestigen|Ziehe das Band zum Gesicht (Richtung Stirn/Augen)|Ellbogen hoch und weit nach außen ziehen|Schulterblätter hinten zusammenkneifen",
+ "guideFacePullMuscles": "Hintere Schulter|Rotatorenmanschette",
+ "guideFacePullMistakes": "Ellbogen zu tief|Kopf nach vorne schieben",
+
+ "guideAbWheelTitle": "Ab Wheel Rollout",
+ "guideAbWheelLore": "Ein Stahlkern, der jeden Treffer absorbiert.",
+ "guideAbWheelSteps": "Knie am Boden, Rad vor den Knien|Rolle nach vorne, halte den Rücken rund/stabil (Hollow Body)|Gehe nur so weit, wie du den Rücken stabil halten kannst|Ziehe dich aus dem Bauchmuskel zurück",
+ "guideAbWheelMuscles": "Core (Anti-Extension)",
+ "guideAbWheelMistakes": "Hohlkreuz (Gefährlich!)|Ziehen aus den Armen",
+
+ "guidePlankTitle": "Plank",
+ "guidePlankLore": "Unbeweglich wie ein Fels in der Brandung.",
+ "guidePlankSteps": "Unterarmstütz, Körper bildet eine Linie|Gesäß und Bauch fest anspannen|Schulterblätter auseinanderdrücken|Atmen nicht vergessen!",
+ "guidePlankMuscles": "Core",
+ "guidePlankMistakes": "Hüfte hängt durch|Gesäß zu hoch",
+
+ "codexTitle": "Kreaturen-Kodex",
+
+ "enemyIronGolemName": "Eisengolem",
+ "enemyIronGolemTitle": "Das Gewicht der Erde",
+ "enemyIronGolemDesc": "Geschmiedet aus den tektonischen Platten der tiefen Erde, existiert der Eisengolem nur, um die Schwachen zu zermalmen. Er verkörpert die unerbittliche Schwerkraft, die auf eine schwere Last wirkt.\n\nEr respektiert nur eines: Die rohe Kraft der BEINE, die seinem erdrückenden Gewicht standhalten können.",
+ "enemyIronGolemNemesis": "Kniebeugen-Nemesis",
+
+ "enemyGravityDemonName": "Schwerkraft-Dämon",
+ "enemyGravityDemonTitle": "Der Sog des Abgrunds",
+ "enemyGravityDemonDesc": "Ein Geist reiner Abwärtskraft, der sich an den Rücken von Abenteurern klammert. Er flüstert Lügen der Schwäche in dein Ohr, während er dich in den Abgrund zieht.\n\nNur wer einen Rücken aus Stahl und den Willen hat, sich hochzuziehen, kann seinem Griff entkommen.",
+ "enemyGravityDemonNemesis": "Klimmzug-Nemesis",
+
+ "enemyPressurePhantomName": "Druck-Phantom",
+ "enemyPressurePhantomTitle": "Der unsichtbare Zermalmer",
+ "enemyPressurePhantomDesc": "Eine ätherische Entität, die die Luft um dich herum komprimiert. Es versucht, Brust und Schultern derer kollabieren zu lassen, die es wagen, dagegen zu drücken.\n\nBesiege es, indem du mit explosiver Dip-Kraft durch den Schmerz drückst.",
+ "enemyPressurePhantomNemesis": "Dip-Nemesis"
+}
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
new file mode 100644
index 0000000..63dc211
--- /dev/null
+++ b/lib/l10n/app_en.arb
@@ -0,0 +1,310 @@
+{
+ "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",
+
+ "loginWelcomeBack": "WELCOME BACK",
+ "loginSubtitle": "Time to level up your strength",
+ "loginErrorInvalid": "Invalid email or password",
+ "loginErrorConnection": "Could not connect to server.\nPlease check your internet connection.",
+ "loginErrorTimeout": "Connection timeout.\nPlease try again.",
+ "loginErrorGeneric": "Login failed. Please try again.",
+ "emailLabel": "Email",
+ "emailEmptyError": "Please enter your email",
+ "emailInvalidError": "Please enter a valid email",
+ "passwordLabel": "Password",
+ "passwordEmptyError": "Please enter your password",
+ "passwordLengthError": "Password must be at least 8 characters",
+ "loginButton": "LOGIN",
+ "loginNoAccount": "Don't have an account? ",
+ "loginRegisterButton": "REGISTER",
+
+ "registerTitle": "CREATE ACCOUNT",
+ "registerSubtitle": "Begin your strength journey",
+ "registerEmailHelper": "You will use this to login",
+ "continueButton": "CONTINUE",
+ "registerHaveAccount": "Already have an account? ",
+ "registerLoginButton": "LOGIN",
+
+ "hubNoActiveCycle": "No active cycle",
+ "hubCreateCycle": "Create New Cycle",
+ "hubCycleLabel": "Cycle",
+ "hubActiveLabel": "Active",
+ "hubActiveYes": "Yes",
+ "navHistory": "History",
+ "navInventory": "Inventory",
+ "navStats": "Stats",
+ "navCodex": "Codex",
+ "missionBriefingTitle": "MISSION BRIEFING",
+ "missionBriefingBody": "The enemy is fleeing! We have a 20-minute window to intercept.",
+ "missionBriefingDensity": "Combat Density: {sets} Sets",
+ "@missionBriefingDensity": {
+ "placeholders": {
+ "sets": {
+ "type": "int"
+ }
+ }
+ },
+ "missionBriefingInterval": "Interval: Every {seconds} seconds",
+ "@missionBriefingInterval": {
+ "placeholders": {
+ "seconds": {
+ "type": "String"
+ }
+ }
+ },
+ "missionBriefingHardcore": "⚠️ HARDCORE MODE",
+ "abortButton": "ABORT",
+ "engageButton": "ENGAGE",
+
+ "inventoryTitle": "Manage Equipment",
+ "saveButton": "SAVE",
+ "inventoryBarbellWeight": "Barbell Weight",
+ "inventoryPresets": "Quick Presets",
+ "inventoryPresetHome": "Home Gym",
+ "inventoryPresetCommercial": "Commercial",
+ "inventoryPresetMinimal": "Minimal",
+ "inventoryPlates": "Plates Available",
+ "inventoryBands": "Resistance Bands (Assistance)",
+ "saveChangesButton": "SAVE CHANGES",
+ "inventoryUpdatedSuccess": "Inventory updated successfully",
+ "inventorySaveError": "Error saving: {error}",
+ "@inventorySaveError": {
+ "placeholders": {
+ "error": {
+ "type": "String"
+ }
+ }
+ },
+
+ "statsTitle": "Statistics & Cycles",
+ "statsProgressAnalysis": "Progress Analysis",
+ "statsCycleTitle": "CYCLE {number}",
+ "@statsCycleTitle": {
+ "placeholders": {
+ "number": {
+ "type": "int"
+ }
+ }
+ },
+ "statsCurrentTM": "Current Training Maxes (TM)",
+ "statsFinishCycle": "FINISH CYCLE & LEVEL UP",
+ "statsCycleFinishedTitle": "Dungeon Cleared!",
+ "statsCycleFinishedBody": "You have defeated the guardians of this cycle. But deeper in the dungeon, stronger foes await...",
+ "statsTMIncreased": "Your Training Maxes have increased:",
+ "statsStalled": "STALLED",
+ "statsEnterNextLevel": "ENTER NEXT LEVEL",
+
+ "historyTitle": "Quest Log",
+ "historyEmptyTitle": "No completed quests yet",
+ "historyEmptyBody": "Complete a workout to fill your journal",
+ "historyUnknownWorkout": "Unknown Workout",
+
+ "battleWave": "WAVE {current} / {total}",
+ "@battleWave": {
+ "placeholders": {
+ "current": {"type": "int"},
+ "total": {"type": "int"}
+ }
+ },
+ "battleSet": "Set {current} of {total}",
+ "@battleSet": {
+ "placeholders": {
+ "current": {"type": "int"},
+ "total": {"type": "int"}
+ }
+ },
+ "battleWeight": "WEIGHT",
+ "battleReps": "REPS",
+ "battleAssistance": "ASSISTANCE",
+ "battleCompleteSet": "COMPLETE SET",
+ "battleRest": "REST",
+ "battleSkipRest": "SKIP REST",
+ "battleUpNext": "UP NEXT: {exercise}",
+ "@battleUpNext": {
+ "placeholders": {
+ "exercise": {"type": "String"}
+ }
+ },
+ "battleRaidComplete": "RAID COMPLETE!",
+ "battleBackToHub": "BACK TO HUB",
+ "levelUpTitle": "LEVEL UP!",
+ "levelUpBody": "You have grown stronger!",
+ "levelUpSubtitle": "The monsters tremble at your new power.",
+ "battleAbandonTitle": "Abandon Raid?",
+ "battleAbandonBody": "Your progress will not be saved.",
+ "cancelButton": "CANCEL",
+ "abandonButton": "ABANDON",
+ "amrapResultTitle": "🔥 AMRAP RESULT 🔥",
+ "amrapResultBody": "Go all out! How many did you get?",
+ "amrapConfirm": "CONFIRM RESULT",
+ "emomFinishedTitle": "MISSION ACCOMPLISHED",
+ "emomFinishedBody": "Time is up. Did you push further?",
+ "emomSetsCompleted": "SETS COMPLETED",
+ "emomConfirm": "CONFIRM & FINISH",
+ "emomRepsPerRound": "Reps per Round",
+
+ "questTabDailies": "DAILIES",
+ "questTabJourney": "JOURNEY",
+ "questEmptyDailies": "No daily quests available.\nCome back tomorrow!",
+ "questEmptyJourney": "Your journey has just begun.",
+
+ "setupProfileTitle": "Setup Profile",
+ "bodyweightTitle": "What's your current bodyweight?",
+ "bodyweightSubtitle": "We need this to calculate your weighted calisthenics exercises",
+ "unitKg": "KG",
+ "unitLbs": "LBS",
+
+ "strengthTestTitle": "Strength Test",
+ "strengthTestSubtitle": "Combat Calibration",
+ "strengthTestBody": "We need to assess your current power level to assign the correct monsters.",
+ "strengthLegs": "Leg Strength",
+ "strengthPull": "Pull Strength",
+ "strengthPush": "Push Strength",
+ "exerciseSquat": "Back Squat",
+ "exercisePullup": "Weighted Pull-up",
+ "exerciseRow": "Pendlay Row",
+ "exerciseDip": "Weighted Dip",
+ "exerciseBench": "Bench Press",
+ "canDoOneRep": "Can do 1 rep?",
+ "isAssisted": "Assisted (Bands)?",
+ "addWeightLabel": "Add. Weight (kg)",
+ "weightLabel": "Weight (kg)",
+ "bandAssistanceLabel": "Band Assistance (kg)",
+ "rowWeightLabel": "Row Weight (kg)",
+ "repsLabel": "Reps",
+ "reps5rmLabel": "5RM Reps (usually 5)",
+ "est1rm": "Est. 1RM",
+ "trainingMaxLabel": "Training Max (90%)",
+ "adjustedWendler": "Adjusted: Wendler 5/3/1",
+ "tmExplanation": "Your \"Training Max\" (TM) is your base combat power (90% of 1RM). For bodyweight exercises, we adjust the strategy.",
+
+ "setupEquipmentTitle": "Equipment Setup",
+ "setupInventoryTitle": "Equipment Inventory",
+ "setupInventorySubtitle": "Tell us what equipment you have available",
+ "setupBandsSubtitle": "Select bands you have for pullup/dip assistance",
+ "nextStepButton": "NEXT STEP",
+
+ "setupAvatarTitle": "Choose Your Hero",
+ "finishButton": "FINISH",
+ "setupAvatarSubtitle": "This is how the legends will remember you.",
+ "secureAccountTitle": "Secure Your Account",
+ "secureAccountBody": "Choose a strong password to protect your progress",
+ "confirmPasswordLabel": "Confirm Password",
+ "passwordsDoNotMatch": "Passwords do not match",
+ "confirmButton": "CONFIRM",
+
+ "guidePullupTitle": "Weighted Pull-Up",
+ "guidePullupLore": "Pulling your body against gravity is the ultimate proof of upper body strength.",
+ "guidePullupSteps": "Grip the bar slightly wider than shoulder width (overhand)|Engage core and pull shoulder blades down/back|Pull yourself up until chin is over the bar|Lower yourself with control",
+ "guidePullupMuscles": "Latissimus|Biceps|Forearms",
+ "guidePullupMistakes": "Swinging (Kipping)|Half reps|Shoulders shrugged up",
+
+ "guideDipTitle": "Weighted Dip",
+ "guideDipLore": "A fundamental pushing move to overcome walls.",
+ "guideDipSteps": "Support yourself on the bars, arms straight|Lean forward slightly for more chest focus|Lower body until shoulders are below elbows|Push explosively back to start position",
+ "guideDipMuscles": "Chest|Triceps|Front Delt",
+ "guideDipMistakes": "Not enough depth|Elbows flaring out too much",
+
+ "guideSquatTitle": "Low Bar Back Squat",
+ "guideSquatLore": "The mother of all battles. Trains the entire body armor.",
+ "guideSquatSteps": "Place bar on rear delts (not neck)|Feet shoulder width, toes slightly out|Inhale deeply (Bracing) and push hips back|Squat down (hip crease below knee)|Push up through heels/midfoot",
+ "guideSquatMuscles": "Quadriceps|Glutes|Core|Spinal Erectors",
+ "guideSquatMistakes": "Knees caving in|Back rounding|Not enough depth",
+
+ "guideBenchTitle": "Bench Press",
+ "guideBenchLore": "The standard test for pure pushing power.",
+ "guideBenchSteps": "Lie on bench, eyes under bar|Feet planted firmly, slight arch in back|Lower bar controlled to lower chest|Press bar explosively up",
+ "guideBenchMuscles": "Chest|Triceps|Front Delt",
+ "guideBenchMistakes": "Elbows flared 90° (Injury risk)|Butt lifting off bench",
+
+ "guideOhpTitle": "Overhead Press",
+ "guideOhpLore": "Pressing an object overhead requires pure stability.",
+ "guideOhpSteps": "Bar rests on front delts|Firm stance, squeeze glutes and abs|Move head back slightly, press bar vertically|At top, push head 'through the window' of arms",
+ "guideOhpMuscles": "Shoulders|Triceps|Core",
+ "guideOhpMistakes": "Excessive arching (Leaning back)|Using legs (Push Press)",
+
+ "guideRdlTitle": "Romanian Deadlift",
+ "guideRdlLore": "Builds the posterior chain, essential for stability.",
+ "guideRdlSteps": "Start standing with bar|Push hips far back, legs stay nearly straight|Lower bar along legs until just below knees|Feel stretch in hamstrings and stand back up",
+ "guideRdlMuscles": "Hamstrings|Glutes|Lower Back",
+ "guideRdlMistakes": "Back rounding|Bar drifting away from body",
+
+ "guideRowTitle": "Pendlay Row",
+ "guideRowLore": "Explosive pulling power from the floor. For a strong back.",
+ "guideRowSteps": "Torso parallel to floor, back straight|Bar rests dead on floor each rep|Pull bar explosively to lower sternum|Lower controlled, reset tension",
+ "guideRowMuscles": "Latissimus|Traps|Rear Delt",
+ "guideRowMistakes": "Torso raising up|Jerking with momentum",
+
+ "guideCurlTitle": "Barbell Curl",
+ "guideCurlLore": "Isolated power for the finishing strike.",
+ "guideCurlSteps": "Standing stance, underhand grip|Elbows fixed at sides|Curl bar to chest, squeeze at top|Lower slowly",
+ "guideCurlMuscles": "Biceps",
+ "guideCurlMistakes": "Swinging from hips|Elbows drifting forward",
+
+ "guideKbSwingTitle": "Kettlebell Swing",
+ "guideKbSwingLore": "Ballistic power and endurance. The hips are the engine.",
+ "guideKbSwingSteps": "Hip-width stance, KB on floor in front|Hike-Pass: Pull KB back between legs|Extend hips explosively (Snap!), KB flies to chest height|Let KB swing back controllably",
+ "guideKbSwingMuscles": "Glutes|Hamstrings|Core|Cardio",
+ "guideKbSwingMistakes": "Squatting instead of hinging|Arms lifting the weight",
+
+ "guideKbSnatchTitle": "Kettlebell Snatch",
+ "guideKbSnatchLore": "The Tsar of Kettlebell exercises. Total body control.",
+ "guideKbSnatchSteps": "Start like Swing (One arm)|Hip power accelerates ball upwards|At head height: Punch through handle|Soft catch in lockout overhead",
+ "guideKbSnatchMuscles": "Full Body|Shoulders|Grip",
+ "guideKbSnatchMistakes": "Bell slamming on forearm|Not enough hip power",
+
+ "guideKbThrusterTitle": "Kettlebell Thruster",
+ "guideKbThrusterLore": "A brutal combination of squat and press.",
+ "guideKbThrusterSteps": "KB in rack position (chest)|Deep squat|Use momentum from standing up to press KB overhead|Return to rack for next squat",
+ "guideKbThrusterMuscles": "Legs|Shoulders|Cardio",
+ "guideKbThrusterMistakes": "Pausing between squat and press|Back rounding in squat",
+
+ "guideKbCleanPressTitle": "KB Clean & Press",
+ "guideKbCleanPressLore": "Two movements in harmony.",
+ "guideKbCleanPressSteps": "Clean: Pull KB from floor to rack position|Press: Press strictly overhead|Lower to rack, then floor (or swing)",
+ "guideKbCleanPressMuscles": "Shoulders|Back|Legs",
+ "guideKbCleanPressMistakes": "Clean slams arm|Arching back during press",
+
+ "guideFacePullTitle": "Band Face Pull",
+ "guideFacePullLore": "Protects shoulders from the wear of battle.",
+ "guideFacePullSteps": "Attach band at head height|Pull band towards face (forehead/eyes)|Pull elbows high and wide|Squeeze shoulder blades together",
+ "guideFacePullMuscles": "Rear Delt|Rotator Cuff",
+ "guideFacePullMistakes": "Elbows too low|Head pushing forward",
+
+ "guideAbWheelTitle": "Ab Wheel Rollout",
+ "guideAbWheelLore": "A core of steel to absorb any hit.",
+ "guideAbWheelSteps": "Knees on floor, wheel in front|Roll forward, keep back rounded/stable (Hollow Body)|Go only as far as you can maintain stability|Pull back using abs",
+ "guideAbWheelMuscles": "Core (Anti-Extension)",
+ "guideAbWheelMistakes": "Arching back (Dangerous!)|Pulling with arms",
+
+ "guidePlankTitle": "Plank",
+ "guidePlankLore": "Immovable as a rock in the surf.",
+ "guidePlankSteps": "Forearm support, body forms a line|Squeeze glutes and abs hard|Push shoulder blades apart|Don't forget to breathe!",
+ "guidePlankMuscles": "Core",
+ "guidePlankMistakes": "Hips sagging|Butt too high",
+
+ "codexTitle": "Creature Codex",
+
+ "enemyIronGolemName": "Iron Golem",
+ "enemyIronGolemTitle": "The Weight of the Earth",
+ "enemyIronGolemDesc": "Forged from the tectonic plates of the Deep Earth, the Iron Golem exists only to crush the weak. It embodies the unrelenting force of gravity acting on a heavy load.\n\nIt respects only one thing: The raw power of the LEGS that can stand up against its crushing weight.",
+ "enemyIronGolemNemesis": "Squat Nemesis",
+
+ "enemyGravityDemonName": "Gravity Demon",
+ "enemyGravityDemonTitle": "The Abyssal Pull",
+ "enemyGravityDemonDesc": "A spirit of pure downward force that clings to the back of adventurers. It whispers lies of weakness into your ear while dragging you towards the abyss.\n\nOnly those with a back of steel and the will to pull themselves up can escape its grasp.",
+ "enemyGravityDemonNemesis": "Pull-up Nemesis",
+
+ "enemyPressurePhantomName": "Pressure Phantom",
+ "enemyPressurePhantomTitle": "The Invisible Crusher",
+ "enemyPressurePhantomDesc": "An ethereal entity that compresses the very air around you. It seeks to collapse the chest and shoulders of any who dare to push against it.\n\nDefeat it by pushing through the pain with explosive dipping power.",
+ "enemyPressurePhantomNemesis": "Dip Nemesis"
+}
diff --git a/lib/main.dart b/lib/main.dart
index 2790be2..df186ee 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -1,35 +1,36 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
-import 'package:isar/isar.dart';
-import 'package:path_provider/path_provider.dart';
-
import 'src/app.dart';
-import 'src/shared/data/local/collections/user_collection.dart';
-import 'src/shared/data/local/collections/cycle_collection.dart';
-import 'src/shared/data/local/collections/workout_collection.dart';
+import 'src/shared/data/local/app_database.dart';
+import 'package:flutter_dotenv/flutter_dotenv.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
+ try {
+ await dotenv.load(fileName: '.env');
+ debugPrint('Environment loaded: ${dotenv.env['ENVIRONMENT']}');
+ debugPrint('API URL: ${dotenv.env['API_BASE_URL']}');
+ } catch (e) {
+ debugPrint('Could not load .env file: $e');
+ debugPrint('Using default production values');
+ }
+
await SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
- final dir = await getApplicationDocumentsDirectory();
- final isar = await Isar.open(
- [UserCollectionSchema, CycleCollectionSchema, WorkoutCollectionSchema],
- directory: dir.path,
- name: 'slrpg_db',
- );
+ final database = AppDatabase();
runApp(
ProviderScope(
- overrides: [isarProvider.overrideWithValue(isar)],
+ overrides: [appDatabaseProvider.overrideWithValue(database)],
child: const SLRPGApp(),
),
);
}
-final isarProvider = Provider((ref) => throw UnimplementedError());
+final appDatabaseProvider =
+ Provider((ref) => throw UnimplementedError());
diff --git a/lib/src/app.dart b/lib/src/app.dart
index 4e18f19..f40f8b8 100644
--- a/lib/src/app.dart
+++ b/lib/src/app.dart
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
+import 'package:flutter_localizations/flutter_localizations.dart';
+import 'package:slrpg_app/l10n/app_localizations.dart';
import 'core/theme/app_theme.dart';
import 'core/routing/app_router.dart';
@@ -17,7 +19,16 @@ class SLRPGApp extends ConsumerWidget {
debugShowCheckedModeBanner: false,
theme: AppTheme.darkTheme,
routerConfig: router,
+ localizationsDelegates: const [
+ AppLocalizations.delegate,
+ GlobalMaterialLocalizations.delegate,
+ GlobalWidgetsLocalizations.delegate,
+ GlobalCupertinoLocalizations.delegate,
+ ],
+ supportedLocales: const [
+ Locale('en'),
+ Locale('de'),
+ ],
);
}
}
-
diff --git a/lib/src/core/constants/app_constants.dart b/lib/src/core/constants/app_constants.dart
index 42f7109..977d621 100644
--- a/lib/src/core/constants/app_constants.dart
+++ b/lib/src/core/constants/app_constants.dart
@@ -1,9 +1,36 @@
import 'package:flutter/material.dart';
+import 'package:flutter_dotenv/flutter_dotenv.dart';
class AppConstants {
+ // ✅ API Configuration aus Environment
+ static String get apiBaseUrl =>
+ dotenv.env['API_BASE_URL'] ?? 'https://slift.patanix.de';
+
+ static String get apiVersion => dotenv.env['API_VERSION'] ?? 'v1';
+
+ static String get environment => dotenv.env['ENVIRONMENT'] ?? 'production';
+
+ static bool get isDebugMode =>
+ dotenv.env['DEBUG_MODE']?.toLowerCase() == 'true';
+
+ // ✅ Helper Getter
+ static bool get isDevelopment => environment == 'development';
+ static bool get isProduction => environment == 'production';
+
+ // Debug Info
+ static void printConfig() {
+ debugPrint('═══════════════════════════════════');
+ debugPrint('🔧 APP CONFIGURATION');
+ debugPrint('Environment: $environment');
+ debugPrint('API Base URL: $apiBaseUrl');
+ debugPrint('API Version: $apiVersion');
+ debugPrint('Debug Mode: $isDebugMode');
+ debugPrint('═══════════════════════════════════');
+ }
// API Configuration
- static const String apiBaseUrl = 'http://10.0.2.2:8090'; // Android emulator
- static const String apiVersion = 'v1';
+ // static const String apiBaseUrl = 'http://10.0.2.2:8090'; // Android emulator
+ // static const String apiBaseUrl = 'https://slift.patanix.de';
+ // static const String apiVersion = 'v1';
// Wendler 5/3/1 Constants
static const double trainingMaxPercentage = 0.9;
@@ -12,14 +39,14 @@ class AppConstants {
// XP System
static const int baseXP = 1000;
- static const double xpMultiplier = 1.15;
+ static const double xpMultiplier = 1.25;
static const int maxLevel = 100;
// XP Rewards
static const int workoutCompleteXP = 100;
- static const double volumeXPRate = 0.1; // XP per kg
+ static const double volumeXPRate = 0.01; // XP per kg
static const int amrapBonusXPPerRep = 25;
- static const int prBonusXP = 500;
+ static const int prBonusXP = 200;
static const int cycleCompleteXP = 500;
// Rounding Steps
diff --git a/lib/src/core/constants/asset_paths.dart b/lib/src/core/constants/asset_paths.dart
index a86cabb..c34b4e6 100644
--- a/lib/src/core/constants/asset_paths.dart
+++ b/lib/src/core/constants/asset_paths.dart
@@ -43,6 +43,9 @@ class AssetPaths {
static String getAvatarPath(String gender, int variant) {
return 'assets/images/avatars/$gender/$variant.png';
}
+
+ static const String audioBeepShort = 'audio/beep_short.ogg';
+ static const String audioBeepLong = 'audio/beep_long.ogg';
}
class PlateColors {
diff --git a/lib/src/core/debug/debug_config_screen.dart b/lib/src/core/debug/debug_config_screen.dart
new file mode 100644
index 0000000..c938977
--- /dev/null
+++ b/lib/src/core/debug/debug_config_screen.dart
@@ -0,0 +1,96 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_dotenv/flutter_dotenv.dart';
+
+import '../constants/app_constants.dart';
+import '../theme/app_theme.dart';
+
+class DebugConfigScreen extends StatelessWidget {
+ const DebugConfigScreen({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('🔧 Configuration'),
+ ),
+ body: ListView(
+ padding: const EdgeInsets.all(16),
+ children: [
+ _buildSection(
+ 'Environment',
+ [
+ _buildRow('Environment', AppConstants.environment),
+ _buildRow('Debug Mode', AppConstants.isDebugMode.toString()),
+ _buildRow(
+ 'Is Development', AppConstants.isDevelopment.toString()),
+ _buildRow('Is Production', AppConstants.isProduction.toString()),
+ ],
+ ),
+ const SizedBox(height: 16),
+ _buildSection(
+ 'API Configuration',
+ [
+ _buildRow('Base URL', AppConstants.apiBaseUrl),
+ _buildRow('API Version', AppConstants.apiVersion),
+ ],
+ ),
+ const SizedBox(height: 16),
+ _buildSection(
+ 'All Environment Variables',
+ dotenv.env.entries.map((e) => _buildRow(e.key, e.value)).toList(),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildSection(String title, List children) {
+ return Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ title,
+ style: const TextStyle(
+ fontSize: 18,
+ fontWeight: FontWeight.bold,
+ color: AppTheme.primaryColor,
+ ),
+ ),
+ const Divider(),
+ ...children,
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildRow(String key, String value) {
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 4),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ SizedBox(
+ width: 120,
+ child: Text(
+ key,
+ style: const TextStyle(
+ fontWeight: FontWeight.bold,
+ color: Colors.grey,
+ ),
+ ),
+ ),
+ Expanded(
+ child: Text(
+ value,
+ style: const TextStyle(color: Colors.white),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/src/core/routing/app_router.dart b/lib/src/core/routing/app_router.dart
index 7a12beb..8deb418 100644
--- a/lib/src/core/routing/app_router.dart
+++ b/lib/src/core/routing/app_router.dart
@@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
import '../../features/authentication/presentation/screens/login_screen.dart';
import '../../features/authentication/presentation/screens/profile_screen.dart';
import '../../features/authentication/presentation/screens/register_screen.dart';
+import '../../features/gamification/presentation/screens/quest_log.dart';
import '../../features/onboarding/presentation/screens/avatar_setup_screen.dart';
import '../../features/onboarding/presentation/screens/welcome_screen.dart';
import '../../features/onboarding/presentation/screens/bodyweight_input_screen.dart';
@@ -24,6 +25,22 @@ final routerProvider = Provider((ref) {
return GoRouter(
initialLocation: '/splash',
+ redirect: (context, state) async {
+ final user = await userRepo.getLocalUser();
+ final isAuthenticated = user != null;
+
+ final isOnAuthPage = state.matchedLocation == '/login' ||
+ state.matchedLocation == '/register' ||
+ state.matchedLocation.startsWith('/onboarding');
+
+ if (!isAuthenticated &&
+ !isOnAuthPage &&
+ state.matchedLocation != '/splash') {
+ return '/login';
+ }
+
+ return null;
+ },
routes: [
// Splash / Initial Route
GoRoute(
@@ -113,6 +130,11 @@ final routerProvider = Provider((ref) {
name: 'codex',
builder: (context, state) => const CodexScreen(),
),
+ GoRoute(
+ path: '/quests',
+ name: 'quests',
+ builder: (context, state) => const QuestLogScreen(),
+ ),
],
);
});
@@ -159,7 +181,7 @@ class _SplashScreenState extends ConsumerState {
),
Positioned.fill(
child: Container(
- color: Colors.black.withOpacity(0.5),
+ color: Colors.black.withValues(alpha: 0.5),
),
),
Center(
@@ -170,11 +192,11 @@ class _SplashScreenState extends ConsumerState {
width: 120,
height: 120,
decoration: BoxDecoration(
- color: const Color(0xFF00E5FF).withOpacity(0.9),
+ color: const Color(0xFF00E5FF).withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
- color: const Color(0xFF00E5FF).withOpacity(0.6),
+ color: const Color(0xFF00E5FF).withValues(alpha: 0.6),
blurRadius: 20,
spreadRadius: 5,
),
diff --git a/lib/src/core/theme/app_theme.dart b/lib/src/core/theme/app_theme.dart
index 104f8e0..a916685 100644
--- a/lib/src/core/theme/app_theme.dart
+++ b/lib/src/core/theme/app_theme.dart
@@ -87,7 +87,7 @@ class AppTheme {
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
- color: primaryColor.withOpacity(0.3),
+ color: primaryColor.withValues(alpha: 0.3),
width: 1,
),
),
@@ -97,11 +97,11 @@ class AppTheme {
fillColor: surfaceColor,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
- borderSide: BorderSide(color: primaryColor.withOpacity(0.5)),
+ borderSide: BorderSide(color: primaryColor.withValues(alpha: 0.5)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
- borderSide: BorderSide(color: primaryColor.withOpacity(0.3)),
+ borderSide: BorderSide(color: primaryColor.withValues(alpha: 0.3)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
diff --git a/lib/src/features/authentication/presentation/screens/login_screen.dart b/lib/src/features/authentication/presentation/screens/login_screen.dart
index cc4d1a2..5e8b772 100644
--- a/lib/src/features/authentication/presentation/screens/login_screen.dart
+++ b/lib/src/features/authentication/presentation/screens/login_screen.dart
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
+import 'package:slrpg_app/l10n/app_localizations.dart';
import '../../../../shared/data/repositories/user_repository.dart';
import '../../../../core/theme/app_theme.dart';
@@ -16,18 +17,30 @@ class _LoginScreenState extends ConsumerState {
final _formKey = GlobalKey();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
+ final _emailFocusNode = FocusNode();
+ final _passwordFocusNode = FocusNode();
+
bool _isLoading = false;
bool _obscurePassword = true;
+ String? _errorMessage;
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
+ _emailFocusNode.dispose();
+ _passwordFocusNode.dispose();
super.dispose();
}
Future _handleLogin() async {
- if (!_formKey.currentState!.validate()) return;
+ FocusScope.of(context).unfocus();
+
+ setState(() => _errorMessage = null);
+
+ if (!_formKey.currentState!.validate()) {
+ return;
+ }
setState(() => _isLoading = true);
@@ -43,141 +56,236 @@ class _LoginScreenState extends ConsumerState {
}
} catch (e) {
if (mounted) {
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(
- content: Text('Login failed: ${e.toString()}'),
- backgroundColor: AppTheme.errorColor,
- ),
- );
- }
- } finally {
- if (mounted) {
- setState(() => _isLoading = false);
+ setState(() {
+ _isLoading = false;
+ _errorMessage = _parseErrorMessage(e.toString());
+ });
}
}
}
+ String _parseErrorMessage(String error) {
+ if (error.contains('400')) {
+ return 'Invalid email or password';
+ } else if (error.contains('SocketException') ||
+ error.contains('Connection refused') ||
+ error.contains('Network is unreachable')) {
+ return 'Could not connect to server.\nPlease check your internet connection.';
+ } else if (error.contains('timeout')) {
+ return 'Connection timeout.\nPlease try again.';
+ }
+ return 'Login failed. Please try again.';
+ }
+
@override
Widget build(BuildContext context) {
+ final l10n = AppLocalizations.of(context)!;
return Scaffold(
- body: SafeArea(
- child: Center(
- child: SingleChildScrollView(
- padding: const EdgeInsets.all(24),
- child: Form(
- key: _formKey,
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- // Logo
- Container(
- width: 100,
- height: 100,
- decoration: BoxDecoration(
- color: AppTheme.primaryColor,
- borderRadius: BorderRadius.circular(20),
- ),
- child: const Icon(
- Icons.fitness_center,
- size: 56,
- color: Colors.black,
- ),
+ body: GestureDetector(
+ onTap: () => FocusScope.of(context).unfocus(),
+ child: SafeArea(
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ return SingleChildScrollView(
+ physics: const ClampingScrollPhysics(),
+ child: ConstrainedBox(
+ constraints: BoxConstraints(
+ minHeight: constraints.maxHeight,
),
- const SizedBox(height: 24),
+ child: IntrinsicHeight(
+ child: Padding(
+ padding: const EdgeInsets.all(24),
+ child: Form(
+ key: _formKey,
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ const Spacer(),
- Text(
- 'WELCOME BACK',
- style: Theme.of(context).textTheme.displayMedium,
- textAlign: TextAlign.center,
- ),
- const SizedBox(height: 8),
- Text(
- 'Time to level up your strength',
- style: Theme.of(context).textTheme.bodyMedium,
- textAlign: TextAlign.center,
- ),
- const SizedBox(height: 48),
-
- TextFormField(
- controller: _emailController,
- keyboardType: TextInputType.emailAddress,
- decoration: const InputDecoration(
- labelText: 'Email',
- prefixIcon: Icon(Icons.email_outlined),
- ),
- validator: (value) {
- if (value == null || value.isEmpty) {
- return 'Please enter your email';
- }
- if (!value.contains('@')) {
- return 'Please enter a valid email';
- }
- return null;
- },
- ),
- const SizedBox(height: 16),
-
- TextFormField(
- controller: _passwordController,
- obscureText: _obscurePassword,
- decoration: InputDecoration(
- labelText: 'Password',
- prefixIcon: const Icon(Icons.lock_outline),
- suffixIcon: IconButton(
- icon: Icon(
- _obscurePassword
- ? Icons.visibility_outlined
- : Icons.visibility_off_outlined,
- ),
- onPressed: () {
- setState(() => _obscurePassword = !_obscurePassword);
- },
- ),
- ),
- validator: (value) {
- if (value == null || value.isEmpty) {
- return 'Please enter your password';
- }
- if (value.length < 8) {
- return 'Password must be at least 8 characters';
- }
- return null;
- },
- ),
- const SizedBox(height: 32),
-
- ElevatedButton(
- onPressed: _isLoading ? null : _handleLogin,
- child: _isLoading
- ? const SizedBox(
- height: 20,
- width: 20,
- child: CircularProgressIndicator(
- strokeWidth: 2,
- color: Colors.black,
+ Container(
+ width: 100,
+ height: 100,
+ decoration: BoxDecoration(
+ color: AppTheme.primaryColor,
+ borderRadius: BorderRadius.circular(20),
+ boxShadow: [
+ BoxShadow(
+ color: AppTheme.primaryColor
+ .withValues(alpha: 0.4),
+ blurRadius: 20,
+ spreadRadius: 2,
+ ),
+ ],
+ ),
+ child: const Icon(
+ Icons.fitness_center,
+ size: 56,
+ color: Colors.black,
+ ),
),
- )
- : const Text('LOGIN'),
- ),
- const SizedBox(height: 16),
+ const SizedBox(height: 32),
- Row(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- Text(
- "Don't have an account? ",
- style: Theme.of(context).textTheme.bodyMedium,
+ Text(
+ l10n.loginWelcomeBack,
+ style: Theme.of(context).textTheme.displayMedium,
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 8),
+ Text(
+ l10n.loginSubtitle,
+ style: Theme.of(context).textTheme.bodyMedium,
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 48),
+
+ if (_errorMessage != null)
+ Container(
+ padding: const EdgeInsets.all(16),
+ margin: const EdgeInsets.only(bottom: 16),
+ decoration: BoxDecoration(
+ color: AppTheme.errorColor
+ .withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(
+ color: AppTheme.errorColor,
+ width: 1,
+ ),
+ ),
+ child: Row(
+ children: [
+ const Icon(
+ Icons.error_outline,
+ color: AppTheme.errorColor,
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Text(
+ _errorMessage!,
+ style: const TextStyle(
+ color: AppTheme.errorColor,
+ fontSize: 14,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+
+ TextFormField(
+ controller: _emailController,
+ focusNode: _emailFocusNode,
+ keyboardType: TextInputType.emailAddress,
+ textInputAction: TextInputAction.next,
+ enabled: !_isLoading,
+ decoration: InputDecoration(
+ labelText: l10n.emailLabel,
+ prefixIcon: Icon(Icons.email_outlined),
+ ),
+ onFieldSubmitted: (_) {
+ _passwordFocusNode.requestFocus();
+ },
+ validator: (value) {
+ if (value == null || value.isEmpty) {
+ return l10n.emailEmptyError;
+ }
+ if (!value.contains('@')) {
+ return l10n.emailInvalidError;
+ }
+ return null;
+ },
+ ),
+ const SizedBox(height: 16),
+
+ // Password Field
+ TextFormField(
+ controller: _passwordController,
+ focusNode: _passwordFocusNode,
+ obscureText: _obscurePassword,
+ textInputAction: TextInputAction.done,
+ enabled: !_isLoading,
+ decoration: InputDecoration(
+ labelText: l10n.passwordLabel,
+ prefixIcon: const Icon(Icons.lock_outline),
+ suffixIcon: IconButton(
+ icon: Icon(
+ _obscurePassword
+ ? Icons.visibility_outlined
+ : Icons.visibility_off_outlined,
+ ),
+ onPressed: () {
+ setState(() {
+ _obscurePassword = !_obscurePassword;
+ });
+ },
+ ),
+ ),
+ onFieldSubmitted: (_) => _handleLogin(),
+ validator: (value) {
+ if (value == null || value.isEmpty) {
+ return l10n.passwordEmptyError;
+ }
+ if (value.length < 8) {
+ return l10n.passwordLengthError;
+ }
+ return null;
+ },
+ ),
+ const SizedBox(height: 32),
+
+ ElevatedButton(
+ onPressed: _isLoading ? null : _handleLogin,
+ style: ElevatedButton.styleFrom(
+ padding:
+ const EdgeInsets.symmetric(vertical: 16),
+ disabledBackgroundColor: AppTheme.primaryColor
+ .withValues(alpha: 0.5),
+ ),
+ child: _isLoading
+ ? const SizedBox(
+ height: 20,
+ width: 20,
+ child: CircularProgressIndicator(
+ strokeWidth: 2,
+ color: Colors.black,
+ ),
+ )
+ : Text(
+ l10n.loginButton,
+ style: TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.bold,
+ letterSpacing: 1.2,
+ ),
+ ),
+ ),
+ const SizedBox(height: 16),
+
+ Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Text(
+ l10n.loginNoAccount,
+ style: Theme.of(context).textTheme.bodyMedium,
+ ),
+ TextButton(
+ onPressed: _isLoading
+ ? null
+ : () => context.go('/register'),
+ child: Text(l10n.loginRegisterButton),
+ ),
+ ],
+ ),
+
+ const Spacer(),
+ ],
+ ),
),
- TextButton(
- onPressed: () => context.go('/register'),
- child: const Text('REGISTER'),
- ),
- ],
+ ),
),
- ],
- ),
- ),
+ ),
+ );
+ },
),
),
),
diff --git a/lib/src/features/authentication/presentation/screens/profile_screen.dart b/lib/src/features/authentication/presentation/screens/profile_screen.dart
index daf80ea..a4ca385 100644
--- a/lib/src/features/authentication/presentation/screens/profile_screen.dart
+++ b/lib/src/features/authentication/presentation/screens/profile_screen.dart
@@ -1,14 +1,16 @@
+import 'package:drift/drift.dart' hide Column;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../shared/data/repositories/user_repository.dart';
-import '../../../../shared/data/local/collections/user_collection.dart';
+import '../../../../shared/data/local/app_database.dart';
import '../../../gamification/domain/entities/avatar_config.dart';
import '../../../gamification/presentation/widgets/avatar_editor.dart';
import '../../../gamification/presentation/widgets/avatar_renderer.dart';
-import 'dart:convert';
+import '../../../gamification/domain/entities/item_catalog.dart';
+import '../../../../shared/domain/logic/wendler_calculator.dart';
class ProfileScreen extends ConsumerStatefulWidget {
const ProfileScreen({super.key});
@@ -46,6 +48,11 @@ class _ProfileScreenState extends ConsumerState {
await ref
.read(userRepositoryProvider)
.updateBodyweight(_currentBodyweight);
+
+ if (_user != null) {
+ _user = _user!.copyWith(currentBodyweight: _currentBodyweight);
+ }
+
if (mounted) {
setState(() => _hasChanges = false);
ScaffoldMessenger.of(context).showSnackBar(
@@ -136,6 +143,139 @@ class _ProfileScreenState extends ConsumerState {
);
}
+ void _showBackgroundSelector() {
+ final currentLevel = _user?.level ?? 1;
+ final currentConfig = _user?.avatarConfig != null
+ ? AvatarConfig.fromJson(_user!.avatarConfig!)
+ : const AvatarConfig();
+
+ showModalBottomSheet(
+ context: context,
+ backgroundColor: AppTheme.surfaceColor,
+ builder: (context) => Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Padding(
+ padding: const EdgeInsets.all(16),
+ child: Text('Select Scenery',
+ style: Theme.of(context).textTheme.titleLarge),
+ ),
+ SizedBox(
+ height: 200,
+ child: ListView.builder(
+ scrollDirection: Axis.horizontal,
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ itemCount: ItemCatalog.backgrounds.length,
+ itemBuilder: (context, index) {
+ final item = ItemCatalog.backgrounds[index];
+ final isUnlocked = currentLevel >= item.unlockLevel;
+ final isSelected = currentConfig.selectedBackground == item.id;
+
+ return GestureDetector(
+ onTap: isUnlocked
+ ? () async {
+ Navigator.pop(context);
+ setState(() => _isLoading = true);
+
+ final newConfig = AvatarConfig(
+ gender: currentConfig.gender,
+ variant: currentConfig.variant,
+ selectedBackground: item.id,
+ );
+
+ final updatedUser = _user!.copyWith(
+ avatarConfig: Value(newConfig.toJson()),
+ isDirty: true,
+ );
+ _user = updatedUser;
+
+ await ref
+ .read(userRepositoryProvider)
+ .saveLocalUser(_user!);
+ setState(() => _isLoading = false);
+ }
+ : null,
+ child: Container(
+ width: 140,
+ margin: const EdgeInsets.only(right: 12, bottom: 16),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(
+ color:
+ isSelected ? AppTheme.primaryColor : Colors.white12,
+ width: isSelected ? 3 : 1,
+ ),
+ image: DecorationImage(
+ image: AssetImage(item.assetPath),
+ fit: BoxFit.cover,
+ colorFilter: isUnlocked
+ ? null
+ : const ColorFilter.mode(
+ Colors.black87, BlendMode.darken),
+ ),
+ ),
+ child: Stack(
+ children: [
+ if (!isUnlocked)
+ Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Icon(Icons.lock, color: Colors.white54),
+ const SizedBox(height: 4),
+ Text(
+ 'Lvl ${item.unlockLevel}',
+ style: const TextStyle(
+ color: Colors.white,
+ fontWeight: FontWeight.bold,
+ shadows: [
+ Shadow(
+ blurRadius: 4, color: Colors.black)
+ ]),
+ ),
+ ],
+ ),
+ ),
+ if (isSelected)
+ const Positioned(
+ top: 8,
+ right: 8,
+ child: Icon(Icons.check_circle,
+ color: AppTheme.primaryColor),
+ ),
+ Positioned(
+ bottom: 0,
+ left: 0,
+ right: 0,
+ child: Container(
+ padding: const EdgeInsets.all(8),
+ decoration: BoxDecoration(
+ color: Colors.black.withValues(alpha: 0.7),
+ borderRadius: const BorderRadius.vertical(
+ bottom: Radius.circular(10)),
+ ),
+ child: Text(
+ item.name,
+ style: const TextStyle(
+ color: Colors.white,
+ fontSize: 10,
+ overflow: TextOverflow.ellipsis),
+ textAlign: TextAlign.center,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
void _confirmDangerAction(
String title, String content, VoidCallback onConfirm) {
showDialog(
@@ -163,8 +303,8 @@ class _ProfileScreenState extends ConsumerState {
}
void _showAvatarEditor() {
- final currentConfig = _user?.avatarConfigJson != null
- ? AvatarConfig.fromJson(jsonDecode(_user!.avatarConfigJson!))
+ final currentConfig = _user?.avatarConfig != null
+ ? AvatarConfig.fromJson(_user!.avatarConfig!)
: const AvatarConfig();
showModalBottomSheet(
@@ -199,19 +339,70 @@ class _ProfileScreenState extends ConsumerState {
).then((result) async {
if (result is AvatarConfig) {
setState(() => _isLoading = true);
- _user!.avatarConfigJson = jsonEncode(result.toJson());
- _user!.isDirty = true;
+
+ final updatedUser = _user!.copyWith(
+ avatarConfig: Value(result.toJson()),
+ isDirty: true,
+ );
+ _user = updatedUser;
+
await ref.read(userRepositoryProvider).saveLocalUser(_user!);
setState(() => _isLoading = false);
}
});
}
+ AccessoryTemplate _getTemplateFromSettings(Map settings) {
+ final key = settings['accessory_template'] as String?;
+ if (key == 'hypertrophy') return AccessoryTemplate.hypertrophy;
+ if (key == 'conditioning') return AccessoryTemplate.conditioning;
+ if (key == 'journey_pullup') return AccessoryTemplate.journey_pullup;
+ return AccessoryTemplate.none;
+ }
+
+ Future _updateTemplate(AccessoryTemplate newTemplate) async {
+ setState(() => _isLoading = true);
+
+ String templateKey = 'none';
+ if (newTemplate == AccessoryTemplate.hypertrophy) {
+ templateKey = 'hypertrophy';
+ }
+ if (newTemplate == AccessoryTemplate.conditioning) {
+ templateKey = 'conditioning';
+ }
+ if (newTemplate == AccessoryTemplate.journey_pullup) {
+ templateKey = 'journey_pullup';
+ }
+
+ final currentSettings =
+ Map.from(_user!.inventorySettings ?? {});
+ currentSettings['accessory_template'] = templateKey;
+
+ try {
+ final updatedUser = _user!.copyWith(
+ inventorySettings: Value(currentSettings),
+ isDirty: true,
+ );
+
+ await ref.read(userRepositoryProvider).saveLocalUser(updatedUser);
+
+ ref.read(userRepositoryProvider).updateInventory(currentSettings);
+
+ setState(() {
+ _user = updatedUser;
+ _isLoading = false;
+ });
+ } catch (e) {
+ setState(() => _isLoading = false);
+ // Error handling...
+ }
+ }
+
@override
Widget build(BuildContext context) {
final userRepo = ref.watch(userRepositoryProvider);
- final avatarConfig = _user?.avatarConfigJson != null
- ? AvatarConfig.fromJson(jsonDecode(_user!.avatarConfigJson!))
+ final avatarConfig = _user?.avatarConfig != null
+ ? AvatarConfig.fromJson(_user!.avatarConfig!)
: const AvatarConfig();
return Scaffold(
@@ -234,172 +425,292 @@ class _ProfileScreenState extends ConsumerState {
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
- : ListView(
- padding: const EdgeInsets.all(16),
- children: [
- Center(
- child: Stack(
- children: [
- AvatarRenderer(
- config: avatarConfig,
- size: 120,
- ),
- Positioned(
- bottom: 0,
- right: 0,
- child: CircleAvatar(
- backgroundColor: AppTheme.surfaceColor,
- radius: 18,
- child: IconButton(
- icon: const Icon(Icons.edit, size: 16),
- onPressed: _showAvatarEditor,
- ),
- ),
- ),
- ],
- ),
- ),
- const SizedBox(height: 32),
- Text('Physical Stats',
- 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,
+ : SafeArea(
+ child: ListView(
+ padding: const EdgeInsets.all(16),
+ children: [
+ Center(
+ child: Stack(
children: [
- Text('Current Bodyweight',
- style: Theme.of(context).textTheme.bodyMedium),
- Row(
- children: [
- Expanded(
- child: Slider(
- value: _currentBodyweight,
- min: 40,
- max: 150,
- divisions: 220, // 0.5 steps
- label: _currentBodyweight.toStringAsFixed(1),
- activeColor: AppTheme.primaryColor,
- onChanged: (val) => setState(() {
- _currentBodyweight = val;
- _hasChanges = true;
- }),
- ),
+ AvatarRenderer(
+ config: avatarConfig,
+ size: 120,
+ ),
+ Positioned(
+ bottom: 0,
+ right: 0,
+ child: CircleAvatar(
+ backgroundColor: AppTheme.surfaceColor,
+ radius: 18,
+ child: IconButton(
+ icon: const Icon(Icons.edit, size: 16),
+ onPressed: _showAvatarEditor,
),
- Text(
- '${_currentBodyweight.toStringAsFixed(1)} kg',
- style: Theme.of(context)
- .textTheme
- .titleMedium
- ?.copyWith(
- fontWeight: FontWeight.bold,
- color: AppTheme.primaryColor,
- ),
- ),
- ],
+ ),
),
],
),
),
- ),
- const SizedBox(height: 32),
- Text('Account Security',
- style: Theme.of(context)
- .textTheme
- .titleLarge
- ?.copyWith(color: AppTheme.textPrimary)),
- const SizedBox(height: 8),
- ListTile(
- leading: const Icon(Icons.lock_outline),
- title: const Text('Change Password'),
- trailing: const Icon(Icons.chevron_right),
- onTap: _showChangePasswordDialog,
- ),
- const Divider(),
- const SizedBox(height: 24),
- Text('Danger Zone',
- style: Theme.of(context)
- .textTheme
- .titleLarge
- ?.copyWith(color: AppTheme.errorColor)),
- const SizedBox(height: 8),
- Container(
- decoration: BoxDecoration(
- border:
- Border.all(color: AppTheme.errorColor.withOpacity(0.5)),
- borderRadius: BorderRadius.circular(12),
- color: AppTheme.errorColor.withOpacity(0.05),
+ const SizedBox(height: 32),
+ Center(
+ child: OutlinedButton.icon(
+ onPressed: _showBackgroundSelector,
+ icon: const Icon(Icons.landscape),
+ label: const Text('CHANGE SCENERY'),
+ ),
),
- child: Column(
- children: [
- ListTile(
- leading: const Icon(Icons.refresh,
- color: AppTheme.errorColor),
- title: const Text('Reset Progress',
- style: TextStyle(color: AppTheme.errorColor)),
- subtitle:
- const Text('Resets Level, XP and Training History'),
- onTap: () => _confirmDangerAction(
- 'Reset Progress?',
- 'This will delete all your workouts and reset your Level to 1. This cannot be undone.',
- () async {
- setState(() => _isLoading = true);
- await userRepo.resetProgress();
- if (mounted) {
- setState(() => _isLoading = false);
- context.go('/hub');
- }
- },
- ),
+ const SizedBox(height: 32),
+ Text('Physical Stats',
+ 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('Current Bodyweight',
+ style: Theme.of(context).textTheme.bodyMedium),
+ Row(
+ children: [
+ Expanded(
+ child: Slider(
+ value: _currentBodyweight,
+ min: 40,
+ max: 150,
+ divisions: 220,
+ label: _currentBodyweight.toStringAsFixed(1),
+ activeColor: AppTheme.primaryColor,
+ onChanged: (val) => setState(() {
+ _currentBodyweight = val;
+ _hasChanges = true;
+ }),
+ ),
+ ),
+ Text(
+ '${_currentBodyweight.toStringAsFixed(1)} kg',
+ style: Theme.of(context)
+ .textTheme
+ .titleMedium
+ ?.copyWith(
+ fontWeight: FontWeight.bold,
+ color: AppTheme.primaryColor,
+ ),
+ ),
+ ],
+ ),
+ ],
),
- const Divider(height: 1),
- ListTile(
- leading: const Icon(Icons.delete_forever,
- color: AppTheme.errorColor),
- title: const Text('Delete Account',
- style: TextStyle(color: AppTheme.errorColor)),
- subtitle: const Text(
- 'Permanently delete your account and data'),
- onTap: () => _confirmDangerAction(
- 'Delete Account?',
- 'Are you sure you want to delete your account? All data will be lost forever.',
- () async {
- setState(() => _isLoading = true);
- try {
- await userRepo.deleteAccount();
- if (mounted) context.go('/login');
- } catch (e) {
+ ),
+ ),
+ const SizedBox(height: 32),
+ Text('Training Focus',
+ 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('Accessory Template',
+ style: Theme.of(context).textTheme.bodyMedium),
+ const SizedBox(height: 12),
+ _buildTemplateSelector(),
+ ],
+ ),
+ ),
+ ),
+ Text('Account Security',
+ style: Theme.of(context)
+ .textTheme
+ .titleLarge
+ ?.copyWith(color: AppTheme.textPrimary)),
+ const SizedBox(height: 8),
+ ListTile(
+ leading: const Icon(Icons.lock_outline),
+ title: const Text('Change Password'),
+ trailing: const Icon(Icons.chevron_right),
+ onTap: _showChangePasswordDialog,
+ ),
+ const Divider(),
+ const SizedBox(height: 24),
+ Text('Danger Zone',
+ style: Theme.of(context)
+ .textTheme
+ .titleLarge
+ ?.copyWith(color: AppTheme.errorColor)),
+ const SizedBox(height: 8),
+ Container(
+ decoration: BoxDecoration(
+ border: Border.all(
+ color: AppTheme.errorColor.withValues(alpha: 0.5)),
+ borderRadius: BorderRadius.circular(12),
+ color: AppTheme.errorColor.withValues(alpha: 0.05),
+ ),
+ child: Column(
+ children: [
+ ListTile(
+ leading: const Icon(Icons.refresh,
+ color: AppTheme.errorColor),
+ title: const Text('Reset Progress',
+ style: TextStyle(color: AppTheme.errorColor)),
+ subtitle: const Text(
+ 'Resets Level, XP and Training History'),
+ onTap: () => _confirmDangerAction(
+ 'Reset Progress?',
+ 'This will delete all your workouts and reset your Level to 1. This cannot be undone.',
+ () async {
+ setState(() => _isLoading = true);
+ await userRepo.resetProgress();
if (mounted) {
setState(() => _isLoading = false);
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text('Error: $e')),
- );
+ context.go('/hub');
}
- }
- },
+ },
+ ),
),
- ),
- ],
+ const Divider(height: 1),
+ ListTile(
+ leading: const Icon(Icons.delete_forever,
+ color: AppTheme.errorColor),
+ title: const Text('Delete Account',
+ style: TextStyle(color: AppTheme.errorColor)),
+ subtitle: const Text(
+ 'Permanently delete your account and data'),
+ onTap: () => _confirmDangerAction(
+ 'Delete Account?',
+ 'Are you sure you want to delete your account? All data will be lost forever.',
+ () async {
+ setState(() => _isLoading = true);
+ try {
+ await userRepo.deleteAccount();
+ if (mounted) context.go('/login');
+ } catch (e) {
+ if (mounted) {
+ setState(() => _isLoading = false);
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Error: $e')),
+ );
+ }
+ }
+ },
+ ),
+ ),
+ ],
+ ),
),
- ),
- const SizedBox(height: 32),
- OutlinedButton.icon(
- onPressed: () async {
- await userRepo.logout();
- if (mounted) context.go('/login');
- },
- icon: const Icon(Icons.logout),
- label: const Text('LOGOUT'),
- style: OutlinedButton.styleFrom(
- padding: const EdgeInsets.symmetric(vertical: 16),
+ const SizedBox(height: 32),
+ OutlinedButton.icon(
+ onPressed: () async {
+ await userRepo.logout();
+ if (mounted) context.go('/login');
+ },
+ icon: const Icon(Icons.logout),
+ label: const Text('LOGOUT'),
+ style: OutlinedButton.styleFrom(
+ padding: const EdgeInsets.symmetric(vertical: 16),
+ ),
),
- ),
- ],
+ const SizedBox(
+ height: 50,
+ )
+ ],
+ ),
),
);
}
+
+ Widget _buildTemplateSelector() {
+ final current = _getTemplateFromSettings(_user?.inventorySettings ?? {});
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ _RadioTile(
+ value: AccessoryTemplate.none,
+ groupValue: current,
+ title: 'Strength Only',
+ subtitle: 'Main Lifts + FSL. Pure & Fast.',
+ onChanged: (val) => _updateTemplate(val!),
+ ),
+ const Divider(height: 1),
+ _RadioTile(
+ value: AccessoryTemplate.hypertrophy,
+ groupValue: current,
+ title: 'Hypertrophy Support',
+ subtitle: 'Bodybuilding accessories to build muscle armor.',
+ onChanged: (val) => _updateTemplate(val!),
+ ),
+ const Divider(height: 1),
+ _RadioTile(
+ value: AccessoryTemplate.conditioning,
+ groupValue: current,
+ title: 'The Engine (Conditioning)',
+ subtitle: '15 min Kettlebell intervals to boost stamina.',
+ onChanged: (val) => _updateTemplate(val!),
+ ),
+ const Padding(
+ padding: EdgeInsets.fromLTRB(16, 24, 16, 8),
+ child: Text(
+ 'ACTIVE JOURNEYS',
+ style: TextStyle(
+ color: Colors.grey,
+ fontSize: 12,
+ fontWeight: FontWeight.bold,
+ letterSpacing: 1.5),
+ ),
+ ),
+ _RadioTile(
+ value: AccessoryTemplate.journey_pullup,
+ groupValue: current,
+ title: 'Quest: The First Pull-Up',
+ subtitle: 'Specific progression to master your bodyweight.',
+ onChanged: (val) => _updateTemplate(val!),
+ ),
+ ],
+ );
+ }
+}
+
+class _RadioTile extends StatelessWidget {
+ final T value;
+ final T groupValue;
+ final String title;
+ final String subtitle;
+ final ValueChanged onChanged;
+
+ const _RadioTile({
+ required this.value,
+ required this.groupValue,
+ required this.title,
+ required this.subtitle,
+ required this.onChanged,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final isSelected = value == groupValue;
+ return RadioListTile(
+ value: value,
+ groupValue: groupValue,
+ onChanged: onChanged,
+ activeColor: AppTheme.primaryColor,
+ title: Text(
+ title,
+ style: TextStyle(
+ fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
+ color: isSelected ? AppTheme.primaryColor : Colors.white,
+ ),
+ ),
+ subtitle: Text(subtitle, style: const TextStyle(fontSize: 12)),
+ contentPadding: EdgeInsets.zero,
+ );
+ }
}
diff --git a/lib/src/features/authentication/presentation/screens/register_screen.dart b/lib/src/features/authentication/presentation/screens/register_screen.dart
index 13fe485..c846d8f 100644
--- a/lib/src/features/authentication/presentation/screens/register_screen.dart
+++ b/lib/src/features/authentication/presentation/screens/register_screen.dart
@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
+import 'package:slrpg_app/l10n/app_localizations.dart';
import '../../../../core/theme/app_theme.dart';
-import '../../../../core/constants/app_constants.dart';
import '../../../onboarding/presentation/screens/bodyweight_input_screen.dart';
class RegisterScreen extends ConsumerStatefulWidget {
@@ -16,32 +16,32 @@ class RegisterScreen extends ConsumerStatefulWidget {
class _RegisterScreenState extends ConsumerState {
final _formKey = GlobalKey();
final _emailController = TextEditingController();
- final _passwordController = TextEditingController();
- final _confirmPasswordController = TextEditingController();
- bool _obscurePassword = true;
- bool _obscureConfirmPassword = true;
+ final _emailFocusNode = FocusNode();
@override
void dispose() {
_emailController.dispose();
- _passwordController.dispose();
- _confirmPasswordController.dispose();
+ _emailFocusNode.dispose();
super.dispose();
}
void _handleRegister() {
- if (!_formKey.currentState!.validate()) return;
+ FocusScope.of(context).unfocus();
- ref.read(onboardingDataProvider.notifier).update((state) => {
- 'email': _emailController.text.trim(),
- 'password': _passwordController.text,
- });
+ if (!_formKey.currentState!.validate()) {
+ return;
+ }
+
+ ref.read(onboardingDataProvider.notifier).updateData({
+ 'email': _emailController.text.trim(),
+ });
context.go('/onboarding/welcome');
}
@override
Widget build(BuildContext context) {
+ final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
leading: IconButton(
@@ -49,120 +49,98 @@ class _RegisterScreenState extends ConsumerState {
onPressed: () => context.go('/login'),
),
),
- body: SafeArea(
- child: Center(
- child: SingleChildScrollView(
- padding: const EdgeInsets.all(24),
- child: Form(
- key: _formKey,
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- Text(
- 'CREATE ACCOUNT',
- style: Theme.of(context).textTheme.displayMedium,
- textAlign: TextAlign.center,
+ body: GestureDetector(
+ onTap: () => FocusScope.of(context).unfocus(),
+ child: SafeArea(
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ return SingleChildScrollView(
+ physics: const ClampingScrollPhysics(),
+ child: ConstrainedBox(
+ constraints: BoxConstraints(
+ minHeight: constraints.maxHeight,
),
- const SizedBox(height: 8),
- Text(
- 'Begin your strength journey',
- style: Theme.of(context).textTheme.bodyMedium,
- textAlign: TextAlign.center,
- ),
- const SizedBox(height: 48),
- TextFormField(
- controller: _emailController,
- keyboardType: TextInputType.emailAddress,
- decoration: const InputDecoration(
- labelText: 'Email',
- prefixIcon: Icon(Icons.email_outlined),
- ),
- validator: (value) {
- if (value == null || value.isEmpty) {
- return 'Please enter your email';
- }
- if (!value.contains('@')) {
- return 'Please enter a valid email';
- }
- return null;
- },
- ),
- const SizedBox(height: 16),
- TextFormField(
- controller: _passwordController,
- obscureText: _obscurePassword,
- decoration: InputDecoration(
- labelText: 'Password',
- prefixIcon: const Icon(Icons.lock_outline),
- suffixIcon: IconButton(
- icon: Icon(
- _obscurePassword
- ? Icons.visibility_outlined
- : Icons.visibility_off_outlined,
+ child: IntrinsicHeight(
+ child: Padding(
+ padding: const EdgeInsets.all(24),
+ child: Form(
+ key: _formKey,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ const Spacer(),
+ Text(
+ l10n.registerTitle,
+ style: Theme.of(context).textTheme.displayMedium,
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 8),
+ Text(
+ l10n.registerSubtitle,
+ style: Theme.of(context).textTheme.bodyMedium,
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 48),
+ TextFormField(
+ controller: _emailController,
+ focusNode: _emailFocusNode,
+ keyboardType: TextInputType.emailAddress,
+ textInputAction: TextInputAction.next,
+ decoration: InputDecoration(
+ labelText: l10n.emailLabel,
+ prefixIcon: Icon(Icons.email_outlined),
+ helperText: l10n.registerEmailHelper,
+ ),
+ onFieldSubmitted: (_) {},
+ validator: (value) {
+ if (value == null || value.isEmpty) {
+ return l10n.emailEmptyError;
+ }
+ if (!value.contains('@')) {
+ return l10n.emailInvalidError;
+ }
+ return null;
+ },
+ ),
+ const SizedBox(height: 32),
+ ElevatedButton(
+ onPressed: _handleRegister,
+ style: ElevatedButton.styleFrom(
+ padding:
+ const EdgeInsets.symmetric(vertical: 16),
+ ),
+ child: Text(
+ l10n.continueButton,
+ style: TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.bold,
+ letterSpacing: 1.2,
+ ),
+ ),
+ ),
+ const SizedBox(height: 16),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Text(
+ l10n.registerHaveAccount,
+ style: Theme.of(context).textTheme.bodyMedium,
+ ),
+ TextButton(
+ onPressed: () => context.go('/login'),
+ child: Text(l10n.registerLoginButton),
+ ),
+ ],
+ ),
+ const Spacer(),
+ ],
),
- onPressed: () {
- setState(() => _obscurePassword = !_obscurePassword);
- },
),
),
- validator: (value) {
- if (value == null || value.isEmpty) {
- return 'Please enter a password';
- }
- if (value.length < 8) {
- return 'Password must be at least 8 characters';
- }
- return null;
- },
),
- const SizedBox(height: 16),
- TextFormField(
- controller: _confirmPasswordController,
- obscureText: _obscureConfirmPassword,
- decoration: InputDecoration(
- labelText: 'Confirm Password',
- prefixIcon: const Icon(Icons.lock_outline),
- suffixIcon: IconButton(
- icon: Icon(
- _obscureConfirmPassword
- ? Icons.visibility_outlined
- : Icons.visibility_off_outlined,
- ),
- onPressed: () {
- setState(() => _obscureConfirmPassword =
- !_obscureConfirmPassword);
- },
- ),
- ),
- validator: (value) {
- if (value != _passwordController.text) {
- return 'Passwords do not match';
- }
- return null;
- },
- ),
- const SizedBox(height: 32),
- ElevatedButton(
- onPressed: _handleRegister,
- child: const Text('CONTINUE'),
- ),
- const SizedBox(height: 16),
- Row(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- Text(
- 'Already have an account? ',
- style: Theme.of(context).textTheme.bodyMedium,
- ),
- TextButton(
- onPressed: () => context.go('/login'),
- child: const Text('LOGIN'),
- ),
- ],
- ),
- ],
- ),
- ),
+ ),
+ );
+ },
),
),
),
diff --git a/lib/src/features/dashboard/presentation/screens/hub_screen.dart b/lib/src/features/dashboard/presentation/screens/hub_screen.dart
index 8fc4725..8b73e84 100644
--- a/lib/src/features/dashboard/presentation/screens/hub_screen.dart
+++ b/lib/src/features/dashboard/presentation/screens/hub_screen.dart
@@ -1,26 +1,29 @@
-import 'dart:convert';
-
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
+import 'package:slrpg_app/l10n/app_localizations.dart';
-import '../../../../core/constants/asset_paths.dart';
+import '../../../../core/constants/app_constants.dart';
+import '../../../../core/debug/debug_config_screen.dart';
import '../../../../core/theme/app_theme.dart';
+import '../../../../shared/data/local/app_database.dart';
import '../../../../shared/data/repositories/user_repository.dart';
import '../../../../shared/data/repositories/cycle_repository.dart';
+import '../../../../shared/data/repositories/workout_repository.dart';
+import '../../../../shared/data/remote/sync_service.dart';
import '../../../../shared/domain/logic/xp_calculator.dart';
+import '../../../../shared/domain/logic/wendler_calculator.dart';
+import '../../../../shared/domain/entities/exercise.dart';
+import '../../../../shared/domain/entities/workout_set.dart';
+import '../../../gamification/domain/entities/avatar_config.dart';
+import '../../../gamification/domain/entities/item_catalog.dart';
+import '../../../gamification/presentation/widgets/avatar_renderer.dart';
+import '../../../gamification/presentation/widgets/quest_board.dart';
import '../widgets/xp_bar_widget.dart';
import '../widgets/level_display.dart';
import '../widgets/start_raid_button.dart';
-import '../../../../shared/data/local/collections/user_collection.dart';
-import '../../../../shared/data/local/collections/cycle_collection.dart';
-import '../../../../shared/data/repositories/workout_repository.dart';
-import '../../../../shared/domain/entities/exercise.dart';
-import '../../../../shared/domain/logic/wendler_calculator.dart';
-import '../../../../shared/data/remote/sync_service.dart';
-import '../../../../shared/domain/entities/workout_set.dart';
-import '../../../gamification/domain/entities/avatar_config.dart';
-import '../../../gamification/presentation/widgets/avatar_renderer.dart';
+import '../../../gamification/application/quest_service.dart';
+import '../../../workout_runner/application/workout_generator_service.dart';
class HubScreen extends ConsumerStatefulWidget {
const HubScreen({super.key});
@@ -41,70 +44,21 @@ class _HubScreenState extends ConsumerState {
@override
void initState() {
super.initState();
-
- WidgetsBinding.instance.addPostFrameCallback((_) {
- _runSync();
+ WidgetsBinding.instance.addPostFrameCallback((_) async {
+ await _runSync();
+ await ref.read(questServiceProvider).checkAndGenerateQuests();
});
}
- List _generateExercises({
- required int week,
- required int day,
- required Map trainingMaxes,
- required double bodyweight,
- }) {
- final exercises = [];
-
- void addExercise(String id, String name, ExerciseType type, bool isMain) {
- final tm = trainingMaxes[id] ?? 0.0;
- List sets;
-
- if (isMain) {
- sets = WendlerCalculator.generateSets(
- week: week,
- trainingMax: tm,
- exerciseType: type,
- currentBodyweight: bodyweight,
- );
- } else {
- if (week == 4) return;
-
- sets = WendlerCalculator.generateFSLSets(
- trainingMax: tm,
- exerciseType: type,
- currentBodyweight: bodyweight,
- );
- }
-
- if (sets.isNotEmpty) {
- exercises.add(Exercise(
- exerciseId: id,
- exerciseName: isMain ? name : '$name (FSL)',
- bodyweightAtSession: bodyweight,
- sets: sets,
- ));
- }
- }
-
- if (day == 1) {
- addExercise('squat', 'Back Squat', ExerciseType.squat, true);
- addExercise('pullup', 'Weighted Pull-up', ExerciseType.pullup, false);
- } else if (day == 2) {
- addExercise('dip', 'Weighted Dip', ExerciseType.dip, true);
- addExercise('squat', 'Back Squat', ExerciseType.squat, false);
- } else if (day == 3) {
- addExercise('pullup', 'Weighted Pull-up', ExerciseType.pullup, true);
- addExercise('dip', 'Weighted Dip', ExerciseType.dip, false);
- }
-
- return exercises;
- }
-
Future _startNextWorkout(
CycleCollection cycle, UserCollection user) async {
try {
final workoutRepo = ref.read(workoutRepositoryProvider);
- final cycleRepo = ref.read(cycleRepositoryProvider);
+ final workoutGenerator = ref.read(workoutGeneratorServiceProvider);
+
+ final tmsDynamic = cycle.trainingMaxes;
+ final trainingMaxes = Map.from(tmsDynamic
+ .map((key, value) => MapEntry(key, (value as num).toDouble())));
int targetWeek = 1;
int targetDay = 1;
@@ -137,9 +91,6 @@ class _HubScreenState extends ConsumerState {
}
return;
}
-
- final trainingMaxes = cycleRepo.getCurrentTrainingMaxes();
-
var workout = await workoutRepo.getWorkoutByWeekDay(
cycleId: cycleRefId,
localCycleId: localCycleId,
@@ -148,11 +99,21 @@ class _HubScreenState extends ConsumerState {
);
if (workout == null) {
- final exercises = _generateExercises(
+ final activeTemplate = _getTemplateFromUser(user);
+ int? conditioningSets;
+
+ if (activeTemplate == AccessoryTemplate.conditioning) {
+ conditioningSets = await _showConditioningDialog();
+ if (conditioningSets == null) return;
+ }
+
+ final exercises = workoutGenerator.generateWorkout(
week: targetWeek,
day: targetDay,
trainingMaxes: trainingMaxes,
- bodyweight: user.currentBodyweight,
+ user: user,
+ template: activeTemplate,
+ conditioningSets: conditioningSets,
);
final userId = user.serverId ?? user.id.toString();
@@ -162,7 +123,7 @@ class _HubScreenState extends ConsumerState {
cycleId: cycleRefId,
week: targetWeek,
day: targetDay,
- exercisesJson: jsonEncode(exercises.map((e) => e.toJson()).toList()),
+ exercises: exercises.map((e) => e.toJson()).toList(),
);
}
@@ -183,10 +144,94 @@ class _HubScreenState extends ConsumerState {
}
}
+ AccessoryTemplate _getTemplateFromUser(UserCollection user) {
+ final settings = user.inventorySettings ?? {};
+ final key = settings['accessory_template'] as String?;
+ if (key == 'hypertrophy') return AccessoryTemplate.hypertrophy;
+ if (key == 'conditioning') return AccessoryTemplate.conditioning;
+ return AccessoryTemplate.none;
+ }
+
+ Future _showConditioningDialog() async {
+ int sets = 20;
+ final l10n = AppLocalizations.of(context)!;
+
+ return await showDialog(
+ context: context,
+ barrierDismissible: false,
+ builder: (context) {
+ return StatefulBuilder(
+ builder: (context, setDialogState) {
+ final interval = (20 * 60) / sets;
+
+ return AlertDialog(
+ title: Text(
+ l10n.missionBriefingTitle,
+ style: TextStyle(
+ color: AppTheme.textSecondary,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ l10n.missionBriefingBody,
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 24),
+ Text(
+ l10n.missionBriefingDensity(sets),
+ style: Theme.of(context).textTheme.titleLarge?.copyWith(
+ color: AppTheme.primaryColor,
+ fontWeight: FontWeight.bold),
+ ),
+ Text(
+ l10n.missionBriefingInterval(interval.toStringAsFixed(0)),
+ style: const TextStyle(color: Colors.grey),
+ ),
+ const SizedBox(height: 16),
+ Slider(
+ value: sets.toDouble(),
+ min: 10,
+ max: 30,
+ divisions: 20,
+ activeColor: AppTheme.primaryColor,
+ onChanged: (val) {
+ setDialogState(() => sets = val.toInt());
+ },
+ ),
+ const SizedBox(height: 8),
+ if (sets >= 20)
+ const Text('⚠️ HARDCORE MODE',
+ style: TextStyle(
+ color: AppTheme.errorColor,
+ fontSize: 10,
+ fontWeight: FontWeight.bold)),
+ ],
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context, null),
+ child: Text(l10n.abortButton),
+ ),
+ ElevatedButton(
+ onPressed: () => Navigator.pop(context, sets),
+ child: Text(l10n.engageButton),
+ ),
+ ],
+ );
+ },
+ );
+ },
+ );
+ }
+
@override
Widget build(BuildContext context) {
final userRepo = ref.watch(userRepositoryProvider);
final cycleRepo = ref.watch(cycleRepositoryProvider);
+ final l10n = AppLocalizations.of(context)!;
return Scaffold(
body: SafeArea(
@@ -202,9 +247,12 @@ class _HubScreenState extends ConsumerState {
final user = snapshot.data![0] as UserCollection?;
final cycle = snapshot.data![1] as CycleCollection?;
- final avatarConfig = user?.avatarConfigJson != null
- ? AvatarConfig.fromJson(jsonDecode(user!.avatarConfigJson!))
+
+ final avatarConfig = user?.avatarConfig != null
+ ? AvatarConfig.fromJson(user!.avatarConfig!)
: const AvatarConfig();
+ final bgItem =
+ ItemCatalog.getBackground(avatarConfig.selectedBackground);
if (user == null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -223,8 +271,9 @@ class _HubScreenState extends ConsumerState {
children: [
Positioned.fill(
child: Image.asset(
- AssetPaths.bgStreetParkDay,
+ bgItem.assetPath,
fit: BoxFit.cover,
+ key: ValueKey(bgItem.assetPath),
),
),
Positioned.fill(
@@ -234,8 +283,8 @@ class _HubScreenState extends ConsumerState {
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
- Colors.black.withOpacity(0.6),
- Colors.black.withOpacity(0.85),
+ Colors.black.withValues(alpha: 0.6),
+ Colors.black.withValues(alpha: 0.85),
],
),
),
@@ -248,6 +297,17 @@ class _HubScreenState extends ConsumerState {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
+ if (AppConstants.isDevelopment)
+ IconButton(
+ icon: const Icon(Icons.settings),
+ onPressed: () => Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) =>
+ const DebugConfigScreen(),
+ ),
+ ),
+ ),
IconButton(
icon: const Icon(Icons.person_outline),
onPressed: () => context.go('/profile'),
@@ -289,6 +349,8 @@ class _HubScreenState extends ConsumerState {
nextLevelXP: nextLevelXP,
),
),
+ const SizedBox(height: 16),
+ const QuestBoardWidget(),
const Spacer(flex: 2),
if (cycle != null)
Padding(
@@ -303,7 +365,7 @@ class _HubScreenState extends ConsumerState {
child: Column(
children: [
Text(
- 'No active cycle',
+ l10n.hubNoActiveCycle,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 16),
@@ -311,7 +373,7 @@ class _HubScreenState extends ConsumerState {
onPressed: () {
context.push('/onboarding/strength-test');
},
- child: const Text('Create New Cycle'),
+ child: Text(l10n.hubCreateCycle),
),
],
),
@@ -324,8 +386,11 @@ class _HubScreenState extends ConsumerState {
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_StatBox(
- label: 'Cycle', value: '#${cycle.cycleNumber}'),
- _StatBox(label: 'Active', value: 'Yes'),
+ label: l10n.hubCycleLabel,
+ value: '#${cycle.cycleNumber}'),
+ _StatBox(
+ label: l10n.hubActiveLabel,
+ value: l10n.hubActiveYes),
],
),
),
@@ -339,7 +404,7 @@ class _HubScreenState extends ConsumerState {
top: Radius.circular(24)),
boxShadow: [
BoxShadow(
- color: Colors.black.withOpacity(0.2),
+ color: Colors.black.withValues(alpha: 0.2),
blurRadius: 10,
offset: const Offset(0, -5),
),
@@ -350,24 +415,24 @@ class _HubScreenState extends ConsumerState {
children: [
_NavButton(
icon: Icons.history,
- label: 'History',
+ label: l10n.navHistory,
onTap: () => context.go('/history'),
),
_NavButton(
icon: Icons.inventory_2_outlined,
- label: 'Inventory',
+ label: l10n.navInventory,
onTap: () => context.go('/inventory'),
),
_NavButton(
icon: Icons.bar_chart,
- label: 'Stats',
+ label: l10n.navStats,
onTap: () {
context.go('/stats');
},
),
_NavButton(
icon: Icons.auto_stories,
- label: 'Codex',
+ label: l10n.navCodex,
onTap: () => context.go('/codex'),
),
],
@@ -401,7 +466,7 @@ class _StatBox extends StatelessWidget {
color: AppTheme.surfaceColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(
- color: AppTheme.primaryColor.withOpacity(0.3),
+ color: AppTheme.primaryColor.withValues(alpha: 0.3),
),
),
child: Column(
diff --git a/lib/src/features/dashboard/presentation/widgets/level_display.dart b/lib/src/features/dashboard/presentation/widgets/level_display.dart
index 73a651c..9eb8cab 100644
--- a/lib/src/features/dashboard/presentation/widgets/level_display.dart
+++ b/lib/src/features/dashboard/presentation/widgets/level_display.dart
@@ -23,7 +23,7 @@ class LevelDisplay extends StatelessWidget {
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
- color: AppTheme.primaryColor.withOpacity(0.5),
+ color: AppTheme.primaryColor.withValues(alpha: 0.5),
blurRadius: 12,
spreadRadius: 2,
),
@@ -57,4 +57,3 @@ class LevelDisplay extends StatelessWidget {
);
}
}
-
diff --git a/lib/src/features/dashboard/presentation/widgets/start_raid_button.dart b/lib/src/features/dashboard/presentation/widgets/start_raid_button.dart
index e46d41e..ecb179f 100644
--- a/lib/src/features/dashboard/presentation/widgets/start_raid_button.dart
+++ b/lib/src/features/dashboard/presentation/widgets/start_raid_button.dart
@@ -54,7 +54,8 @@ class _StartRaidButtonState extends State
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
- color: AppTheme.primaryColor.withOpacity(_glowAnimation.value),
+ color: AppTheme.primaryColor
+ .withValues(alpha: _glowAnimation.value),
blurRadius: 20,
spreadRadius: 5,
),
@@ -96,4 +97,3 @@ class _StartRaidButtonState extends State
);
}
}
-
diff --git a/lib/src/features/dashboard/presentation/widgets/xp_bar_widget.dart b/lib/src/features/dashboard/presentation/widgets/xp_bar_widget.dart
index 3b5e5fb..57e5245 100644
--- a/lib/src/features/dashboard/presentation/widgets/xp_bar_widget.dart
+++ b/lib/src/features/dashboard/presentation/widgets/xp_bar_widget.dart
@@ -49,7 +49,7 @@ class XPBarWidget extends StatelessWidget {
color: AppTheme.xpBarBackground,
borderRadius: BorderRadius.circular(16),
border: Border.all(
- color: AppTheme.primaryColor.withOpacity(0.3),
+ color: AppTheme.primaryColor.withValues(alpha: 0.3),
width: 2,
),
),
@@ -64,13 +64,13 @@ class XPBarWidget extends StatelessWidget {
gradient: LinearGradient(
colors: [
AppTheme.primaryColor,
- AppTheme.primaryColor.withOpacity(0.7),
+ AppTheme.primaryColor.withValues(alpha: 0.7),
],
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
- color: AppTheme.primaryColor.withOpacity(0.5),
+ color: AppTheme.primaryColor.withValues(alpha: 0.5),
blurRadius: 8,
spreadRadius: 1,
),
@@ -86,15 +86,15 @@ class XPBarWidget extends StatelessWidget {
child: Text(
'${(progress * 100).toStringAsFixed(0)}%',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
- fontWeight: FontWeight.bold,
- color: Colors.white,
- shadows: [
- const Shadow(
- color: Colors.black,
- blurRadius: 4,
- ),
- ],
+ fontWeight: FontWeight.bold,
+ color: Colors.white,
+ shadows: [
+ const Shadow(
+ color: Colors.black,
+ blurRadius: 4,
),
+ ],
+ ),
),
),
],
@@ -103,4 +103,3 @@ class XPBarWidget extends StatelessWidget {
);
}
}
-
diff --git a/lib/src/features/gamification/application/quest_service.dart b/lib/src/features/gamification/application/quest_service.dart
new file mode 100644
index 0000000..670d51b
--- /dev/null
+++ b/lib/src/features/gamification/application/quest_service.dart
@@ -0,0 +1,179 @@
+import 'dart:math';
+import 'package:flutter/foundation.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:drift/drift.dart';
+
+import '../../../shared/data/local/app_database.dart';
+import '../../../shared/data/repositories/user_repository.dart';
+import '../../../shared/data/repositories/workout_repository.dart';
+import '../data/repositories/quest_repository.dart';
+import '../../../core/constants/app_constants.dart';
+
+enum QuestTrigger {
+ workoutComplete,
+ volume,
+ repCount,
+ inventoryChange,
+}
+
+final questServiceProvider = Provider((ref) {
+ final questRepo = ref.watch(questRepositoryProvider);
+ final userRepo = ref.watch(userRepositoryProvider);
+ return QuestService(ref: ref, questRepo: questRepo, userRepo: userRepo);
+});
+
+class QuestService {
+ final Ref ref;
+ final QuestRepository questRepo;
+ final UserRepository userRepo;
+
+ QuestService({
+ required this.ref,
+ required this.questRepo,
+ required this.userRepo,
+ });
+
+ Future checkAndGenerateQuests() async {
+ await _cleanupExpired();
+ await _generateDailiesIfNeeded();
+ await _ensureStoryQuests();
+ }
+
+ Future _cleanupExpired() async {
+ await questRepo.cleanupExpiredQuests();
+ }
+
+ Future _generateDailiesIfNeeded() async {
+ final activeDailies = await questRepo.getActiveQuests();
+ final hasDailies = activeDailies.any((q) => q.type == 'daily');
+
+ if (!hasDailies) {
+ debugPrint('🎲 Generating new Daily Quests...');
+ final random = Random();
+ final newQuests = [];
+
+ final pool = List<_QuestTemplate>.from(_dailyQuestPool)..shuffle();
+ final selected = pool.take(3);
+
+ final now = DateTime.now();
+ final endOfDay = DateTime(now.year, now.month, now.day, 23, 59, 59);
+
+ for (var template in selected) {
+ newQuests.add(QuestCollection(
+ id: 'daily_${now.millisecondsSinceEpoch}_${random.nextInt(1000)}',
+ type: 'daily',
+ title: template.title,
+ description: template.description,
+ targetValue: template.target,
+ currentValue: 0,
+ rewardXP: template.xp,
+ rewardItem: template.itemId,
+ isCompleted: false,
+ isClaimed: false,
+ expiresAt: endOfDay,
+ createdAt: now,
+ ));
+ }
+
+ for (var q in newQuests) {
+ await questRepo.createQuest(q);
+ }
+ }
+ }
+
+ Future _ensureStoryQuests() async {
+ final activeQuests = await questRepo.getActiveQuests();
+
+ // Helper: Prüft ob Quest-ID schon existiert (aktiv oder erledigt)
+ // Hinweis: getActiveQuests liefert aktuell alle nicht-abgelaufenen.
+ // Für Story Quests (die nie ablaufen) reicht das.
+ bool hasQuest(String id) => activeQuests.any((q) => q.id == id);
+
+ if (!hasQuest('story_initiate')) {
+ await questRepo.createQuest(QuestCollection(
+ id: 'story_initiate',
+ type: 'story',
+ title: 'The Awakening',
+ description: 'Complete your first workout to prove your worth.',
+ targetValue: 1,
+ currentValue: 0,
+ rewardXP: 100,
+ isCompleted: false,
+ isClaimed: false,
+ createdAt: DateTime.now(),
+ ));
+ }
+
+ if (!hasQuest('story_inventory')) {
+ await questRepo.createQuest(QuestCollection(
+ id: 'story_inventory',
+ type: 'story',
+ title: 'Armory Master',
+ description: 'Setup your equipment inventory.',
+ targetValue: 1,
+ currentValue: 0,
+ rewardXP: 50,
+ isCompleted: false,
+ isClaimed: false,
+ createdAt: DateTime.now(),
+ ));
+ final inventory = await userRepo.getInventorySettingsAsync();
+ if ((inventory['plates'] as List).isNotEmpty) {
+ await questRepo.updateProgress('story_inventory', 1);
+ }
+ }
+ }
+
+ Future reportEvent(QuestTrigger trigger, {dynamic data}) async {
+ final activeQuests = await questRepo.getActiveQuests();
+
+ for (var quest in activeQuests) {
+ if (quest.isCompleted) continue;
+
+ if (quest.id == 'story_initiate' &&
+ trigger == QuestTrigger.workoutComplete) {
+ await questRepo.updateProgress(quest.id, 1);
+ }
+
+ if (quest.title == 'Volume Eater' && trigger == QuestTrigger.volume) {
+ final volume = data as int; // kg
+ await questRepo.updateProgress(quest.id, volume);
+ }
+
+ if (quest.title == 'Workout Warrior' &&
+ trigger == QuestTrigger.workoutComplete) {
+ await questRepo.updateProgress(quest.id, 1);
+ }
+
+ if (quest.title == 'Rep Collector' && trigger == QuestTrigger.repCount) {
+ final reps = data as int;
+ await questRepo.updateProgress(quest.id, reps);
+ }
+ }
+ }
+}
+
+// --- QUEST DEFINITIONS ---
+
+class _QuestTemplate {
+ final String title;
+ final String description;
+ final int target;
+ final int xp;
+ final String? itemId;
+
+ const _QuestTemplate(this.title, this.description, this.target, this.xp,
+ [this.itemId]);
+}
+
+const List<_QuestTemplate> _dailyQuestPool = [
+ _QuestTemplate(
+ 'Volume Eater', 'Move a total of 500kg in a single day.', 500, 100),
+ _QuestTemplate('Workout Warrior', 'Complete 1 Workout today.', 1, 50),
+ _QuestTemplate('Rep Collector',
+ 'Perform 50 total repetitions across all exercises.', 50, 75),
+ _QuestTemplate('Early Bird', 'Start a workout before noon.', 1,
+ 50), // Logik müsste Zeit prüfen
+ _QuestTemplate(
+ 'Iron Discipline', 'Log your bodyweight in the profile.', 1, 25),
+];
diff --git a/lib/src/features/gamification/data/repositories/quest_repository.dart b/lib/src/features/gamification/data/repositories/quest_repository.dart
new file mode 100644
index 0000000..d60f6b3
--- /dev/null
+++ b/lib/src/features/gamification/data/repositories/quest_repository.dart
@@ -0,0 +1,83 @@
+import 'package:drift/drift.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import '../../../../shared/data/local/app_database.dart';
+import '../../../../../main.dart';
+
+final questRepositoryProvider = Provider((ref) {
+ final db = ref.watch(appDatabaseProvider);
+ return QuestRepository(db: db);
+});
+
+class QuestRepository {
+ final AppDatabase db;
+
+ QuestRepository({required this.db});
+
+ Future> getActiveQuests() async {
+ final now = DateTime.now();
+ return await (db.select(db.quests)
+ ..where((q) {
+ final notExpired =
+ q.expiresAt.isNull() | q.expiresAt.isBiggerThanValue(now);
+ return notExpired;
+ })
+ ..orderBy([
+ (q) =>
+ OrderingTerm(expression: q.isCompleted, mode: OrderingMode.asc)
+ ])) // Offene zuerst
+ .get();
+ }
+
+ Future createQuest(QuestCollection quest) async {
+ await db.into(db.quests).insertOnConflictUpdate(QuestsCompanion(
+ id: Value(quest.id),
+ type: Value(quest.type),
+ title: Value(quest.title),
+ description: Value(quest.description),
+ targetValue: Value(quest.targetValue),
+ currentValue: Value(quest.currentValue),
+ rewardXP: Value(quest.rewardXP),
+ rewardItem: Value(quest.rewardItem),
+ expiresAt: Value(quest.expiresAt),
+ createdAt: Value(DateTime.now()),
+ ));
+ }
+
+ Future updateProgress(String questId, int addValue) async {
+ final quest = await (db.select(db.quests)
+ ..where((q) => q.id.equals(questId)))
+ .getSingleOrNull();
+ if (quest != null && !quest.isCompleted) {
+ final newValue = quest.currentValue + addValue;
+ final isComplete = newValue >= quest.targetValue;
+
+ await (db.update(db.quests)..where((q) => q.id.equals(questId)))
+ .write(QuestsCompanion(
+ currentValue: Value(newValue),
+ isCompleted: Value(isComplete),
+ ));
+ }
+ }
+
+ Future claimQuest(String questId) async {
+ await (db.update(db.quests)..where((q) => q.id.equals(questId))).write(
+ const QuestsCompanion(isClaimed: Value(true)),
+ );
+ }
+
+ Future cleanupExpiredQuests() async {
+ final now = DateTime.now();
+ await (db.delete(db.quests)
+ ..where((q) => q.expiresAt.isSmallerThanValue(now)))
+ .go();
+ }
+
+ Stream> watchQuests() {
+ return (db.select(db.quests)
+ ..orderBy([
+ (q) =>
+ OrderingTerm(expression: q.isCompleted, mode: OrderingMode.desc)
+ ]))
+ .watch();
+ }
+}
diff --git a/lib/src/features/gamification/domain/entities/avatar_config.dart b/lib/src/features/gamification/domain/entities/avatar_config.dart
index f731cf2..09a5f37 100644
--- a/lib/src/features/gamification/domain/entities/avatar_config.dart
+++ b/lib/src/features/gamification/domain/entities/avatar_config.dart
@@ -1,18 +1,50 @@
+// import '../../../../core/constants/asset_paths.dart';
+
+// class AvatarConfig {
+// final String gender; // 'male' or 'female'
+// final int variant; // 1 to 8
+// final String selectedBackground;
+
+// const AvatarConfig({
+// this.gender = 'male',
+// this.variant = 1,
+// this.selectedBackground = 'bg_street_day',
+// });
+
+// factory AvatarConfig.fromJson(Map json) {
+// return AvatarConfig(
+// gender: json['gender'] ?? 'male',
+// variant: json['variant'] ?? 1,
+// );
+// }
+
+// Map toJson() {
+// return {
+// 'gender': gender,
+// 'variant': variant,
+// };
+// }
+
+// String get assetPath => AssetPaths.getAvatarPath(gender, variant);
+// }
import '../../../../core/constants/asset_paths.dart';
class AvatarConfig {
- final String gender; // 'male' or 'female'
- final int variant; // 1 to 8
+ final String gender;
+ final int variant;
+ final String selectedBackground; // NEU
const AvatarConfig({
this.gender = 'male',
this.variant = 1,
+ this.selectedBackground = 'bg_street_day', // Default
});
factory AvatarConfig.fromJson(Map json) {
return AvatarConfig(
gender: json['gender'] ?? 'male',
variant: json['variant'] ?? 1,
+ selectedBackground: json['selected_background'] ?? 'bg_street_day', // NEU
);
}
@@ -20,6 +52,7 @@ class AvatarConfig {
return {
'gender': gender,
'variant': variant,
+ 'selected_background': selectedBackground, // NEU
};
}
diff --git a/lib/src/features/gamification/domain/entities/item_catalog.dart b/lib/src/features/gamification/domain/entities/item_catalog.dart
new file mode 100644
index 0000000..63370fb
--- /dev/null
+++ b/lib/src/features/gamification/domain/entities/item_catalog.dart
@@ -0,0 +1,68 @@
+import '../../../../core/constants/asset_paths.dart';
+
+enum ItemCategory { background, badge }
+
+class UnlockableItem {
+ final String id;
+ final String name;
+ final String description;
+ final String assetPath;
+ final ItemCategory category;
+ final int unlockLevel; // 0 = Start
+ final String? unlockQuestId; // Optional für später
+
+ const UnlockableItem({
+ required this.id,
+ required this.name,
+ required this.description,
+ required this.assetPath,
+ required this.category,
+ this.unlockLevel = 0,
+ this.unlockQuestId,
+ });
+}
+
+class ItemCatalog {
+ // Alle verfügbaren Hintergründe
+ static const List backgrounds = [
+ UnlockableItem(
+ id: 'bg_street_day',
+ name: 'Street Park (Day)',
+ description: 'Where every journey begins.',
+ assetPath: AssetPaths.bgStreetParkDay,
+ category: ItemCategory.background,
+ unlockLevel: 0, // Start
+ ),
+ UnlockableItem(
+ id: 'bg_street_night',
+ name: 'Street Park (Night)',
+ description: 'For those who grind while others sleep.',
+ assetPath: AssetPaths.bgStreetParkNight,
+ category: ItemCategory.background,
+ unlockLevel: 5,
+ ),
+ UnlockableItem(
+ id: 'bg_commercial',
+ name: 'Commercial Gym',
+ description: 'Clean equipment, AC, and mirrors everywhere.',
+ assetPath: AssetPaths.bgCommercialGym,
+ category: ItemCategory.background,
+ unlockLevel: 10,
+ ),
+ UnlockableItem(
+ id: 'bg_underground',
+ name: 'Underground Dojo',
+ description: 'No rules. Just heavy iron and concrete.',
+ assetPath: AssetPaths.bgUndergroundGym,
+ category: ItemCategory.background,
+ unlockLevel: 20,
+ ),
+ ];
+
+ static UnlockableItem getBackground(String id) {
+ return backgrounds.firstWhere(
+ (b) => b.id == id,
+ orElse: () => backgrounds.first, // Fallback auf Start
+ );
+ }
+}
diff --git a/lib/src/features/gamification/presentation/screens/codex_screen.dart b/lib/src/features/gamification/presentation/screens/codex_screen.dart
index 5b7b478..8806ff1 100644
--- a/lib/src/features/gamification/presentation/screens/codex_screen.dart
+++ b/lib/src/features/gamification/presentation/screens/codex_screen.dart
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
+import 'package:slrpg_app/l10n/app_localizations.dart';
+
import '../../../../core/theme/app_theme.dart';
import '../../../../core/constants/asset_paths.dart';
@@ -8,9 +10,11 @@ class CodexScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
+ final l10n = AppLocalizations.of(context)!;
+
return Scaffold(
appBar: AppBar(
- title: const Text('Creature Codex'),
+ title: Text(l10n.codexTitle),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/hub'),
@@ -18,40 +22,31 @@ class CodexScreen extends StatelessWidget {
),
body: ListView(
padding: const EdgeInsets.all(16),
- children: const [
+ children: [
_LoreCard(
- name: 'Iron Golem',
- title: 'The Weight of the Earth',
- description:
- 'Forged from the tectonic plates of the Deep Earth, the Iron Golem exists only to crush the weak. '
- 'It embodies the unrelenting force of gravity acting on a heavy load.\n\n'
- 'It respects only one thing: The raw power of the LEGS that can stand up against its crushing weight.',
+ name: l10n.enemyIronGolemName,
+ title: l10n.enemyIronGolemTitle,
+ description: l10n.enemyIronGolemDesc,
assetPath: AssetPaths.enemyIronGolem,
- exercise: 'Squat Nemesis',
+ exercise: l10n.enemyIronGolemNemesis,
color: Colors.redAccent,
),
- SizedBox(height: 24),
+ const SizedBox(height: 24),
_LoreCard(
- name: 'Gravity Demon',
- title: 'The Abyssal Pull',
- description:
- 'A spirit of pure downward force that clings to the back of adventurers. '
- 'It whispers lies of weakness into your ear while dragging you towards the abyss.\n\n'
- 'Only those with a back of steel and the will to pull themselves up can escape its grasp.',
+ name: l10n.enemyGravityDemonName,
+ title: l10n.enemyGravityDemonTitle,
+ description: l10n.enemyGravityDemonDesc,
assetPath: AssetPaths.enemyGravityDemon,
- exercise: 'Pull-up Nemesis',
+ exercise: l10n.enemyGravityDemonNemesis,
color: Colors.purpleAccent,
),
- SizedBox(height: 24),
+ const SizedBox(height: 24),
_LoreCard(
- name: 'Pressure Phantom',
- title: 'The Invisible Crusher',
- description:
- 'An ethereal entity that compresses the very air around you. '
- 'It seeks to collapse the chest and shoulders of any who dare to push against it.\n\n'
- 'Defeat it by pushing through the pain with explosive dipping power.',
+ name: l10n.enemyPressurePhantomName,
+ title: l10n.enemyPressurePhantomTitle,
+ description: l10n.enemyPressurePhantomDesc,
assetPath: AssetPaths.enemyPressurePhantom,
- exercise: 'Dip Nemesis',
+ exercise: l10n.enemyPressurePhantomNemesis,
color: Colors.cyanAccent,
),
],
@@ -83,14 +78,14 @@ class _LoreCard extends StatelessWidget {
clipBehavior: Clip.antiAlias,
child: Container(
decoration: BoxDecoration(
- border: Border.all(color: color.withOpacity(0.5), width: 1),
+ border: Border.all(color: color.withValues(alpha: 0.5), width: 1),
borderRadius: BorderRadius.circular(16),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppTheme.surfaceColor,
- color.withOpacity(0.1),
+ color.withValues(alpha: 0.1),
],
),
),
@@ -112,7 +107,7 @@ class _LoreCard extends StatelessWidget {
child: Image.asset(
assetPath,
fit: BoxFit.contain,
- color: Colors.white.withOpacity(0.9),
+ color: Colors.white.withValues(alpha: 0.9),
colorBlendMode: BlendMode.modulate,
),
),
diff --git a/lib/src/features/gamification/presentation/screens/quest_log.dart b/lib/src/features/gamification/presentation/screens/quest_log.dart
new file mode 100644
index 0000000..28c54c3
--- /dev/null
+++ b/lib/src/features/gamification/presentation/screens/quest_log.dart
@@ -0,0 +1,92 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+import 'package:slrpg_app/l10n/app_localizations.dart';
+import '../../../../core/theme/app_theme.dart';
+import '../../data/repositories/quest_repository.dart';
+import '../../../../shared/data/local/app_database.dart'; // Für QuestCollection Typ
+import '../widgets/quest_item.dart';
+
+class QuestLogScreen extends ConsumerWidget {
+ const QuestLogScreen({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final questRepo = ref.watch(questRepositoryProvider);
+ final l10n = AppLocalizations.of(context)!;
+
+ return DefaultTabController(
+ length: 2,
+ child: Scaffold(
+ appBar: AppBar(
+ title: const Text('Quest Log'),
+ leading: IconButton(
+ icon: const Icon(Icons.arrow_back),
+ onPressed: () => context.go('/hub'),
+ ),
+ bottom: TabBar(
+ indicatorColor: AppTheme.primaryColor,
+ labelColor: AppTheme.primaryColor,
+ unselectedLabelColor: Colors.grey,
+ tabs: [
+ Tab(text: l10n.questTabDailies),
+ Tab(text: l10n.questTabJourney),
+ ],
+ ),
+ ),
+ body: StreamBuilder>(
+ stream: questRepo.watchQuests(),
+ builder: (context, snapshot) {
+ if (snapshot.connectionState == ConnectionState.waiting) {
+ return const Center(child: CircularProgressIndicator());
+ }
+ if (snapshot.hasError) {
+ return Center(child: Text('Error: ${snapshot.error}'));
+ }
+
+ final allQuests = snapshot.data ?? [];
+
+ final dailies = allQuests.where((q) => q.type == 'daily').toList();
+ final story = allQuests.where((q) => q.type != 'daily').toList();
+
+ return TabBarView(
+ children: [
+ _QuestList(
+ quests: dailies, emptyMessage: l10n.questEmptyDailies),
+ _QuestList(quests: story, emptyMessage: l10n.questEmptyJourney),
+ ],
+ );
+ },
+ ),
+ ),
+ );
+ }
+}
+
+class _QuestList extends StatelessWidget {
+ final List quests;
+ final String emptyMessage;
+
+ const _QuestList({required this.quests, required this.emptyMessage});
+
+ @override
+ Widget build(BuildContext context) {
+ if (quests.isEmpty) {
+ return Center(
+ child: Text(
+ emptyMessage,
+ textAlign: TextAlign.center,
+ style: TextStyle(color: AppTheme.textSecondary),
+ ),
+ );
+ }
+
+ return ListView.builder(
+ padding: const EdgeInsets.all(16),
+ itemCount: quests.length,
+ itemBuilder: (context, index) {
+ return QuestItem(quest: quests[index]);
+ },
+ );
+ }
+}
diff --git a/lib/src/features/gamification/presentation/widgets/avatar_renderer.dart b/lib/src/features/gamification/presentation/widgets/avatar_renderer.dart
index 7bd9aa8..faf9b32 100644
--- a/lib/src/features/gamification/presentation/widgets/avatar_renderer.dart
+++ b/lib/src/features/gamification/presentation/widgets/avatar_renderer.dart
@@ -20,7 +20,7 @@ class AvatarRenderer extends StatelessWidget {
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
- color: Colors.black.withOpacity(0.3),
+ color: Colors.black.withValues(alpha: 0.3),
blurRadius: 10,
spreadRadius: 2,
),
diff --git a/lib/src/features/gamification/presentation/widgets/quest_board.dart b/lib/src/features/gamification/presentation/widgets/quest_board.dart
new file mode 100644
index 0000000..0cc6fcd
--- /dev/null
+++ b/lib/src/features/gamification/presentation/widgets/quest_board.dart
@@ -0,0 +1,110 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+import '../../../../core/theme/app_theme.dart';
+import '../../../gamification/data/repositories/quest_repository.dart';
+import '../../../../shared/data/local/app_database.dart';
+
+class QuestBoardWidget extends ConsumerWidget {
+ const QuestBoardWidget({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final questRepo = ref.watch(questRepositoryProvider);
+
+ return StreamBuilder>(
+ stream: questRepo.watchQuests(),
+ builder: (context, snapshot) {
+ final allQuests = snapshot.data ?? [];
+ final activeQuests = allQuests
+ .where(
+ (q) => !q.isClaimed && (q.type == 'daily' || q.type == 'story'))
+ .take(3)
+ .toList();
+
+ if (activeQuests.isEmpty) return const SizedBox.shrink();
+
+ return Container(
+ margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: AppTheme.surfaceColor,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: Colors.white10),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(
+ 'DAILY BOUNTIES',
+ style: Theme.of(context).textTheme.labelSmall?.copyWith(
+ color: AppTheme.secondaryColor,
+ letterSpacing: 1.5,
+ fontWeight: FontWeight.bold),
+ ),
+ GestureDetector(
+ onTap: () => context.go('/quests'),
+ child: const Text('VIEW ALL >',
+ style: TextStyle(fontSize: 10, color: Colors.grey)),
+ ),
+ ],
+ ),
+ const SizedBox(height: 12),
+ ...activeQuests.map((q) => _MiniQuestRow(quest: q)).toList(),
+ ],
+ ),
+ );
+ },
+ );
+ }
+}
+
+class _MiniQuestRow extends StatelessWidget {
+ final QuestCollection quest;
+ const _MiniQuestRow({required this.quest});
+
+ @override
+ Widget build(BuildContext context) {
+ final progress = (quest.currentValue / quest.targetValue).clamp(0.0, 1.0);
+ final isComplete = quest.isCompleted;
+
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 8),
+ child: Row(
+ children: [
+ Icon(
+ isComplete ? Icons.check_circle : Icons.circle_outlined,
+ size: 16,
+ color: isComplete ? AppTheme.successColor : Colors.grey,
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ quest.title,
+ style: TextStyle(
+ fontSize: 12,
+ color: isComplete ? Colors.grey : Colors.white,
+ decoration: isComplete ? TextDecoration.lineThrough : null,
+ ),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ if (!isComplete)
+ SizedBox(
+ width: 40,
+ height: 4,
+ child: LinearProgressIndicator(
+ value: progress,
+ backgroundColor: Colors.white10,
+ color: AppTheme.secondaryColor,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/src/features/gamification/presentation/widgets/quest_item.dart b/lib/src/features/gamification/presentation/widgets/quest_item.dart
new file mode 100644
index 0000000..6704ac5
--- /dev/null
+++ b/lib/src/features/gamification/presentation/widgets/quest_item.dart
@@ -0,0 +1,193 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import '../../../../core/theme/app_theme.dart';
+import '../../../../shared/data/local/app_database.dart';
+import '../../data/repositories/quest_repository.dart';
+import '../../domain/entities/item_catalog.dart';
+
+class QuestItem extends ConsumerStatefulWidget {
+ final QuestCollection quest;
+
+ const QuestItem({
+ super.key,
+ required this.quest,
+ });
+
+ @override
+ ConsumerState createState() => _QuestItemState();
+}
+
+class _QuestItemState extends ConsumerState {
+ bool _isClaiming = false;
+
+ Future _handleClaim() async {
+ setState(() => _isClaiming = true);
+ try {
+ final questRepo = ref.read(questRepositoryProvider);
+ await questRepo.claimQuest(widget.quest.id);
+
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Row(
+ children: [
+ const Icon(Icons.check_circle, color: AppTheme.successColor),
+ const SizedBox(width: 8),
+ Text('Reward collected: ${widget.quest.rewardXP} XP!'),
+ ],
+ ),
+ backgroundColor: Colors.black87,
+ ),
+ );
+ }
+ } catch (e) {
+ if (mounted) {
+ ScaffoldMessenger.of(context)
+ .showSnackBar(SnackBar(content: Text('Error: $e')));
+ }
+ } finally {
+ if (mounted) setState(() => _isClaiming = false);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final progress =
+ (widget.quest.currentValue / widget.quest.targetValue).clamp(0.0, 1.0);
+ final isComplete = widget.quest.isCompleted;
+ final isClaimed = widget.quest.isClaimed;
+
+ return Card(
+ margin: const EdgeInsets.only(bottom: 12),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(12),
+ side: isComplete && !isClaimed
+ ? const BorderSide(color: AppTheme.successColor, width: 1)
+ : BorderSide.none,
+ ),
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ // Header
+ Row(
+ children: [
+ Icon(
+ _getIconForType(widget.quest.type),
+ color: isComplete
+ ? AppTheme.successColor
+ : AppTheme.primaryColor,
+ size: 20,
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ widget.quest.title,
+ style: Theme.of(context).textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.bold,
+ color: isClaimed ? Colors.grey : Colors.white,
+ decoration:
+ isClaimed ? TextDecoration.lineThrough : null,
+ ),
+ ),
+ ),
+ if (isClaimed)
+ const Icon(Icons.check, color: Colors.grey, size: 20)
+ else if (widget.quest.rewardXP > 0)
+ Container(
+ padding:
+ const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
+ decoration: BoxDecoration(
+ color: AppTheme.xpBarFill.withValues(alpha: 0.2),
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: Text(
+ '+${widget.quest.rewardXP} XP',
+ style: const TextStyle(
+ color: AppTheme.primaryColor,
+ fontSize: 12,
+ fontWeight: FontWeight.bold),
+ ),
+ ),
+ ],
+ ),
+
+ const SizedBox(height: 8),
+ Text(
+ widget.quest.description,
+ style: TextStyle(
+ color: isClaimed ? Colors.grey : AppTheme.textSecondary,
+ fontSize: 12),
+ ),
+ const SizedBox(height: 16),
+
+ // Progress Bar & Action
+ Row(
+ children: [
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ ClipRRect(
+ borderRadius: BorderRadius.circular(4),
+ child: LinearProgressIndicator(
+ value: progress,
+ backgroundColor: Colors.grey[800],
+ color: isComplete
+ ? AppTheme.successColor
+ : AppTheme.primaryColor,
+ minHeight: 8,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ '${widget.quest.currentValue} / ${widget.quest.targetValue}',
+ style:
+ const TextStyle(color: Colors.grey, fontSize: 10),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(width: 16),
+ if (isComplete && !isClaimed)
+ ElevatedButton(
+ onPressed: _isClaiming ? null : _handleClaim,
+ style: ElevatedButton.styleFrom(
+ backgroundColor: AppTheme.successColor,
+ padding: const EdgeInsets.symmetric(
+ horizontal: 16, vertical: 0),
+ minimumSize: const Size(0, 32),
+ ),
+ child: _isClaiming
+ ? const SizedBox(
+ width: 12,
+ height: 12,
+ child: CircularProgressIndicator(
+ strokeWidth: 2, color: Colors.white))
+ : const Text('CLAIM', style: TextStyle(fontSize: 12)),
+ )
+ else if (widget.quest.rewardItem != null && !isClaimed)
+ const Icon(Icons.inventory_2,
+ color: AppTheme.secondaryColor, size: 20),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ IconData _getIconForType(String type) {
+ switch (type) {
+ case 'daily':
+ return Icons.today;
+ case 'story':
+ return Icons.auto_stories;
+ case 'milestone':
+ return Icons.emoji_events;
+ default:
+ return Icons.task_alt;
+ }
+ }
+}
diff --git a/lib/src/features/history/presentation/screens/history_screen.dart b/lib/src/features/history/presentation/screens/history_screen.dart
index 47326e8..85c8917 100644
--- a/lib/src/features/history/presentation/screens/history_screen.dart
+++ b/lib/src/features/history/presentation/screens/history_screen.dart
@@ -1,15 +1,14 @@
-import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
+import 'package:slrpg_app/l10n/app_localizations.dart';
import '../../../../core/theme/app_theme.dart';
+import '../../../../shared/data/local/app_database.dart';
import '../../../../shared/data/repositories/user_repository.dart';
import '../../../../shared/data/repositories/workout_repository.dart';
-import '../../../../shared/data/local/collections/workout_collection.dart';
import '../../../../shared/domain/entities/exercise.dart';
-import '../../../../shared/domain/entities/workout_set.dart';
class HistoryScreen extends ConsumerStatefulWidget {
const HistoryScreen({super.key});
@@ -27,16 +26,15 @@ class _HistoryScreenState extends ConsumerState {
if (user == null) return [];
final userId = user.serverId ?? user.id.toString();
- return workoutRepo.getCompletedWorkouts(userId); // ID übergeben
+ return workoutRepo.getCompletedWorkouts(userId);
}
@override
Widget build(BuildContext context) {
- final workoutRepo = ref.watch(workoutRepositoryProvider);
-
+ final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
- title: const Text('Quest Log'),
+ title: Text(l10n.historyTitle),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/hub'),
@@ -57,16 +55,16 @@ class _HistoryScreenState extends ConsumerState {
Icon(
Icons.history_edu,
size: 80,
- color: AppTheme.primaryColor.withOpacity(0.5),
+ color: AppTheme.primaryColor.withValues(alpha: 0.5),
),
const SizedBox(height: 16),
Text(
- 'No completed quests yet',
+ l10n.historyEmptyTitle,
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 8),
Text(
- 'Complete a workout to fill your journal',
+ l10n.historyEmptyBody,
style: Theme.of(context).textTheme.bodyMedium,
),
],
@@ -74,7 +72,7 @@ class _HistoryScreenState extends ConsumerState {
);
}
- final workouts = snapshot.data!
+ final workouts = List.from(snapshot.data!)
..sort((a, b) => b.completedAt!.compareTo(a.completedAt!));
return ListView.builder(
@@ -98,8 +96,10 @@ class _WorkoutHistoryCard extends StatelessWidget {
List _parseExercises() {
try {
- final List jsonList = jsonDecode(workout.exercisesJson);
- return jsonList.map((json) => Exercise.fromJson(json)).toList();
+ final List list = workout.exercises;
+ return list
+ .map((json) => Exercise.fromJson(json as Map))
+ .toList();
} catch (e) {
debugPrint('Error parsing workout history: $e');
return [];
@@ -111,6 +111,7 @@ class _WorkoutHistoryCard extends StatelessWidget {
final dateStr = DateFormat.yMMMd().format(workout.completedAt!);
final timeStr = DateFormat.jm().format(workout.completedAt!);
final exercises = _parseExercises();
+ final l10n = AppLocalizations.of(context)!;
final summary = exercises
.map((e) =>
@@ -124,7 +125,7 @@ class _WorkoutHistoryCard extends StatelessWidget {
tilePadding: const EdgeInsets.all(16),
leading: _buildDateBadge(context, workout),
title: Text(
- summary.isEmpty ? 'Unknown Workout' : summary,
+ summary.isEmpty ? l10n.historyUnknownWorkout : summary,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor,
@@ -182,9 +183,9 @@ class _WorkoutHistoryCard extends StatelessWidget {
width: 50,
height: 50,
decoration: BoxDecoration(
- color: AppTheme.primaryColor.withOpacity(0.1),
+ color: AppTheme.primaryColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
- border: Border.all(color: AppTheme.primaryColor.withOpacity(0.3)),
+ border: Border.all(color: AppTheme.primaryColor.withValues(alpha: 0.3)),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -237,10 +238,10 @@ class _ExerciseDetailRow extends StatelessWidget {
Table(
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
columnWidths: const {
- 0: FlexColumnWidth(1), // Set
- 1: FlexColumnWidth(2), // Weight
- 2: FlexColumnWidth(2), // Reps
- 3: FlexColumnWidth(1), // Type (AMRAP/FSL)
+ 0: FlexColumnWidth(1),
+ 1: FlexColumnWidth(2),
+ 2: FlexColumnWidth(2),
+ 3: FlexColumnWidth(1),
},
children: exercise.sets.where((s) => s.completed).map((set) {
return TableRow(
diff --git a/lib/src/features/inventory/presentation/screens/inventory_screen.dart b/lib/src/features/inventory/presentation/screens/inventory_screen.dart
index 1372aad..72555bd 100644
--- a/lib/src/features/inventory/presentation/screens/inventory_screen.dart
+++ b/lib/src/features/inventory/presentation/screens/inventory_screen.dart
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
+import 'package:slrpg_app/l10n/app_localizations.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/constants/app_constants.dart';
@@ -29,7 +30,7 @@ class _InventoryScreenState extends ConsumerState {
Future _loadCurrentInventory() async {
final userRepo = ref.read(userRepositoryProvider);
- final inventory = userRepo.getInventorySettings();
+ final inventory = await userRepo.getInventorySettingsAsync();
final barWeight = (inventory['bar_weight'] as num?)?.toDouble() ?? 20.0;
@@ -62,7 +63,8 @@ class _InventoryScreenState extends ConsumerState {
};
for (var b in bandsList) {
- final color = b['color'] as String;
+ final band = b as Map;
+ final color = band['color'] as String;
if (bandMap.containsKey(color)) {
bandMap[color] = true;
}
@@ -120,13 +122,16 @@ class _InventoryScreenState extends ConsumerState {
}
Future _saveChanges() async {
+ final l10n = AppLocalizations.of(context)!;
setState(() => _isLoading = true);
try {
final userRepo = ref.read(userRepositoryProvider);
final platesList = [];
_plateInventory.forEach((weight, count) {
- for (int i = 0; i < count; i++) platesList.add(weight);
+ for (int i = 0; i < count; i++) {
+ platesList.add(weight);
+ }
});
final bandsList =