Merge branch 'add-pullup-journey'

* add-pullup-journey:
  feat: add localization based translations for german and english to all screens
  chore: update deps and add localization
  feat: add exercise informations and first l10n strings
  fix: fix error in set preview
  feat: add assisted exercise an next set preview
  feat: improve ui
  feat: add more security enviroment variable based settings
  feat: improve weight redability
  feat: added pullup journey
  refactor: update dependencies and fix code errors to work with new dependency versions
  refactor: clean up smaller fixes
  feat: Completed with Starter MainLifts
  feat: add basic quest engine
  refactor: rebuild using drift instead of isar
This commit is contained in:
Patryk Hegenberg 2026-01-12 12:55:27 +01:00
commit 246672b24d
76 changed files with 10341 additions and 8597 deletions

4
.gitignore vendored
View file

@ -43,3 +43,7 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
.env
.env.production
.env.development

View file

@ -1,49 +1,5 @@
<!--<manifest xmlns:android="http://schemas.android.com/apk/res/android">-->
<!-- <application-->
<!-- android:label="slrpg_app"-->
<!-- android:name="${applicationName}"-->
<!-- android:icon="@mipmap/ic_launcher">-->
<!-- <activity-->
<!-- android:name=".MainActivity"-->
<!-- android:exported="true"-->
<!-- android:launchMode="singleTop"-->
<!-- android:taskAffinity=""-->
<!-- android:theme="@style/LaunchTheme"-->
<!-- android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"-->
<!-- android:hardwareAccelerated="true"-->
<!-- android:windowSoftInputMode="adjustResize">-->
<!-- &lt;!&ndash; Specifies an Android theme to apply to this Activity as soon as-->
<!-- the Android process has started. This theme is visible to the user-->
<!-- while the Flutter UI initializes. After that, this theme continues-->
<!-- to determine the Window background behind the Flutter UI. &ndash;&gt;-->
<!-- <meta-data-->
<!-- android:name="io.flutter.embedding.android.NormalTheme"-->
<!-- android:resource="@style/NormalTheme"-->
<!-- />-->
<!-- <intent-filter>-->
<!-- <action android:name="android.intent.action.MAIN"/>-->
<!-- <category android:name="android.intent.category.LAUNCHER"/>-->
<!-- </intent-filter>-->
<!-- </activity>-->
<!-- &lt;!&ndash; Don't delete the meta-data below.-->
<!-- This is used by the Flutter tool to generate GeneratedPluginRegistrant.java &ndash;&gt;-->
<!-- <meta-data-->
<!-- android:name="flutterEmbedding"-->
<!-- android:value="2" />-->
<!-- </application>-->
<!-- &lt;!&ndash; Required to query activities that can process text, see:-->
<!-- https://developer.android.com/training/package-visibility and-->
<!-- https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.-->
<!-- In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. &ndash;&gt;-->
<!-- <queries>-->
<!-- <intent>-->
<!-- <action android:name="android.intent.action.PROCESS_TEXT"/>-->
<!-- <data android:mimeType="text/plain"/>-->
<!-- </intent>-->
<!-- </queries>-->
<!--</manifest>-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="streetlifting_rpg"
android:name="${applicationName}"
@ -70,4 +26,4 @@
android:name="flutterEmbedding"
android:value="2" />
</application>
</manifest>
</manifest>

BIN
assets/audio/beep_long.ogg Normal file

Binary file not shown.

BIN
assets/audio/beep_short.ogg Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

3
l10n.yaml Normal file
View file

@ -0,0 +1,3 @@
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart

310
lib/l10n/app_de.arb Normal file
View file

@ -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"
}

310
lib/l10n/app_en.arb Normal file
View file

@ -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"
}

View file

@ -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<Isar>((ref) => throw UnimplementedError());
final appDatabaseProvider =
Provider<AppDatabase>((ref) => throw UnimplementedError());

View file

@ -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'),
],
);
}
}

View file

@ -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

View file

@ -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 {

View file

@ -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<Widget> 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),
),
),
],
),
);
}
}

View file

@ -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<GoRouter>((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<GoRouter>((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<SplashScreen> {
),
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<SplashScreen> {
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,
),

View file

@ -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),

View file

@ -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<LoginScreen> {
final _formKey = GlobalKey<FormState>();
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<void> _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<LoginScreen> {
}
} 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'),
),
],
),
),
],
),
),
),
);
},
),
),
),

View file

@ -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<ProfileScreen> {
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<ProfileScreen> {
);
}
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<ProfileScreen> {
}
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<ProfileScreen> {
).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<String, dynamic> 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<void> _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<String, dynamic>.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<ProfileScreen> {
),
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<AccessoryTemplate>(
value: AccessoryTemplate.none,
groupValue: current,
title: 'Strength Only',
subtitle: 'Main Lifts + FSL. Pure & Fast.',
onChanged: (val) => _updateTemplate(val!),
),
const Divider(height: 1),
_RadioTile<AccessoryTemplate>(
value: AccessoryTemplate.hypertrophy,
groupValue: current,
title: 'Hypertrophy Support',
subtitle: 'Bodybuilding accessories to build muscle armor.',
onChanged: (val) => _updateTemplate(val!),
),
const Divider(height: 1),
_RadioTile<AccessoryTemplate>(
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<AccessoryTemplate>(
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<T> extends StatelessWidget {
final T value;
final T groupValue;
final String title;
final String subtitle;
final ValueChanged<T?> 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<T>(
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,
);
}
}

View file

@ -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<RegisterScreen> {
final _formKey = GlobalKey<FormState>();
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<RegisterScreen> {
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'),
),
],
),
],
),
),
),
);
},
),
),
),

View file

@ -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<HubScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_runSync();
WidgetsBinding.instance.addPostFrameCallback((_) async {
await _runSync();
await ref.read(questServiceProvider).checkAndGenerateQuests();
});
}
List<Exercise> _generateExercises({
required int week,
required int day,
required Map<String, double> trainingMaxes,
required double bodyweight,
}) {
final exercises = <Exercise>[];
void addExercise(String id, String name, ExerciseType type, bool isMain) {
final tm = trainingMaxes[id] ?? 0.0;
List<WorkoutSet> 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<void> _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<String, double>.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<HubScreen> {
}
return;
}
final trainingMaxes = cycleRepo.getCurrentTrainingMaxes();
var workout = await workoutRepo.getWorkoutByWeekDay(
cycleId: cycleRefId,
localCycleId: localCycleId,
@ -148,11 +99,21 @@ class _HubScreenState extends ConsumerState<HubScreen> {
);
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<HubScreen> {
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<HubScreen> {
}
}
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<int?> _showConditioningDialog() async {
int sets = 20;
final l10n = AppLocalizations.of(context)!;
return await showDialog<int>(
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<HubScreen> {
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<HubScreen> {
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<HubScreen> {
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<HubScreen> {
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<HubScreen> {
nextLevelXP: nextLevelXP,
),
),
const SizedBox(height: 16),
const QuestBoardWidget(),
const Spacer(flex: 2),
if (cycle != null)
Padding(
@ -303,7 +365,7 @@ class _HubScreenState extends ConsumerState<HubScreen> {
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<HubScreen> {
onPressed: () {
context.push('/onboarding/strength-test');
},
child: const Text('Create New Cycle'),
child: Text(l10n.hubCreateCycle),
),
],
),
@ -324,8 +386,11 @@ class _HubScreenState extends ConsumerState<HubScreen> {
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<HubScreen> {
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<HubScreen> {
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(

View file

@ -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 {
);
}
}

View file

@ -54,7 +54,8 @@ class _StartRaidButtonState extends State<StartRaidButton>
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<StartRaidButton>
);
}
}

View file

@ -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 {
);
}
}

View file

@ -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<QuestService>((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<void> checkAndGenerateQuests() async {
await _cleanupExpired();
await _generateDailiesIfNeeded();
await _ensureStoryQuests();
}
Future<void> _cleanupExpired() async {
await questRepo.cleanupExpiredQuests();
}
Future<void> _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 = <QuestCollection>[];
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<void> _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<void> 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),
];

View file

@ -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<QuestRepository>((ref) {
final db = ref.watch(appDatabaseProvider);
return QuestRepository(db: db);
});
class QuestRepository {
final AppDatabase db;
QuestRepository({required this.db});
Future<List<QuestCollection>> 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<void> 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<void> 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<void> claimQuest(String questId) async {
await (db.update(db.quests)..where((q) => q.id.equals(questId))).write(
const QuestsCompanion(isClaimed: Value(true)),
);
}
Future<void> cleanupExpiredQuests() async {
final now = DateTime.now();
await (db.delete(db.quests)
..where((q) => q.expiresAt.isSmallerThanValue(now)))
.go();
}
Stream<List<QuestCollection>> watchQuests() {
return (db.select(db.quests)
..orderBy([
(q) =>
OrderingTerm(expression: q.isCompleted, mode: OrderingMode.desc)
]))
.watch();
}
}

View file

@ -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<String, dynamic> json) {
// return AvatarConfig(
// gender: json['gender'] ?? 'male',
// variant: json['variant'] ?? 1,
// );
// }
// Map<String, dynamic> 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<String, dynamic> 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
};
}

View file

@ -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<UnlockableItem> 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
);
}
}

View file

@ -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,
),
),

View file

@ -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<List<QuestCollection>>(
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<QuestCollection> 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]);
},
);
}
}

View file

@ -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,
),

View file

@ -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<List<QuestCollection>>(
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,
),
),
],
),
);
}
}

View file

@ -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<QuestItem> createState() => _QuestItemState();
}
class _QuestItemState extends ConsumerState<QuestItem> {
bool _isClaiming = false;
Future<void> _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;
}
}
}

View file

@ -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<HistoryScreen> {
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<HistoryScreen> {
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<HistoryScreen> {
);
}
final workouts = snapshot.data!
final workouts = List<WorkoutCollection>.from(snapshot.data!)
..sort((a, b) => b.completedAt!.compareTo(a.completedAt!));
return ListView.builder(
@ -98,8 +96,10 @@ class _WorkoutHistoryCard extends StatelessWidget {
List<Exercise> _parseExercises() {
try {
final List<dynamic> jsonList = jsonDecode(workout.exercisesJson);
return jsonList.map((json) => Exercise.fromJson(json)).toList();
final List<dynamic> list = workout.exercises;
return list
.map((json) => Exercise.fromJson(json as Map<String, dynamic>))
.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(

View file

@ -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<InventoryScreen> {
Future<void> _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<InventoryScreen> {
};
for (var b in bandsList) {
final color = b['color'] as String;
final band = b as Map<String, dynamic>;
final color = band['color'] as String;
if (bandMap.containsKey(color)) {
bandMap[color] = true;
}
@ -120,13 +122,16 @@ class _InventoryScreenState extends ConsumerState<InventoryScreen> {
}
Future<void> _saveChanges() async {
final l10n = AppLocalizations.of(context)!;
setState(() => _isLoading = true);
try {
final userRepo = ref.read(userRepositoryProvider);
final platesList = <double>[];
_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 = <Map<String, dynamic>>[];
@ -150,7 +155,7 @@ class _InventoryScreenState extends ConsumerState<InventoryScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Inventory updated successfully')),
SnackBar(content: Text(l10n.inventoryUpdatedSuccess)),
);
setState(() {
_hasChanges = false;
@ -186,13 +191,14 @@ class _InventoryScreenState extends ConsumerState<InventoryScreen> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
if (_isLoading && !_hasChanges) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return Scaffold(
appBar: AppBar(
title: const Text('Manage Equipment'),
title: Text(l10n.inventoryTitle),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/hub'),
@ -201,7 +207,7 @@ class _InventoryScreenState extends ConsumerState<InventoryScreen> {
if (_hasChanges)
TextButton(
onPressed: _saveChanges,
child: const Text('SAVE',
child: Text(l10n.saveButton,
style: TextStyle(
color: AppTheme.primaryColor,
fontWeight: FontWeight.bold)),
@ -219,7 +225,7 @@ class _InventoryScreenState extends ConsumerState<InventoryScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Barbell Weight',
Text(l10n.inventoryBarbellWeight,
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 16),
Row(
@ -250,32 +256,52 @@ class _InventoryScreenState extends ConsumerState<InventoryScreen> {
),
),
const SizedBox(height: 24),
Text('Quick Presets',
Text(l10n.inventoryPresets,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(color: AppTheme.textSecondary)),
const SizedBox(height: 8),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
ActionChip(
label: const Text('Home Gym'),
onPressed: () => _applyPreset('home')),
const SizedBox(width: 8),
ActionChip(
label: const Text('Commercial'),
onPressed: () => _applyPreset('commercial')),
const SizedBox(width: 8),
ActionChip(
label: const Text('Minimal'),
onPressed: () => _applyPreset('minimal')),
],
),
LayoutBuilder(
builder: (context, constraints) {
final screenWidth = constraints.maxWidth;
final chipWidth = 130.0;
final spacing = 8.0;
final totalWidth = (chipWidth * 3) + (spacing * 2);
if (screenWidth < totalWidth) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildPresetChip(l10n.inventoryPresetHome, 'home'),
const SizedBox(height: 8),
_buildPresetChip(
l10n.inventoryPresetCommercial, 'commercial'),
const SizedBox(height: 8),
_buildPresetChip(l10n.inventoryPresetMinimal, 'minimal'),
],
);
} else {
return Row(
children: [
Expanded(
child: _buildPresetChip(
l10n.inventoryPresetHome, 'home')),
const SizedBox(width: 8),
Expanded(
child: _buildPresetChip(
l10n.inventoryPresetCommercial, 'commercial')),
const SizedBox(width: 8),
Expanded(
child: _buildPresetChip(
l10n.inventoryPresetMinimal, 'minimal')),
],
);
}
},
),
const SizedBox(height: 24),
Text('Plates Available',
Text(l10n.inventoryPlates,
style: Theme.of(context)
.textTheme
.titleMedium
@ -294,43 +320,130 @@ class _InventoryScreenState extends ConsumerState<InventoryScreen> {
);
}),
const SizedBox(height: 24),
Text('Resistance Bands (Assistance)',
Text(l10n.inventoryBands,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(color: AppTheme.textSecondary)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: _bandInventory.entries.map((entry) {
final resistance = AppConstants.defaultBands[entry.key] ?? 0;
return FilterChip(
label: Text('${entry.key} (~${resistance.toInt()}kg)'),
selected: entry.value,
onSelected: (bool selected) {
setState(() {
_bandInventory[entry.key] = selected;
_hasChanges = true;
});
LayoutBuilder(
builder: (context, constraints) {
final screenWidth = constraints.maxWidth;
int crossAxisCount = 2;
if (screenWidth > 600) {
crossAxisCount = 4;
} else if (screenWidth > 400) {
crossAxisCount = 2;
}
final bandEntries = _bandInventory.entries.toList();
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 2.5,
),
itemCount: bandEntries.length,
itemBuilder: (context, index) {
final entry = bandEntries[index];
final resistance =
AppConstants.defaultBands[entry.key] ?? 0;
return _buildBandChip(
entry.key,
resistance.toInt(),
entry.value,
);
},
selectedColor: _getBandColor(entry.key).withOpacity(0.3),
checkmarkColor: _getBandColor(entry.key),
side: BorderSide(color: _getBandColor(entry.key)),
);
}).toList(),
},
),
const SizedBox(height: 40),
if (_hasChanges)
ElevatedButton(
onPressed: _isLoading ? null : _saveChanges,
child: _isLoading
? const CircularProgressIndicator(color: Colors.black)
: const Text('SAVE CHANGES'),
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
color: Colors.black,
strokeWidth: 2,
),
)
: Text(l10n.saveChangesButton),
),
],
),
),
);
}
Widget _buildPresetChip(String label, String preset) {
return OutlinedButton(
onPressed: () => _applyPreset(preset),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
side: BorderSide(
color: AppTheme.primaryColor.withValues(alpha: 0.5),
),
),
child: Text(label),
);
}
Widget _buildBandChip(String color, int resistance, bool isSelected) {
return InkWell(
onTap: () {
setState(() {
_bandInventory[color] = !isSelected;
_hasChanges = true;
});
},
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isSelected
? _getBandColor(color).withValues(alpha: 0.2)
: AppTheme.surfaceColor,
border: Border.all(
color: _getBandColor(color),
width: isSelected ? 2 : 1,
),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isSelected)
Icon(
Icons.check_circle,
color: _getBandColor(color),
size: 18,
),
if (isSelected) const SizedBox(width: 4),
Expanded(
child: Text(
'$color (~${resistance}kg)',
style: TextStyle(
color: isSelected
? _getBandColor(color)
: AppTheme.textSecondary,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
fontSize: 12,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
}

View file

@ -19,6 +19,16 @@ class PlateCounter extends StatelessWidget {
return colorValue != null ? Color(colorValue) : Colors.grey;
}
Color _getTextColor(double weight) {
if (weight == 5.0) {
return Colors.black;
}
if (weight <= 2.5) {
return Colors.white70;
}
return Colors.white;
}
@override
Widget build(BuildContext context) {
return Card(
@ -43,8 +53,8 @@ class PlateCounter extends StatelessWidget {
weight == weight.toInt()
? '${weight.toInt()}'
: weight.toStringAsFixed(2),
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: _getTextColor(weight),
fontWeight: FontWeight.bold,
fontSize: 12,
),
@ -68,10 +78,10 @@ class PlateCounter extends StatelessWidget {
height: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.1),
color: AppTheme.primaryColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border:
Border.all(color: AppTheme.primaryColor.withOpacity(0.3)),
border: Border.all(
color: AppTheme.primaryColor.withValues(alpha: 0.3)),
),
child: Text(
count.toString(),

View file

@ -1,12 +1,15 @@
import 'dart:convert';
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:drift/drift.dart' hide Column;
import 'package:slrpg_app/l10n/app_localizations.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/constants/app_constants.dart';
import '../../../../shared/data/repositories/user_repository.dart';
import '../../../../shared/data/repositories/cycle_repository.dart';
import '../../../../shared/data/local/app_database.dart';
import '../../../gamification/domain/entities/avatar_config.dart';
import '../../../gamification/presentation/widgets/avatar_editor.dart';
import 'bodyweight_input_screen.dart';
@ -23,64 +26,96 @@ class _AvatarSetupScreenState extends ConsumerState<AvatarSetupScreen> {
bool _isLoading = false;
Future<void> _handleFinish() async {
final password = await _showPasswordDialog();
if (password == null) return;
setState(() => _isLoading = true);
try {
final onboardingData = ref.read(onboardingDataProvider);
final userRepo = ref.read(userRepositoryProvider);
final inventorySettings =
onboardingData['inventory_settings'] as Map<String, dynamic>;
(onboardingData['inventory_settings'] as Map<String, dynamic>?) ?? {};
final exerciseVariants =
onboardingData['exercise_variants'] as Map<String, dynamic>?;
var user = await userRepo.getLocalUser();
if (user == null) {
user = await userRepo.register(
email: onboardingData['email'] ?? '',
password: onboardingData['password'] ?? '',
bodyweight: onboardingData['bodyweight'] ?? 80.0,
inventorySettings: inventorySettings,
);
} else {
user.currentBodyweight =
onboardingData['bodyweight'] ?? user.currentBodyweight;
user.inventorySettingsJson = jsonEncode(inventorySettings);
user.isDirty = true;
await userRepo.saveLocalUser(user);
final email = onboardingData['email'] as String? ?? '';
final bodyweight =
(onboardingData['bodyweight'] as num?)?.toDouble() ?? 80.0;
try {
await userRepo.updateBodyweight(user.currentBodyweight);
await userRepo.updateInventory(inventorySettings);
} catch (e) {
// Sync macht das später
if (email.isEmpty || password.isEmpty) {
throw Exception('Email or password is missing!');
}
user = await userRepo.register(
email: email,
password: password,
bodyweight: bodyweight,
inventorySettings: inventorySettings,
exerciseVariants: exerciseVariants,
);
await Future.delayed(const Duration(milliseconds: 100));
user = await userRepo.getLocalUser();
if (user == null) {
throw Exception(
'User registration succeeded but user not found in DB');
}
} else {
user = user.copyWith(
currentBodyweight:
(onboardingData['bodyweight'] as num?)?.toDouble() ??
user.currentBodyweight,
inventorySettings: Value(inventorySettings),
isDirty: true,
);
await userRepo.saveLocalUser(user);
}
user!.avatarConfigJson = jsonEncode(_config.toJson());
user.isDirty = true;
await userRepo.saveLocalUser(user);
final avatarJson = _config.toJson();
final trainingMaxes =
onboardingData['training_maxes'] as Map<String, dynamic>?;
if (trainingMaxes != null) {
final cycleRepo = ref.read(cycleRepositoryProvider);
final tmMap = <String, double>{
'squat': (trainingMaxes['squat'] as num?)?.toDouble() ?? 100.0,
'pullup': (trainingMaxes['pullup'] as num?)?.toDouble() ?? 80.0,
'dip': (trainingMaxes['dip'] as num?)?.toDouble() ?? 90.0,
};
await cycleRepo.createCycle(tmMap);
user = user.copyWith(
avatarConfig: Value(avatarJson),
isDirty: true,
);
await userRepo.saveLocalUser(user);
try {
final trainingMaxes =
onboardingData['training_maxes'] as Map<String, dynamic>?;
if (trainingMaxes != null && trainingMaxes.isNotEmpty) {
final cycleRepo = ref.read(cycleRepositoryProvider);
final tmMap = <String, double>{
'squat': (trainingMaxes['squat'] as num?)?.toDouble() ?? 100.0,
'pullup': (trainingMaxes['pullup'] as num?)?.toDouble() ?? 80.0,
'dip': (trainingMaxes['dip'] as num?)?.toDouble() ?? 90.0,
};
final cycle = await cycleRepo.createCycle(tmMap);
}
} catch (e, stackTrace) {
log('❌ CYCLE ERROR (non-critical): $e');
log(' Error type: ${e.runtimeType}');
log(' Stack:\n$stackTrace');
}
if (mounted) {
ref.read(onboardingDataProvider.notifier).state = {};
ref.read(onboardingDataProvider.notifier).clear();
context.go('/hub');
}
} catch (e) {
} catch (e, stackTrace) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Setup failed: $e'),
backgroundColor: AppTheme.errorColor),
content: Text('Setup failed: $e'),
backgroundColor: AppTheme.errorColor,
duration: const Duration(seconds: 5),
),
);
}
} finally {
@ -90,9 +125,10 @@ class _AvatarSetupScreenState extends ConsumerState<AvatarSetupScreen> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: const Text('Choose Your Hero'),
title: Text(l10n.setupAvatarTitle),
actions: [
TextButton(
onPressed: _isLoading ? null : _handleFinish,
@ -101,7 +137,7 @@ class _AvatarSetupScreenState extends ConsumerState<AvatarSetupScreen> {
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2))
: const Text('FINISH',
: Text(l10n.finishButton,
style: TextStyle(
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor)),
@ -114,8 +150,8 @@ class _AvatarSetupScreenState extends ConsumerState<AvatarSetupScreen> {
padding: const EdgeInsets.all(16),
color: AppTheme.surfaceColor,
width: double.infinity,
child: const Text(
'This is how the legends will remember you.',
child: Text(
l10n.setupAvatarSubtitle,
textAlign: TextAlign.center,
style: TextStyle(fontStyle: FontStyle.italic, color: Colors.grey),
),
@ -130,4 +166,66 @@ class _AvatarSetupScreenState extends ConsumerState<AvatarSetupScreen> {
),
);
}
Future<String?> _showPasswordDialog() async {
final passwordController = TextEditingController();
final confirmController = TextEditingController();
final formKey = GlobalKey<FormState>();
final l10n = AppLocalizations.of(context)!;
return showDialog<String>(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: Text(l10n.secureAccountTitle),
content: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.secureAccountBody),
const SizedBox(height: 16),
TextFormField(
controller: passwordController,
obscureText: true,
autofocus: true,
decoration: InputDecoration(
labelText: l10n.passwordLabel,
prefixIcon: Icon(Icons.lock),
),
validator: (v) =>
(v?.length ?? 0) < 8 ? 'Min 8 characters' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: confirmController,
obscureText: true,
decoration: InputDecoration(
labelText: l10n.confirmPasswordLabel,
prefixIcon: Icon(Icons.lock_outline),
),
validator: (v) => v != passwordController.text
? l10n.passwordsDoNotMatch
: null,
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.cancelButton),
),
ElevatedButton(
onPressed: () {
if (formKey.currentState!.validate()) {
Navigator.pop(context, passwordController.text);
}
},
child: Text(l10n.confirmButton),
),
],
),
);
}
}

View file

@ -1,13 +1,39 @@
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 'package:riverpod_annotation/riverpod_annotation.dart';
part 'bodyweight_input_screen.g.dart';
// Provider to store onboarding data
final onboardingDataProvider =
StateProvider<Map<String, dynamic>>((ref) => {});
@Riverpod(keepAlive: true)
class OnboardingData extends _$OnboardingData {
@override
Map<String, dynamic> build() => {};
void update(Map<String, dynamic> Function(Map<String, dynamic> state) cb) {
final newData = cb(state);
state = {...state, ...newData};
}
void updateData(Map<String, dynamic> newValue) {
state = {...state, ...newValue};
}
void clear() {
state = {};
}
void removeKey(String key) {
final newState = Map<String, dynamic>.from(state);
newState.remove(key);
state = newState;
}
}
class BodyweightInputScreen extends ConsumerStatefulWidget {
const BodyweightInputScreen({super.key});
@ -17,26 +43,26 @@ class BodyweightInputScreen extends ConsumerStatefulWidget {
_BodyweightInputScreenState();
}
class _BodyweightInputScreenState
extends ConsumerState<BodyweightInputScreen> {
class _BodyweightInputScreenState extends ConsumerState<BodyweightInputScreen> {
double _bodyweight = 80.0;
bool _useKg = true;
void _handleContinue() {
// Store bodyweight
ref.read(onboardingDataProvider.notifier).update((state) => {
...state,
'bodyweight': _bodyweight,
});
ref.read(onboardingDataProvider.notifier).updateData({
'bodyweight': _bodyweight,
});
context.go('/onboarding/strength-test');
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: const Text('Setup Profile'),
title: Text(l10n.setupProfileTitle),
// title: const Text('Setup Profile'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/onboarding/welcome'),
@ -58,12 +84,14 @@ class _BodyweightInputScreenState
// Title
Text(
'What\'s your current bodyweight?',
l10n.bodyweightTitle,
// 'What\'s your current bodyweight?',
style: Theme.of(context).textTheme.displayMedium,
),
const SizedBox(height: 16),
Text(
'We need this to calculate your weighted calisthenics exercises',
l10n.bodyweightSubtitle,
// 'We need this to calculate your weighted calisthenics exercises',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 48),
@ -73,9 +101,9 @@ class _BodyweightInputScreenState
mainAxisAlignment: MainAxisAlignment.center,
children: [
SegmentedButton<bool>(
segments: const [
ButtonSegment(value: true, label: Text('KG')),
ButtonSegment(value: false, label: Text('LBS')),
segments: [
ButtonSegment(value: true, label: Text(l10n.unitKg)),
ButtonSegment(value: false, label: Text(l10n.unitLbs)),
],
selected: {_useKg},
onSelectionChanged: (Set<bool> newSelection) {
@ -134,7 +162,7 @@ class _BodyweightInputScreenState
// Continue Button
ElevatedButton(
onPressed: _handleContinue,
child: const Text('CONTINUE'),
child: Text(l10n.continueButton),
),
],
),
@ -143,4 +171,3 @@ class _BodyweightInputScreenState
);
}
}

View file

@ -0,0 +1,59 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'bodyweight_input_screen.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(OnboardingData)
final onboardingDataProvider = OnboardingDataProvider._();
final class OnboardingDataProvider
extends $NotifierProvider<OnboardingData, Map<String, dynamic>> {
OnboardingDataProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'onboardingDataProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$onboardingDataHash();
@$internal
@override
OnboardingData create() => OnboardingData();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Map<String, dynamic> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Map<String, dynamic>>(value),
);
}
}
String _$onboardingDataHash() => r'639bee078cad2141ddbcb7e802af999a609dee01';
abstract class _$OnboardingData extends $Notifier<Map<String, dynamic>> {
Map<String, dynamic> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<Map<String, dynamic>, Map<String, dynamic>>;
final element = ref.element as $ClassProviderElement<
AnyNotifier<Map<String, dynamic>, Map<String, dynamic>>,
Map<String, dynamic>,
Object?,
Object?>;
element.handleCreate(ref, build);
}
}

View file

@ -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';
@ -82,7 +83,9 @@ class _InventorySetupScreenState extends ConsumerState<InventorySetupScreen> {
void _handleNext() {
final platesList = <double>[];
_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 = <Map<String, dynamic>>[];
@ -102,10 +105,9 @@ class _InventorySetupScreenState extends ConsumerState<InventorySetupScreen> {
'bands': bandsList,
};
ref.read(onboardingDataProvider.notifier).update((state) => {
...state,
'inventory_settings': inventorySettings,
});
ref.read(onboardingDataProvider.notifier).updateData({
'inventory_settings': inventorySettings,
});
context.push('/onboarding/avatar');
}
@ -167,8 +169,7 @@ class _InventorySetupScreenState extends ConsumerState<InventorySetupScreen> {
}
if (mounted) {
ref.read(onboardingDataProvider.notifier).state = {};
ref.read(onboardingDataProvider.notifier).clear();
context.go('/hub');
}
} catch (e, stackTrace) {
@ -214,9 +215,10 @@ class _InventorySetupScreenState extends ConsumerState<InventorySetupScreen> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: const Text('Equipment Setup'),
title: Text(l10n.setupEquipmentTitle),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/onboarding/strength-test'),
@ -235,17 +237,17 @@ class _InventorySetupScreenState extends ConsumerState<InventorySetupScreen> {
),
const SizedBox(height: 32),
Text(
'Equipment Inventory',
l10n.setupInventoryTitle,
style: Theme.of(context).textTheme.displayMedium,
),
const SizedBox(height: 8),
Text(
'Tell us what equipment you have available',
l10n.setupInventorySubtitle,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 32),
Text(
'Barbell Weight',
l10n.inventoryBarbellWeight,
style: Theme.of(context)
.textTheme
.titleLarge
@ -272,40 +274,55 @@ class _InventorySetupScreenState extends ConsumerState<InventorySetupScreen> {
),
const SizedBox(height: 32),
Text(
'Quick Presets',
l10n.inventoryPresets,
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(color: AppTheme.textPrimary),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => _applyPreset('home'),
child: const Text('Home Gym'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton(
onPressed: () => _applyPreset('commercial'),
child: const Text('Commercial'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton(
onPressed: () => _applyPreset('minimal'),
child: const Text('Minimal'),
),
),
],
LayoutBuilder(
builder: (context, constraints) {
final screenWidth = constraints.maxWidth;
final chipWidth = 130.0;
final spacing = 8.0;
final totalWidth = (chipWidth * 3) + (spacing * 2);
if (screenWidth < totalWidth) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildPresetButton(l10n.inventoryPresetHome, 'home'),
const SizedBox(height: 8),
_buildPresetButton(
l10n.inventoryPresetCommercial, 'commercial'),
const SizedBox(height: 8),
_buildPresetButton(
l10n.inventoryPresetMinimal, 'minimal'),
],
);
} else {
return Row(
children: [
Expanded(
child: _buildPresetButton(
l10n.inventoryPresetHome, 'home')),
const SizedBox(width: 8),
Expanded(
child: _buildPresetButton(
l10n.inventoryPresetCommercial, 'commercial')),
const SizedBox(width: 8),
Expanded(
child: _buildPresetButton(
l10n.inventoryPresetMinimal, 'minimal')),
],
);
}
},
),
const SizedBox(height: 32),
Text(
'Available Plates',
l10n.inventoryPlates,
style: Theme.of(context)
.textTheme
.titleLarge
@ -325,7 +342,7 @@ class _InventorySetupScreenState extends ConsumerState<InventorySetupScreen> {
}).toList(),
const SizedBox(height: 32),
Text(
'Resistance Bands (Assistance)',
l10n.inventoryBands,
style: Theme.of(context)
.textTheme
.titleLarge
@ -333,41 +350,63 @@ class _InventorySetupScreenState extends ConsumerState<InventorySetupScreen> {
),
const SizedBox(height: 8),
Text(
'Select bands you have for pullup/dip assistance',
l10n.setupBandsSubtitle,
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: _bandInventory.entries.map((entry) {
final resistance = AppConstants.defaultBands[entry.key] ?? 0;
return FilterChip(
label: Text('${entry.key} (~${resistance.toInt()}kg)'),
selected: entry.value,
onSelected: (bool selected) {
setState(() {
_bandInventory[entry.key] = selected;
});
LayoutBuilder(
builder: (context, constraints) {
final screenWidth = constraints.maxWidth;
int crossAxisCount = 2;
if (screenWidth > 600) {
crossAxisCount = 4;
} else if (screenWidth > 400) {
crossAxisCount = 2;
}
final bandEntries = _bandInventory.entries.toList();
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 2.5,
),
itemCount: bandEntries.length,
itemBuilder: (context, index) {
final entry = bandEntries[index];
final resistance =
AppConstants.defaultBands[entry.key] ?? 0;
return _buildBandChip(
entry.key,
resistance.toInt(),
entry.value,
);
},
selectedColor: _getBandColor(entry.key).withOpacity(0.3),
checkmarkColor: _getBandColor(entry.key),
labelStyle: TextStyle(
color: entry.value ? Colors.white : Colors.grey,
),
side: BorderSide(
color: _getBandColor(entry.key),
),
);
}).toList(),
},
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: _handleNext,
child: const Text('NEXT STEP'),
onPressed: _isLoading ? null : _handleNext,
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
color: Colors.black,
strokeWidth: 2,
),
)
: Text(l10n.nextStepButton),
),
],
),
@ -375,4 +414,61 @@ class _InventorySetupScreenState extends ConsumerState<InventorySetupScreen> {
),
);
}
Widget _buildPresetButton(String label, String preset) {
return OutlinedButton(
onPressed: () => _applyPreset(preset),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: Text(label),
);
}
Widget _buildBandChip(String color, int resistance, bool isSelected) {
return InkWell(
onTap: () {
setState(() {
_bandInventory[color] = !isSelected;
});
},
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isSelected
? _getBandColor(color).withValues(alpha: 0.2)
: AppTheme.surfaceColor,
border: Border.all(
color: _getBandColor(color),
width: isSelected ? 2 : 1,
),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isSelected)
Icon(
Icons.check_circle,
color: _getBandColor(color),
size: 18,
),
if (isSelected) const SizedBox(width: 4),
Expanded(
child: Text(
'$color (~${resistance}kg)',
style: TextStyle(
color: isSelected ? Colors.white : AppTheme.textSecondary,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
fontSize: 12,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
}

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.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 '../../../../shared/domain/logic/wendler_calculator.dart';
@ -17,16 +18,24 @@ class StrengthTestScreen extends ConsumerStatefulWidget {
class _StrengthTestScreenState extends ConsumerState<StrengthTestScreen> {
final _formKey = GlobalKey<FormState>();
final _squatWeightController = TextEditingController(text: '100');
final _squatWeightController = TextEditingController(text: '60');
final _squatRepsController = TextEditingController(text: '5');
final _pullupWeightController = TextEditingController(text: '0');
final _pullupRepsController = TextEditingController(text: '8');
bool _canDoPullup = true;
final _pullWeightController = TextEditingController(text: '0');
final _pullRepsController = TextEditingController(text: '5');
bool _canDoDip = true;
final _dipWeightController = TextEditingController(text: '0');
final _dipRepsController = TextEditingController(text: '10');
final _benchWeightController = TextEditingController(text: '40');
final _pushRepsController = TextEditingController(text: '5');
Map<String, double> _calculated1RMs = {};
Map<String, double> _calculatedTMs = {};
bool _isAssistedPull = false;
bool _isAssistedDip = false;
@override
void initState() {
super.initState();
@ -37,65 +46,148 @@ class _StrengthTestScreenState extends ConsumerState<StrengthTestScreen> {
void dispose() {
_squatWeightController.dispose();
_squatRepsController.dispose();
_pullupWeightController.dispose();
_pullupRepsController.dispose();
_pullWeightController.dispose();
_pullRepsController.dispose();
_dipWeightController.dispose();
_dipRepsController.dispose();
_pushRepsController.dispose();
super.dispose();
}
void _calculateAll() {
final bodyweight = ref.read(onboardingDataProvider)['bodyweight'] ?? 80.0;
// Squat bleibt gleich...
final squatWeight = double.tryParse(_squatWeightController.text) ?? 0;
final squatReps = int.tryParse(_squatRepsController.text) ?? 1;
final squat1RM = WendlerCalculator.calculate1RM(squatWeight, squatReps);
final squatTM = WendlerCalculator.calculateTrainingMax(squat1RM);
final pullupAdditional = double.tryParse(_pullupWeightController.text) ?? 0;
final pullupReps = int.tryParse(_pullupRepsController.text) ?? 1;
final pullupTotal = bodyweight + pullupAdditional;
final pullup1RM = WendlerCalculator.calculate1RM(pullupTotal, pullupReps);
final pullupTM = WendlerCalculator.calculateTrainingMax(pullup1RM);
// PULL CALCULATION (Angepasst)
double pull1RM = 0.0;
if (_canDoPullup) {
final inputWeight = double.tryParse(_pullWeightController.text) ?? 0;
final reps = int.tryParse(_pullRepsController.text) ?? 1;
final dipAdditional = double.tryParse(_dipWeightController.text) ?? 0;
final dipReps = int.tryParse(_dipRepsController.text) ?? 1;
final dipTotal = bodyweight + dipAdditional;
final dip1RM = WendlerCalculator.calculate1RM(dipTotal, dipReps);
final dipTM = WendlerCalculator.calculateTrainingMax(dip1RM);
// LOGIK: Assisted vs Weighted
double totalLoad;
if (_isAssistedPull) {
totalLoad = (bodyweight - inputWeight).clamp(0.0, double.infinity);
} else {
totalLoad = bodyweight + inputWeight;
}
pull1RM = WendlerCalculator.calculate1RM(totalLoad, reps);
} else {
final weight = double.tryParse(_pullWeightController.text) ?? 0;
final reps = int.tryParse(_pullRepsController.text) ?? 1;
pull1RM = WendlerCalculator.calculate1RM(weight, reps);
}
final pullTM = WendlerCalculator.calculateTrainingMax(pull1RM);
// PUSH CALCULATION (Angepasst)
double push1RM = 0.0;
if (_canDoDip) {
final inputWeight = double.tryParse(_dipWeightController.text) ?? 0;
final reps = int.tryParse(_pushRepsController.text) ?? 1;
// LOGIK: Assisted vs Weighted
double totalLoad;
if (_isAssistedDip) {
totalLoad = (bodyweight - inputWeight).clamp(0.0, double.infinity);
} else {
totalLoad = bodyweight + inputWeight;
}
push1RM = WendlerCalculator.calculate1RM(totalLoad, reps);
} else {
final weight = double.tryParse(_benchWeightController.text) ?? 0;
final reps = int.tryParse(_pushRepsController.text) ?? 1;
push1RM = WendlerCalculator.calculate1RM(weight, reps);
}
final pushTM = WendlerCalculator.calculateTrainingMax(push1RM);
setState(() {
_calculated1RMs = {
'squat': squat1RM,
'pullup': pullup1RM,
'dip': dip1RM,
'pullup': pull1RM,
'dip': push1RM,
};
_calculatedTMs = {
'squat': squatTM,
'pullup': pullupTM,
'dip': dipTM,
'pullup': pullTM,
'dip': pushTM,
};
});
}
// void _calculateAll() {
// final bodyweight = ref.read(onboardingDataProvider)['bodyweight'] ?? 80.0;
// final squatWeight = double.tryParse(_squatWeightController.text) ?? 0;
// final squatReps = int.tryParse(_squatRepsController.text) ?? 1;
// final squat1RM = WendlerCalculator.calculate1RM(squatWeight, squatReps);
// final squatTM = WendlerCalculator.calculateTrainingMax(squat1RM);
// double pull1RM = 0.0;
// if (_canDoPullup) {
// final added = double.tryParse(_pullWeightController.text) ?? 0;
// final reps = int.tryParse(_pullRepsController.text) ?? 1;
// pull1RM = WendlerCalculator.calculate1RM(bodyweight + added, reps);
// } else {
// final weight = double.tryParse(_pullWeightController.text) ?? 0;
// final reps = int.tryParse(_pullRepsController.text) ?? 1;
// pull1RM = WendlerCalculator.calculate1RM(weight, reps);
// }
// final pullTM = WendlerCalculator.calculateTrainingMax(pull1RM);
// double push1RM = 0.0;
// if (_canDoDip) {
// final added = double.tryParse(_dipWeightController.text) ?? 0;
// final reps = int.tryParse(_pushRepsController.text) ?? 1;
// push1RM = WendlerCalculator.calculate1RM(bodyweight + added, reps);
// } else {
// final weight = double.tryParse(_benchWeightController.text) ?? 0;
// final reps = int.tryParse(_pushRepsController.text) ?? 1;
// push1RM = WendlerCalculator.calculate1RM(weight, reps);
// }
// final pushTM = WendlerCalculator.calculateTrainingMax(push1RM);
// setState(() {
// _calculated1RMs = {
// 'squat': squat1RM,
// 'pullup': pull1RM,
// 'dip': push1RM,
// };
// _calculatedTMs = {
// 'squat': squatTM,
// 'pullup': pullTM,
// 'dip': pushTM,
// };
// });
// }
void _handleContinue() {
if (!_formKey.currentState!.validate()) return;
ref.read(onboardingDataProvider.notifier).update((state) => {
...state,
'training_maxes': _calculatedTMs,
});
final variants = <String, String>{
'pull': _canDoPullup ? 'pullup' : 'row',
'push': _canDoDip ? 'dip' : 'bench',
};
ref.read(onboardingDataProvider.notifier).updateData({
'training_maxes': _calculatedTMs,
'exercise_variants': variants,
});
context.go('/onboarding/inventory');
}
@override
Widget build(BuildContext context) {
final bodyweight = ref.watch(onboardingDataProvider)['bodyweight'] ?? 80.0;
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: const Text('Strength Test'),
title: Text(l10n.strengthTestTitle),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/onboarding/bodyweight'),
@ -116,74 +208,118 @@ class _StrengthTestScreenState extends ConsumerState<StrengthTestScreen> {
),
const SizedBox(height: 32),
Text(
'Combat Calibration',
l10n.strengthTestSubtitle,
style: Theme.of(context).textTheme.displayMedium,
),
const SizedBox(height: 8),
Text(
'We need to assess your current power level to assign the correct monsters.', // Flavor
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 8),
Text(
'Enter your recent best performance for each exercise',
l10n.strengthTestBody,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 32),
_ExerciseCard(
title: l10n.strengthLegs,
exerciseName: 'Back Squat',
icon: Icons.accessibility_new,
weightController: _squatWeightController,
repsController: _squatRepsController,
isBodyweight: false,
calculated1RM: _calculated1RMs['squat'] ?? 0,
calculatedTM: _calculatedTMs['squat'] ?? 0,
onChanged: _calculateAll,
result1RM: _calculated1RMs['squat'] ?? 0,
resultTM: _calculatedTMs['squat'] ?? 0,
),
const SizedBox(height: 16),
_ExerciseCard(
exerciseName: 'Weighted Pull-up',
_AdaptiveExerciseCard(
slotTitle: l10n.strengthPull,
primaryName: 'Weighted Pull-up',
secondaryName: 'Pendlay Row',
icon: Icons.north,
weightController: _pullupWeightController,
repsController: _pullupRepsController,
isBodyweight: true,
bodyweight: bodyweight,
calculated1RM: _calculated1RMs['pullup'] ?? 0,
calculatedTM: _calculatedTMs['pullup'] ?? 0,
isCapable: _canDoPullup,
onToggleCapability: (val) {
setState(() {
_canDoPullup = val;
_pullWeightController.text = '0';
_pullRepsController.text = '5';
_calculateAll();
});
},
isAssisted: _isAssistedPull,
onToggleAssisted: (val) {
setState(() {
_isAssistedPull = val;
_calculateAll();
});
},
weightController: _pullWeightController,
repsController: _pullRepsController,
weightLabel: _canDoPullup
? (_isAssistedPull
? 'Band Assistance (kg)'
: 'Added Weight (kg)')
: 'Row Weight (kg)',
// weightLabel:
// _canDoPullup ? 'Add. Weight (kg)' : 'Row Weight (kg)',
repsLabel: _canDoPullup ? 'Reps' : '5RM Reps (usually 5)',
showResults: true,
result1RM: _calculated1RMs['pullup'] ?? 0,
resultTM: _calculatedTMs['pullup'] ?? 0,
onChanged: _calculateAll,
),
const SizedBox(height: 16),
_ExerciseCard(
exerciseName: 'Weighted Dip',
_AdaptiveExerciseCard(
slotTitle: l10n.strengthPush,
primaryName: 'Weighted Dip',
secondaryName: 'Bench Press',
icon: Icons.south,
weightController: _dipWeightController,
repsController: _dipRepsController,
isBodyweight: true,
bodyweight: bodyweight,
calculated1RM: _calculated1RMs['dip'] ?? 0,
calculatedTM: _calculatedTMs['dip'] ?? 0,
isCapable: _canDoDip,
onToggleCapability: (val) {
setState(() {
_canDoDip = val;
_dipWeightController.text = '0';
_pushRepsController.text = '5';
_calculateAll();
});
},
isAssisted: _isAssistedDip,
onToggleAssisted: (val) {
setState(() {
_isAssistedDip = val;
_calculateAll();
});
},
weightController:
_canDoDip ? _dipWeightController : _benchWeightController,
repsController: _pushRepsController,
weightLabel: _canDoDip
? (_isAssistedDip
? 'Band Assistance (kg)'
: 'Added Weight (kg)')
: 'Weight (kg)',
// weightLabel: _canDoDip ? 'Add. Weight (kg)' : 'Weight (kg)',
repsLabel: 'Reps',
showWeightInput: true,
showResults: true,
result1RM: _calculated1RMs['dip'] ?? 0,
resultTM: _calculatedTMs['dip'] ?? 0,
onChanged: _calculateAll,
),
const SizedBox(height: 32),
Container(
padding: const EdgeInsets.all(16),
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),
),
color: AppTheme.primaryColor.withValues(alpha: 0.3)),
),
child: Row(
children: [
const Icon(
Icons.info_outline,
color: AppTheme.primaryColor,
),
const Icon(Icons.info_outline,
color: AppTheme.primaryColor),
const SizedBox(width: 12),
Expanded(
child: Text(
'Your "Training Max" (TM) is your base combat power. We calculate it as 90% of your max potential to ensure long-term survival.', // Flavor
l10n.tmExplanation,
style: Theme.of(context)
.textTheme
.bodySmall
@ -196,7 +332,7 @@ class _StrengthTestScreenState extends ConsumerState<StrengthTestScreen> {
const SizedBox(height: 32),
ElevatedButton(
onPressed: _handleContinue,
child: const Text('CONTINUE'),
child: Text(l10n.continueButton),
),
],
),
@ -208,57 +344,61 @@ class _StrengthTestScreenState extends ConsumerState<StrengthTestScreen> {
}
class _ExerciseCard extends StatelessWidget {
final String title;
final String exerciseName;
final IconData icon;
final TextEditingController weightController;
final TextEditingController repsController;
final bool isBodyweight;
final double bodyweight;
final double calculated1RM;
final double calculatedTM;
final double result1RM;
final double resultTM;
final VoidCallback onChanged;
const _ExerciseCard({
required this.title,
required this.exerciseName,
required this.icon,
required this.weightController,
required this.repsController,
this.isBodyweight = false,
this.bodyweight = 0,
required this.calculated1RM,
required this.calculatedTM,
required this.isBodyweight,
required this.result1RM,
required this.resultTM,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Text(title.toUpperCase(),
style: const TextStyle(
color: AppTheme.textSecondary,
fontSize: 12,
fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Row(
children: [
Container(
width: 40,
height: 40,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
),
color: AppTheme.primaryColor.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8)),
child: Icon(icon, color: AppTheme.primaryColor),
),
const SizedBox(width: 12),
Text(
exerciseName,
style: Theme.of(context).textTheme.titleLarge,
),
Text(exerciseName,
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(color: AppTheme.textPrimary)),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
@ -268,21 +408,15 @@ class _ExerciseCard extends StatelessWidget {
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'^\d+\.?\d{0,2}')),
RegExp(r'^\d+\.?\d{0,2}'))
],
decoration: InputDecoration(
labelText: isBodyweight
? 'Additional Weight (kg)'
: 'Weight (kg)',
isDense: true,
),
labelText: isBodyweight
? l10n.addWeightLabel
: l10n.weightLabel,
isDense: true),
onChanged: (_) => onChanged(),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Required';
}
return null;
},
validator: (v) => v!.isEmpty ? 'Required' : null,
),
),
const SizedBox(width: 12),
@ -290,56 +424,17 @@ class _ExerciseCard extends StatelessWidget {
child: TextFormField(
controller: repsController,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
decoration: const InputDecoration(
labelText: 'Reps',
isDense: true,
),
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: InputDecoration(
labelText: l10n.repsLabel, isDense: true),
onChanged: (_) => onChanged(),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Required';
}
final reps = int.tryParse(value);
if (reps == null || reps < 1 || reps > 20) {
return '1-20';
}
return null;
},
validator: (v) => v!.isEmpty ? 'Required' : null,
),
),
],
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.surfaceColor,
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
if (isBodyweight)
_ResultRow(
label: 'Total Weight',
value:
'${(bodyweight + (double.tryParse(weightController.text) ?? 0)).toStringAsFixed(1)} kg',
),
_ResultRow(
label: 'Estimated 1RM',
value: '${calculated1RM.toStringAsFixed(1)} kg',
),
_ResultRow(
label: 'Training Max (90%)',
value: '${calculatedTM.toStringAsFixed(1)} kg',
highlight: true,
),
],
),
),
_ResultBox(rm: result1RM, tm: resultTM),
],
),
),
@ -347,36 +442,204 @@ class _ExerciseCard extends StatelessWidget {
}
}
class _ResultRow extends StatelessWidget {
final String label;
final String value;
final bool highlight;
class _AdaptiveExerciseCard extends StatelessWidget {
final String slotTitle;
final String primaryName;
final String secondaryName;
final IconData icon;
final bool isCapable;
final ValueChanged<bool> onToggleCapability;
final TextEditingController weightController;
final TextEditingController repsController;
final String weightLabel;
final String repsLabel;
final bool showWeightInput;
final bool showResults;
final double result1RM;
final double resultTM;
final VoidCallback onChanged;
final bool isAssisted;
final ValueChanged<bool>? onToggleAssisted;
const _ResultRow({
required this.label,
required this.value,
this.highlight = false,
const _AdaptiveExerciseCard({
required this.slotTitle,
required this.primaryName,
required this.secondaryName,
required this.icon,
required this.isCapable,
required this.onToggleCapability,
required this.weightController,
required this.repsController,
required this.weightLabel,
required this.repsLabel,
this.showWeightInput = true,
this.showResults = true,
required this.result1RM,
required this.resultTM,
required this.onChanged,
this.isAssisted = false,
this.onToggleAssisted,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
final l10n = AppLocalizations.of(context)!;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Text(slotTitle.toUpperCase(),
style: const TextStyle(
color: AppTheme.textSecondary,
fontSize: 12,
fontWeight: FontWeight.bold)),
]),
Row(
children: [
Row(
children: [
Text(l10n.canDoOneRep,
style: TextStyle(
fontSize: 12,
color: isCapable
? AppTheme.successColor
: Colors.grey)),
Switch(
value: isCapable,
activeThumbColor: AppTheme.successColor,
onChanged: onToggleCapability,
),
],
),
if (isCapable && onToggleAssisted != null)
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(l10n.isAssisted,
style: TextStyle(
fontSize: 12,
color: isAssisted
? AppTheme.primaryColor
: Colors.grey)),
Switch(
value: isAssisted,
activeThumbColor: AppTheme.primaryColor,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onChanged: onToggleAssisted,
),
],
),
],
),
const SizedBox(height: 4),
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8)),
child: Icon(icon, color: AppTheme.primaryColor),
),
const SizedBox(width: 12),
Text(isCapable ? primaryName : secondaryName,
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(color: AppTheme.textPrimary)),
],
),
if (!isCapable) ...[
const SizedBox(height: 8),
Text(
'Adjusted: ${"Wendler 5/3/1"}',
style: const TextStyle(
color: AppTheme.secondaryColor,
fontSize: 12,
fontStyle: FontStyle.italic),
),
],
const SizedBox(height: 16),
Row(
children: [
if (showWeightInput)
Expanded(
flex: 2,
child: TextFormField(
controller: weightController,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'^\d+\.?\d{0,2}'))
],
decoration: InputDecoration(
labelText: weightLabel, isDense: true),
onChanged: (_) => onChanged(),
validator: (v) => v!.isEmpty ? 'Required' : null,
),
)
else
const Spacer(flex: 2),
if (showWeightInput) const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: repsController,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration:
InputDecoration(labelText: repsLabel, isDense: true),
onChanged: (_) => onChanged(),
validator: (v) => v!.isEmpty ? 'Required' : null,
),
),
],
),
if (showResults) ...[
const SizedBox(height: 16),
_ResultBox(rm: result1RM, tm: resultTM),
],
],
),
),
);
}
}
class _ResultBox extends StatelessWidget {
final double rm;
final double tm;
const _ResultBox({required this.rm, required this.tm});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.surfaceColor, borderRadius: BorderRadius.circular(8)),
child: Column(
children: [
Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: highlight ? FontWeight.bold : null,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(l10n.est1rm),
Text('${rm.toStringAsFixed(1)} kg',
style: Theme.of(context).textTheme.bodyLarge),
],
),
Text(
value,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: highlight ? AppTheme.primaryColor : null,
fontWeight: highlight ? FontWeight.bold : null,
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(l10n.trainingMaxLabel),
Text('${tm.toStringAsFixed(1)} kg',
style: const TextStyle(
color: AppTheme.primaryColor,
fontWeight: FontWeight.bold)),
],
),
],
),

View file

@ -1,5 +1,6 @@
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';
@ -9,6 +10,8 @@ class WelcomeScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
body: Stack(
children: [
@ -20,7 +23,7 @@ class WelcomeScreen extends StatelessWidget {
),
Positioned.fill(
child: Container(
color: Colors.black.withOpacity(0.7),
color: Colors.black.withValues(alpha: 0.7),
),
),
SafeArea(
@ -35,11 +38,11 @@ class WelcomeScreen extends StatelessWidget {
width: 100,
height: 100,
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.9),
color: AppTheme.primaryColor.withValues(alpha: 0.9),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: AppTheme.primaryColor.withOpacity(0.5),
color: AppTheme.primaryColor.withValues(alpha: 0.5),
blurRadius: 20)
],
),
@ -48,7 +51,7 @@ class WelcomeScreen extends StatelessWidget {
),
const SizedBox(height: 32),
Text(
'ENTER THE ARENA',
l10n.enterTheArena,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Colors.white70,
letterSpacing: 2,
@ -67,9 +70,8 @@ class WelcomeScreen extends StatelessWidget {
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
const Text(
'The Iron Golems have awakened. The Gravity Demons are pulling the world into the abyss.\n\n'
'Only a true Streetlifter can stop them. Are you ready to forge your body into a weapon?',
Text(
l10n.introText,
style: TextStyle(
fontSize: 16, height: 1.5, color: Colors.white),
textAlign: TextAlign.center,
@ -77,21 +79,20 @@ class WelcomeScreen extends StatelessWidget {
const SizedBox(height: 48),
_FeatureItem(
icon: Icons.shield,
title: 'Build Your Armor',
description: 'Progressive overload based on Wendler 5/3/1.',
title: l10n.featureArmorTitle,
description: l10n.featureArmorDesc,
),
const SizedBox(height: 16),
_FeatureItem(
icon: Icons.videogame_asset,
title: 'Slay Monsters',
description:
'Turn every rep into damage against epic foes.',
title: l10n.featureMonstersTitle,
description: l10n.featureMonstersDesc,
),
const SizedBox(height: 16),
_FeatureItem(
icon: Icons.inventory_2,
title: 'Gather Loot',
description: 'Earn XP, level up, and unlock new gear.',
title: l10n.featureLootTitle,
description: l10n.featureLootDesc,
),
const Spacer(),
ElevatedButton(
@ -100,14 +101,14 @@ class WelcomeScreen extends StatelessWidget {
backgroundColor: AppTheme.primaryColor,
padding: const EdgeInsets.symmetric(vertical: 20),
),
child: const Text('BEGIN YOUR JOURNEY',
child: Text(l10n.beginJourney,
style: TextStyle(
fontWeight: FontWeight.bold, letterSpacing: 1)),
),
const SizedBox(height: 16),
TextButton(
onPressed: () => context.go('/login'),
child: const Text('Already a hero? Login here',
child: Text(l10n.loginPrompt,
style: TextStyle(color: Colors.white54)),
),
],
@ -139,7 +140,7 @@ class _FeatureItem extends StatelessWidget {
width: 48,
height: 48,
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.2),
color: AppTheme.primaryColor.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: Icon(

View file

@ -1,12 +1,12 @@
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 'package:slrpg_app/src/shared/data/local/tables.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../shared/data/local/app_database.dart';
import '../../../../shared/data/repositories/cycle_repository.dart';
import '../../../../shared/data/local/collections/cycle_collection.dart';
import '../../../../shared/data/remote/api_client.dart'; // Zugriff auf API
import '../../../../shared/data/repositories/user_repository.dart';
import '../../../../shared/data/repositories/workout_repository.dart';
import '../../../../shared/domain/entities/exercise.dart';
@ -24,7 +24,7 @@ class StatsScreen extends ConsumerStatefulWidget {
class _StatsScreenState extends ConsumerState<StatsScreen> {
bool _isLoading = false;
String _selectedExercise = 'squat'; // squat, pullup, dip
String _selectedExercise = 'squat';
String _selectedRange = '3m'; // 1m, 3m, 1y, all
List<StatsDataPoint> _chartData = [];
bool _isChartLoading = true;
@ -55,19 +55,15 @@ class _StatsScreenState extends ConsumerState<StatsScreen> {
for (var workout in allWorkouts) {
if (workout.completedAt == null) continue;
List<dynamic> exercisesJson = [];
try {
exercisesJson = jsonDecode(workout.exercisesJson);
} catch (e) {
continue;
}
final exercisesList = workout.exercises;
double max1RM = 0.0;
double sessionVolume = 0.0;
bool foundExercise = false;
double trainingMax = 0.0;
for (var exJson in exercisesJson) {
for (var exDynamic in exercisesList) {
final exJson = exDynamic as Map<String, dynamic>;
final exercise = Exercise.fromJson(exJson);
if (exercise.exerciseId == _selectedExercise) {
@ -147,12 +143,10 @@ class _StatsScreenState extends ConsumerState<StatsScreen> {
try {
final cycleRepo = ref.read(cycleRepositoryProvider);
final oldTMs =
jsonDecode(currentCycle.trainingMaxesJson) as Map<String, dynamic>;
final oldTMs = currentCycle.trainingMaxes;
final newCycle = await cycleRepo.finishCycle();
final newTMs =
jsonDecode(newCycle.trainingMaxesJson) as Map<String, dynamic>;
final newTMs = newCycle.trainingMaxes;
if (mounted) {
await showDialog(
@ -183,23 +177,49 @@ class _StatsScreenState extends ConsumerState<StatsScreen> {
@override
Widget build(BuildContext context) {
final cycleRepo = ref.watch(cycleRepositoryProvider);
final userRepo = ref.watch(userRepositoryProvider);
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: const Text('Statistics & Cycles'),
title: Text(l10n.statsTitle),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/hub'),
),
),
body: FutureBuilder<CycleCollection?>(
future: cycleRepo.getCurrentCycle(),
body: FutureBuilder<List<dynamic>>(
future: Future.wait([
cycleRepo.getCurrentCycle(),
userRepo.getLocalUser(),
]),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final currentCycle = snapshot.data;
final currentCycle = snapshot.data?[0] as CycleCollection?;
final user = snapshot.data?[1] as UserCollection;
final variants = user.exerciseVariants ?? {};
final pullVariant = variants['pull'] ?? 'pullup';
final pushVariant = variants['push'] ?? 'dip';
String getLabel(String id) {
switch (id) {
case 'squat':
return 'Squat';
case 'pullup':
return 'Pull-up';
case 'row':
return 'Row';
case 'dip':
return 'Dip';
case 'bench':
return 'Bench';
default:
return id;
}
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
@ -209,55 +229,53 @@ class _StatsScreenState extends ConsumerState<StatsScreen> {
if (currentCycle != null) ...[
_CurrentCycleCard(
cycle: currentCycle,
user: user,
onFinish: _isLoading
? null
: () => _handleFinishCycle(currentCycle),
),
const SizedBox(height: 24),
],
Text(
'Progress Analysis',
l10n.statsProgressAnalysis,
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(color: AppTheme.textPrimary),
),
const SizedBox(height: 16),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_FilterChip(
label: 'Squat',
label: l10n.exerciseSquat,
isSelected: _selectedExercise == 'squat',
onTap: () => _onFilterChanged('squat', _selectedRange),
),
const SizedBox(width: 8),
_FilterChip(
label: 'Pull-up',
isSelected: _selectedExercise == 'pullup',
onTap: () => _onFilterChanged('pullup', _selectedRange),
label: getLabel(pullVariant),
isSelected: _selectedExercise == pullVariant,
onTap: () =>
_onFilterChanged(pullVariant, _selectedRange),
),
const SizedBox(width: 8),
_FilterChip(
label: 'Dip',
isSelected: _selectedExercise == 'dip',
onTap: () => _onFilterChanged('dip', _selectedRange),
label: getLabel(pushVariant),
isSelected: _selectedExercise == pushVariant,
onTap: () =>
_onFilterChanged(pushVariant, _selectedRange),
),
],
),
),
const SizedBox(height: 16),
_isChartLoading
? const SizedBox(
height: 250,
child: Center(child: CircularProgressIndicator()))
: ProgressChart(data: _chartData),
// (Optional: Range Selector unten drunter '1M', '3M', '1Y'...)
],
),
);
@ -269,13 +287,36 @@ class _StatsScreenState extends ConsumerState<StatsScreen> {
class _CurrentCycleCard extends StatelessWidget {
final CycleCollection cycle;
final UserCollection user;
final VoidCallback? onFinish;
const _CurrentCycleCard({required this.cycle, required this.onFinish});
const _CurrentCycleCard(
{required this.cycle, required this.user, required this.onFinish});
@override
Widget build(BuildContext context) {
final tms = jsonDecode(cycle.trainingMaxesJson) as Map<String, dynamic>;
final tms = cycle.trainingMaxes;
final variants = user.exerciseVariants ?? {};
final pullVariant = variants['pull'] ?? 'pullup';
final pushVariant = variants['push'] ?? 'dip';
final l10n = AppLocalizations.of(context)!;
String getLabel(String id) {
switch (id) {
case 'squat':
return l10n.exerciseSquat;
case 'pullup':
return l10n.exercisePullup;
case 'row':
return l10n.exerciseRow;
case 'dip':
return l10n.exerciseDip;
case 'bench':
return l10n.exerciseBench;
default:
return id;
}
}
return Card(
child: Padding(
@ -287,7 +328,7 @@ class _CurrentCycleCard extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'CYCLE ${cycle.cycleNumber}',
l10n.statsCycleTitle(cycle.cycleNumber),
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: AppTheme.primaryColor,
fontSize: 24,
@ -297,11 +338,11 @@ class _CurrentCycleCard extends StatelessWidget {
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.successColor.withOpacity(0.2),
color: AppTheme.successColor.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'ACTIVE',
child: Text(
l10n.hubActiveYes,
style: TextStyle(
color: AppTheme.successColor,
fontWeight: FontWeight.bold,
@ -311,19 +352,25 @@ class _CurrentCycleCard extends StatelessWidget {
],
),
const Divider(height: 32),
Text('Current Training Maxes (TM)',
Text(l10n.statsCurrentTM,
style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 16),
_StatRow(label: 'Squat', value: '${tms['squat']} kg'),
_StatRow(label: 'Pull-up', value: '${tms['pullup']} kg'),
_StatRow(label: 'Dip', value: '${tms['dip']} kg'),
_StatRow(
label: l10n.exerciseSquat,
value: '${tms['squat'].toStringAsFixed(2)} kg'),
_StatRow(
label: getLabel(pullVariant),
value: '${tms['pullup'].toStringAsFixed(2)} kg'),
_StatRow(
label: getLabel(pushVariant),
value: '${tms['dip'].toStringAsFixed(2)} kg'),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: onFinish,
icon: const Icon(Icons.upgrade),
label: const Text('FINISH CYCLE & LEVEL UP'),
label: Text(l10n.statsFinishCycle),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.secondaryColor,
foregroundColor: Colors.white,
@ -373,33 +420,40 @@ class _CycleFinishDialog extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return AlertDialog(
title: const Text('Dungeon Cleared!'),
title: Text(l10n.statsCycleFinishedTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'You have defeated the guardians of this cycle. But deeper in the dungeon, stronger foes await...'), // Story
Text(
l10n.statsCycleFinishedBody,
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
const Text('Your Training Maxes have increased:',
Text(l10n.statsTMIncreased,
style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
_DiffRow(
name: 'Squat', oldVal: oldTMs['squat'], newVal: newTMs['squat']),
name: l10n.exerciseSquat,
oldVal: (oldTMs['squat'] as num).toDouble(),
newVal: (newTMs['squat'] as num).toDouble()),
_DiffRow(
name: 'Pull-up',
oldVal: oldTMs['pullup'],
newVal: newTMs['pullup']),
_DiffRow(name: 'Dip', oldVal: oldTMs['dip'], newVal: newTMs['dip']),
name: l10n.exercisePullup,
oldVal: (oldTMs['pullup'] as num).toDouble(),
newVal: (newTMs['pullup'] as num).toDouble()),
_DiffRow(
name: l10n.exerciseDip,
oldVal: (oldTMs['dip'] as num).toDouble(),
newVal: (newTMs['dip'] as num).toDouble()),
],
),
actions: [
ElevatedButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('ENTER NEXT LEVEL'),
child: Text(l10n.statsEnterNextLevel),
),
],
);
@ -424,15 +478,15 @@ class _DiffRow extends StatelessWidget {
child: Row(
children: [
Expanded(child: Text(name)),
Text('${oldVal.toStringAsFixed(1)}',
Text('${oldVal.toStringAsFixed(2)}',
style: const TextStyle(color: Colors.grey)),
Text(
newVal.toStringAsFixed(1),
newVal.toStringAsFixed(2),
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(width: 8),
if (isPositive)
Text('+${diff.toStringAsFixed(1)}',
Text('+${diff.toStringAsFixed(2)}',
style: const TextStyle(
color: AppTheme.successColor, fontWeight: FontWeight.bold))
else
@ -458,14 +512,15 @@ class _FilterChip extends StatelessWidget {
label: Text(label),
selected: isSelected,
onSelected: (_) => onTap(),
selectedColor: AppTheme.primaryColor.withOpacity(0.2),
selectedColor: AppTheme.primaryColor.withValues(alpha: 0.2),
labelStyle: TextStyle(
color: isSelected ? AppTheme.primaryColor : Colors.grey,
fontWeight: FontWeight.bold,
),
side: BorderSide(
color:
isSelected ? AppTheme.primaryColor : Colors.grey.withOpacity(0.3),
color: isSelected
? AppTheme.primaryColor
: Colors.grey.withValues(alpha: 0.3),
),
);
}

View file

@ -51,7 +51,7 @@ class ProgressChart extends StatelessWidget {
decoration: BoxDecoration(
color: AppTheme.surfaceColor,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppTheme.primaryColor.withOpacity(0.1)),
border: Border.all(color: AppTheme.primaryColor.withValues(alpha: 0.1)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@ -148,14 +148,12 @@ class ProgressChart extends StatelessWidget {
),
belowBarData: BarAreaData(
show: true,
color: AppTheme.primaryColor.withOpacity(0.1),
color: AppTheme.primaryColor.withValues(alpha: 0.1),
),
),
],
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
// FIX 2: Alte API nutzen (tooltipBgColor statt getTooltipColor)
// tooltipBgColor: AppTheme.surfaceColor,
getTooltipColor: (touchedSpot) => AppTheme.surfaceColor,
getTooltipItems: (touchedSpots) {
return touchedSpots.map((spot) {

View file

@ -0,0 +1,212 @@
import 'package:flutter/material.dart';
import 'package:slrpg_app/l10n/app_localizations.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../shared/domain/models/exercise_guide.dart';
class ExerciseGuideSheet extends StatelessWidget {
final String exerciseId;
const ExerciseGuideSheet({super.key, required this.exerciseId});
@override
Widget build(BuildContext context) {
String lookupId = exerciseId;
if (exerciseId.contains('kb_snatch')) lookupId = 'kb_snatch';
final l10n = AppLocalizations.of(context)!;
final library = ExerciseGuide.getLibrary(l10n);
final guide = library[exerciseId];
if (guide == null) {
return Container(
padding: const EdgeInsets.all(32),
child: const Text('No ancient scroll found for this technique.',
textAlign: TextAlign.center, style: TextStyle(color: Colors.grey)),
);
}
return DraggableScrollableSheet(
initialChildSize: 0.85,
minChildSize: 0.5,
maxChildSize: 0.95,
builder: (context, scrollController) {
return Container(
decoration: const BoxDecoration(
color: AppTheme.surfaceColor,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
children: [
const SizedBox(height: 12),
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey[700],
borderRadius: BorderRadius.circular(2),
),
),
Expanded(
child: ListView(
controller: scrollController,
padding: const EdgeInsets.all(24),
children: [
Text(
guide.title.toUpperCase(),
style:
Theme.of(context).textTheme.headlineMedium?.copyWith(
color: AppTheme.primaryColor,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
_buildDifficultyBadge(guide.difficulty),
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.backgroundColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white12),
),
child: Text(
'"${guide.rpgLore}"',
style: const TextStyle(
fontStyle: FontStyle.italic,
color: AppTheme.textSecondary,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 32),
_buildSectionTitle(context, 'EXECUTION'),
const SizedBox(height: 16),
...guide.steps
.asMap()
.entries
.map((entry) => _buildStep(entry.key + 1, entry.value)),
const SizedBox(height: 32),
_buildSectionTitle(context, 'COMMON MISTAKES'),
const SizedBox(height: 16),
...guide.commonMistakes.map((m) => _buildMistake(m)),
const SizedBox(height: 32),
_buildSectionTitle(context, 'ATTRIBUTES AFFECTED'),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: guide.muscles
.map((m) => Chip(
label: Text(m),
backgroundColor: AppTheme.primaryColor
.withValues(alpha: 0.1),
labelStyle: const TextStyle(
color: AppTheme.primaryColor),
side: BorderSide.none,
))
.toList(),
),
const SizedBox(height: 40),
],
),
),
],
),
);
},
);
}
Widget _buildDifficultyBadge(String diff) {
Color color;
switch (diff) {
case 'Novice':
color = Colors.green;
break;
case 'Adept':
color = Colors.orange;
break;
case 'Master':
color = AppTheme.errorColor;
break;
default:
color = Colors.grey;
}
return Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withValues(alpha: 0.5)),
),
child: Text(
diff.toUpperCase(),
style: TextStyle(
color: color, fontSize: 12, fontWeight: FontWeight.bold),
),
),
);
}
Widget _buildSectionTitle(BuildContext context, String title) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
),
),
const Divider(
color: AppTheme.primaryColor, thickness: 2, endIndent: 250),
],
);
}
Widget _buildStep(int index, String text) {
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 24,
height: 24,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppTheme.primaryColor,
shape: BoxShape.circle,
),
child: Text('$index',
style: const TextStyle(
color: Colors.black, fontWeight: FontWeight.bold)),
),
const SizedBox(width: 16),
Expanded(
child: Text(text,
style: const TextStyle(color: Colors.white70, height: 1.4))),
],
),
);
}
Widget _buildMistake(String text) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
children: [
const Icon(Icons.close, color: AppTheme.errorColor, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(text, style: const TextStyle(color: Colors.white70))),
],
),
);
}
}

View file

@ -0,0 +1,312 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../shared/data/local/app_database.dart';
import '../../../shared/domain/entities/exercise.dart';
import '../../../shared/domain/entities/workout_set.dart';
import '../../../shared/domain/logic/wendler_calculator.dart';
final workoutGeneratorServiceProvider =
Provider<WorkoutGeneratorService>((ref) {
return WorkoutGeneratorService();
});
class WorkoutGeneratorService {
List<Exercise> generateWorkout({
required int week,
required int day,
required Map<String, double> trainingMaxes,
required UserCollection user,
required AccessoryTemplate template,
int? conditioningSets,
}) {
final exercises = <Exercise>[];
exercises.addAll(_generateMainLifts(week, day, trainingMaxes, user));
if (template == AccessoryTemplate.hypertrophy) {
exercises
.addAll(_generateHypertrophyAccessories(day, trainingMaxes, user));
} else if (template == AccessoryTemplate.conditioning) {
final sets = (conditioningSets != null && conditioningSets > 0)
? conditioningSets
: 15;
exercises.addAll(_generateConditioning(day, sets));
} else if (template == AccessoryTemplate.journey_pullup) {
exercises.addAll(_generatePullUpJourney(day, trainingMaxes));
}
return exercises;
}
List<Exercise> _generateMainLifts(int week, int day,
Map<String, double> trainingMaxes, UserCollection user) {
final exercises = <Exercise>[];
final variants = user.exerciseVariants ?? {};
(String, String, ExerciseType) resolveVariant(String slot, String defaultId,
String defaultName, ExerciseType defaultType) {
final variant = variants[slot];
if (slot == 'pull') {
if (variant == 'row') return ('row', 'Pendlay Row', ExerciseType.row);
return ('pullup', 'Weighted Pull-up', ExerciseType.pullup);
}
if (slot == 'push') {
if (variant == 'bench') {
return ('bench', 'Bench Press', ExerciseType.bench);
}
return ('dip', 'Weighted Dip', ExerciseType.dip);
}
return (defaultId, defaultName, defaultType);
}
void addExercise(String slot, String defaultId, String defaultName,
ExerciseType defaultType, bool isMain) {
final (id, name, type) =
resolveVariant(slot, defaultId, defaultName, defaultType);
final tm = trainingMaxes[defaultId] ?? 0.0;
List<WorkoutSet> sets;
if (isMain) {
sets = WendlerCalculator.generateSets(
week: week,
trainingMax: tm,
exerciseType: type,
currentBodyweight: user.currentBodyweight,
);
} else {
if (week == 4) return;
sets = WendlerCalculator.generateFSLSets(
trainingMax: tm,
exerciseType: type,
currentBodyweight: user.currentBodyweight,
);
}
if (sets.isNotEmpty) {
exercises.add(Exercise(
exerciseId: id,
exerciseName: isMain ? name : '$name (FSL)',
bodyweightAtSession: user.currentBodyweight,
sets: sets,
));
}
}
if (day == 1) {
addExercise('legs', 'squat', 'Back Squat', ExerciseType.squat, true);
addExercise(
'pull', 'pullup', 'Weighted Pull-up', ExerciseType.pullup, false);
} else if (day == 2) {
addExercise('push', 'dip', 'Weighted Dip', ExerciseType.dip, true);
addExercise('legs', 'squat', 'Back Squat', ExerciseType.squat, false);
} else if (day == 3) {
addExercise(
'pull', 'pullup', 'Weighted Pull-up', ExerciseType.pullup, true);
addExercise('push', 'dip', 'Weighted Dip', ExerciseType.dip, false);
}
return exercises;
}
List<Exercise> _generateHypertrophyAccessories(
int day, Map<String, double> trainingMaxes, UserCollection user) {
final accessories = <Exercise>[];
double calculateWeight(double referenceTm, double percentage) {
final raw = referenceTm * percentage;
return (raw / 2.5).round() * 2.5;
}
Exercise createSimple(String id, String name, int sets, int reps,
{double weight = 0.0}) {
return Exercise(
exerciseId: id,
exerciseName: name,
bodyweightAtSession: 0,
sets: List.generate(
sets,
(i) => WorkoutSet(
setNumber: i + 1,
repsTarget: reps,
targetWeightTotal: weight,
repsActual: 0,
isAmrap: false,
completed: false,
)),
);
}
final squatTm = trainingMaxes['squat'] ?? 0.0;
final dipTm = trainingMaxes['dip'] ?? 0.0;
final pullupTm = trainingMaxes['pullup'] ?? 0.0;
switch (day) {
case 1: // Squat Tag
// RDL: ~40% vom Squat TM
accessories.add(createSimple('rdl', 'Romanian Deadlift', 3, 10,
weight: calculateWeight(squatTm, 0.4)));
accessories.add(_createIntervalExercise(
id: 'kb_snatch_acc',
name: 'KB Snatch',
sets: 10,
intervalSeconds: 60,
repsPerSet: 10));
break;
case 2: // Dip Tag (Push)
// OHP: ~30% vom System-Dip-TM (konservativ für 3x10)
accessories.add(createSimple('ohp', 'Overhead Press', 3, 10,
weight: calculateWeight(dipTm, 0.3)));
accessories.add(createSimple('face_pull', 'Band Face Pull', 3, 10));
accessories.add(createSimple('ab_roll', 'Ab Wheel Rollout', 3, 10));
break;
case 3: // Pullup Tag (Pull)
// Curls: ~20% vom System-Pullup-TM
accessories.add(createSimple('curl', 'Barbell Curl', 3, 10,
weight: calculateWeight(pullupTm, 0.2)));
accessories.add(_createIntervalExercise(
id: 'kb_swing',
name: '2H KB Swing',
sets: 10,
intervalSeconds: 60,
repsPerSet: 5));
accessories.add(createSimple('plank', 'Plank (30s)', 3, 1));
break;
}
return accessories;
}
List<Exercise> _generatePullUpJourney(
int day, Map<String, double> trainingMaxes) {
final exercises = <Exercise>[];
Exercise createAccessory(
String id, String name, ExerciseType type, int sets, int reps,
{double weight = 0.0}) {
return Exercise(
exerciseId: id,
exerciseName: name,
bodyweightAtSession: 0,
sets: List.generate(
sets,
(i) => WorkoutSet(
setNumber: i + 1,
repsTarget: reps,
targetWeightTotal: weight,
repsActual: 0,
isAmrap: false,
completed: false,
)),
);
}
double calculateWeight(double referenceTm, double percentage) {
final raw = referenceTm * percentage;
return (raw / 2.5).round() * 2.5;
}
switch (day) {
case 1:
exercises.add(createAccessory('scap_pull', 'Scapular Pull-Ups',
ExerciseType.scapular_pull, 3, 10));
exercises.add(createAccessory(
'plank', 'Core Plank (45s)', ExerciseType.plank, 3, 1));
break;
case 2:
exercises.add(createAccessory(
'inv_row', 'Australian Pull-Ups', ExerciseType.inverted_row, 4, 8));
exercises.add(createAccessory(
'face_pull', 'Band Face Pull', ExerciseType.face_pull, 3, 15));
break;
case 3:
exercises.add(createAccessory('neg_pull', 'Negative Pull-Ups (5s slow)',
ExerciseType.negative_pullup, 3, 4));
final rowTm = trainingMaxes['row'] ?? 0.0;
final curlWeight = rowTm > 0 ? calculateWeight(rowTm, 0.3) : 0.0;
exercises.add(createAccessory(
'curl', 'Barbell Curl', ExerciseType.curl_barbell, 3, 10,
weight: curlWeight));
break;
}
return exercises;
}
List<Exercise> _generateConditioning(int day, int targetSets) {
final accessories = <Exercise>[];
const totalTimeSeconds = 20 * 60;
final intervalSeconds = (totalTimeSeconds / targetSets).floor();
String id;
String name;
switch (day) {
case 1:
id = 'kb_clean_press';
name = 'KB Clean & Press';
break;
case 2:
id = 'kb_snatch_cond';
name = 'KB Snatch';
break;
case 3:
id = 'kb_thruster';
name = 'KB Thruster';
break;
default:
return [];
}
accessories.add(_createIntervalExercise(
id: id,
name: name,
sets: targetSets,
intervalSeconds: intervalSeconds,
repsPerSet: 5,
));
return accessories;
}
Exercise _createIntervalExercise({
required String id,
required String name,
required int sets,
required int intervalSeconds,
required int repsPerSet,
}) {
return Exercise(
exerciseId: id,
exerciseName: '$name (${_formatIntervalName(intervalSeconds)})',
bodyweightAtSession: 0,
intervalSeconds: intervalSeconds,
sets: List.generate(
sets,
(i) => WorkoutSet(
setNumber: i + 1,
repsTarget: repsPerSet,
targetWeightTotal: 0,
repsActual: 0,
isAmrap: false,
completed: false,
)),
);
}
String _formatIntervalName(int seconds) {
if (seconds == 60) return 'EMOM';
return 'E${seconds}S';
}
}

View file

@ -0,0 +1,250 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
import 'package:audioplayers/audioplayers.dart';
import '../../../../core/constants/asset_paths.dart';
class EmomTimerWidget extends StatefulWidget {
final int intervalSeconds;
final int totalSets;
final int currentSet;
final VoidCallback onSetComplete;
final VoidCallback onWorkoutComplete;
const EmomTimerWidget({
super.key,
required this.intervalSeconds,
required this.totalSets,
required this.currentSet,
required this.onSetComplete,
required this.onWorkoutComplete,
});
@override
State<EmomTimerWidget> createState() => _EmomTimerWidgetState();
}
class _EmomTimerWidgetState extends State<EmomTimerWidget>
with TickerProviderStateMixin {
Timer? _timer;
late int _secondsRemaining;
bool _isRunning = false;
late AnimationController _pulseController;
late AudioPlayer _audioPlayer;
@override
void initState() {
super.initState();
_secondsRemaining = widget.intervalSeconds;
_audioPlayer = AudioPlayer();
_audioPlayer.setAudioContext(
AudioContext(
android: AudioContextAndroid(
isSpeakerphoneOn: false,
stayAwake: false,
contentType: AndroidContentType.sonification,
usageType: AndroidUsageType.notificationEvent,
audioFocus: AndroidAudioFocus.none,
),
// iOS: AudioContextIOS(
// category: AVAudioSessionCategory.ambient,
// options: [
// AVAudioSessionOptions.mixWithOthers,
// ],
// ),
),
);
_pulseController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
lowerBound: 1.0,
upperBound: 1.1,
);
}
@override
void dispose() {
_timer?.cancel();
_pulseController.dispose();
_audioPlayer.dispose();
super.dispose();
}
Future<void> _playSound(bool isLong) async {
try {
final path = isLong ? 'audio/beep_long.ogg' : 'audio/beep_short.ogg';
if (_audioPlayer.state == PlayerState.playing) {
await _audioPlayer.stop();
}
await _audioPlayer.play(AssetSource(path));
} catch (e) {
debugPrint('Audio error: $e');
}
}
void _startTimer() {
setState(() => _isRunning = true);
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (_secondsRemaining > 0) {
setState(() => _secondsRemaining--);
if (_secondsRemaining <= 3) {
_pulseController.forward().then((_) => _pulseController.reverse());
_playSound(false);
}
} else {
_playSound(true);
_handleRoundComplete();
}
});
}
void _handleRoundComplete() {
if (widget.currentSet < widget.totalSets) {
widget.onSetComplete();
setState(() {
_secondsRemaining = widget.intervalSeconds;
});
} else {
_timer?.cancel();
setState(() => _isRunning = false);
widget.onWorkoutComplete();
}
}
void _pauseTimer() {
_timer?.cancel();
setState(() => _isRunning = false);
}
String _formatTime(int seconds) {
final m = seconds ~/ 60;
final s = seconds % 60;
return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
final progress = 1.0 - (_secondsRemaining / widget.intervalSeconds);
return Column(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppTheme.primaryColor),
),
child: Text(
'ROUND ${widget.currentSet} / ${widget.totalSets}',
style: const TextStyle(
color: AppTheme.primaryColor,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
),
),
),
const SizedBox(height: 32),
ScaleTransition(
scale: _pulseController,
child: SizedBox(
width: 240,
height: 240,
child: Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: 240,
height: 240,
child: CircularProgressIndicator(
value: 1.0,
strokeWidth: 12,
color: Colors.white10,
),
),
SizedBox(
width: 240,
height: 240,
child: CircularProgressIndicator(
value: progress,
strokeWidth: 12,
color: _secondsRemaining <= 3
? AppTheme.errorColor
: AppTheme.primaryColor,
strokeCap: StrokeCap.round,
),
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatTime(_secondsRemaining),
style: Theme.of(context).textTheme.displayLarge?.copyWith(
fontSize: 64,
color: Colors.white,
fontFamily: 'monospace',
),
),
if (!_isRunning &&
widget.currentSet == 1 &&
_secondsRemaining == widget.intervalSeconds)
Text(
'READY?',
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(color: Colors.grey),
)
else if (!_isRunning)
Text(
'PAUSED',
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(color: AppTheme.errorColor),
),
],
),
],
),
),
),
const SizedBox(height: 48),
if (!_isRunning)
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: _startTimer,
icon: const Icon(Icons.play_arrow),
label: Text(widget.currentSet == 1 &&
_secondsRemaining == widget.intervalSeconds
? 'IGNITE ENGINE'
: 'RESUME'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.successColor,
foregroundColor: Colors.white,
),
),
)
else
SizedBox(
width: double.infinity,
height: 56,
child: OutlinedButton.icon(
onPressed: _pauseTimer,
icon: const Icon(Icons.pause),
label: const Text('PAUSE'),
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.errorColor,
side: const BorderSide(color: AppTheme.errorColor),
),
),
),
],
);
}
}

View file

@ -53,7 +53,7 @@ class EnemyHPBar extends StatelessWidget {
color: Colors.red[900],
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppTheme.errorColor.withOpacity(0.5),
color: AppTheme.errorColor.withValues(alpha: 0.5),
width: 2,
),
),
@ -72,7 +72,7 @@ class EnemyHPBar extends StatelessWidget {
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: AppTheme.errorColor.withOpacity(0.5),
color: AppTheme.errorColor.withValues(alpha: 0.5),
blurRadius: 8,
),
],

View file

@ -1,3 +1,172 @@
// import 'package:flutter/material.dart';
// import '../../../../core/theme/app_theme.dart';
// import '../../../../core/constants/asset_paths.dart';
// class PlateVisualizer extends StatelessWidget {
// final List<double> plateConfiguration;
// final bool isTwoSided;
// final String exerciseName;
// const PlateVisualizer({
// super.key,
// required this.plateConfiguration,
// required this.isTwoSided,
// required this.exerciseName,
// });
// Color _getPlateColor(double weight) {
// final colorValue = PlateColors.colors[weight];
// return colorValue != null ? Color(colorValue) : Colors.grey;
// }
// Color _getTextColor(double weight) {
// if (weight == 5.0) {
// return Colors.black;
// }
// if (weight <= 2.5) {
// return Colors.white70;
// }
// return Colors.white;
// }
// @override
// Widget build(BuildContext context) {
// if (plateConfiguration.isEmpty) {
// return Card(
// child: Padding(
// padding: const EdgeInsets.all(32),
// child: Column(
// children: [
// Icon(
// isTwoSided ? Icons.fitness_center : Icons.accessibility,
// size: 64,
// color: AppTheme.primaryColor.withValues(alpha: 0.5),
// ),
// const SizedBox(height: 16),
// Text(
// isTwoSided ? 'Bar Only' : 'Bodyweight Only',
// style: Theme.of(context)
// .textTheme
// .titleLarge
// ?.copyWith(color: AppTheme.textSecondary),
// ),
// ],
// ),
// ),
// );
// }
// return Card(
// child: Padding(
// padding: const EdgeInsets.all(16),
// child: Column(
// children: [
// Text(
// isTwoSided ? 'Load Per Side' : 'Load on Belt',
// style: Theme.of(context).textTheme.titleMedium,
// ),
// const SizedBox(height: 16),
// if (isTwoSided) _buildBarbellView() else _buildBeltView(),
// const SizedBox(height: 16),
// Text(
// 'Total: ${plateConfiguration.fold<double>(0, (sum, p) => sum + p).toStringAsFixed(1)} kg ${isTwoSided ? 'per side' : ''}',
// style: Theme.of(context).textTheme.bodyMedium?.copyWith(
// color: AppTheme.primaryColor,
// ),
// ),
// ],
// ),
// ),
// );
// }
// Widget _buildBarbellView() {
// return SizedBox(
// height: 120,
// child: Row(
// mainAxisAlignment: MainAxisAlignment.center,
// crossAxisAlignment: CrossAxisAlignment.center,
// children: [
// Container(
// width: 8,
// height: 80,
// color: Colors.grey[800],
// ),
// ...plateConfiguration.map((weight) {
// final size = _getPlateSize(weight);
// return Container(
// width: 20,
// height: size,
// margin: const EdgeInsets.symmetric(horizontal: 2),
// decoration: BoxDecoration(
// color: _getPlateColor(weight),
// border: Border.all(color: Colors.white24, width: 2),
// borderRadius: BorderRadius.circular(4),
// ),
// );
// }).toList(),
// Container(
// width: 40,
// height: 20,
// decoration: BoxDecoration(
// color: Colors.grey[700],
// borderRadius: BorderRadius.circular(4),
// ),
// child: Center(
// child: Container(
// width: 30,
// height: 10,
// decoration: BoxDecoration(
// color: Colors.grey[600],
// borderRadius: BorderRadius.circular(2),
// ),
// ),
// ),
// ),
// ],
// ),
// );
// }
// Widget _buildBeltView() {
// return Wrap(
// spacing: 8,
// runSpacing: 8,
// alignment: WrapAlignment.center,
// children: plateConfiguration.map((weight) {
// return Container(
// width: _getPlateSize(weight) * 0.8,
// height: _getPlateSize(weight) * 0.8,
// decoration: BoxDecoration(
// color: _getPlateColor(weight),
// shape: BoxShape.circle,
// border: Border.all(color: Colors.white24, width: 3),
// ),
// child: Center(
// child: Text(
// weight == weight.toInt()
// ? '${weight.toInt()}'
// : weight.toStringAsFixed(2),
// style: const TextStyle(
// color: Colors.white,
// fontWeight: FontWeight.bold,
// fontSize: 14,
// ),
// ),
// ),
// );
// }).toList(),
// );
// }
// double _getPlateSize(double weight) {
// if (weight >= 20) return 120.0;
// if (weight >= 10) return 100.0;
// if (weight >= 5) return 80.0;
// return 60.0;
// }
// }
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/constants/asset_paths.dart';
@ -19,6 +188,16 @@ class PlateVisualizer extends StatelessWidget {
return colorValue != null ? Color(colorValue) : Colors.grey;
}
Color _getTextColor(double weight) {
if (weight == 5.0) {
return Colors.black;
}
if (weight <= 2.5) {
return Colors.white70;
}
return Colors.white;
}
@override
Widget build(BuildContext context) {
if (plateConfiguration.isEmpty) {
@ -30,7 +209,7 @@ class PlateVisualizer extends StatelessWidget {
Icon(
isTwoSided ? Icons.fitness_center : Icons.accessibility,
size: 64,
color: AppTheme.primaryColor.withOpacity(0.5),
color: AppTheme.primaryColor.withValues(alpha: 0.5),
),
const SizedBox(height: 16),
Text(
@ -59,7 +238,7 @@ class PlateVisualizer extends StatelessWidget {
if (isTwoSided) _buildBarbellView() else _buildBeltView(),
const SizedBox(height: 16),
Text(
'Total: ${plateConfiguration.fold<double>(0, (sum, p) => sum + p).toStringAsFixed(1)} kg ${isTwoSided ? 'per side' : ''}',
'Total: ${plateConfiguration.fold<double>(0, (sum, p) => sum + p).toStringAsFixed(2)} kg ${isTwoSided ? 'per side' : ''}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppTheme.primaryColor,
),
@ -137,8 +316,8 @@ class PlateVisualizer extends StatelessWidget {
weight == weight.toInt()
? '${weight.toInt()}'
: weight.toStringAsFixed(2),
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: _getTextColor(weight),
fontWeight: FontWeight.bold,
fontSize: 14,
),

View file

@ -0,0 +1,37 @@
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
import 'tables.dart';
import 'converters/json_converter.dart';
part 'app_database.g.dart';
@DriftDatabase(tables: [Users, Cycles, Workouts, Quests])
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
@override
int get schemaVersion => 3;
@override
MigrationStrategy get migration => MigrationStrategy(
onUpgrade: (Migrator m, int from, int to) async {
if (from < 2) {
await m.createTable(quests);
}
if (from < 3) {
await m.addColumn(users, users.exerciseVariants);
}
},
);
}
LazyDatabase _openConnection() {
return LazyDatabase(() async {
final dbFolder = await getApplicationDocumentsDirectory();
final file = File(p.join(dbFolder.path, 'slrpg.sqlite'));
return NativeDatabase.createInBackground(file);
});
}

File diff suppressed because it is too large Load diff

View file

@ -1,27 +0,0 @@
import 'package:isar/isar.dart';
part 'cycle_collection.g.dart';
@collection
class CycleCollection {
Id id = Isar.autoIncrement;
@Index(unique: true)
String? serverId;
String userId = ''; // Local reference
int cycleNumber = 1;
DateTime startDate = DateTime.now();
DateTime? endDate;
bool isActive = true;
// Training Maxes (stored as JSON string)
String trainingMaxesJson = '{}';
bool isDirty = false;
DateTime createdAt = DateTime.now();
DateTime updatedAt = DateTime.now();
}

File diff suppressed because it is too large Load diff

View file

@ -1,25 +0,0 @@
import 'package:isar/isar.dart';
part 'user_collection.g.dart';
@collection
class UserCollection {
Id id = Isar.autoIncrement;
@Index(unique: true)
String? serverId;
String email = '';
int xp = 0;
int level = 1;
double currentBodyweight = 70.0;
String? inventorySettingsJson;
String? avatarConfigJson;
DateTime? lastSyncAt;
bool isDirty = false;
DateTime createdAt = DateTime.now();
DateTime updatedAt = DateTime.now();
}

File diff suppressed because it is too large Load diff

View file

@ -1,32 +0,0 @@
import 'package:isar/isar.dart';
part 'workout_collection.g.dart';
@collection
class WorkoutCollection {
Id id = Isar.autoIncrement;
// @Index(unique: true)
@Index()
String? serverId;
String userId = '';
String cycleId = '';
int week = 1; // 1-4
int day = 1; // 1-3
DateTime? scheduledDate;
DateTime? completedAt;
int xpEarned = 0;
// Exercises data (JSON string)
String exercisesJson = '[]';
String notes = '';
bool isDirty = false;
DateTime createdAt = DateTime.now();
DateTime updatedAt = DateTime.now();
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,36 @@
import 'dart:convert';
import 'package:drift/drift.dart';
class MapConverter extends TypeConverter<Map<String, dynamic>, String> {
const MapConverter();
@override
Map<String, dynamic> fromSql(String fromDb) {
if (fromDb.isEmpty) return {};
try {
return json.decode(fromDb) as Map<String, dynamic>;
} catch (_) {
return {};
}
}
@override
String toSql(Map<String, dynamic> value) => json.encode(value);
}
class ListConverter extends TypeConverter<List<dynamic>, String> {
const ListConverter();
@override
List<dynamic> fromSql(String fromDb) {
if (fromDb.isEmpty) return [];
try {
return json.decode(fromDb) as List<dynamic>;
} catch (_) {
return [];
}
}
@override
String toSql(List<dynamic> value) => json.encode(value);
}

View file

@ -0,0 +1,92 @@
import 'package:drift/drift.dart';
import 'converters/json_converter.dart';
@DataClassName('UserCollection')
class Users extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get serverId => text().nullable().unique()();
TextColumn get email => text().withDefault(const Constant(''))();
IntColumn get xp => integer().withDefault(const Constant(0))();
IntColumn get level => integer().withDefault(const Constant(1))();
RealColumn get currentBodyweight =>
real().withDefault(const Constant(70.0))();
TextColumn get exerciseVariants =>
text().map(const MapConverter()).nullable()();
TextColumn get inventorySettings =>
text().map(const MapConverter()).nullable()();
TextColumn get avatarConfig => text().map(const MapConverter()).nullable()();
DateTimeColumn get lastSyncAt => dateTime().nullable()();
BoolColumn get isDirty => boolean().withDefault(const Constant(false))();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
}
@DataClassName('CycleCollection')
class Cycles extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get serverId => text().nullable().unique()();
TextColumn get userId => text()();
IntColumn get cycleNumber => integer()();
DateTimeColumn get startDate => dateTime()();
DateTimeColumn get endDate => dateTime().nullable()();
BoolColumn get isActive => boolean().withDefault(const Constant(true))();
TextColumn get trainingMaxes => text().map(const MapConverter())();
BoolColumn get isDirty => boolean().withDefault(const Constant(false))();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
}
@DataClassName('WorkoutCollection')
class Workouts extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get serverId => text().nullable().unique()();
TextColumn get userId => text()();
TextColumn get cycleId => text()();
IntColumn get week => integer()();
IntColumn get day => integer()();
DateTimeColumn get scheduledDate => dateTime().nullable()();
DateTimeColumn get completedAt => dateTime().nullable()();
IntColumn get xpEarned => integer().withDefault(const Constant(0))();
TextColumn get exercises => text().map(const ListConverter())();
TextColumn get notes => text().withDefault(const Constant(''))();
BoolColumn get isDirty => boolean().withDefault(const Constant(false))();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
}
@DataClassName('QuestCollection')
class Quests extends Table {
TextColumn get id => text()();
TextColumn get type => text()(); // 'daily', 'milestone', 'story'
TextColumn get title => text()();
TextColumn get description => text()();
IntColumn get targetValue => integer()();
IntColumn get currentValue => integer().withDefault(const Constant(0))();
IntColumn get rewardXP => integer()();
TextColumn get rewardItem => text().nullable()();
BoolColumn get isCompleted => boolean().withDefault(const Constant(false))();
BoolColumn get isClaimed => boolean().withDefault(const Constant(false))();
DateTimeColumn get expiresAt => dateTime().nullable()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {id};
}

View file

@ -10,6 +10,9 @@ class ApiClient {
final FlutterSecureStorage _storage;
final Logger _logger;
bool _isRefreshing = false;
final List<Function> _requestsQueue = [];
ApiClient({
FlutterSecureStorage? storage,
Logger? logger,
@ -49,15 +52,117 @@ class ApiClient {
},
onError: (error, handler) async {
if (error.response?.statusCode == 401) {
_logger.w('Unauthorized - clearing token');
await _storage.delete(key: AppConstants.keyAuthToken);
final token = await _storage.read(key: AppConstants.keyAuthToken);
if (token != null && !_isRefreshing) {
_isRefreshing = true;
_logger.w('🔄 Token expired, attempting refresh...');
try {
final newToken = await refreshToken();
if (newToken != null) {
error.requestOptions.headers['Authorization'] =
'Bearer $newToken';
final response = await _dio.fetch(error.requestOptions);
_isRefreshing = false;
_processQueue(newToken);
return handler.resolve(response);
} else {
_logger.e('❌ Token refresh failed - logging out');
await _storage.delete(key: AppConstants.keyAuthToken);
_isRefreshing = false;
_clearQueue();
return handler.next(error);
}
} catch (e) {
_logger.e('❌ Refresh error: $e');
await _storage.delete(key: AppConstants.keyAuthToken);
_isRefreshing = false;
_clearQueue();
return handler.next(error);
}
} else if (_isRefreshing) {
_logger.i('⏳ Waiting for token refresh...');
return _queueRequest(() async {
final newToken =
await _storage.read(key: AppConstants.keyAuthToken);
if (newToken != null) {
error.requestOptions.headers['Authorization'] =
'Bearer $newToken';
return await _dio.fetch(error.requestOptions);
}
throw error;
}, handler);
} else {
await _storage.delete(key: AppConstants.keyAuthToken);
return handler.next(error);
}
}
// onError: (error, handler) async {
// if (error.response?.statusCode == 401) {
// _logger.w('Unauthorized - clearing token');
// await _storage.delete(key: AppConstants.keyAuthToken);
// }
return handler.next(error);
},
),
);
}
Future<void> _queueRequest(
Future<Response> Function() request,
ErrorInterceptorHandler handler,
) async {
_requestsQueue.add(() async {
try {
final response = await request();
handler.resolve(response);
} catch (e) {
handler.reject(e as DioException);
}
});
}
void _processQueue(String newToken) {
for (final request in _requestsQueue) {
request();
}
_requestsQueue.clear();
}
void _clearQueue() {
_requestsQueue.clear();
}
Future<String?> refreshToken() async {
try {
final token = await _storage.read(key: AppConstants.keyAuthToken);
if (token == null) return null;
final response = await _dio.post(
'/api/collections/users/auth-refresh',
options: Options(
headers: {'Authorization': 'Bearer $token'},
),
);
final newToken = response.data['token'];
if (newToken != null) {
await _storage.write(key: AppConstants.keyAuthToken, value: newToken);
_logger.i('✅ Token refreshed successfully');
return newToken;
}
return null;
} catch (e) {
_logger.e('❌ Token refresh failed', error: e);
return null;
}
}
Future<Map<String, dynamic>> login(String email, String password) async {
try {
final response = await _dio.post(
@ -85,6 +190,7 @@ class ApiClient {
required String password,
required double bodyweight,
required Map<String, dynamic> inventorySettings,
Map<String, dynamic>? exerciseVariants,
}) async {
try {
final response = await _dio.post(
@ -97,6 +203,7 @@ class ApiClient {
'level': 1,
'current_bodyweight': bodyweight,
'inventory_settings': inventorySettings,
'exercise_variants': exerciseVariants ?? {},
'avatar_config': {
'skin_tone': 'medium',
'hair_style': 'short_01',

View file

@ -1,30 +1,28 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:isar/isar.dart';
import 'package:drift/drift.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../../../main.dart';
import '../../../core/constants/app_constants.dart';
import '../local/collections/user_collection.dart';
import '../local/collections/cycle_collection.dart';
import '../local/collections/workout_collection.dart';
import 'api_client.dart';
import '../local/app_database.dart';
import '../repositories/user_repository.dart';
import 'api_client.dart';
final syncServiceProvider = Provider<SyncService>((ref) {
final isar = ref.watch(isarProvider);
final db = ref.watch(appDatabaseProvider);
final apiClient = ref.watch(apiClientProvider);
return SyncService(isar: isar, apiClient: apiClient);
return SyncService(db: db, apiClient: apiClient);
});
class SyncService {
final Isar isar;
final AppDatabase db;
final ApiClient apiClient;
final _storage = const FlutterSecureStorage();
bool _isSyncing = false;
SyncService({required this.isar, required this.apiClient});
SyncService({required this.db, required this.apiClient});
Future<void> sync() async {
if (_isSyncing) return;
@ -32,160 +30,206 @@ class SyncService {
try {
debugPrint('🔄 Starting Sync...');
final dirtyCycles =
await isar.cycleCollections.filter().isDirtyEqualTo(true).findAll();
final dirtyCycles = await (db.select(db.cycles)
..where((c) => c.isDirty.equals(true)))
.get();
for (var cycle in dirtyCycles) {
try {
if (cycle.serverId == null) {
debugPrint(
'📤 Pushing new cycle ${cycle.cycleNumber} to server...');
Map<String, double> tmsMap = {};
try {
final tms = jsonDecode(cycle.trainingMaxesJson);
tmsMap = Map<String, double>.from(
tms.map((k, v) => MapEntry(k, (v as num).toDouble())));
} catch (e) {
debugPrint('⚠️ Error parsing TMs for cycle ${cycle.id}: $e');
tmsMap = {'squat': 0.0, 'pullup': 0.0, 'dip': 0.0};
}
debugPrint('📤 Pushing new cycle ${cycle.cycleNumber}...');
final tmsMap = cycle.trainingMaxes
.map((k, v) => MapEntry(k, (v as num).toDouble()));
final response = await apiClient.createCycle(tmsMap);
final newServerId = response['id'];
await isar.writeTxn(() async {
cycle.serverId = newServerId;
cycle.isDirty = false;
await isar.cycleCollections.put(cycle);
await db.transaction(() async {
await (db.update(db.cycles)..where((c) => c.id.equals(cycle.id)))
.write(
CyclesCompanion(
serverId: Value(newServerId),
isDirty: const Value(false),
),
);
final oldLocalIdRef = cycle.id.toString();
final orphanWorkouts = await isar.workoutCollections
.filter()
.cycleIdEqualTo(oldLocalIdRef)
.findAll();
for (var w in orphanWorkouts) {
w.cycleId = newServerId;
w.isDirty = true;
await isar.workoutCollections.put(w);
debugPrint('🔗 Relinked workout ${w.id} to cycle $newServerId');
}
await (db.update(db.workouts)
..where((w) => w.cycleId.equals(oldLocalIdRef)))
.write(
WorkoutsCompanion(
cycleId: Value(newServerId),
isDirty: const Value(true),
),
);
});
} else {
await isar.writeTxn(() async {
cycle.isDirty = false;
await isar.cycleCollections.put(cycle);
});
await (db.update(db.cycles)..where((c) => c.id.equals(cycle.id)))
.write(const CyclesCompanion(isDirty: Value(false)));
}
} catch (e) {
debugPrint('❌ Failed to sync cycle: $e');
return;
debugPrint('❌ Failed to sync cycle ${cycle.id}: $e');
}
}
final dirtyUser =
await isar.userCollections.filter().isDirtyEqualTo(true).findFirst();
final dirtyUser = await (db.select(db.users)
..where((u) => u.isDirty.equals(true)))
.getSingleOrNull();
final dirtyWorkouts = await (db.select(db.workouts)
..where((w) => w.isDirty.equals(true)))
.get();
final dirtyWorkouts =
await isar.workoutCollections.filter().isDirtyEqualTo(true).findAll();
final validWorkouts =
dirtyWorkouts.where((w) => w.cycleId.length > 5).toList();
if (dirtyUser == null && dirtyWorkouts.isEmpty) {
debugPrint('✅ Nothing to push.');
} else {
final pushData = <String, dynamic>{
'workouts': dirtyWorkouts.where((w) {
return w.cycleId.length > 5;
}).map((w) {
return {
'id': w.serverId,
'local_id': w.id,
'cycle_id': w.cycleId,
'week': w.week,
'day': w.day,
'completed_at': w.completedAt?.toIso8601String(),
'xp_earned': w.xpEarned,
'notes': w.notes,
'exercises': jsonDecode(w.exercisesJson),
};
}).toList(),
'user_stats': dirtyUser != null
? {
'xp': dirtyUser.xp,
'level': dirtyUser.level,
'current_bodyweight': dirtyUser.currentBodyweight,
final pushData = <String, dynamic>{
'workouts': validWorkouts.map((w) {
return {
'id': w.serverId,
'local_id': w.id,
'cycle_id': w.cycleId,
'week': w.week,
'day': w.day,
'completed_at': w.completedAt?.toIso8601String(),
'xp_earned': w.xpEarned,
'notes': w.notes,
'exercises': w.exercises,
};
}).toList(),
'user_stats': dirtyUser != null
? {
'xp': dirtyUser.xp,
'level': dirtyUser.level,
'current_bodyweight': dirtyUser.currentBodyweight,
'exercise_variants': dirtyUser.exerciseVariants,
}
: null,
};
final lastSync = await _storage.read(key: AppConstants.keyLastSync);
if ((pushData['workouts'] as List).isNotEmpty ||
pushData['user_stats'] != null) {
debugPrint('📤 Pushing data...');
final response = await apiClient.sync(
lastSyncTimestamp: lastSync ?? '',
pushData: pushData,
);
await db.transaction(() async {
if (dirtyUser != null) {
await (db.update(db.users)..where((u) => u.id.equals(dirtyUser.id)))
.write(const UsersCompanion(isDirty: Value(false)));
}
for (var w in validWorkouts) {
await (db.update(db.workouts)..where((dw) => dw.id.equals(w.id)))
.write(const WorkoutsCompanion(isDirty: Value(false)));
}
if (response['pull_data'] != null) {
if (response['pull_data']['cycles'] != null) {
final pulledCycles = response['pull_data']['cycles'] as List;
for (var cJson in pulledCycles) {
final serverId = cJson['id'] as String;
final existing = await (db.select(db.cycles)
..where((c) => c.serverId.equals(serverId)))
.getSingleOrNull();
final tms = cJson['training_maxes'] as Map<String, dynamic>;
final companion = CyclesCompanion(
serverId: Value(serverId),
userId: Value(cJson['user_id']),
cycleNumber: Value(cJson['cycle_number']),
startDate: Value(DateTime.parse(cJson['start_date'])),
endDate: Value(DateTime.tryParse(cJson['end_date'] ?? '')),
isActive: Value(cJson['is_active'] ?? false),
trainingMaxes: Value(tms),
isDirty: const Value(false),
updatedAt: Value(DateTime.now()),
createdAt: existing == null
? Value(DateTime.now())
: const Value.absent(),
);
if (existing != null) {
await (db.update(db.cycles)
..where((c) => c.id.equals(existing.id)))
.write(companion);
} else {
await db.into(db.cycles).insert(companion);
}
: null,
};
if ((pushData['workouts'] as List).length < dirtyWorkouts.length) {
debugPrint(
'⚠️ Skipped some workouts because they lack a valid server cycle ID.');
}
final lastSync = await _storage.read(key: AppConstants.keyLastSync);
if ((pushData['workouts'] as List).isNotEmpty ||
pushData['user_stats'] != null) {
debugPrint('📤 Pushing data...');
final response = await apiClient.sync(
lastSyncTimestamp: lastSync ?? '',
pushData: pushData,
);
await isar.writeTxn(() async {
if (dirtyUser != null) {
dirtyUser.isDirty = false;
await isar.userCollections.put(dirtyUser);
}
for (var w in dirtyWorkouts) {
w.isDirty = false;
await isar.workoutCollections.put(w);
}
if (response['pull_data'] != null &&
response['pull_data']['workouts'] != null) {
final pulledWorkouts = response['pull_data']['workouts'] as List;
for (var wJson in pulledWorkouts) {
final serverId = wJson['id'];
var workout = await isar.workoutCollections
.filter()
.serverIdEqualTo(serverId)
.findFirst();
workout ??= WorkoutCollection();
workout
..serverId = serverId
..cycleId = wJson['cycle_id']
..userId = wJson['user_id']
..week = wJson['week']
..day = wJson['day']
..completedAt = DateTime.tryParse(wJson['completed_at'] ?? '')
..xpEarned = wJson['xp_earned'] ?? 0
..exercisesJson = jsonEncode(wJson['exercises'])
..isDirty = false
..updatedAt = DateTime.now();
await isar.workoutCollections.put(workout);
}
}
});
if (response['server_timestamp'] != null) {
await _storage.write(
key: AppConstants.keyLastSync,
value: response['server_timestamp'],
);
if (response['pull_data']['workouts'] != null) {
final pulledWorkouts = response['pull_data']['workouts'] as List;
debugPrint('📥 Pulled ${pulledWorkouts.length} workouts.');
for (var wJson in pulledWorkouts) {
final serverId = wJson['id'] as String;
final cycleId = wJson['cycle_id'] as String;
final week = wJson['week'] as int;
final day = wJson['day'] as int;
var existing = await (db.select(db.workouts)
..where((w) => w.serverId.equals(serverId)))
.getSingleOrNull();
if (existing == null) {
final candidates = await (db.select(db.workouts)
..where((w) =>
w.cycleId.equals(cycleId) &
w.week.equals(week) &
w.day.equals(day)))
.get();
if (candidates.isNotEmpty) {
existing = candidates.first;
debugPrint(
'🔄 Merging local workout ${existing.id} with server ID $serverId');
}
}
final companion = WorkoutsCompanion(
serverId: Value(serverId),
cycleId: Value(cycleId),
userId: Value(wJson['user_id']),
week: Value(week),
day: Value(day),
completedAt:
Value(DateTime.tryParse(wJson['completed_at'] ?? '')),
xpEarned: Value(wJson['xp_earned'] ?? 0),
exercises: Value(wJson['exercises'] ?? []),
notes: Value(wJson['notes'] ?? ''),
isDirty: const Value(false),
updatedAt: Value(DateTime.now()),
createdAt: existing == null
? Value(DateTime.now())
: const Value.absent(),
);
if (existing != null) {
await (db.update(db.workouts)
..where((w) => w.id.equals(existing!.id)))
.write(companion);
} else {
await db.into(db.workouts).insert(companion);
}
}
}
}
});
if (response['server_timestamp'] != null) {
await _storage.write(
key: AppConstants.keyLastSync,
value: response['server_timestamp']);
}
}
debugPrint('✅ Sync completed successfully');
} catch (e) {
} catch (e, stack) {
debugPrint('❌ Sync failed: $e');
debugPrint(stack.toString());
} finally {
_isSyncing = false;
}

View file

@ -1,45 +1,49 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:isar/isar.dart';
import 'package:drift/drift.dart';
import 'dart:convert';
import '../local/collections/cycle_collection.dart';
import '../local/app_database.dart';
import '../remote/api_client.dart';
import '../../../../main.dart';
import 'user_repository.dart';
import '../../../core/constants/app_constants.dart';
import '../local/collections/workout_collection.dart';
final cycleRepositoryProvider = Provider<CycleRepository>((ref) {
final isar = ref.watch(isarProvider);
final db = ref.watch(appDatabaseProvider);
final apiClient = ref.watch(apiClientProvider);
return CycleRepository(isar: isar, apiClient: apiClient);
return CycleRepository(db: db, apiClient: apiClient);
});
class CycleRepository {
final Isar isar;
final AppDatabase db;
final ApiClient apiClient;
CycleRepository({required this.isar, required this.apiClient});
CycleRepository({required this.db, required this.apiClient});
Future<CycleCollection?> getCurrentCycle() async {
return await isar.cycleCollections
.filter()
.isActiveEqualTo(true)
.findFirst();
return await (db.select(db.cycles)
..where((c) => c.isActive.equals(true))
..limit(1))
.getSingleOrNull();
}
Future<List<CycleCollection>> getAllCycles() async {
return await isar.cycleCollections.where().findAll();
return await db.select(db.cycles).get();
}
Future<CycleCollection> createCycle(Map<String, double> trainingMaxes) async {
try {
return await db.transaction(() async {
final currentCycle = await getCurrentCycle();
if (currentCycle != null) {
currentCycle.isActive = false;
currentCycle.endDate = DateTime.now();
await saveCycle(currentCycle);
final updateOld = CyclesCompanion(
isActive: const Value(false),
endDate: Value(DateTime.now()),
updatedAt: Value(DateTime.now()),
isDirty: const Value(true),
);
await (db.update(db.cycles)..where((c) => c.id.equals(currentCycle.id)))
.write(updateOld);
}
final allCycles = await getAllCycles();
@ -50,34 +54,44 @@ class CycleRepository {
.reduce((a, b) => a > b ? a : b) +
1;
final userRepo = UserRepository(isar: isar, apiClient: ApiClient());
final user = await userRepo.getLocalUser();
final user = await (db.select(db.users)..limit(1)).getSingleOrNull();
if (user == null) {
throw Exception('No user found for cycle creation');
}
final newCycle = CycleCollection()
..userId = user.serverId ?? user.id.toString()
..cycleNumber = nextNumber
..startDate = DateTime.now()
..isActive = true
..trainingMaxesJson = jsonEncode(trainingMaxes)
..isDirty = true;
final newCycleCompanion = CyclesCompanion(
userId: Value(user.serverId ?? user.id.toString()),
cycleNumber: Value(nextNumber),
startDate: Value(DateTime.now()),
isActive: const Value(true),
trainingMaxes: Value(trainingMaxes),
isDirty: const Value(true),
createdAt: Value(DateTime.now()),
updatedAt: Value(DateTime.now()),
);
await saveCycle(newCycle);
final newId = await db.into(db.cycles).insert(newCycleCompanion);
var newCycle = await (db.select(db.cycles)
..where((c) => c.id.equals(newId)))
.getSingle();
try {
final response = await apiClient.createCycle(trainingMaxes);
newCycle.serverId = response['id'];
newCycle.isDirty = false;
await saveCycle(newCycle);
} catch (e) {}
await (db.update(db.cycles)..where((c) => c.id.equals(newId))).write(
CyclesCompanion(
serverId: Value(response['id']),
isDirty: const Value(false),
),
);
newCycle = await (db.select(db.cycles)
..where((c) => c.id.equals(newId)))
.getSingle();
} catch (e) {
// API Fehler ignorieren, wird später gesynct
}
return newCycle;
} catch (e, stackTrace) {
rethrow;
}
});
}
Future<CycleCollection> finishCycle() async {
@ -87,24 +101,26 @@ class CycleRepository {
}
final cycleIdRef = currentCycle.serverId ?? currentCycle.id.toString();
final localCycleId = currentCycle.id.toString();
final completedMainWorkouts = await isar.workoutCollections
.filter()
.weekLessThan(4)
.completedAtIsNotNull()
.group((q) => q
.cycleIdEqualTo(cycleIdRef)
.or()
.cycleIdEqualTo(currentCycle.id.toString()))
.count();
final workoutsQuery = db.select(db.workouts)
..where((w) {
final weekCheck = w.week.isSmallerThanValue(4);
final completedCheck = w.completedAt.isNotNull();
final cycleCheck =
w.cycleId.equals(cycleIdRef) | w.cycleId.equals(localCycleId);
return weekCheck & completedCheck & cycleCheck;
});
final completedMainWorkouts = (await workoutsQuery.get()).length;
if (completedMainWorkouts < 9) {
final missing = 9 - completedMainWorkouts;
throw Exception(
'Cycle incomplete! You still have $missing workouts left in the main phase (Weeks 1-3). Finish them before leveling up.');
}
final currentTMs =
jsonDecode(currentCycle.trainingMaxesJson) as Map<String, dynamic>;
final currentTMs = currentCycle.trainingMaxes;
final newTMs = <String, double>{
'squat': (currentTMs['squat'] as num?)?.toDouble() ?? 0.0,
@ -112,23 +128,27 @@ class CycleRepository {
'dip': (currentTMs['dip'] as num?)?.toDouble() ?? 0.0,
};
final week3Workouts = await isar.workoutCollections
.filter()
.weekEqualTo(3)
.group((q) => q
.cycleIdEqualTo(cycleIdRef)
.or()
.cycleIdEqualTo(currentCycle.id.toString()))
.findAll();
final week3Workouts = await (db.select(db.workouts)
..where((w) {
final weekCheck = w.week.equals(3);
final cycleCheck =
w.cycleId.equals(cycleIdRef) | w.cycleId.equals(localCycleId);
return weekCheck & cycleCheck;
}))
.get();
bool checkSuccess(String exerciseId) {
for (var workout in week3Workouts) {
try {
final exercises = jsonDecode(workout.exercisesJson) as List;
for (var ex in exercises) {
final exercises = workout.exercises;
for (var exData in exercises) {
final ex = exData as Map<String, dynamic>;
if (ex['exerciseId'] == exerciseId) {
final sets = ex['sets'] as List;
for (var s in sets) {
for (var sData in sets) {
final s = sData as Map<String, dynamic>;
if (s['isAmrap'] == true) {
final reps = s['repsActual'] as int? ?? 0;
if (reps >= 1) {
@ -170,33 +190,23 @@ class CycleRepository {
try {
await apiClient.finishCycle(currentCycle.serverId!);
} catch (e) {
// Fehler ignorieren, wird später gesynct
// Fehler ignorieren
}
}
return await createCycle(newTMs);
}
Future<void> saveCycle(CycleCollection cycle) async {
cycle.updatedAt = DateTime.now();
await isar.writeTxn(() async {
await isar.cycleCollections.put(cycle);
});
}
Map<String, double> getCurrentTrainingMaxes() {
final cycle =
isar.cycleCollections.filter().isActiveEqualTo(true).findFirstSync();
Future<Map<String, double>> getCurrentTrainingMaxesAsync() async {
final cycle = await getCurrentCycle();
if (cycle != null) {
final tms = jsonDecode(cycle.trainingMaxesJson);
final tms = cycle.trainingMaxes;
return {
'squat': (tms['squat'] as num?)?.toDouble() ?? 0.0,
'pullup': (tms['pullup'] as num?)?.toDouble() ?? 0.0,
'dip': (tms['dip'] as num?)?.toDouble() ?? 0.0,
};
}
return {'squat': 0.0, 'pullup': 0.0, 'dip': 0.0};
}
}

View file

@ -1,62 +1,76 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:isar/isar.dart';
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:drift/drift.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../local/collections/cycle_collection.dart';
import '../local/collections/user_collection.dart';
import '../local/collections/workout_collection.dart';
import '../local/app_database.dart';
import '../remote/api_client.dart';
import '../../../../main.dart';
import '../../../core/constants/app_constants.dart';
final userRepositoryProvider = Provider<UserRepository>((ref) {
final isar = ref.watch(isarProvider);
final db = ref.watch(appDatabaseProvider);
final apiClient = ref.watch(apiClientProvider);
return UserRepository(isar: isar, apiClient: apiClient);
return UserRepository(db: db, apiClient: apiClient);
});
final apiClientProvider = Provider<ApiClient>((ref) => ApiClient());
class UserRepository {
final Isar isar;
final AppDatabase db;
final ApiClient apiClient;
final _storage = const FlutterSecureStorage(); // NEU: Instanz für Logout
UserRepository({required this.isar, required this.apiClient});
UserRepository({required this.db, required this.apiClient});
Future<UserCollection?> getLocalUser() async {
return await isar.userCollections.where().findFirst();
return await (db.select(db.users)..limit(1)).getSingleOrNull();
}
Future<void> saveLocalUser(UserCollection user) async {
user.updatedAt = DateTime.now();
await isar.writeTxn(() async {
await isar.userCollections.put(user);
});
final companion = user.toCompanion(true).copyWith(
updatedAt: Value(DateTime.now()),
);
await db.into(db.users).insertOnConflictUpdate(companion);
}
Future<void> updateXP(int xpToAdd) async {
final user = await getLocalUser();
if (user != null) {
user.xp += xpToAdd;
user.isDirty = true;
await saveLocalUser(user);
final newXp = user.xp + xpToAdd;
final companion = UsersCompanion(
xp: Value(newXp),
isDirty: const Value(true),
updatedAt: Value(DateTime.now()),
);
await (db.update(db.users)..where((u) => u.id.equals(user.id)))
.write(companion);
}
}
Future<void> updateLevel(int newLevel) async {
final user = await getLocalUser();
if (user != null) {
user.level = newLevel;
user.isDirty = true;
await saveLocalUser(user);
final companion = UsersCompanion(
level: Value(newLevel),
isDirty: const Value(true),
updatedAt: Value(DateTime.now()),
);
await (db.update(db.users)..where((u) => u.id.equals(user.id)))
.write(companion);
}
}
Future<void> updateBodyweight(double bodyweight) async {
final user = await getLocalUser();
if (user != null) {
user.currentBodyweight = bodyweight;
user.isDirty = true;
await saveLocalUser(user);
final companion = UsersCompanion(
currentBodyweight: Value(bodyweight),
isDirty: const Value(true),
updatedAt: Value(DateTime.now()),
);
await (db.update(db.users)..where((u) => u.id.equals(user.id)))
.write(companion);
try {
await apiClient.updateBodyweight(bodyweight);
@ -67,9 +81,13 @@ class UserRepository {
Future<void> updateInventory(Map<String, dynamic> inventory) async {
final user = await getLocalUser();
if (user != null) {
user.inventorySettingsJson = jsonEncode(inventory);
user.isDirty = true;
await saveLocalUser(user);
final companion = UsersCompanion(
inventorySettings: Value(inventory),
isDirty: const Value(true),
updatedAt: Value(DateTime.now()),
);
await (db.update(db.users)..where((u) => u.id.equals(user.id)))
.write(companion);
try {
await apiClient.updateInventory(inventory);
@ -79,21 +97,7 @@ class UserRepository {
Future<UserCollection> login(String email, String password) async {
final response = await apiClient.login(email, password);
final user = UserCollection()
..serverId = response['record']['id']
..email = response['record']['email']
..xp = response['record']['xp'] ?? 0
..level = response['record']['level'] ?? 1
..currentBodyweight =
(response['record']['current_bodyweight'] ?? 70.0).toDouble()
..inventorySettingsJson =
jsonEncode(response['record']['inventory_settings'] ?? {})
..avatarConfigJson = jsonEncode(response['record']['avatar_config'] ?? {})
..lastSyncAt = DateTime.now();
await saveLocalUser(user);
return user;
return _saveUserFromApi(response['record']);
}
Future<UserCollection> register({
@ -101,6 +105,7 @@ class UserRepository {
required String password,
required double bodyweight,
required Map<String, dynamic> inventorySettings,
Map<String, dynamic>? exerciseVariants,
}) async {
try {
final response = await apiClient.register(
@ -108,51 +113,79 @@ class UserRepository {
password: password,
bodyweight: bodyweight,
inventorySettings: inventorySettings,
exerciseVariants: exerciseVariants,
);
final record = response['record'] ?? response;
var user = await _saveUserFromApi(record);
final user = UserCollection()
..serverId = record['id']?.toString()
..email = record['email']?.toString() ?? email
..xp = (record['xp'] as num?)?.toInt() ?? 0
..level = (record['level'] as num?)?.toInt() ?? 1
..currentBodyweight =
(record['current_bodyweight'] as num?)?.toDouble() ?? bodyweight
..inventorySettingsJson =
jsonEncode(record['inventory_settings'] ?? inventorySettings)
..avatarConfigJson = jsonEncode(record['avatar_config'] ??
{
'skin_tone': 'medium',
'hair_style': 'short_01',
'clothing': 'basic_tee',
'unlocked_items': ['basic_tee'],
})
..lastSyncAt = DateTime.now();
if (exerciseVariants != null && exerciseVariants.isNotEmpty) {
final serverVariants = user.exerciseVariants;
if (serverVariants == null || serverVariants.isEmpty) {
final companion = user.toCompanion(true).copyWith(
exerciseVariants: Value(exerciseVariants),
isDirty: const Value(true),
updatedAt: Value(DateTime.now()),
);
await db.into(db.users).insertOnConflictUpdate(companion);
await saveLocalUser(user);
user = (await (db.select(db.users)
..where((u) => u.id.equals(user.id)))
.getSingle());
}
}
try {
await apiClient.login(email, password);
} catch (e) {}
return user;
} catch (e, stackTrace) {
} catch (e) {
rethrow;
}
}
Future<UserCollection> _saveUserFromApi(Map<String, dynamic> record) async {
await db.delete(db.users).go();
final companion = UsersCompanion(
serverId: Value(record['id']),
email: Value(record['email'] ?? ''),
xp: Value((record['xp'] as num?)?.toInt() ?? 0),
level: Value((record['level'] as num?)?.toInt() ?? 1),
currentBodyweight:
Value((record['current_bodyweight'] as num?)?.toDouble() ?? 70.0),
inventorySettings: Value(record['inventory_settings'] ?? {}),
exerciseVariants: Value(record['exercise_variants'] ?? {}),
avatarConfig: Value(record['avatar_config'] ?? {}),
lastSyncAt: Value(DateTime.now()),
isDirty: const Value(false),
createdAt: Value(DateTime.now()),
updatedAt: Value(DateTime.now()),
);
final id = await db.into(db.users).insert(companion);
return (await (db.select(db.users)..where((u) => u.id.equals(id)))
.getSingle());
}
Future<void> logout() async {
await apiClient.logout();
await isar.writeTxn(() async {
await isar.userCollections.clear();
await _storage.delete(key: AppConstants.keyLastSync);
await db.transaction(() async {
await db.delete(db.users).go();
await db.delete(db.cycles).go();
await db.delete(db.workouts).go();
await db.delete(db.quests).go();
});
}
Map<String, dynamic> getInventorySettings() {
final user = isar.userCollections.where().findFirstSync();
if (user?.inventorySettingsJson != null) {
return jsonDecode(user!.inventorySettingsJson!);
Future<Map<String, dynamic>> getInventorySettingsAsync() async {
final user = await getLocalUser();
if (user?.inventorySettings != null) {
return user!.inventorySettings!;
}
return {
'bar_weight': 20.0,
@ -161,14 +194,14 @@ class UserRepository {
};
}
List<double> getAvailablePlates() {
final inventory = getInventorySettings();
Future<List<double>> getAvailablePlates() async {
final inventory = await getInventorySettingsAsync();
final plates = inventory['plates'] as List?;
return plates?.map((e) => (e as num).toDouble()).toList() ?? [];
}
double getBarWeight() {
final inventory = getInventorySettings();
Future<double> getBarWeight() async {
final inventory = await getInventorySettingsAsync();
return (inventory['bar_weight'] as num?)?.toDouble() ?? 20.0;
}
@ -204,17 +237,17 @@ class UserRepository {
"Server connection required to reset progress. Please try again when online.");
}
user.xp = 0;
user.level = 1;
final companion = UsersCompanion(
xp: const Value(0),
level: const Value(1),
isDirty: const Value(false),
updatedAt: Value(DateTime.now()),
);
await (db.update(db.users)..where((u) => u.id.equals(user.id)))
.write(companion);
user.isDirty = false;
await isar.writeTxn(() async {
await isar.userCollections.put(user);
await isar.cycleCollections.clear();
await isar.workoutCollections.clear();
});
await db.delete(db.cycles).go();
await db.delete(db.workouts).go();
}
}
}

View file

@ -1,49 +1,44 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:isar/isar.dart';
import 'dart:convert';
import '../local/collections/workout_collection.dart';
import 'package:drift/drift.dart';
import '../local/app_database.dart';
import '../remote/api_client.dart';
import '../../../../main.dart';
import 'user_repository.dart';
final workoutRepositoryProvider = Provider<WorkoutRepository>((ref) {
final isar = ref.watch(isarProvider);
final db = ref.watch(appDatabaseProvider);
final apiClient = ref.watch(apiClientProvider);
return WorkoutRepository(isar: isar, apiClient: apiClient);
return WorkoutRepository(db: db, apiClient: apiClient);
});
class WorkoutRepository {
final Isar isar;
final AppDatabase db;
final ApiClient apiClient;
WorkoutRepository({required this.isar, required this.apiClient});
WorkoutRepository({required this.db, required this.apiClient});
Future<List<WorkoutCollection>> getAllWorkouts() async {
return await isar.workoutCollections.where().findAll();
return await db.select(db.workouts).get();
}
Future<List<WorkoutCollection>> getWorkoutsForCycle(String cycleId) async {
return await isar.workoutCollections
.filter()
.cycleIdEqualTo(cycleId)
.findAll();
return await (db.select(db.workouts)
..where((w) => w.cycleId.equals(cycleId)))
.get();
}
Future<List<WorkoutCollection>> getCompletedWorkouts(String userId) async {
return await isar.workoutCollections
.filter()
.userIdEqualTo(userId)
.completedAtIsNotNull()
.findAll();
return await (db.select(db.workouts)
..where((w) => w.userId.equals(userId) & w.completedAt.isNotNull()))
.get();
}
Future<void> saveWorkout(WorkoutCollection workout) async {
workout.updatedAt = DateTime.now();
workout.isDirty = true;
await isar.writeTxn(() async {
await isar.workoutCollections.put(workout);
});
final companion = workout.toCompanion(true).copyWith(
updatedAt: Value(DateTime.now()),
isDirty: const Value(true),
);
await db.into(db.workouts).insertOnConflictUpdate(companion);
}
Future<WorkoutCollection> createWorkout({
@ -51,27 +46,42 @@ class WorkoutRepository {
required String cycleId,
required int week,
required int day,
required String exercisesJson,
required List<dynamic> exercises,
}) async {
final workout = WorkoutCollection()
..userId = userId
..cycleId = cycleId
..week = week
..day = day
..exercisesJson = exercisesJson
..scheduledDate = DateTime.now();
final companion = WorkoutsCompanion(
userId: Value(userId),
cycleId: Value(cycleId),
week: Value(week),
day: Value(day),
exercises: Value(exercises),
scheduledDate: Value(DateTime.now()),
xpEarned: const Value(0),
notes: const Value(''),
isDirty: const Value(true),
createdAt: Value(DateTime.now()),
updatedAt: Value(DateTime.now()),
);
await saveWorkout(workout);
return workout;
final id = await db.into(db.workouts).insert(companion);
return await (db.select(db.workouts)..where((w) => w.id.equals(id)))
.getSingle();
}
Future<void> completeWorkout(
WorkoutCollection workout, {
required int xpEarned,
}) async {
workout.completedAt = DateTime.now();
workout.xpEarned = xpEarned;
await saveWorkout(workout);
final companion = WorkoutsCompanion(
id: Value(workout.id),
completedAt: Value(DateTime.now()),
xpEarned: Value(xpEarned),
exercises: Value(workout.exercises),
isDirty: const Value(true),
updatedAt: Value(DateTime.now()),
);
await (db.update(db.workouts)..where((w) => w.id.equals(workout.id)))
.write(companion);
}
Future<WorkoutCollection?> getWorkoutByWeekDay({
@ -80,16 +90,23 @@ class WorkoutRepository {
required int week,
required int day,
}) async {
return await isar.workoutCollections
.filter()
.weekEqualTo(week)
.dayEqualTo(day)
.group((q) {
var query = q.cycleIdEqualTo(cycleId);
if (localCycleId != null) {
query = query.or().cycleIdEqualTo(localCycleId);
}
return query;
}).findFirst();
return await (db.select(db.workouts)
..where((w) {
final weekDayCheck = w.week.equals(week) & w.day.equals(day);
Expression<bool> cycleCheck = w.cycleId.equals(cycleId);
if (localCycleId != null) {
cycleCheck = cycleCheck | w.cycleId.equals(localCycleId);
}
return weekDayCheck & cycleCheck;
})
..limit(1))
.getSingleOrNull();
}
Future<WorkoutCollection?> getWorkoutById(int id) async {
return await (db.select(db.workouts)..where((w) => w.id.equals(id)))
.getSingleOrNull();
}
}

View file

@ -5,15 +5,15 @@ part 'exercise.freezed.dart';
part 'exercise.g.dart';
@freezed
class Exercise with _$Exercise {
abstract class Exercise with _$Exercise {
const factory Exercise({
required String exerciseId,
required String exerciseName,
@Default(0.0) double bodyweightAtSession,
@Default([]) List<WorkoutSet> sets,
int? intervalSeconds,
}) = _Exercise;
factory Exercise.fromJson(Map<String, dynamic> json) =>
_$ExerciseFromJson(json);
}

View file

@ -25,8 +25,12 @@ mixin _$Exercise {
double get bodyweightAtSession => throw _privateConstructorUsedError;
List<WorkoutSet> get sets => throw _privateConstructorUsedError;
/// Serializes this Exercise to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
/// Create a copy of Exercise
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$ExerciseCopyWith<Exercise> get copyWith =>
throw _privateConstructorUsedError;
}
@ -53,6 +57,8 @@ class _$ExerciseCopyWithImpl<$Res, $Val extends Exercise>
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of Exercise
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
@ -105,6 +111,8 @@ class __$$ExerciseImplCopyWithImpl<$Res>
_$ExerciseImpl _value, $Res Function(_$ExerciseImpl) _then)
: super(_value, _then);
/// Create a copy of Exercise
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
@ -182,12 +190,14 @@ class _$ExerciseImpl implements _Exercise {
const DeepCollectionEquality().equals(other._sets, _sets));
}
@JsonKey(ignore: true)
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType, exerciseId, exerciseName,
bodyweightAtSession, const DeepCollectionEquality().hash(_sets));
@JsonKey(ignore: true)
/// Create a copy of Exercise
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$ExerciseImplCopyWith<_$ExerciseImpl> get copyWith =>
@ -219,8 +229,11 @@ abstract class _Exercise implements Exercise {
double get bodyweightAtSession;
@override
List<WorkoutSet> get sets;
/// Create a copy of Exercise
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(ignore: true)
@JsonKey(includeFromJson: false, includeToJson: false)
_$$ExerciseImplCopyWith<_$ExerciseImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View file

@ -4,7 +4,7 @@ part 'training_maxes.freezed.dart';
part 'training_maxes.g.dart';
@freezed
class TrainingMaxes with _$TrainingMaxes {
abstract class TrainingMaxes with _$TrainingMaxes {
const factory TrainingMaxes({
@Default(0.0) double squat,
@Default(0.0) double pullup,
@ -14,4 +14,3 @@ class TrainingMaxes with _$TrainingMaxes {
factory TrainingMaxes.fromJson(Map<String, dynamic> json) =>
_$TrainingMaxesFromJson(json);
}

View file

@ -24,8 +24,12 @@ mixin _$TrainingMaxes {
double get pullup => throw _privateConstructorUsedError;
double get dip => throw _privateConstructorUsedError;
/// Serializes this TrainingMaxes to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
/// Create a copy of TrainingMaxes
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$TrainingMaxesCopyWith<TrainingMaxes> get copyWith =>
throw _privateConstructorUsedError;
}
@ -49,6 +53,8 @@ class _$TrainingMaxesCopyWithImpl<$Res, $Val extends TrainingMaxes>
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of TrainingMaxes
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
@ -92,6 +98,8 @@ class __$$TrainingMaxesImplCopyWithImpl<$Res>
_$TrainingMaxesImpl _value, $Res Function(_$TrainingMaxesImpl) _then)
: super(_value, _then);
/// Create a copy of TrainingMaxes
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
@ -150,11 +158,13 @@ class _$TrainingMaxesImpl implements _TrainingMaxes {
(identical(other.dip, dip) || other.dip == dip));
}
@JsonKey(ignore: true)
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType, squat, pullup, dip);
@JsonKey(ignore: true)
/// Create a copy of TrainingMaxes
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$TrainingMaxesImplCopyWith<_$TrainingMaxesImpl> get copyWith =>
@ -183,8 +193,11 @@ abstract class _TrainingMaxes implements TrainingMaxes {
double get pullup;
@override
double get dip;
/// Create a copy of TrainingMaxes
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(ignore: true)
@JsonKey(includeFromJson: false, includeToJson: false)
_$$TrainingMaxesImplCopyWith<_$TrainingMaxesImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View file

@ -4,7 +4,7 @@ part 'workout_set.freezed.dart';
part 'workout_set.g.dart';
@freezed
class WorkoutSet with _$WorkoutSet {
abstract class WorkoutSet with _$WorkoutSet {
const factory WorkoutSet({
@Default(1) int setNumber,
@Default(0) int targetPercentage,
@ -20,4 +20,3 @@ class WorkoutSet with _$WorkoutSet {
factory WorkoutSet.fromJson(Map<String, dynamic> json) =>
_$WorkoutSetFromJson(json);
}

View file

@ -30,8 +30,12 @@ mixin _$WorkoutSet {
bool get completed => throw _privateConstructorUsedError;
int? get rpe => throw _privateConstructorUsedError;
/// Serializes this WorkoutSet to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
/// Create a copy of WorkoutSet
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$WorkoutSetCopyWith<WorkoutSet> get copyWith =>
throw _privateConstructorUsedError;
}
@ -64,6 +68,8 @@ class _$WorkoutSetCopyWithImpl<$Res, $Val extends WorkoutSet>
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of WorkoutSet
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
@ -146,6 +152,8 @@ class __$$WorkoutSetImplCopyWithImpl<$Res>
_$WorkoutSetImpl _value, $Res Function(_$WorkoutSetImpl) _then)
: super(_value, _then);
/// Create a copy of WorkoutSet
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
@ -272,7 +280,7 @@ class _$WorkoutSetImpl implements _WorkoutSet {
(identical(other.rpe, rpe) || other.rpe == rpe));
}
@JsonKey(ignore: true)
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
@ -286,7 +294,9 @@ class _$WorkoutSetImpl implements _WorkoutSet {
completed,
rpe);
@JsonKey(ignore: true)
/// Create a copy of WorkoutSet
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$WorkoutSetImplCopyWith<_$WorkoutSetImpl> get copyWith =>
@ -333,8 +343,11 @@ abstract class _WorkoutSet implements WorkoutSet {
bool get completed;
@override
int? get rpe;
/// Create a copy of WorkoutSet
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(ignore: true)
@JsonKey(includeFromJson: false, includeToJson: false)
_$$WorkoutSetImplCopyWith<_$WorkoutSetImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View file

@ -2,7 +2,35 @@ import 'dart:math';
import '../entities/workout_set.dart';
import '../../../core/constants/app_constants.dart';
enum ExerciseType { squat, pullup, dip }
enum ExerciseType {
// Main Lifts
squat,
pullup,
dip,
row,
bench,
// Hypertrophy Accessories
deadlift_romanian,
curl_barbell,
press_overhead,
face_pull,
ab_wheel,
plank,
// Conditioning (Kettlebell)
kb_swing,
kb_snatch,
kb_thruster,
kb_clean_press,
// pullup journey
scapular_pull,
inverted_row,
negative_pullup,
}
enum AccessoryTemplate { none, hypertrophy, conditioning, journey_pullup }
class WendlerCalculator {
static const Map<int, List<double>> weekPercentages = {
@ -34,7 +62,9 @@ class WendlerCalculator {
final rounded = _roundWeight(targetTotal, exerciseType);
double plateWeight = 0;
if (exerciseType != ExerciseType.squat) {
if (exerciseType != ExerciseType.squat ||
exerciseType != ExerciseType.row ||
exerciseType != ExerciseType.bench) {
plateWeight = max(0, rounded - currentBodyweight);
}
@ -51,8 +81,38 @@ class WendlerCalculator {
return sets;
}
static List<WorkoutSet> generateLinearSets({
required double trainingMax,
required ExerciseType exerciseType,
required double currentBodyweight,
int setsCount = 3,
int repsCount = 5,
}) {
final sets = <WorkoutSet>[];
final targetTotal = _roundWeight(trainingMax, exerciseType);
double plateWeight = 0;
for (int i = 0; i < setsCount; i++) {
sets.add(WorkoutSet(
setNumber: i + 1,
targetPercentage: 100,
targetWeightTotal: targetTotal,
plateWeight: plateWeight,
repsTarget: repsCount,
repsActual: 0,
isAmrap: (i == setsCount - 1),
));
}
return sets;
}
static double _roundWeight(double weight, ExerciseType type) {
final step = type == ExerciseType.squat
final step = (type == ExerciseType.squat ||
type == ExerciseType.row ||
type == ExerciseType.bench)
? AppConstants.squatRoundingStep
: AppConstants.calisthenicsRoundingStep;
return (weight / step).floor() * step;
@ -86,7 +146,9 @@ class WendlerCalculator {
final rounded = _roundWeight(targetTotal, exerciseType);
double plateWeight = 0;
if (exerciseType != ExerciseType.squat) {
if (exerciseType != ExerciseType.squat ||
exerciseType != ExerciseType.row ||
exerciseType != ExerciseType.bench) {
plateWeight = max(0, rounded - currentBodyweight);
}

View file

@ -0,0 +1,148 @@
import 'package:slrpg_app/l10n/app_localizations.dart';
class ExerciseGuide {
final String title;
final String difficulty;
final String rpgLore;
final List<String> steps;
final List<String> muscles;
final List<String> commonMistakes;
const ExerciseGuide({
required this.title,
required this.difficulty,
required this.rpgLore,
required this.steps,
required this.muscles,
required this.commonMistakes,
});
static List<String> _split(String input) {
return input.split('|').map((e) => e.trim()).toList();
}
static Map<String, ExerciseGuide> getLibrary(AppLocalizations l10n) {
return {
'pullup': ExerciseGuide(
title: l10n.guidePullupTitle,
difficulty: 'Adept',
rpgLore: l10n.guidePullupLore,
steps: _split(l10n.guidePullupSteps),
muscles: _split(l10n.guidePullupMuscles),
commonMistakes: _split(l10n.guidePullupMistakes),
),
'dip': ExerciseGuide(
title: l10n.guideDipTitle,
difficulty: 'Adept',
rpgLore: l10n.guideDipLore,
steps: _split(l10n.guideDipSteps),
muscles: _split(l10n.guideDipMuscles),
commonMistakes: _split(l10n.guideDipMistakes),
),
'squat': ExerciseGuide(
title: l10n.guideSquatTitle,
difficulty: 'Master',
rpgLore: l10n.guideSquatLore,
steps: _split(l10n.guideSquatSteps),
muscles: _split(l10n.guideSquatMuscles),
commonMistakes: _split(l10n.guideSquatMistakes),
),
'bench': ExerciseGuide(
title: l10n.guideBenchTitle,
difficulty: 'Novice',
rpgLore: l10n.guideBenchLore,
steps: _split(l10n.guideBenchSteps),
muscles: _split(l10n.guideBenchMuscles),
commonMistakes: _split(l10n.guideBenchMistakes),
),
'ohp': ExerciseGuide(
title: l10n.guideOhpTitle,
difficulty: 'Adept',
rpgLore: l10n.guideOhpLore,
steps: _split(l10n.guideOhpSteps),
muscles: _split(l10n.guideOhpMuscles),
commonMistakes: _split(l10n.guideOhpMistakes),
),
'rdl': ExerciseGuide(
title: l10n.guideRdlTitle,
difficulty: 'Adept',
rpgLore: l10n.guideRdlLore,
steps: _split(l10n.guideRdlSteps),
muscles: _split(l10n.guideRdlMuscles),
commonMistakes: _split(l10n.guideRdlMistakes),
),
'row': ExerciseGuide(
title: l10n.guideRowTitle,
difficulty: 'Adept',
rpgLore: l10n.guideRowLore,
steps: _split(l10n.guideRowSteps),
muscles: _split(l10n.guideRowMuscles),
commonMistakes: _split(l10n.guideRowMistakes),
),
'curl': ExerciseGuide(
title: l10n.guideCurlTitle,
difficulty: 'Novice',
rpgLore: l10n.guideCurlLore,
steps: _split(l10n.guideCurlSteps),
muscles: _split(l10n.guideCurlMuscles),
commonMistakes: _split(l10n.guideCurlMistakes),
),
'kb_swing': ExerciseGuide(
title: l10n.guideKbSwingTitle,
difficulty: 'Adept',
rpgLore: l10n.guideKbSwingLore,
steps: _split(l10n.guideKbSwingSteps),
muscles: _split(l10n.guideKbSwingMuscles),
commonMistakes: _split(l10n.guideKbSwingMistakes),
),
'kb_snatch': ExerciseGuide(
title: l10n.guideKbSnatchTitle,
difficulty: 'Master',
rpgLore: l10n.guideKbSnatchLore,
steps: _split(l10n.guideKbSnatchSteps),
muscles: _split(l10n.guideKbSnatchMuscles),
commonMistakes: _split(l10n.guideKbSnatchMistakes),
),
'kb_thruster': ExerciseGuide(
title: l10n.guideKbThrusterTitle,
difficulty: 'Master',
rpgLore: l10n.guideKbThrusterLore,
steps: _split(l10n.guideKbThrusterSteps),
muscles: _split(l10n.guideKbThrusterMuscles),
commonMistakes: _split(l10n.guideKbThrusterMistakes),
),
'kb_clean_press': ExerciseGuide(
title: l10n.guideKbCleanPressTitle,
difficulty: 'Adept',
rpgLore: l10n.guideKbCleanPressLore,
steps: _split(l10n.guideKbCleanPressSteps),
muscles: _split(l10n.guideKbCleanPressMuscles),
commonMistakes: _split(l10n.guideKbCleanPressMistakes),
),
'face_pull': ExerciseGuide(
title: l10n.guideFacePullTitle,
difficulty: 'Novice',
rpgLore: l10n.guideFacePullLore,
steps: _split(l10n.guideFacePullSteps),
muscles: _split(l10n.guideFacePullMuscles),
commonMistakes: _split(l10n.guideFacePullMistakes),
),
'ab_wheel': ExerciseGuide(
title: l10n.guideAbWheelTitle,
difficulty: 'Adept',
rpgLore: l10n.guideAbWheelLore,
steps: _split(l10n.guideAbWheelSteps),
muscles: _split(l10n.guideAbWheelMuscles),
commonMistakes: _split(l10n.guideAbWheelMistakes),
),
'plank': ExerciseGuide(
title: l10n.guidePlankTitle,
difficulty: 'Novice',
rpgLore: l10n.guidePlankLore,
steps: _split(l10n.guidePlankSteps),
muscles: _split(l10n.guidePlankMuscles),
commonMistakes: _split(l10n.guidePlankMistakes),
),
};
}
}

View file

@ -5,26 +5,26 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: ae92f5d747aee634b87f89d9946000c2de774be1d6ac3e58268224348cd0101a
sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d
url: "https://pub.dev"
source: hosted
version: "61.0.0"
version: "91.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: ea3d8652bda62982addfd92fdc2d0214e5f82e43325104990d4f4c4a2a313562
sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08
url: "https://pub.dev"
source: hosted
version: "5.13.0"
analyzer_plugin:
version: "8.4.1"
analyzer_buffer:
dependency: transitive
description:
name: analyzer_plugin
sha256: c1d5f167683de03d5ab6c3b53fc9aeefc5d59476e7810ba7bbddff50c6f4392d
name: analyzer_buffer
sha256: aba2f75e63b3135fd1efaa8b6abefe1aa6e41b6bd9806221620fa48f98156033
url: "https://pub.dev"
source: hosted
version: "0.11.2"
version: "0.1.11"
args:
dependency: transitive
description:
@ -41,6 +41,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.13.0"
audioplayers:
dependency: "direct main"
description:
name: audioplayers
sha256: "5441fa0ceb8807a5ad701199806510e56afde2b4913d9d17c2f19f2902cf0ae4"
url: "https://pub.dev"
source: hosted
version: "6.5.1"
audioplayers_android:
dependency: transitive
description:
name: audioplayers_android
sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605"
url: "https://pub.dev"
source: hosted
version: "5.2.1"
audioplayers_darwin:
dependency: transitive
description:
name: audioplayers_darwin
sha256: "0811d6924904ca13f9ef90d19081e4a87f7297ddc19fc3d31f60af1aaafee333"
url: "https://pub.dev"
source: hosted
version: "6.3.0"
audioplayers_linux:
dependency: transitive
description:
name: audioplayers_linux
sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001
url: "https://pub.dev"
source: hosted
version: "4.2.1"
audioplayers_platform_interface:
dependency: transitive
description:
name: audioplayers_platform_interface
sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656"
url: "https://pub.dev"
source: hosted
version: "7.1.1"
audioplayers_web:
dependency: transitive
description:
name: audioplayers_web
sha256: "1c0f17cec68455556775f1e50ca85c40c05c714a99c5eb1d2d57cc17ba5522d7"
url: "https://pub.dev"
source: hosted
version: "5.1.1"
audioplayers_windows:
dependency: transitive
description:
name: audioplayers_windows
sha256: "4048797865105b26d47628e6abb49231ea5de84884160229251f37dfcbe52fd7"
url: "https://pub.dev"
source: hosted
version: "4.2.1"
boolean_selector:
dependency: transitive
description:
@ -53,18 +109,18 @@ packages:
dependency: transitive
description:
name: build
sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0"
sha256: c1668065e9ba04752570ad7e038288559d1e2ca5c6d0131c0f5f55e39e777413
url: "https://pub.dev"
source: hosted
version: "2.4.1"
version: "4.0.3"
build_config:
dependency: transitive
description:
name: build_config
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
version: "1.2.0"
build_daemon:
dependency: transitive
description:
@ -73,30 +129,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.1"
build_resolvers:
dependency: transitive
description:
name: build_resolvers
sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
build_runner:
dependency: "direct dev"
description:
name: build_runner
sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d"
sha256: "110c56ef29b5eb367b4d17fc79375fa8c18a6cd7acd92c05bb3986c17a079057"
url: "https://pub.dev"
source: hosted
version: "2.4.13"
build_runner_core:
dependency: transitive
description:
name: build_runner_core
sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0
url: "https://pub.dev"
source: hosted
version: "7.3.2"
version: "2.10.4"
built_collection:
dependency: transitive
description:
@ -145,6 +185,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
charcode:
dependency: transitive
description:
name: charcode
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
url: "https://pub.dev"
source: hosted
version: "1.4.0"
checked_yaml:
dependency: transitive
description:
@ -153,6 +201,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.4"
cli_config:
dependency: transitive
description:
name: cli_config
sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
url: "https://pub.dev"
source: hosted
version: "0.2.0"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock:
dependency: transitive
description:
@ -185,6 +249,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.2"
coverage:
dependency: transitive
description:
name: coverage
sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d"
url: "https://pub.dev"
source: hosted
version: "1.15.0"
crypto:
dependency: transitive
description:
@ -201,30 +273,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.8"
custom_lint_core:
dependency: transitive
description:
name: custom_lint_core
sha256: a85e8f78f4c52f6c63cdaf8c872eb573db0231dcdf3c3a5906d493c1f8bc20e6
url: "https://pub.dev"
source: hosted
version: "0.6.3"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: "1efa911ca7086affd35f463ca2fc1799584fb6aa89883cf0af8e3664d6a02d55"
sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b
url: "https://pub.dev"
source: hosted
version: "2.3.2"
dartx:
dependency: transitive
description:
name: dartx
sha256: "8b25435617027257d43e6508b5fe061012880ddfdaa75a71d607c3de2a13d244"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
version: "3.1.3"
dio:
dependency: "direct main"
description:
@ -241,6 +297,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.1"
drift:
dependency: "direct main"
description:
name: drift
sha256: "3669e1b68d7bffb60192ac6ba9fd2c0306804d7a00e5879f6364c69ecde53a7f"
url: "https://pub.dev"
source: hosted
version: "2.30.0"
drift_dev:
dependency: "direct dev"
description:
name: drift_dev
sha256: afe4d1d2cfce6606c86f11a6196e974a2ddbfaa992956ce61e054c9b1899c769
url: "https://pub.dev"
source: hosted
version: "2.30.0"
drift_flutter:
dependency: "direct main"
description:
name: drift_flutter
sha256: c07120854742a0cae2f7501a0da02493addde550db6641d284983c08762e60a7
url: "https://pub.dev"
source: hosted
version: "0.2.8"
equatable:
dependency: "direct main"
description:
@ -302,6 +382,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.4.1"
flutter_dotenv:
dependency: "direct main"
description:
name: flutter_dotenv
sha256: b7c7be5cd9f6ef7a78429cabd2774d3c4af50e79cb2b7593e3d5d763ef95c61b
url: "https://pub.dev"
source: hosted
version: "5.2.1"
flutter_lints:
dependency: "direct dev"
description:
@ -314,58 +402,58 @@ packages:
dependency: "direct main"
description:
name: flutter_riverpod
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
sha256: "38ec6c303e2c83ee84512f5fc2a82ae311531021938e63d7137eccc107bf3c02"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
version: "3.1.0"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40
url: "https://pub.dev"
source: hosted
version: "9.2.4"
version: "10.0.0"
flutter_secure_storage_darwin:
dependency: transitive
description:
name: flutter_secure_storage_darwin
sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3"
url: "https://pub.dev"
source: hosted
version: "0.2.0"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda"
url: "https://pub.dev"
source: hosted
version: "1.2.3"
flutter_secure_storage_macos:
dependency: transitive
description:
name: flutter_secure_storage_macos
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
url: "https://pub.dev"
source: hosted
version: "3.1.3"
version: "3.0.0"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
version: "2.0.1"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
version: "2.1.0"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
url: "https://pub.dev"
source: hosted
version: "3.1.2"
version: "4.1.0"
flutter_svg:
dependency: "direct main"
description:
@ -388,18 +476,18 @@ packages:
dependency: "direct dev"
description:
name: freezed
sha256: a434911f643466d78462625df76fd9eb13e57348ff43fe1f77bbe909522c67a1
sha256: "13065f10e135263a4f5a4391b79a8efc5fb8106f8dd555a9e49b750b45393d77"
url: "https://pub.dev"
source: hosted
version: "2.5.2"
version: "3.2.3"
freezed_annotation:
dependency: "direct main"
description:
name: freezed_annotation
sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2
sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8"
url: "https://pub.dev"
source: hosted
version: "2.4.4"
version: "3.1.0"
frontend_server_client:
dependency: transitive
description:
@ -420,18 +508,18 @@ packages:
dependency: "direct main"
description:
name: go_router
sha256: c92d18e1fe994cb06d48aa786c46b142a5633067e8297cff6b5a3ac742620104
sha256: eff94d2a6fc79fa8b811dde79c7549808c2346037ee107a1121b4a644c745f2a
url: "https://pub.dev"
source: hosted
version: "17.0.0"
version: "17.0.1"
google_fonts:
dependency: "direct main"
description:
name: google_fonts
sha256: "517b20870220c48752eafa0ba1a797a092fb22df0d89535fd9991e86ee2cdd9c"
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
url: "https://pub.dev"
source: hosted
version: "6.3.2"
version: "6.3.3"
graphs:
dependency: transitive
description:
@ -480,38 +568,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.5"
isar:
dependency: "direct main"
description:
name: isar
sha256: "99165dadb2cf2329d3140198363a7e7bff9bbd441871898a87e26914d25cf1ea"
url: "https://pub.dev"
source: hosted
version: "3.1.0+1"
isar_flutter_libs:
dependency: "direct main"
description:
name: isar_flutter_libs
sha256: bc6768cc4b9c61aabff77152e7f33b4b17d2fc93134f7af1c3dd51500fe8d5e8
url: "https://pub.dev"
source: hosted
version: "3.1.0+1"
isar_generator:
dependency: "direct dev"
description:
name: isar_generator
sha256: "76c121e1295a30423604f2f819bc255bc79f852f3bc8743a24017df6068ad133"
url: "https://pub.dev"
source: hosted
version: "3.1.0+1"
js:
dependency: transitive
description:
name: js
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.6.7"
version: "0.7.2"
json_annotation:
dependency: "direct main"
description:
@ -524,10 +588,10 @@ packages:
dependency: "direct dev"
description:
name: json_serializable
sha256: ea1432d167339ea9b5bb153f0571d0039607a873d6e04e0117af043f14a1fd4b
sha256: c5b2ee75210a0f263c6c7b9eeea80553dbae96ea1bf57f02484e806a3ffdffa3
url: "https://pub.dev"
source: hosted
version: "6.8.0"
version: "6.11.2"
leak_tracker:
dependency: transitive
description:
@ -608,6 +672,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0"
mockito:
dependency: transitive
description:
name: mockito
sha256: dac24d461418d363778d53198d9ac0510b9d073869f078450f195766ec48d05e
url: "https://pub.dev"
source: hosted
version: "5.6.1"
node_preamble:
dependency: transitive
description:
name: node_preamble
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
octo_image:
dependency: transitive
description:
@ -744,38 +824,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.0"
recase:
dependency: transitive
description:
name: recase
sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213
url: "https://pub.dev"
source: hosted
version: "4.1.0"
riverpod:
dependency: transitive
description:
name: riverpod
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
sha256: "16ff608d21e8ea64364f2b7c049c94a02ab81668f78845862b6e88b71dd4935a"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
version: "3.1.0"
riverpod_analyzer_utils:
dependency: transitive
description:
name: riverpod_analyzer_utils
sha256: "8b71f03fc47ae27d13769496a1746332df4cec43918aeba9aff1e232783a780f"
sha256: "947b05d04c52a546a2ac6b19ef2a54b08520ff6bdf9f23d67957a4c8df1c3bc0"
url: "https://pub.dev"
source: hosted
version: "0.5.1"
version: "1.0.0-dev.8"
riverpod_annotation:
dependency: "direct main"
description:
name: riverpod_annotation
sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8
sha256: cc1474bc2df55ec3c1da1989d139dcef22cd5e2bd78da382e867a69a8eca2e46
url: "https://pub.dev"
source: hosted
version: "2.6.1"
version: "4.0.0"
riverpod_generator:
dependency: "direct dev"
description:
name: riverpod_generator
sha256: d451608bf17a372025fc36058863737636625dfdb7e3cbf6142e0dfeb366ab22
sha256: e43b1537229cc8f487f09b0c20d15dba840acbadcf5fc6dad7ad5e8ab75950dc
url: "https://pub.dev"
source: hosted
version: "2.4.0"
version: "4.0.0+1"
rxdart:
dependency: transitive
description:
@ -788,18 +876,18 @@ packages:
dependency: "direct main"
description:
name: shared_preferences
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
url: "https://pub.dev"
source: hosted
version: "2.5.3"
version: "2.5.4"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "46a46fd64659eff15f4638bbe19de43f9483f0e0bf024a9fb6b3582064bacc7b"
sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc"
url: "https://pub.dev"
source: hosted
version: "2.4.17"
version: "2.4.18"
shared_preferences_foundation:
dependency: transitive
description:
@ -848,14 +936,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_packages_handler:
dependency: transitive
description:
name: shelf_packages_handler
sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
shelf_static:
dependency: transitive
description:
name: shelf_static
sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
url: "https://pub.dev"
source: hosted
version: "1.1.3"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
version: "3.0.0"
shimmer:
dependency: "direct main"
description:
@ -873,18 +977,34 @@ packages:
dependency: transitive
description:
name: source_gen
sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832"
sha256: "07b277b67e0096c45196cbddddf2d8c6ffc49342e88bf31d460ce04605ddac75"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
version: "4.1.1"
source_helper:
dependency: transitive
description:
name: source_helper
sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c"
sha256: "6a3c6cc82073a8797f8c4dc4572146114a39652851c157db37e964d9c7038723"
url: "https://pub.dev"
source: hosted
version: "1.3.5"
version: "1.3.8"
source_map_stack_trace:
dependency: transitive
description:
name: source_map_stack_trace
sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
url: "https://pub.dev"
source: hosted
version: "2.1.2"
source_maps:
dependency: transitive
description:
name: source_maps
sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812"
url: "https://pub.dev"
source: hosted
version: "0.10.13"
source_span:
dependency: transitive
description:
@ -933,6 +1053,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.0"
sqlite3:
dependency: transitive
description:
name: sqlite3
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
url: "https://pub.dev"
source: hosted
version: "2.9.4"
sqlite3_flutter_libs:
dependency: "direct main"
description:
name: sqlite3_flutter_libs
sha256: "1e800ebe7f85a80a66adacaa6febe4d5f4d8b75f244e9838a27cb2ffc7aec08d"
url: "https://pub.dev"
source: hosted
version: "0.5.41"
sqlparser:
dependency: transitive
description:
name: sqlparser
sha256: "162435ede92bcc793ea939fdc0452eef0a73d11f8ed053b58a89792fba749da5"
url: "https://pub.dev"
source: hosted
version: "0.42.1"
stack_trace:
dependency: transitive
description:
@ -989,6 +1133,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test:
dependency: transitive
description:
name: test
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
url: "https://pub.dev"
source: hosted
version: "1.26.3"
test_api:
dependency: transitive
description:
@ -997,22 +1149,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.7"
time:
test_core:
dependency: transitive
description:
name: time
sha256: "370572cf5d1e58adcb3e354c47515da3f7469dac3a95b447117e728e7be6f461"
name: test_core
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
timing:
dependency: transitive
description:
name: timing
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
version: "0.6.12"
typed_data:
dependency: transitive
description:
@ -1073,10 +1217,10 @@ packages:
dependency: transitive
description:
name: watcher
sha256: "592ab6e2892f67760543fb712ff0177f4ec76c031f02f5b4ff8d3fc5eb9fb61a"
sha256: f52385d4f73589977c80797e60fe51014f7f2b957b5e9a62c3f6ada439889249
url: "https://pub.dev"
source: hosted
version: "1.1.4"
version: "1.2.0"
web:
dependency: transitive
description:
@ -1101,6 +1245,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.3"
webkit_inspection_protocol:
dependency: transitive
description:
name: webkit_inspection_protocol
sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
win32:
dependency: transitive
description:
@ -1125,14 +1277,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.6.1"
xxh3:
dependency: transitive
description:
name: xxh3
sha256: "399a0438f5d426785723c99da6b16e136f4953fb1e9db0bf270bd41dd4619916"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
yaml:
dependency: transitive
description:

View file

@ -1,22 +1,27 @@
name: slrpg_app
description: Streetlifting RPG - Gamified Training App
publish_to: 'none'
publish_to: "none"
version: 1.0.0+1
environment:
sdk: '>=3.2.0 <4.0.0'
sdk: ">=3.2.0 <4.0.0"
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
audioplayers: ^6.0.0
flutter_dotenv: ^5.1.0
# State Management
flutter_riverpod: ^2.5.1
riverpod_annotation: ^2.3.5
flutter_riverpod: ^3.1.0
riverpod_annotation: ^4.0.0
# Local Database
isar: ^3.1.0+1
isar_flutter_libs: ^3.1.0+1
drift: ^2.16.0
drift_flutter: ^0.2.8
sqlite3_flutter_libs: ^0.5.20
path_provider: ^2.1.3
# Networking
@ -24,7 +29,7 @@ dependencies:
pretty_dio_logger: ^1.3.1
# Storage
flutter_secure_storage: ^9.0.0
flutter_secure_storage: ^10.0.0
shared_preferences: ^2.2.3
# UI Components
@ -36,7 +41,7 @@ dependencies:
# Utilities
intl: ^0.20.2
freezed_annotation: ^2.4.1
freezed_annotation: ^3.1.0
json_annotation: ^4.9.0
equatable: ^2.0.5
logger: ^2.3.0
@ -51,13 +56,14 @@ dev_dependencies:
# Code Generation
build_runner: ^2.4.9
riverpod_generator: ^2.4.0
isar_generator: ^3.1.0+1
freezed: ^2.5.2
riverpod_generator: ^4.0.0+1
drift_dev: ^2.16.0
freezed: ^3.2.3
json_serializable: ^6.8.0
flutter:
uses-material-design: true
generate: true
assets:
- assets/images/
@ -66,6 +72,10 @@ flutter:
- assets/images/plates/
- assets/images/enemies/
- assets/images/backgrounds/
- assets/audio/
- .env
- .env.development
- .env.production
# fonts:
# - family: PixelFont