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:
commit
246672b24d
76 changed files with 10341 additions and 8597 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -43,3 +43,7 @@ app.*.map.json
|
||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.production
|
||||||
|
.env.development
|
||||||
|
|
|
||||||
|
|
@ -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">-->
|
|
||||||
<!-- <!– 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. –>-->
|
|
||||||
<!-- <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>-->
|
|
||||||
<!-- <!– Don't delete the meta-data below.-->
|
|
||||||
<!-- This is used by the Flutter tool to generate GeneratedPluginRegistrant.java –>-->
|
|
||||||
<!-- <meta-data-->
|
|
||||||
<!-- android:name="flutterEmbedding"-->
|
|
||||||
<!-- android:value="2" />-->
|
|
||||||
<!-- </application>-->
|
|
||||||
<!-- <!– 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. –>-->
|
|
||||||
<!-- <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">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<application
|
<application
|
||||||
android:label="streetlifting_rpg"
|
android:label="streetlifting_rpg"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|
@ -70,4 +26,4 @@
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
BIN
assets/audio/beep_long.ogg
Normal file
BIN
assets/audio/beep_long.ogg
Normal file
Binary file not shown.
BIN
assets/audio/beep_short.ogg
Normal file
BIN
assets/audio/beep_short.ogg
Normal file
Binary file not shown.
BIN
assets/images/backgrounds/commercial_gym.png
Normal file
BIN
assets/images/backgrounds/commercial_gym.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
BIN
assets/images/backgrounds/olympic_gym.png
Normal file
BIN
assets/images/backgrounds/olympic_gym.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
BIN
assets/images/backgrounds/street_park_night.png
Normal file
BIN
assets/images/backgrounds/street_park_night.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
3
l10n.yaml
Normal file
3
l10n.yaml
Normal 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
310
lib/l10n/app_de.arb
Normal 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
310
lib/l10n/app_en.arb
Normal 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"
|
||||||
|
}
|
||||||
|
|
@ -1,35 +1,36 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/app.dart';
|
||||||
import 'src/shared/data/local/collections/user_collection.dart';
|
import 'src/shared/data/local/app_database.dart';
|
||||||
import 'src/shared/data/local/collections/cycle_collection.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
import 'src/shared/data/local/collections/workout_collection.dart';
|
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
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([
|
await SystemChrome.setPreferredOrientations([
|
||||||
DeviceOrientation.portraitUp,
|
DeviceOrientation.portraitUp,
|
||||||
DeviceOrientation.portraitDown,
|
DeviceOrientation.portraitDown,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
final database = AppDatabase();
|
||||||
final isar = await Isar.open(
|
|
||||||
[UserCollectionSchema, CycleCollectionSchema, WorkoutCollectionSchema],
|
|
||||||
directory: dir.path,
|
|
||||||
name: 'slrpg_db',
|
|
||||||
);
|
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
overrides: [isarProvider.overrideWithValue(isar)],
|
overrides: [appDatabaseProvider.overrideWithValue(database)],
|
||||||
child: const SLRPGApp(),
|
child: const SLRPGApp(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final isarProvider = Provider<Isar>((ref) => throw UnimplementedError());
|
final appDatabaseProvider =
|
||||||
|
Provider<AppDatabase>((ref) => throw UnimplementedError());
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:google_fonts/google_fonts.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/theme/app_theme.dart';
|
||||||
import 'core/routing/app_router.dart';
|
import 'core/routing/app_router.dart';
|
||||||
|
|
@ -17,7 +19,16 @@ class SLRPGApp extends ConsumerWidget {
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: AppTheme.darkTheme,
|
theme: AppTheme.darkTheme,
|
||||||
routerConfig: router,
|
routerConfig: router,
|
||||||
|
localizationsDelegates: const [
|
||||||
|
AppLocalizations.delegate,
|
||||||
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
GlobalCupertinoLocalizations.delegate,
|
||||||
|
],
|
||||||
|
supportedLocales: const [
|
||||||
|
Locale('en'),
|
||||||
|
Locale('de'),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,36 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
|
|
||||||
class AppConstants {
|
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
|
// API Configuration
|
||||||
static const String apiBaseUrl = 'http://10.0.2.2:8090'; // Android emulator
|
// static const String apiBaseUrl = 'http://10.0.2.2:8090'; // Android emulator
|
||||||
static const String apiVersion = 'v1';
|
// static const String apiBaseUrl = 'https://slift.patanix.de';
|
||||||
|
// static const String apiVersion = 'v1';
|
||||||
|
|
||||||
// Wendler 5/3/1 Constants
|
// Wendler 5/3/1 Constants
|
||||||
static const double trainingMaxPercentage = 0.9;
|
static const double trainingMaxPercentage = 0.9;
|
||||||
|
|
@ -12,14 +39,14 @@ class AppConstants {
|
||||||
|
|
||||||
// XP System
|
// XP System
|
||||||
static const int baseXP = 1000;
|
static const int baseXP = 1000;
|
||||||
static const double xpMultiplier = 1.15;
|
static const double xpMultiplier = 1.25;
|
||||||
static const int maxLevel = 100;
|
static const int maxLevel = 100;
|
||||||
|
|
||||||
// XP Rewards
|
// XP Rewards
|
||||||
static const int workoutCompleteXP = 100;
|
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 amrapBonusXPPerRep = 25;
|
||||||
static const int prBonusXP = 500;
|
static const int prBonusXP = 200;
|
||||||
static const int cycleCompleteXP = 500;
|
static const int cycleCompleteXP = 500;
|
||||||
|
|
||||||
// Rounding Steps
|
// Rounding Steps
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,9 @@ class AssetPaths {
|
||||||
static String getAvatarPath(String gender, int variant) {
|
static String getAvatarPath(String gender, int variant) {
|
||||||
return 'assets/images/avatars/$gender/$variant.png';
|
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 {
|
class PlateColors {
|
||||||
|
|
|
||||||
96
lib/src/core/debug/debug_config_screen.dart
Normal file
96
lib/src/core/debug/debug_config_screen.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
|
||||||
import '../../features/authentication/presentation/screens/login_screen.dart';
|
import '../../features/authentication/presentation/screens/login_screen.dart';
|
||||||
import '../../features/authentication/presentation/screens/profile_screen.dart';
|
import '../../features/authentication/presentation/screens/profile_screen.dart';
|
||||||
import '../../features/authentication/presentation/screens/register_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/avatar_setup_screen.dart';
|
||||||
import '../../features/onboarding/presentation/screens/welcome_screen.dart';
|
import '../../features/onboarding/presentation/screens/welcome_screen.dart';
|
||||||
import '../../features/onboarding/presentation/screens/bodyweight_input_screen.dart';
|
import '../../features/onboarding/presentation/screens/bodyweight_input_screen.dart';
|
||||||
|
|
@ -24,6 +25,22 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||||
|
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
initialLocation: '/splash',
|
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: [
|
routes: [
|
||||||
// Splash / Initial Route
|
// Splash / Initial Route
|
||||||
GoRoute(
|
GoRoute(
|
||||||
|
|
@ -113,6 +130,11 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||||
name: 'codex',
|
name: 'codex',
|
||||||
builder: (context, state) => const CodexScreen(),
|
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(
|
Positioned.fill(
|
||||||
child: Container(
|
child: Container(
|
||||||
color: Colors.black.withOpacity(0.5),
|
color: Colors.black.withValues(alpha: 0.5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Center(
|
Center(
|
||||||
|
|
@ -170,11 +192,11 @@ class _SplashScreenState extends ConsumerState<SplashScreen> {
|
||||||
width: 120,
|
width: 120,
|
||||||
height: 120,
|
height: 120,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF00E5FF).withOpacity(0.9),
|
color: const Color(0xFF00E5FF).withValues(alpha: 0.9),
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: const Color(0xFF00E5FF).withOpacity(0.6),
|
color: const Color(0xFF00E5FF).withValues(alpha: 0.6),
|
||||||
blurRadius: 20,
|
blurRadius: 20,
|
||||||
spreadRadius: 5,
|
spreadRadius: 5,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ class AppTheme {
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
side: BorderSide(
|
side: BorderSide(
|
||||||
color: primaryColor.withOpacity(0.3),
|
color: primaryColor.withValues(alpha: 0.3),
|
||||||
width: 1,
|
width: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -97,11 +97,11 @@ class AppTheme {
|
||||||
fillColor: surfaceColor,
|
fillColor: surfaceColor,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: BorderSide(color: primaryColor.withOpacity(0.5)),
|
borderSide: BorderSide(color: primaryColor.withValues(alpha: 0.5)),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: BorderSide(color: primaryColor.withOpacity(0.3)),
|
borderSide: BorderSide(color: primaryColor.withValues(alpha: 0.3)),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:slrpg_app/l10n/app_localizations.dart';
|
||||||
|
|
||||||
import '../../../../shared/data/repositories/user_repository.dart';
|
import '../../../../shared/data/repositories/user_repository.dart';
|
||||||
import '../../../../core/theme/app_theme.dart';
|
import '../../../../core/theme/app_theme.dart';
|
||||||
|
|
@ -16,18 +17,30 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
final _emailController = TextEditingController();
|
final _emailController = TextEditingController();
|
||||||
final _passwordController = TextEditingController();
|
final _passwordController = TextEditingController();
|
||||||
|
final _emailFocusNode = FocusNode();
|
||||||
|
final _passwordFocusNode = FocusNode();
|
||||||
|
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
bool _obscurePassword = true;
|
bool _obscurePassword = true;
|
||||||
|
String? _errorMessage;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_emailController.dispose();
|
_emailController.dispose();
|
||||||
_passwordController.dispose();
|
_passwordController.dispose();
|
||||||
|
_emailFocusNode.dispose();
|
||||||
|
_passwordFocusNode.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleLogin() async {
|
Future<void> _handleLogin() async {
|
||||||
if (!_formKey.currentState!.validate()) return;
|
FocusScope.of(context).unfocus();
|
||||||
|
|
||||||
|
setState(() => _errorMessage = null);
|
||||||
|
|
||||||
|
if (!_formKey.currentState!.validate()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
|
|
@ -43,141 +56,236 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
setState(() {
|
||||||
SnackBar(
|
_isLoading = false;
|
||||||
content: Text('Login failed: ${e.toString()}'),
|
_errorMessage = _parseErrorMessage(e.toString());
|
||||||
backgroundColor: AppTheme.errorColor,
|
});
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() => _isLoading = false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: SafeArea(
|
body: GestureDetector(
|
||||||
child: Center(
|
onTap: () => FocusScope.of(context).unfocus(),
|
||||||
child: SingleChildScrollView(
|
child: SafeArea(
|
||||||
padding: const EdgeInsets.all(24),
|
child: LayoutBuilder(
|
||||||
child: Form(
|
builder: (context, constraints) {
|
||||||
key: _formKey,
|
return SingleChildScrollView(
|
||||||
child: Column(
|
physics: const ClampingScrollPhysics(),
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
child: ConstrainedBox(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
constraints: BoxConstraints(
|
||||||
children: [
|
minHeight: constraints.maxHeight,
|
||||||
// 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,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
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(
|
Container(
|
||||||
'WELCOME BACK',
|
width: 100,
|
||||||
style: Theme.of(context).textTheme.displayMedium,
|
height: 100,
|
||||||
textAlign: TextAlign.center,
|
decoration: BoxDecoration(
|
||||||
),
|
color: AppTheme.primaryColor,
|
||||||
const SizedBox(height: 8),
|
borderRadius: BorderRadius.circular(20),
|
||||||
Text(
|
boxShadow: [
|
||||||
'Time to level up your strength',
|
BoxShadow(
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
color: AppTheme.primaryColor
|
||||||
textAlign: TextAlign.center,
|
.withValues(alpha: 0.4),
|
||||||
),
|
blurRadius: 20,
|
||||||
const SizedBox(height: 48),
|
spreadRadius: 2,
|
||||||
|
),
|
||||||
TextFormField(
|
],
|
||||||
controller: _emailController,
|
),
|
||||||
keyboardType: TextInputType.emailAddress,
|
child: const Icon(
|
||||||
decoration: const InputDecoration(
|
Icons.fitness_center,
|
||||||
labelText: 'Email',
|
size: 56,
|
||||||
prefixIcon: Icon(Icons.email_outlined),
|
color: Colors.black,
|
||||||
),
|
),
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
)
|
const SizedBox(height: 32),
|
||||||
: const Text('LOGIN'),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
Row(
|
Text(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
l10n.loginWelcomeBack,
|
||||||
children: [
|
style: Theme.of(context).textTheme.displayMedium,
|
||||||
Text(
|
textAlign: TextAlign.center,
|
||||||
"Don't have an account? ",
|
),
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
|
import 'package:drift/drift.dart' hide Column;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../../../core/theme/app_theme.dart';
|
import '../../../../core/theme/app_theme.dart';
|
||||||
import '../../../../shared/data/repositories/user_repository.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/domain/entities/avatar_config.dart';
|
||||||
import '../../../gamification/presentation/widgets/avatar_editor.dart';
|
import '../../../gamification/presentation/widgets/avatar_editor.dart';
|
||||||
import '../../../gamification/presentation/widgets/avatar_renderer.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 {
|
class ProfileScreen extends ConsumerStatefulWidget {
|
||||||
const ProfileScreen({super.key});
|
const ProfileScreen({super.key});
|
||||||
|
|
@ -46,6 +48,11 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||||
await ref
|
await ref
|
||||||
.read(userRepositoryProvider)
|
.read(userRepositoryProvider)
|
||||||
.updateBodyweight(_currentBodyweight);
|
.updateBodyweight(_currentBodyweight);
|
||||||
|
|
||||||
|
if (_user != null) {
|
||||||
|
_user = _user!.copyWith(currentBodyweight: _currentBodyweight);
|
||||||
|
}
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _hasChanges = false);
|
setState(() => _hasChanges = false);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
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(
|
void _confirmDangerAction(
|
||||||
String title, String content, VoidCallback onConfirm) {
|
String title, String content, VoidCallback onConfirm) {
|
||||||
showDialog(
|
showDialog(
|
||||||
|
|
@ -163,8 +303,8 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showAvatarEditor() {
|
void _showAvatarEditor() {
|
||||||
final currentConfig = _user?.avatarConfigJson != null
|
final currentConfig = _user?.avatarConfig != null
|
||||||
? AvatarConfig.fromJson(jsonDecode(_user!.avatarConfigJson!))
|
? AvatarConfig.fromJson(_user!.avatarConfig!)
|
||||||
: const AvatarConfig();
|
: const AvatarConfig();
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
|
|
@ -199,19 +339,70 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||||
).then((result) async {
|
).then((result) async {
|
||||||
if (result is AvatarConfig) {
|
if (result is AvatarConfig) {
|
||||||
setState(() => _isLoading = true);
|
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!);
|
await ref.read(userRepositoryProvider).saveLocalUser(_user!);
|
||||||
setState(() => _isLoading = false);
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final userRepo = ref.watch(userRepositoryProvider);
|
final userRepo = ref.watch(userRepositoryProvider);
|
||||||
final avatarConfig = _user?.avatarConfigJson != null
|
final avatarConfig = _user?.avatarConfig != null
|
||||||
? AvatarConfig.fromJson(jsonDecode(_user!.avatarConfigJson!))
|
? AvatarConfig.fromJson(_user!.avatarConfig!)
|
||||||
: const AvatarConfig();
|
: const AvatarConfig();
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|
@ -234,172 +425,292 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||||
),
|
),
|
||||||
body: _isLoading
|
body: _isLoading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: ListView(
|
: SafeArea(
|
||||||
padding: const EdgeInsets.all(16),
|
child: ListView(
|
||||||
children: [
|
padding: const EdgeInsets.all(16),
|
||||||
Center(
|
children: [
|
||||||
child: Stack(
|
Center(
|
||||||
children: [
|
child: Stack(
|
||||||
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,
|
|
||||||
children: [
|
children: [
|
||||||
Text('Current Bodyweight',
|
AvatarRenderer(
|
||||||
style: Theme.of(context).textTheme.bodyMedium),
|
config: avatarConfig,
|
||||||
Row(
|
size: 120,
|
||||||
children: [
|
),
|
||||||
Expanded(
|
Positioned(
|
||||||
child: Slider(
|
bottom: 0,
|
||||||
value: _currentBodyweight,
|
right: 0,
|
||||||
min: 40,
|
child: CircleAvatar(
|
||||||
max: 150,
|
backgroundColor: AppTheme.surfaceColor,
|
||||||
divisions: 220, // 0.5 steps
|
radius: 18,
|
||||||
label: _currentBodyweight.toStringAsFixed(1),
|
child: IconButton(
|
||||||
activeColor: AppTheme.primaryColor,
|
icon: const Icon(Icons.edit, size: 16),
|
||||||
onChanged: (val) => setState(() {
|
onPressed: _showAvatarEditor,
|
||||||
_currentBodyweight = val;
|
|
||||||
_hasChanges = true;
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Text(
|
),
|
||||||
'${_currentBodyweight.toStringAsFixed(1)} kg',
|
|
||||||
style: Theme.of(context)
|
|
||||||
.textTheme
|
|
||||||
.titleMedium
|
|
||||||
?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: AppTheme.primaryColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 32),
|
||||||
const SizedBox(height: 32),
|
Center(
|
||||||
Text('Account Security',
|
child: OutlinedButton.icon(
|
||||||
style: Theme.of(context)
|
onPressed: _showBackgroundSelector,
|
||||||
.textTheme
|
icon: const Icon(Icons.landscape),
|
||||||
.titleLarge
|
label: const Text('CHANGE SCENERY'),
|
||||||
?.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),
|
|
||||||
),
|
),
|
||||||
child: Column(
|
const SizedBox(height: 32),
|
||||||
children: [
|
Text('Physical Stats',
|
||||||
ListTile(
|
style: Theme.of(context)
|
||||||
leading: const Icon(Icons.refresh,
|
.textTheme
|
||||||
color: AppTheme.errorColor),
|
.titleLarge
|
||||||
title: const Text('Reset Progress',
|
?.copyWith(color: AppTheme.textPrimary)),
|
||||||
style: TextStyle(color: AppTheme.errorColor)),
|
const SizedBox(height: 16),
|
||||||
subtitle:
|
Card(
|
||||||
const Text('Resets Level, XP and Training History'),
|
child: Padding(
|
||||||
onTap: () => _confirmDangerAction(
|
padding: const EdgeInsets.all(16),
|
||||||
'Reset Progress?',
|
child: Column(
|
||||||
'This will delete all your workouts and reset your Level to 1. This cannot be undone.',
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
() async {
|
children: [
|
||||||
setState(() => _isLoading = true);
|
Text('Current Bodyweight',
|
||||||
await userRepo.resetProgress();
|
style: Theme.of(context).textTheme.bodyMedium),
|
||||||
if (mounted) {
|
Row(
|
||||||
setState(() => _isLoading = false);
|
children: [
|
||||||
context.go('/hub');
|
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,
|
const SizedBox(height: 32),
|
||||||
color: AppTheme.errorColor),
|
Text('Training Focus',
|
||||||
title: const Text('Delete Account',
|
style: Theme.of(context)
|
||||||
style: TextStyle(color: AppTheme.errorColor)),
|
.textTheme
|
||||||
subtitle: const Text(
|
.titleLarge
|
||||||
'Permanently delete your account and data'),
|
?.copyWith(color: AppTheme.textPrimary)),
|
||||||
onTap: () => _confirmDangerAction(
|
const SizedBox(height: 16),
|
||||||
'Delete Account?',
|
Card(
|
||||||
'Are you sure you want to delete your account? All data will be lost forever.',
|
child: Padding(
|
||||||
() async {
|
padding: const EdgeInsets.all(16),
|
||||||
setState(() => _isLoading = true);
|
child: Column(
|
||||||
try {
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
await userRepo.deleteAccount();
|
children: [
|
||||||
if (mounted) context.go('/login');
|
Text('Accessory Template',
|
||||||
} catch (e) {
|
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) {
|
if (mounted) {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
context.go('/hub');
|
||||||
SnackBar(content: Text('Error: $e')),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
),
|
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),
|
||||||
const SizedBox(height: 32),
|
OutlinedButton.icon(
|
||||||
OutlinedButton.icon(
|
onPressed: () async {
|
||||||
onPressed: () async {
|
await userRepo.logout();
|
||||||
await userRepo.logout();
|
if (mounted) context.go('/login');
|
||||||
if (mounted) context.go('/login');
|
},
|
||||||
},
|
icon: const Icon(Icons.logout),
|
||||||
icon: const Icon(Icons.logout),
|
label: const Text('LOGOUT'),
|
||||||
label: const Text('LOGOUT'),
|
style: OutlinedButton.styleFrom(
|
||||||
style: OutlinedButton.styleFrom(
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:slrpg_app/l10n/app_localizations.dart';
|
||||||
|
|
||||||
import '../../../../core/theme/app_theme.dart';
|
import '../../../../core/theme/app_theme.dart';
|
||||||
import '../../../../core/constants/app_constants.dart';
|
|
||||||
import '../../../onboarding/presentation/screens/bodyweight_input_screen.dart';
|
import '../../../onboarding/presentation/screens/bodyweight_input_screen.dart';
|
||||||
|
|
||||||
class RegisterScreen extends ConsumerStatefulWidget {
|
class RegisterScreen extends ConsumerStatefulWidget {
|
||||||
|
|
@ -16,32 +16,32 @@ class RegisterScreen extends ConsumerStatefulWidget {
|
||||||
class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
final _emailController = TextEditingController();
|
final _emailController = TextEditingController();
|
||||||
final _passwordController = TextEditingController();
|
final _emailFocusNode = FocusNode();
|
||||||
final _confirmPasswordController = TextEditingController();
|
|
||||||
bool _obscurePassword = true;
|
|
||||||
bool _obscureConfirmPassword = true;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_emailController.dispose();
|
_emailController.dispose();
|
||||||
_passwordController.dispose();
|
_emailFocusNode.dispose();
|
||||||
_confirmPasswordController.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleRegister() {
|
void _handleRegister() {
|
||||||
if (!_formKey.currentState!.validate()) return;
|
FocusScope.of(context).unfocus();
|
||||||
|
|
||||||
ref.read(onboardingDataProvider.notifier).update((state) => {
|
if (!_formKey.currentState!.validate()) {
|
||||||
'email': _emailController.text.trim(),
|
return;
|
||||||
'password': _passwordController.text,
|
}
|
||||||
});
|
|
||||||
|
ref.read(onboardingDataProvider.notifier).updateData({
|
||||||
|
'email': _emailController.text.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
context.go('/onboarding/welcome');
|
context.go('/onboarding/welcome');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
|
|
@ -49,120 +49,98 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||||
onPressed: () => context.go('/login'),
|
onPressed: () => context.go('/login'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: GestureDetector(
|
||||||
child: Center(
|
onTap: () => FocusScope.of(context).unfocus(),
|
||||||
child: SingleChildScrollView(
|
child: SafeArea(
|
||||||
padding: const EdgeInsets.all(24),
|
child: LayoutBuilder(
|
||||||
child: Form(
|
builder: (context, constraints) {
|
||||||
key: _formKey,
|
return SingleChildScrollView(
|
||||||
child: Column(
|
physics: const ClampingScrollPhysics(),
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
child: ConstrainedBox(
|
||||||
children: [
|
constraints: BoxConstraints(
|
||||||
Text(
|
minHeight: constraints.maxHeight,
|
||||||
'CREATE ACCOUNT',
|
|
||||||
style: Theme.of(context).textTheme.displayMedium,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
child: IntrinsicHeight(
|
||||||
Text(
|
child: Padding(
|
||||||
'Begin your strength journey',
|
padding: const EdgeInsets.all(24),
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
child: Form(
|
||||||
textAlign: TextAlign.center,
|
key: _formKey,
|
||||||
),
|
child: Column(
|
||||||
const SizedBox(height: 48),
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
TextFormField(
|
children: [
|
||||||
controller: _emailController,
|
const Spacer(),
|
||||||
keyboardType: TextInputType.emailAddress,
|
Text(
|
||||||
decoration: const InputDecoration(
|
l10n.registerTitle,
|
||||||
labelText: 'Email',
|
style: Theme.of(context).textTheme.displayMedium,
|
||||||
prefixIcon: Icon(Icons.email_outlined),
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
validator: (value) {
|
const SizedBox(height: 8),
|
||||||
if (value == null || value.isEmpty) {
|
Text(
|
||||||
return 'Please enter your email';
|
l10n.registerSubtitle,
|
||||||
}
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
if (!value.contains('@')) {
|
textAlign: TextAlign.center,
|
||||||
return 'Please enter a valid email';
|
),
|
||||||
}
|
const SizedBox(height: 48),
|
||||||
return null;
|
TextFormField(
|
||||||
},
|
controller: _emailController,
|
||||||
),
|
focusNode: _emailFocusNode,
|
||||||
const SizedBox(height: 16),
|
keyboardType: TextInputType.emailAddress,
|
||||||
TextFormField(
|
textInputAction: TextInputAction.next,
|
||||||
controller: _passwordController,
|
decoration: InputDecoration(
|
||||||
obscureText: _obscurePassword,
|
labelText: l10n.emailLabel,
|
||||||
decoration: InputDecoration(
|
prefixIcon: Icon(Icons.email_outlined),
|
||||||
labelText: 'Password',
|
helperText: l10n.registerEmailHelper,
|
||||||
prefixIcon: const Icon(Icons.lock_outline),
|
),
|
||||||
suffixIcon: IconButton(
|
onFieldSubmitted: (_) {},
|
||||||
icon: Icon(
|
validator: (value) {
|
||||||
_obscurePassword
|
if (value == null || value.isEmpty) {
|
||||||
? Icons.visibility_outlined
|
return l10n.emailEmptyError;
|
||||||
: Icons.visibility_off_outlined,
|
}
|
||||||
|
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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,29 @@
|
||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:slrpg_app/l10n/app_localizations.dart';
|
||||||
|
|
||||||
import '../../../../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 '../../../../core/theme/app_theme.dart';
|
||||||
|
import '../../../../shared/data/local/app_database.dart';
|
||||||
import '../../../../shared/data/repositories/user_repository.dart';
|
import '../../../../shared/data/repositories/user_repository.dart';
|
||||||
import '../../../../shared/data/repositories/cycle_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/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/xp_bar_widget.dart';
|
||||||
import '../widgets/level_display.dart';
|
import '../widgets/level_display.dart';
|
||||||
import '../widgets/start_raid_button.dart';
|
import '../widgets/start_raid_button.dart';
|
||||||
import '../../../../shared/data/local/collections/user_collection.dart';
|
import '../../../gamification/application/quest_service.dart';
|
||||||
import '../../../../shared/data/local/collections/cycle_collection.dart';
|
import '../../../workout_runner/application/workout_generator_service.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';
|
|
||||||
|
|
||||||
class HubScreen extends ConsumerStatefulWidget {
|
class HubScreen extends ConsumerStatefulWidget {
|
||||||
const HubScreen({super.key});
|
const HubScreen({super.key});
|
||||||
|
|
@ -41,70 +44,21 @@ class _HubScreenState extends ConsumerState<HubScreen> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
await _runSync();
|
||||||
_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(
|
Future<void> _startNextWorkout(
|
||||||
CycleCollection cycle, UserCollection user) async {
|
CycleCollection cycle, UserCollection user) async {
|
||||||
try {
|
try {
|
||||||
final workoutRepo = ref.read(workoutRepositoryProvider);
|
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 targetWeek = 1;
|
||||||
int targetDay = 1;
|
int targetDay = 1;
|
||||||
|
|
@ -137,9 +91,6 @@ class _HubScreenState extends ConsumerState<HubScreen> {
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final trainingMaxes = cycleRepo.getCurrentTrainingMaxes();
|
|
||||||
|
|
||||||
var workout = await workoutRepo.getWorkoutByWeekDay(
|
var workout = await workoutRepo.getWorkoutByWeekDay(
|
||||||
cycleId: cycleRefId,
|
cycleId: cycleRefId,
|
||||||
localCycleId: localCycleId,
|
localCycleId: localCycleId,
|
||||||
|
|
@ -148,11 +99,21 @@ class _HubScreenState extends ConsumerState<HubScreen> {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (workout == null) {
|
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,
|
week: targetWeek,
|
||||||
day: targetDay,
|
day: targetDay,
|
||||||
trainingMaxes: trainingMaxes,
|
trainingMaxes: trainingMaxes,
|
||||||
bodyweight: user.currentBodyweight,
|
user: user,
|
||||||
|
template: activeTemplate,
|
||||||
|
conditioningSets: conditioningSets,
|
||||||
);
|
);
|
||||||
|
|
||||||
final userId = user.serverId ?? user.id.toString();
|
final userId = user.serverId ?? user.id.toString();
|
||||||
|
|
@ -162,7 +123,7 @@ class _HubScreenState extends ConsumerState<HubScreen> {
|
||||||
cycleId: cycleRefId,
|
cycleId: cycleRefId,
|
||||||
week: targetWeek,
|
week: targetWeek,
|
||||||
day: targetDay,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final userRepo = ref.watch(userRepositoryProvider);
|
final userRepo = ref.watch(userRepositoryProvider);
|
||||||
final cycleRepo = ref.watch(cycleRepositoryProvider);
|
final cycleRepo = ref.watch(cycleRepositoryProvider);
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
|
|
@ -202,9 +247,12 @@ class _HubScreenState extends ConsumerState<HubScreen> {
|
||||||
|
|
||||||
final user = snapshot.data![0] as UserCollection?;
|
final user = snapshot.data![0] as UserCollection?;
|
||||||
final cycle = snapshot.data![1] as CycleCollection?;
|
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();
|
: const AvatarConfig();
|
||||||
|
final bgItem =
|
||||||
|
ItemCatalog.getBackground(avatarConfig.selectedBackground);
|
||||||
|
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
|
@ -223,8 +271,9 @@ class _HubScreenState extends ConsumerState<HubScreen> {
|
||||||
children: [
|
children: [
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: Image.asset(
|
child: Image.asset(
|
||||||
AssetPaths.bgStreetParkDay,
|
bgItem.assetPath,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
key: ValueKey(bgItem.assetPath),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
|
|
@ -234,8 +283,8 @@ class _HubScreenState extends ConsumerState<HubScreen> {
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [
|
colors: [
|
||||||
Colors.black.withOpacity(0.6),
|
Colors.black.withValues(alpha: 0.6),
|
||||||
Colors.black.withOpacity(0.85),
|
Colors.black.withValues(alpha: 0.85),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -248,6 +297,17 @@ class _HubScreenState extends ConsumerState<HubScreen> {
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
|
if (AppConstants.isDevelopment)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.settings),
|
||||||
|
onPressed: () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) =>
|
||||||
|
const DebugConfigScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.person_outline),
|
icon: const Icon(Icons.person_outline),
|
||||||
onPressed: () => context.go('/profile'),
|
onPressed: () => context.go('/profile'),
|
||||||
|
|
@ -289,6 +349,8 @@ class _HubScreenState extends ConsumerState<HubScreen> {
|
||||||
nextLevelXP: nextLevelXP,
|
nextLevelXP: nextLevelXP,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const QuestBoardWidget(),
|
||||||
const Spacer(flex: 2),
|
const Spacer(flex: 2),
|
||||||
if (cycle != null)
|
if (cycle != null)
|
||||||
Padding(
|
Padding(
|
||||||
|
|
@ -303,7 +365,7 @@ class _HubScreenState extends ConsumerState<HubScreen> {
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'No active cycle',
|
l10n.hubNoActiveCycle,
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
@ -311,7 +373,7 @@ class _HubScreenState extends ConsumerState<HubScreen> {
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
context.push('/onboarding/strength-test');
|
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,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: [
|
children: [
|
||||||
_StatBox(
|
_StatBox(
|
||||||
label: 'Cycle', value: '#${cycle.cycleNumber}'),
|
label: l10n.hubCycleLabel,
|
||||||
_StatBox(label: 'Active', value: 'Yes'),
|
value: '#${cycle.cycleNumber}'),
|
||||||
|
_StatBox(
|
||||||
|
label: l10n.hubActiveLabel,
|
||||||
|
value: l10n.hubActiveYes),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -339,7 +404,7 @@ class _HubScreenState extends ConsumerState<HubScreen> {
|
||||||
top: Radius.circular(24)),
|
top: Radius.circular(24)),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withOpacity(0.2),
|
color: Colors.black.withValues(alpha: 0.2),
|
||||||
blurRadius: 10,
|
blurRadius: 10,
|
||||||
offset: const Offset(0, -5),
|
offset: const Offset(0, -5),
|
||||||
),
|
),
|
||||||
|
|
@ -350,24 +415,24 @@ class _HubScreenState extends ConsumerState<HubScreen> {
|
||||||
children: [
|
children: [
|
||||||
_NavButton(
|
_NavButton(
|
||||||
icon: Icons.history,
|
icon: Icons.history,
|
||||||
label: 'History',
|
label: l10n.navHistory,
|
||||||
onTap: () => context.go('/history'),
|
onTap: () => context.go('/history'),
|
||||||
),
|
),
|
||||||
_NavButton(
|
_NavButton(
|
||||||
icon: Icons.inventory_2_outlined,
|
icon: Icons.inventory_2_outlined,
|
||||||
label: 'Inventory',
|
label: l10n.navInventory,
|
||||||
onTap: () => context.go('/inventory'),
|
onTap: () => context.go('/inventory'),
|
||||||
),
|
),
|
||||||
_NavButton(
|
_NavButton(
|
||||||
icon: Icons.bar_chart,
|
icon: Icons.bar_chart,
|
||||||
label: 'Stats',
|
label: l10n.navStats,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
context.go('/stats');
|
context.go('/stats');
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
_NavButton(
|
_NavButton(
|
||||||
icon: Icons.auto_stories,
|
icon: Icons.auto_stories,
|
||||||
label: 'Codex',
|
label: l10n.navCodex,
|
||||||
onTap: () => context.go('/codex'),
|
onTap: () => context.go('/codex'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -401,7 +466,7 @@ class _StatBox extends StatelessWidget {
|
||||||
color: AppTheme.surfaceColor,
|
color: AppTheme.surfaceColor,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: AppTheme.primaryColor.withOpacity(0.3),
|
color: AppTheme.primaryColor.withValues(alpha: 0.3),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ class LevelDisplay extends StatelessWidget {
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: AppTheme.primaryColor.withOpacity(0.5),
|
color: AppTheme.primaryColor.withValues(alpha: 0.5),
|
||||||
blurRadius: 12,
|
blurRadius: 12,
|
||||||
spreadRadius: 2,
|
spreadRadius: 2,
|
||||||
),
|
),
|
||||||
|
|
@ -57,4 +57,3 @@ class LevelDisplay extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,8 @@ class _StartRaidButtonState extends State<StartRaidButton>
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: AppTheme.primaryColor.withOpacity(_glowAnimation.value),
|
color: AppTheme.primaryColor
|
||||||
|
.withValues(alpha: _glowAnimation.value),
|
||||||
blurRadius: 20,
|
blurRadius: 20,
|
||||||
spreadRadius: 5,
|
spreadRadius: 5,
|
||||||
),
|
),
|
||||||
|
|
@ -96,4 +97,3 @@ class _StartRaidButtonState extends State<StartRaidButton>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ class XPBarWidget extends StatelessWidget {
|
||||||
color: AppTheme.xpBarBackground,
|
color: AppTheme.xpBarBackground,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: AppTheme.primaryColor.withOpacity(0.3),
|
color: AppTheme.primaryColor.withValues(alpha: 0.3),
|
||||||
width: 2,
|
width: 2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -64,13 +64,13 @@ class XPBarWidget extends StatelessWidget {
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
colors: [
|
colors: [
|
||||||
AppTheme.primaryColor,
|
AppTheme.primaryColor,
|
||||||
AppTheme.primaryColor.withOpacity(0.7),
|
AppTheme.primaryColor.withValues(alpha: 0.7),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: AppTheme.primaryColor.withOpacity(0.5),
|
color: AppTheme.primaryColor.withValues(alpha: 0.5),
|
||||||
blurRadius: 8,
|
blurRadius: 8,
|
||||||
spreadRadius: 1,
|
spreadRadius: 1,
|
||||||
),
|
),
|
||||||
|
|
@ -86,15 +86,15 @@ class XPBarWidget extends StatelessWidget {
|
||||||
child: Text(
|
child: Text(
|
||||||
'${(progress * 100).toStringAsFixed(0)}%',
|
'${(progress * 100).toStringAsFixed(0)}%',
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
shadows: [
|
shadows: [
|
||||||
const Shadow(
|
const Shadow(
|
||||||
color: Colors.black,
|
color: Colors.black,
|
||||||
blurRadius: 4,
|
blurRadius: 4,
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -103,4 +103,3 @@ class XPBarWidget extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
179
lib/src/features/gamification/application/quest_service.dart
Normal file
179
lib/src/features/gamification/application/quest_service.dart
Normal 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),
|
||||||
|
];
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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';
|
import '../../../../core/constants/asset_paths.dart';
|
||||||
|
|
||||||
class AvatarConfig {
|
class AvatarConfig {
|
||||||
final String gender; // 'male' or 'female'
|
final String gender;
|
||||||
final int variant; // 1 to 8
|
final int variant;
|
||||||
|
final String selectedBackground; // NEU
|
||||||
|
|
||||||
const AvatarConfig({
|
const AvatarConfig({
|
||||||
this.gender = 'male',
|
this.gender = 'male',
|
||||||
this.variant = 1,
|
this.variant = 1,
|
||||||
|
this.selectedBackground = 'bg_street_day', // Default
|
||||||
});
|
});
|
||||||
|
|
||||||
factory AvatarConfig.fromJson(Map<String, dynamic> json) {
|
factory AvatarConfig.fromJson(Map<String, dynamic> json) {
|
||||||
return AvatarConfig(
|
return AvatarConfig(
|
||||||
gender: json['gender'] ?? 'male',
|
gender: json['gender'] ?? 'male',
|
||||||
variant: json['variant'] ?? 1,
|
variant: json['variant'] ?? 1,
|
||||||
|
selectedBackground: json['selected_background'] ?? 'bg_street_day', // NEU
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -20,6 +52,7 @@ class AvatarConfig {
|
||||||
return {
|
return {
|
||||||
'gender': gender,
|
'gender': gender,
|
||||||
'variant': variant,
|
'variant': variant,
|
||||||
|
'selected_background': selectedBackground, // NEU
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:slrpg_app/l10n/app_localizations.dart';
|
||||||
|
|
||||||
import '../../../../core/theme/app_theme.dart';
|
import '../../../../core/theme/app_theme.dart';
|
||||||
import '../../../../core/constants/asset_paths.dart';
|
import '../../../../core/constants/asset_paths.dart';
|
||||||
|
|
||||||
|
|
@ -8,9 +10,11 @@ class CodexScreen extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Creature Codex'),
|
title: Text(l10n.codexTitle),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
onPressed: () => context.go('/hub'),
|
onPressed: () => context.go('/hub'),
|
||||||
|
|
@ -18,40 +22,31 @@ class CodexScreen extends StatelessWidget {
|
||||||
),
|
),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: const [
|
children: [
|
||||||
_LoreCard(
|
_LoreCard(
|
||||||
name: 'Iron Golem',
|
name: l10n.enemyIronGolemName,
|
||||||
title: 'The Weight of the Earth',
|
title: l10n.enemyIronGolemTitle,
|
||||||
description:
|
description: l10n.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\n'
|
|
||||||
'It respects only one thing: The raw power of the LEGS that can stand up against its crushing weight.',
|
|
||||||
assetPath: AssetPaths.enemyIronGolem,
|
assetPath: AssetPaths.enemyIronGolem,
|
||||||
exercise: 'Squat Nemesis',
|
exercise: l10n.enemyIronGolemNemesis,
|
||||||
color: Colors.redAccent,
|
color: Colors.redAccent,
|
||||||
),
|
),
|
||||||
SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
_LoreCard(
|
_LoreCard(
|
||||||
name: 'Gravity Demon',
|
name: l10n.enemyGravityDemonName,
|
||||||
title: 'The Abyssal Pull',
|
title: l10n.enemyGravityDemonTitle,
|
||||||
description:
|
description: l10n.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\n'
|
|
||||||
'Only those with a back of steel and the will to pull themselves up can escape its grasp.',
|
|
||||||
assetPath: AssetPaths.enemyGravityDemon,
|
assetPath: AssetPaths.enemyGravityDemon,
|
||||||
exercise: 'Pull-up Nemesis',
|
exercise: l10n.enemyGravityDemonNemesis,
|
||||||
color: Colors.purpleAccent,
|
color: Colors.purpleAccent,
|
||||||
),
|
),
|
||||||
SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
_LoreCard(
|
_LoreCard(
|
||||||
name: 'Pressure Phantom',
|
name: l10n.enemyPressurePhantomName,
|
||||||
title: 'The Invisible Crusher',
|
title: l10n.enemyPressurePhantomTitle,
|
||||||
description:
|
description: l10n.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\n'
|
|
||||||
'Defeat it by pushing through the pain with explosive dipping power.',
|
|
||||||
assetPath: AssetPaths.enemyPressurePhantom,
|
assetPath: AssetPaths.enemyPressurePhantom,
|
||||||
exercise: 'Dip Nemesis',
|
exercise: l10n.enemyPressurePhantomNemesis,
|
||||||
color: Colors.cyanAccent,
|
color: Colors.cyanAccent,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -83,14 +78,14 @@ class _LoreCard extends StatelessWidget {
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
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),
|
borderRadius: BorderRadius.circular(16),
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [
|
colors: [
|
||||||
AppTheme.surfaceColor,
|
AppTheme.surfaceColor,
|
||||||
color.withOpacity(0.1),
|
color.withValues(alpha: 0.1),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -112,7 +107,7 @@ class _LoreCard extends StatelessWidget {
|
||||||
child: Image.asset(
|
child: Image.asset(
|
||||||
assetPath,
|
assetPath,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
color: Colors.white.withOpacity(0.9),
|
color: Colors.white.withValues(alpha: 0.9),
|
||||||
colorBlendMode: BlendMode.modulate,
|
colorBlendMode: BlendMode.modulate,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,7 +20,7 @@ class AvatarRenderer extends StatelessWidget {
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withOpacity(0.3),
|
color: Colors.black.withValues(alpha: 0.3),
|
||||||
blurRadius: 10,
|
blurRadius: 10,
|
||||||
spreadRadius: 2,
|
spreadRadius: 2,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
import 'dart:convert';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:slrpg_app/l10n/app_localizations.dart';
|
||||||
|
|
||||||
import '../../../../core/theme/app_theme.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/user_repository.dart';
|
||||||
import '../../../../shared/data/repositories/workout_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/exercise.dart';
|
||||||
import '../../../../shared/domain/entities/workout_set.dart';
|
|
||||||
|
|
||||||
class HistoryScreen extends ConsumerStatefulWidget {
|
class HistoryScreen extends ConsumerStatefulWidget {
|
||||||
const HistoryScreen({super.key});
|
const HistoryScreen({super.key});
|
||||||
|
|
@ -27,16 +26,15 @@ class _HistoryScreenState extends ConsumerState<HistoryScreen> {
|
||||||
if (user == null) return [];
|
if (user == null) return [];
|
||||||
|
|
||||||
final userId = user.serverId ?? user.id.toString();
|
final userId = user.serverId ?? user.id.toString();
|
||||||
return workoutRepo.getCompletedWorkouts(userId); // ID übergeben
|
return workoutRepo.getCompletedWorkouts(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final workoutRepo = ref.watch(workoutRepositoryProvider);
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Quest Log'),
|
title: Text(l10n.historyTitle),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
onPressed: () => context.go('/hub'),
|
onPressed: () => context.go('/hub'),
|
||||||
|
|
@ -57,16 +55,16 @@ class _HistoryScreenState extends ConsumerState<HistoryScreen> {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.history_edu,
|
Icons.history_edu,
|
||||||
size: 80,
|
size: 80,
|
||||||
color: AppTheme.primaryColor.withOpacity(0.5),
|
color: AppTheme.primaryColor.withValues(alpha: 0.5),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'No completed quests yet',
|
l10n.historyEmptyTitle,
|
||||||
style: Theme.of(context).textTheme.headlineMedium,
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Complete a workout to fill your journal',
|
l10n.historyEmptyBody,
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
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!));
|
..sort((a, b) => b.completedAt!.compareTo(a.completedAt!));
|
||||||
|
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
|
|
@ -98,8 +96,10 @@ class _WorkoutHistoryCard extends StatelessWidget {
|
||||||
|
|
||||||
List<Exercise> _parseExercises() {
|
List<Exercise> _parseExercises() {
|
||||||
try {
|
try {
|
||||||
final List<dynamic> jsonList = jsonDecode(workout.exercisesJson);
|
final List<dynamic> list = workout.exercises;
|
||||||
return jsonList.map((json) => Exercise.fromJson(json)).toList();
|
return list
|
||||||
|
.map((json) => Exercise.fromJson(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Error parsing workout history: $e');
|
debugPrint('Error parsing workout history: $e');
|
||||||
return [];
|
return [];
|
||||||
|
|
@ -111,6 +111,7 @@ class _WorkoutHistoryCard extends StatelessWidget {
|
||||||
final dateStr = DateFormat.yMMMd().format(workout.completedAt!);
|
final dateStr = DateFormat.yMMMd().format(workout.completedAt!);
|
||||||
final timeStr = DateFormat.jm().format(workout.completedAt!);
|
final timeStr = DateFormat.jm().format(workout.completedAt!);
|
||||||
final exercises = _parseExercises();
|
final exercises = _parseExercises();
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
final summary = exercises
|
final summary = exercises
|
||||||
.map((e) =>
|
.map((e) =>
|
||||||
|
|
@ -124,7 +125,7 @@ class _WorkoutHistoryCard extends StatelessWidget {
|
||||||
tilePadding: const EdgeInsets.all(16),
|
tilePadding: const EdgeInsets.all(16),
|
||||||
leading: _buildDateBadge(context, workout),
|
leading: _buildDateBadge(context, workout),
|
||||||
title: Text(
|
title: Text(
|
||||||
summary.isEmpty ? 'Unknown Workout' : summary,
|
summary.isEmpty ? l10n.historyUnknownWorkout : summary,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: AppTheme.primaryColor,
|
color: AppTheme.primaryColor,
|
||||||
|
|
@ -182,9 +183,9 @@ class _WorkoutHistoryCard extends StatelessWidget {
|
||||||
width: 50,
|
width: 50,
|
||||||
height: 50,
|
height: 50,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.primaryColor.withOpacity(0.1),
|
color: AppTheme.primaryColor.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(12),
|
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(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
|
@ -237,10 +238,10 @@ class _ExerciseDetailRow extends StatelessWidget {
|
||||||
Table(
|
Table(
|
||||||
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
|
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
|
||||||
columnWidths: const {
|
columnWidths: const {
|
||||||
0: FlexColumnWidth(1), // Set
|
0: FlexColumnWidth(1),
|
||||||
1: FlexColumnWidth(2), // Weight
|
1: FlexColumnWidth(2),
|
||||||
2: FlexColumnWidth(2), // Reps
|
2: FlexColumnWidth(2),
|
||||||
3: FlexColumnWidth(1), // Type (AMRAP/FSL)
|
3: FlexColumnWidth(1),
|
||||||
},
|
},
|
||||||
children: exercise.sets.where((s) => s.completed).map((set) {
|
children: exercise.sets.where((s) => s.completed).map((set) {
|
||||||
return TableRow(
|
return TableRow(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:slrpg_app/l10n/app_localizations.dart';
|
||||||
|
|
||||||
import '../../../../core/theme/app_theme.dart';
|
import '../../../../core/theme/app_theme.dart';
|
||||||
import '../../../../core/constants/app_constants.dart';
|
import '../../../../core/constants/app_constants.dart';
|
||||||
|
|
@ -29,7 +30,7 @@ class _InventoryScreenState extends ConsumerState<InventoryScreen> {
|
||||||
|
|
||||||
Future<void> _loadCurrentInventory() async {
|
Future<void> _loadCurrentInventory() async {
|
||||||
final userRepo = ref.read(userRepositoryProvider);
|
final userRepo = ref.read(userRepositoryProvider);
|
||||||
final inventory = userRepo.getInventorySettings();
|
final inventory = await userRepo.getInventorySettingsAsync();
|
||||||
|
|
||||||
final barWeight = (inventory['bar_weight'] as num?)?.toDouble() ?? 20.0;
|
final barWeight = (inventory['bar_weight'] as num?)?.toDouble() ?? 20.0;
|
||||||
|
|
||||||
|
|
@ -62,7 +63,8 @@ class _InventoryScreenState extends ConsumerState<InventoryScreen> {
|
||||||
};
|
};
|
||||||
|
|
||||||
for (var b in bandsList) {
|
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)) {
|
if (bandMap.containsKey(color)) {
|
||||||
bandMap[color] = true;
|
bandMap[color] = true;
|
||||||
}
|
}
|
||||||
|
|
@ -120,13 +122,16 @@ class _InventoryScreenState extends ConsumerState<InventoryScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveChanges() async {
|
Future<void> _saveChanges() async {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
try {
|
try {
|
||||||
final userRepo = ref.read(userRepositoryProvider);
|
final userRepo = ref.read(userRepositoryProvider);
|
||||||
|
|
||||||
final platesList = <double>[];
|
final platesList = <double>[];
|
||||||
_plateInventory.forEach((weight, count) {
|
_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>>[];
|
final bandsList = <Map<String, dynamic>>[];
|
||||||
|
|
@ -150,7 +155,7 @@ class _InventoryScreenState extends ConsumerState<InventoryScreen> {
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Inventory updated successfully')),
|
SnackBar(content: Text(l10n.inventoryUpdatedSuccess)),
|
||||||
);
|
);
|
||||||
setState(() {
|
setState(() {
|
||||||
_hasChanges = false;
|
_hasChanges = false;
|
||||||
|
|
@ -186,13 +191,14 @@ class _InventoryScreenState extends ConsumerState<InventoryScreen> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
if (_isLoading && !_hasChanges) {
|
if (_isLoading && !_hasChanges) {
|
||||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Manage Equipment'),
|
title: Text(l10n.inventoryTitle),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
onPressed: () => context.go('/hub'),
|
onPressed: () => context.go('/hub'),
|
||||||
|
|
@ -201,7 +207,7 @@ class _InventoryScreenState extends ConsumerState<InventoryScreen> {
|
||||||
if (_hasChanges)
|
if (_hasChanges)
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: _saveChanges,
|
onPressed: _saveChanges,
|
||||||
child: const Text('SAVE',
|
child: Text(l10n.saveButton,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: AppTheme.primaryColor,
|
color: AppTheme.primaryColor,
|
||||||
fontWeight: FontWeight.bold)),
|
fontWeight: FontWeight.bold)),
|
||||||
|
|
@ -219,7 +225,7 @@ class _InventoryScreenState extends ConsumerState<InventoryScreen> {
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('Barbell Weight',
|
Text(l10n.inventoryBarbellWeight,
|
||||||
style: Theme.of(context).textTheme.titleMedium),
|
style: Theme.of(context).textTheme.titleMedium),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -250,32 +256,52 @@ class _InventoryScreenState extends ConsumerState<InventoryScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text('Quick Presets',
|
Text(l10n.inventoryPresets,
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.titleMedium
|
.titleMedium
|
||||||
?.copyWith(color: AppTheme.textSecondary)),
|
?.copyWith(color: AppTheme.textSecondary)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SingleChildScrollView(
|
LayoutBuilder(
|
||||||
scrollDirection: Axis.horizontal,
|
builder: (context, constraints) {
|
||||||
child: Row(
|
final screenWidth = constraints.maxWidth;
|
||||||
children: [
|
final chipWidth = 130.0;
|
||||||
ActionChip(
|
final spacing = 8.0;
|
||||||
label: const Text('Home Gym'),
|
final totalWidth = (chipWidth * 3) + (spacing * 2);
|
||||||
onPressed: () => _applyPreset('home')),
|
|
||||||
const SizedBox(width: 8),
|
if (screenWidth < totalWidth) {
|
||||||
ActionChip(
|
return Column(
|
||||||
label: const Text('Commercial'),
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
onPressed: () => _applyPreset('commercial')),
|
children: [
|
||||||
const SizedBox(width: 8),
|
_buildPresetChip(l10n.inventoryPresetHome, 'home'),
|
||||||
ActionChip(
|
const SizedBox(height: 8),
|
||||||
label: const Text('Minimal'),
|
_buildPresetChip(
|
||||||
onPressed: () => _applyPreset('minimal')),
|
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),
|
const SizedBox(height: 24),
|
||||||
Text('Plates Available',
|
Text(l10n.inventoryPlates,
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.titleMedium
|
.titleMedium
|
||||||
|
|
@ -294,43 +320,130 @@ class _InventoryScreenState extends ConsumerState<InventoryScreen> {
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text('Resistance Bands (Assistance)',
|
Text(l10n.inventoryBands,
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.titleMedium
|
.titleMedium
|
||||||
?.copyWith(color: AppTheme.textSecondary)),
|
?.copyWith(color: AppTheme.textSecondary)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Wrap(
|
LayoutBuilder(
|
||||||
spacing: 8,
|
builder: (context, constraints) {
|
||||||
runSpacing: 8,
|
final screenWidth = constraints.maxWidth;
|
||||||
children: _bandInventory.entries.map((entry) {
|
|
||||||
final resistance = AppConstants.defaultBands[entry.key] ?? 0;
|
int crossAxisCount = 2;
|
||||||
return FilterChip(
|
if (screenWidth > 600) {
|
||||||
label: Text('${entry.key} (~${resistance.toInt()}kg)'),
|
crossAxisCount = 4;
|
||||||
selected: entry.value,
|
} else if (screenWidth > 400) {
|
||||||
onSelected: (bool selected) {
|
crossAxisCount = 2;
|
||||||
setState(() {
|
}
|
||||||
_bandInventory[entry.key] = selected;
|
|
||||||
_hasChanges = true;
|
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),
|
const SizedBox(height: 40),
|
||||||
if (_hasChanges)
|
if (_hasChanges)
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: _isLoading ? null : _saveChanges,
|
onPressed: _isLoading ? null : _saveChanges,
|
||||||
child: _isLoading
|
child: _isLoading
|
||||||
? const CircularProgressIndicator(color: Colors.black)
|
? const SizedBox(
|
||||||
: const Text('SAVE CHANGES'),
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,16 @@ class PlateCounter extends StatelessWidget {
|
||||||
return colorValue != null ? Color(colorValue) : Colors.grey;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Card(
|
return Card(
|
||||||
|
|
@ -43,8 +53,8 @@ class PlateCounter extends StatelessWidget {
|
||||||
weight == weight.toInt()
|
weight == weight.toInt()
|
||||||
? '${weight.toInt()}'
|
? '${weight.toInt()}'
|
||||||
: weight.toStringAsFixed(2),
|
: weight.toStringAsFixed(2),
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: _getTextColor(weight),
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
),
|
),
|
||||||
|
|
@ -68,10 +78,10 @@ class PlateCounter extends StatelessWidget {
|
||||||
height: 40,
|
height: 40,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.primaryColor.withOpacity(0.1),
|
color: AppTheme.primaryColor.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border:
|
border: Border.all(
|
||||||
Border.all(color: AppTheme.primaryColor.withOpacity(0.3)),
|
color: AppTheme.primaryColor.withValues(alpha: 0.3)),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
count.toString(),
|
count.toString(),
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
import 'dart:convert';
|
import 'dart:developer';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.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/theme/app_theme.dart';
|
||||||
import '../../../../core/constants/app_constants.dart';
|
|
||||||
import '../../../../shared/data/repositories/user_repository.dart';
|
import '../../../../shared/data/repositories/user_repository.dart';
|
||||||
import '../../../../shared/data/repositories/cycle_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/domain/entities/avatar_config.dart';
|
||||||
import '../../../gamification/presentation/widgets/avatar_editor.dart';
|
import '../../../gamification/presentation/widgets/avatar_editor.dart';
|
||||||
import 'bodyweight_input_screen.dart';
|
import 'bodyweight_input_screen.dart';
|
||||||
|
|
@ -23,64 +26,96 @@ class _AvatarSetupScreenState extends ConsumerState<AvatarSetupScreen> {
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
|
||||||
Future<void> _handleFinish() async {
|
Future<void> _handleFinish() async {
|
||||||
|
final password = await _showPasswordDialog();
|
||||||
|
if (password == null) return;
|
||||||
|
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final onboardingData = ref.read(onboardingDataProvider);
|
final onboardingData = ref.read(onboardingDataProvider);
|
||||||
final userRepo = ref.read(userRepositoryProvider);
|
final userRepo = ref.read(userRepositoryProvider);
|
||||||
final inventorySettings =
|
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();
|
var user = await userRepo.getLocalUser();
|
||||||
|
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
user = await userRepo.register(
|
final email = onboardingData['email'] as String? ?? '';
|
||||||
email: onboardingData['email'] ?? '',
|
final bodyweight =
|
||||||
password: onboardingData['password'] ?? '',
|
(onboardingData['bodyweight'] as num?)?.toDouble() ?? 80.0;
|
||||||
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);
|
|
||||||
|
|
||||||
try {
|
if (email.isEmpty || password.isEmpty) {
|
||||||
await userRepo.updateBodyweight(user.currentBodyweight);
|
throw Exception('Email or password is missing!');
|
||||||
await userRepo.updateInventory(inventorySettings);
|
|
||||||
} catch (e) {
|
|
||||||
// Sync macht das später
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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());
|
final avatarJson = _config.toJson();
|
||||||
user.isDirty = true;
|
|
||||||
await userRepo.saveLocalUser(user);
|
|
||||||
|
|
||||||
final trainingMaxes =
|
user = user.copyWith(
|
||||||
onboardingData['training_maxes'] as Map<String, dynamic>?;
|
avatarConfig: Value(avatarJson),
|
||||||
if (trainingMaxes != null) {
|
isDirty: true,
|
||||||
final cycleRepo = ref.read(cycleRepositoryProvider);
|
);
|
||||||
final tmMap = <String, double>{
|
await userRepo.saveLocalUser(user);
|
||||||
'squat': (trainingMaxes['squat'] as num?)?.toDouble() ?? 100.0,
|
try {
|
||||||
'pullup': (trainingMaxes['pullup'] as num?)?.toDouble() ?? 80.0,
|
final trainingMaxes =
|
||||||
'dip': (trainingMaxes['dip'] as num?)?.toDouble() ?? 90.0,
|
onboardingData['training_maxes'] as Map<String, dynamic>?;
|
||||||
};
|
|
||||||
await cycleRepo.createCycle(tmMap);
|
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) {
|
if (mounted) {
|
||||||
ref.read(onboardingDataProvider.notifier).state = {};
|
ref.read(onboardingDataProvider.notifier).clear();
|
||||||
|
|
||||||
context.go('/hub');
|
context.go('/hub');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Setup failed: $e'),
|
content: Text('Setup failed: $e'),
|
||||||
backgroundColor: AppTheme.errorColor),
|
backgroundColor: AppTheme.errorColor,
|
||||||
|
duration: const Duration(seconds: 5),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -90,9 +125,10 @@ class _AvatarSetupScreenState extends ConsumerState<AvatarSetupScreen> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Choose Your Hero'),
|
title: Text(l10n.setupAvatarTitle),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: _isLoading ? null : _handleFinish,
|
onPressed: _isLoading ? null : _handleFinish,
|
||||||
|
|
@ -101,7 +137,7 @@ class _AvatarSetupScreenState extends ConsumerState<AvatarSetupScreen> {
|
||||||
width: 16,
|
width: 16,
|
||||||
height: 16,
|
height: 16,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2))
|
child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
: const Text('FINISH',
|
: Text(l10n.finishButton,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: AppTheme.primaryColor)),
|
color: AppTheme.primaryColor)),
|
||||||
|
|
@ -114,8 +150,8 @@ class _AvatarSetupScreenState extends ConsumerState<AvatarSetupScreen> {
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
color: AppTheme.surfaceColor,
|
color: AppTheme.surfaceColor,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: const Text(
|
child: Text(
|
||||||
'This is how the legends will remember you.',
|
l10n.setupAvatarSubtitle,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(fontStyle: FontStyle.italic, color: Colors.grey),
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,39 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:slrpg_app/l10n/app_localizations.dart';
|
||||||
|
|
||||||
import '../../../../core/theme/app_theme.dart';
|
import '../../../../core/theme/app_theme.dart';
|
||||||
import '../../../../core/constants/app_constants.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
|
// Provider to store onboarding data
|
||||||
final onboardingDataProvider =
|
@Riverpod(keepAlive: true)
|
||||||
StateProvider<Map<String, dynamic>>((ref) => {});
|
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 {
|
class BodyweightInputScreen extends ConsumerStatefulWidget {
|
||||||
const BodyweightInputScreen({super.key});
|
const BodyweightInputScreen({super.key});
|
||||||
|
|
@ -17,26 +43,26 @@ class BodyweightInputScreen extends ConsumerStatefulWidget {
|
||||||
_BodyweightInputScreenState();
|
_BodyweightInputScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BodyweightInputScreenState
|
class _BodyweightInputScreenState extends ConsumerState<BodyweightInputScreen> {
|
||||||
extends ConsumerState<BodyweightInputScreen> {
|
|
||||||
double _bodyweight = 80.0;
|
double _bodyweight = 80.0;
|
||||||
bool _useKg = true;
|
bool _useKg = true;
|
||||||
|
|
||||||
void _handleContinue() {
|
void _handleContinue() {
|
||||||
// Store bodyweight
|
// Store bodyweight
|
||||||
ref.read(onboardingDataProvider.notifier).update((state) => {
|
ref.read(onboardingDataProvider.notifier).updateData({
|
||||||
...state,
|
'bodyweight': _bodyweight,
|
||||||
'bodyweight': _bodyweight,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
context.go('/onboarding/strength-test');
|
context.go('/onboarding/strength-test');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Setup Profile'),
|
title: Text(l10n.setupProfileTitle),
|
||||||
|
// title: const Text('Setup Profile'),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
onPressed: () => context.go('/onboarding/welcome'),
|
onPressed: () => context.go('/onboarding/welcome'),
|
||||||
|
|
@ -58,12 +84,14 @@ class _BodyweightInputScreenState
|
||||||
|
|
||||||
// Title
|
// Title
|
||||||
Text(
|
Text(
|
||||||
'What\'s your current bodyweight?',
|
l10n.bodyweightTitle,
|
||||||
|
// 'What\'s your current bodyweight?',
|
||||||
style: Theme.of(context).textTheme.displayMedium,
|
style: Theme.of(context).textTheme.displayMedium,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
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,
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 48),
|
const SizedBox(height: 48),
|
||||||
|
|
@ -73,9 +101,9 @@ class _BodyweightInputScreenState
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
SegmentedButton<bool>(
|
SegmentedButton<bool>(
|
||||||
segments: const [
|
segments: [
|
||||||
ButtonSegment(value: true, label: Text('KG')),
|
ButtonSegment(value: true, label: Text(l10n.unitKg)),
|
||||||
ButtonSegment(value: false, label: Text('LBS')),
|
ButtonSegment(value: false, label: Text(l10n.unitLbs)),
|
||||||
],
|
],
|
||||||
selected: {_useKg},
|
selected: {_useKg},
|
||||||
onSelectionChanged: (Set<bool> newSelection) {
|
onSelectionChanged: (Set<bool> newSelection) {
|
||||||
|
|
@ -134,7 +162,7 @@ class _BodyweightInputScreenState
|
||||||
// Continue Button
|
// Continue Button
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: _handleContinue,
|
onPressed: _handleContinue,
|
||||||
child: const Text('CONTINUE'),
|
child: Text(l10n.continueButton),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -143,4 +171,3 @@ class _BodyweightInputScreenState
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:slrpg_app/l10n/app_localizations.dart';
|
||||||
|
|
||||||
import '../../../../core/theme/app_theme.dart';
|
import '../../../../core/theme/app_theme.dart';
|
||||||
import '../../../../core/constants/app_constants.dart';
|
import '../../../../core/constants/app_constants.dart';
|
||||||
|
|
@ -82,7 +83,9 @@ class _InventorySetupScreenState extends ConsumerState<InventorySetupScreen> {
|
||||||
void _handleNext() {
|
void _handleNext() {
|
||||||
final platesList = <double>[];
|
final platesList = <double>[];
|
||||||
_plateInventory.forEach((weight, count) {
|
_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>>[];
|
final bandsList = <Map<String, dynamic>>[];
|
||||||
|
|
@ -102,10 +105,9 @@ class _InventorySetupScreenState extends ConsumerState<InventorySetupScreen> {
|
||||||
'bands': bandsList,
|
'bands': bandsList,
|
||||||
};
|
};
|
||||||
|
|
||||||
ref.read(onboardingDataProvider.notifier).update((state) => {
|
ref.read(onboardingDataProvider.notifier).updateData({
|
||||||
...state,
|
'inventory_settings': inventorySettings,
|
||||||
'inventory_settings': inventorySettings,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
context.push('/onboarding/avatar');
|
context.push('/onboarding/avatar');
|
||||||
}
|
}
|
||||||
|
|
@ -167,8 +169,7 @@ class _InventorySetupScreenState extends ConsumerState<InventorySetupScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ref.read(onboardingDataProvider.notifier).state = {};
|
ref.read(onboardingDataProvider.notifier).clear();
|
||||||
|
|
||||||
context.go('/hub');
|
context.go('/hub');
|
||||||
}
|
}
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
|
|
@ -214,9 +215,10 @@ class _InventorySetupScreenState extends ConsumerState<InventorySetupScreen> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Equipment Setup'),
|
title: Text(l10n.setupEquipmentTitle),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
onPressed: () => context.go('/onboarding/strength-test'),
|
onPressed: () => context.go('/onboarding/strength-test'),
|
||||||
|
|
@ -235,17 +237,17 @@ class _InventorySetupScreenState extends ConsumerState<InventorySetupScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
Text(
|
Text(
|
||||||
'Equipment Inventory',
|
l10n.setupInventoryTitle,
|
||||||
style: Theme.of(context).textTheme.displayMedium,
|
style: Theme.of(context).textTheme.displayMedium,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Tell us what equipment you have available',
|
l10n.setupInventorySubtitle,
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
Text(
|
Text(
|
||||||
'Barbell Weight',
|
l10n.inventoryBarbellWeight,
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.titleLarge
|
.titleLarge
|
||||||
|
|
@ -272,40 +274,55 @@ class _InventorySetupScreenState extends ConsumerState<InventorySetupScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
Text(
|
Text(
|
||||||
'Quick Presets',
|
l10n.inventoryPresets,
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.titleLarge
|
.titleLarge
|
||||||
?.copyWith(color: AppTheme.textPrimary),
|
?.copyWith(color: AppTheme.textPrimary),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Row(
|
LayoutBuilder(
|
||||||
children: [
|
builder: (context, constraints) {
|
||||||
Expanded(
|
final screenWidth = constraints.maxWidth;
|
||||||
child: OutlinedButton(
|
final chipWidth = 130.0;
|
||||||
onPressed: () => _applyPreset('home'),
|
final spacing = 8.0;
|
||||||
child: const Text('Home Gym'),
|
final totalWidth = (chipWidth * 3) + (spacing * 2);
|
||||||
),
|
|
||||||
),
|
if (screenWidth < totalWidth) {
|
||||||
const SizedBox(width: 8),
|
return Column(
|
||||||
Expanded(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
child: OutlinedButton(
|
children: [
|
||||||
onPressed: () => _applyPreset('commercial'),
|
_buildPresetButton(l10n.inventoryPresetHome, 'home'),
|
||||||
child: const Text('Commercial'),
|
const SizedBox(height: 8),
|
||||||
),
|
_buildPresetButton(
|
||||||
),
|
l10n.inventoryPresetCommercial, 'commercial'),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(height: 8),
|
||||||
Expanded(
|
_buildPresetButton(
|
||||||
child: OutlinedButton(
|
l10n.inventoryPresetMinimal, 'minimal'),
|
||||||
onPressed: () => _applyPreset('minimal'),
|
],
|
||||||
child: const Text('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),
|
const SizedBox(height: 32),
|
||||||
Text(
|
Text(
|
||||||
'Available Plates',
|
l10n.inventoryPlates,
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.titleLarge
|
.titleLarge
|
||||||
|
|
@ -325,7 +342,7 @@ class _InventorySetupScreenState extends ConsumerState<InventorySetupScreen> {
|
||||||
}).toList(),
|
}).toList(),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
Text(
|
Text(
|
||||||
'Resistance Bands (Assistance)',
|
l10n.inventoryBands,
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.titleLarge
|
.titleLarge
|
||||||
|
|
@ -333,41 +350,63 @@ class _InventorySetupScreenState extends ConsumerState<InventorySetupScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Select bands you have for pullup/dip assistance',
|
l10n.setupBandsSubtitle,
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.bodySmall
|
.bodySmall
|
||||||
?.copyWith(color: AppTheme.textSecondary),
|
?.copyWith(color: AppTheme.textSecondary),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Wrap(
|
LayoutBuilder(
|
||||||
spacing: 8,
|
builder: (context, constraints) {
|
||||||
runSpacing: 8,
|
final screenWidth = constraints.maxWidth;
|
||||||
children: _bandInventory.entries.map((entry) {
|
|
||||||
final resistance = AppConstants.defaultBands[entry.key] ?? 0;
|
int crossAxisCount = 2;
|
||||||
return FilterChip(
|
if (screenWidth > 600) {
|
||||||
label: Text('${entry.key} (~${resistance.toInt()}kg)'),
|
crossAxisCount = 4;
|
||||||
selected: entry.value,
|
} else if (screenWidth > 400) {
|
||||||
onSelected: (bool selected) {
|
crossAxisCount = 2;
|
||||||
setState(() {
|
}
|
||||||
_bandInventory[entry.key] = selected;
|
|
||||||
});
|
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),
|
const SizedBox(height: 32),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: _handleNext,
|
onPressed: _isLoading ? null : _handleNext,
|
||||||
child: const Text('NEXT STEP'),
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:slrpg_app/l10n/app_localizations.dart';
|
||||||
|
|
||||||
import '../../../../core/theme/app_theme.dart';
|
import '../../../../core/theme/app_theme.dart';
|
||||||
import '../../../../shared/domain/logic/wendler_calculator.dart';
|
import '../../../../shared/domain/logic/wendler_calculator.dart';
|
||||||
|
|
@ -17,16 +18,24 @@ class StrengthTestScreen extends ConsumerStatefulWidget {
|
||||||
class _StrengthTestScreenState extends ConsumerState<StrengthTestScreen> {
|
class _StrengthTestScreenState extends ConsumerState<StrengthTestScreen> {
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
final _squatWeightController = TextEditingController(text: '100');
|
final _squatWeightController = TextEditingController(text: '60');
|
||||||
final _squatRepsController = TextEditingController(text: '5');
|
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 _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> _calculated1RMs = {};
|
||||||
Map<String, double> _calculatedTMs = {};
|
Map<String, double> _calculatedTMs = {};
|
||||||
|
|
||||||
|
bool _isAssistedPull = false;
|
||||||
|
bool _isAssistedDip = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
@ -37,65 +46,148 @@ class _StrengthTestScreenState extends ConsumerState<StrengthTestScreen> {
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_squatWeightController.dispose();
|
_squatWeightController.dispose();
|
||||||
_squatRepsController.dispose();
|
_squatRepsController.dispose();
|
||||||
_pullupWeightController.dispose();
|
_pullWeightController.dispose();
|
||||||
_pullupRepsController.dispose();
|
_pullRepsController.dispose();
|
||||||
_dipWeightController.dispose();
|
_dipWeightController.dispose();
|
||||||
_dipRepsController.dispose();
|
_pushRepsController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _calculateAll() {
|
void _calculateAll() {
|
||||||
final bodyweight = ref.read(onboardingDataProvider)['bodyweight'] ?? 80.0;
|
final bodyweight = ref.read(onboardingDataProvider)['bodyweight'] ?? 80.0;
|
||||||
|
|
||||||
|
// Squat bleibt gleich...
|
||||||
final squatWeight = double.tryParse(_squatWeightController.text) ?? 0;
|
final squatWeight = double.tryParse(_squatWeightController.text) ?? 0;
|
||||||
final squatReps = int.tryParse(_squatRepsController.text) ?? 1;
|
final squatReps = int.tryParse(_squatRepsController.text) ?? 1;
|
||||||
final squat1RM = WendlerCalculator.calculate1RM(squatWeight, squatReps);
|
final squat1RM = WendlerCalculator.calculate1RM(squatWeight, squatReps);
|
||||||
final squatTM = WendlerCalculator.calculateTrainingMax(squat1RM);
|
final squatTM = WendlerCalculator.calculateTrainingMax(squat1RM);
|
||||||
|
|
||||||
final pullupAdditional = double.tryParse(_pullupWeightController.text) ?? 0;
|
// PULL CALCULATION (Angepasst)
|
||||||
final pullupReps = int.tryParse(_pullupRepsController.text) ?? 1;
|
double pull1RM = 0.0;
|
||||||
final pullupTotal = bodyweight + pullupAdditional;
|
if (_canDoPullup) {
|
||||||
final pullup1RM = WendlerCalculator.calculate1RM(pullupTotal, pullupReps);
|
final inputWeight = double.tryParse(_pullWeightController.text) ?? 0;
|
||||||
final pullupTM = WendlerCalculator.calculateTrainingMax(pullup1RM);
|
final reps = int.tryParse(_pullRepsController.text) ?? 1;
|
||||||
|
|
||||||
final dipAdditional = double.tryParse(_dipWeightController.text) ?? 0;
|
// LOGIK: Assisted vs Weighted
|
||||||
final dipReps = int.tryParse(_dipRepsController.text) ?? 1;
|
double totalLoad;
|
||||||
final dipTotal = bodyweight + dipAdditional;
|
if (_isAssistedPull) {
|
||||||
final dip1RM = WendlerCalculator.calculate1RM(dipTotal, dipReps);
|
totalLoad = (bodyweight - inputWeight).clamp(0.0, double.infinity);
|
||||||
final dipTM = WendlerCalculator.calculateTrainingMax(dip1RM);
|
} 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(() {
|
setState(() {
|
||||||
_calculated1RMs = {
|
_calculated1RMs = {
|
||||||
'squat': squat1RM,
|
'squat': squat1RM,
|
||||||
'pullup': pullup1RM,
|
'pullup': pull1RM,
|
||||||
'dip': dip1RM,
|
'dip': push1RM,
|
||||||
};
|
};
|
||||||
_calculatedTMs = {
|
_calculatedTMs = {
|
||||||
'squat': squatTM,
|
'squat': squatTM,
|
||||||
'pullup': pullupTM,
|
'pullup': pullTM,
|
||||||
'dip': dipTM,
|
'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() {
|
void _handleContinue() {
|
||||||
if (!_formKey.currentState!.validate()) return;
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
ref.read(onboardingDataProvider.notifier).update((state) => {
|
final variants = <String, String>{
|
||||||
...state,
|
'pull': _canDoPullup ? 'pullup' : 'row',
|
||||||
'training_maxes': _calculatedTMs,
|
'push': _canDoDip ? 'dip' : 'bench',
|
||||||
});
|
};
|
||||||
|
|
||||||
|
ref.read(onboardingDataProvider.notifier).updateData({
|
||||||
|
'training_maxes': _calculatedTMs,
|
||||||
|
'exercise_variants': variants,
|
||||||
|
});
|
||||||
|
|
||||||
context.go('/onboarding/inventory');
|
context.go('/onboarding/inventory');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final bodyweight = ref.watch(onboardingDataProvider)['bodyweight'] ?? 80.0;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Strength Test'),
|
title: Text(l10n.strengthTestTitle),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
onPressed: () => context.go('/onboarding/bodyweight'),
|
onPressed: () => context.go('/onboarding/bodyweight'),
|
||||||
|
|
@ -116,74 +208,118 @@ class _StrengthTestScreenState extends ConsumerState<StrengthTestScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
Text(
|
Text(
|
||||||
'Combat Calibration',
|
l10n.strengthTestSubtitle,
|
||||||
style: Theme.of(context).textTheme.displayMedium,
|
style: Theme.of(context).textTheme.displayMedium,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'We need to assess your current power level to assign the correct monsters.', // Flavor
|
l10n.strengthTestBody,
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Enter your recent best performance for each exercise',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
_ExerciseCard(
|
_ExerciseCard(
|
||||||
|
title: l10n.strengthLegs,
|
||||||
exerciseName: 'Back Squat',
|
exerciseName: 'Back Squat',
|
||||||
icon: Icons.accessibility_new,
|
icon: Icons.accessibility_new,
|
||||||
weightController: _squatWeightController,
|
weightController: _squatWeightController,
|
||||||
repsController: _squatRepsController,
|
repsController: _squatRepsController,
|
||||||
isBodyweight: false,
|
isBodyweight: false,
|
||||||
calculated1RM: _calculated1RMs['squat'] ?? 0,
|
|
||||||
calculatedTM: _calculatedTMs['squat'] ?? 0,
|
|
||||||
onChanged: _calculateAll,
|
onChanged: _calculateAll,
|
||||||
|
result1RM: _calculated1RMs['squat'] ?? 0,
|
||||||
|
resultTM: _calculatedTMs['squat'] ?? 0,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_ExerciseCard(
|
_AdaptiveExerciseCard(
|
||||||
exerciseName: 'Weighted Pull-up',
|
slotTitle: l10n.strengthPull,
|
||||||
|
primaryName: 'Weighted Pull-up',
|
||||||
|
secondaryName: 'Pendlay Row',
|
||||||
icon: Icons.north,
|
icon: Icons.north,
|
||||||
weightController: _pullupWeightController,
|
isCapable: _canDoPullup,
|
||||||
repsController: _pullupRepsController,
|
onToggleCapability: (val) {
|
||||||
isBodyweight: true,
|
setState(() {
|
||||||
bodyweight: bodyweight,
|
_canDoPullup = val;
|
||||||
calculated1RM: _calculated1RMs['pullup'] ?? 0,
|
_pullWeightController.text = '0';
|
||||||
calculatedTM: _calculatedTMs['pullup'] ?? 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,
|
onChanged: _calculateAll,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_ExerciseCard(
|
_AdaptiveExerciseCard(
|
||||||
exerciseName: 'Weighted Dip',
|
slotTitle: l10n.strengthPush,
|
||||||
|
primaryName: 'Weighted Dip',
|
||||||
|
secondaryName: 'Bench Press',
|
||||||
icon: Icons.south,
|
icon: Icons.south,
|
||||||
weightController: _dipWeightController,
|
isCapable: _canDoDip,
|
||||||
repsController: _dipRepsController,
|
onToggleCapability: (val) {
|
||||||
isBodyweight: true,
|
setState(() {
|
||||||
bodyweight: bodyweight,
|
_canDoDip = val;
|
||||||
calculated1RM: _calculated1RMs['dip'] ?? 0,
|
_dipWeightController.text = '0';
|
||||||
calculatedTM: _calculatedTMs['dip'] ?? 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,
|
onChanged: _calculateAll,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.primaryColor.withOpacity(0.1),
|
color: AppTheme.primaryColor.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: AppTheme.primaryColor.withOpacity(0.3),
|
color: AppTheme.primaryColor.withValues(alpha: 0.3)),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
const Icon(Icons.info_outline,
|
||||||
Icons.info_outline,
|
color: AppTheme.primaryColor),
|
||||||
color: AppTheme.primaryColor,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
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)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.bodySmall
|
.bodySmall
|
||||||
|
|
@ -196,7 +332,7 @@ class _StrengthTestScreenState extends ConsumerState<StrengthTestScreen> {
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: _handleContinue,
|
onPressed: _handleContinue,
|
||||||
child: const Text('CONTINUE'),
|
child: Text(l10n.continueButton),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -208,57 +344,61 @@ class _StrengthTestScreenState extends ConsumerState<StrengthTestScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ExerciseCard extends StatelessWidget {
|
class _ExerciseCard extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
final String exerciseName;
|
final String exerciseName;
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final TextEditingController weightController;
|
final TextEditingController weightController;
|
||||||
final TextEditingController repsController;
|
final TextEditingController repsController;
|
||||||
final bool isBodyweight;
|
final bool isBodyweight;
|
||||||
final double bodyweight;
|
final double result1RM;
|
||||||
final double calculated1RM;
|
final double resultTM;
|
||||||
final double calculatedTM;
|
|
||||||
final VoidCallback onChanged;
|
final VoidCallback onChanged;
|
||||||
|
|
||||||
const _ExerciseCard({
|
const _ExerciseCard({
|
||||||
|
required this.title,
|
||||||
required this.exerciseName,
|
required this.exerciseName,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.weightController,
|
required this.weightController,
|
||||||
required this.repsController,
|
required this.repsController,
|
||||||
this.isBodyweight = false,
|
required this.isBodyweight,
|
||||||
this.bodyweight = 0,
|
required this.result1RM,
|
||||||
required this.calculated1RM,
|
required this.resultTM,
|
||||||
required this.calculatedTM,
|
|
||||||
required this.onChanged,
|
required this.onChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
return Card(
|
return Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Header
|
Text(title.toUpperCase(),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 40,
|
padding: const EdgeInsets.all(8),
|
||||||
height: 40,
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.primaryColor.withOpacity(0.2),
|
color: AppTheme.primaryColor.withValues(alpha: 0.2),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8)),
|
||||||
),
|
|
||||||
child: Icon(icon, color: AppTheme.primaryColor),
|
child: Icon(icon, color: AppTheme.primaryColor),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Text(
|
Text(exerciseName,
|
||||||
exerciseName,
|
style: Theme.of(context)
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
.textTheme
|
||||||
),
|
.titleLarge
|
||||||
|
?.copyWith(color: AppTheme.textPrimary)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -268,21 +408,15 @@ class _ExerciseCard extends StatelessWidget {
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
inputFormatters: [
|
inputFormatters: [
|
||||||
FilteringTextInputFormatter.allow(
|
FilteringTextInputFormatter.allow(
|
||||||
RegExp(r'^\d+\.?\d{0,2}')),
|
RegExp(r'^\d+\.?\d{0,2}'))
|
||||||
],
|
],
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: isBodyweight
|
labelText: isBodyweight
|
||||||
? 'Additional Weight (kg)'
|
? l10n.addWeightLabel
|
||||||
: 'Weight (kg)',
|
: l10n.weightLabel,
|
||||||
isDense: true,
|
isDense: true),
|
||||||
),
|
|
||||||
onChanged: (_) => onChanged(),
|
onChanged: (_) => onChanged(),
|
||||||
validator: (value) {
|
validator: (v) => v!.isEmpty ? 'Required' : null,
|
||||||
if (value == null || value.isEmpty) {
|
|
||||||
return 'Required';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|
@ -290,56 +424,17 @@ class _ExerciseCard extends StatelessWidget {
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
controller: repsController,
|
controller: repsController,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
inputFormatters: [
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||||
FilteringTextInputFormatter.digitsOnly,
|
decoration: InputDecoration(
|
||||||
],
|
labelText: l10n.repsLabel, isDense: true),
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Reps',
|
|
||||||
isDense: true,
|
|
||||||
),
|
|
||||||
onChanged: (_) => onChanged(),
|
onChanged: (_) => onChanged(),
|
||||||
validator: (value) {
|
validator: (v) => v!.isEmpty ? 'Required' : null,
|
||||||
if (value == null || value.isEmpty) {
|
|
||||||
return 'Required';
|
|
||||||
}
|
|
||||||
final reps = int.tryParse(value);
|
|
||||||
if (reps == null || reps < 1 || reps > 20) {
|
|
||||||
return '1-20';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
_ResultBox(rm: result1RM, tm: resultTM),
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -347,36 +442,204 @@ class _ExerciseCard extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ResultRow extends StatelessWidget {
|
class _AdaptiveExerciseCard extends StatelessWidget {
|
||||||
final String label;
|
final String slotTitle;
|
||||||
final String value;
|
final String primaryName;
|
||||||
final bool highlight;
|
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({
|
const _AdaptiveExerciseCard({
|
||||||
required this.label,
|
required this.slotTitle,
|
||||||
required this.value,
|
required this.primaryName,
|
||||||
this.highlight = false,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
final l10n = AppLocalizations.of(context)!;
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
return Card(
|
||||||
child: Row(
|
child: Padding(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
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: [
|
children: [
|
||||||
Text(
|
Row(
|
||||||
label,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
children: [
|
||||||
fontWeight: highlight ? FontWeight.bold : null,
|
Text(l10n.est1rm),
|
||||||
),
|
Text('${rm.toStringAsFixed(1)} kg',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
Text(
|
const SizedBox(height: 4),
|
||||||
value,
|
Row(
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
color: highlight ? AppTheme.primaryColor : null,
|
children: [
|
||||||
fontWeight: highlight ? FontWeight.bold : null,
|
Text(l10n.trainingMaxLabel),
|
||||||
),
|
Text('${tm.toStringAsFixed(1)} kg',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
fontWeight: FontWeight.bold)),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:slrpg_app/l10n/app_localizations.dart';
|
||||||
|
|
||||||
import '../../../../core/theme/app_theme.dart';
|
import '../../../../core/theme/app_theme.dart';
|
||||||
import '../../../../core/constants/asset_paths.dart';
|
import '../../../../core/constants/asset_paths.dart';
|
||||||
|
|
@ -9,6 +10,8 @@ class WelcomeScreen extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -20,7 +23,7 @@ class WelcomeScreen extends StatelessWidget {
|
||||||
),
|
),
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: Container(
|
child: Container(
|
||||||
color: Colors.black.withOpacity(0.7),
|
color: Colors.black.withValues(alpha: 0.7),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SafeArea(
|
SafeArea(
|
||||||
|
|
@ -35,11 +38,11 @@ class WelcomeScreen extends StatelessWidget {
|
||||||
width: 100,
|
width: 100,
|
||||||
height: 100,
|
height: 100,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.primaryColor.withOpacity(0.9),
|
color: AppTheme.primaryColor.withValues(alpha: 0.9),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: AppTheme.primaryColor.withOpacity(0.5),
|
color: AppTheme.primaryColor.withValues(alpha: 0.5),
|
||||||
blurRadius: 20)
|
blurRadius: 20)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -48,7 +51,7 @@ class WelcomeScreen extends StatelessWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
Text(
|
Text(
|
||||||
'ENTER THE ARENA',
|
l10n.enterTheArena,
|
||||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||||
color: Colors.white70,
|
color: Colors.white70,
|
||||||
letterSpacing: 2,
|
letterSpacing: 2,
|
||||||
|
|
@ -67,9 +70,8 @@ class WelcomeScreen extends StatelessWidget {
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text(
|
Text(
|
||||||
'The Iron Golems have awakened. The Gravity Demons are pulling the world into the abyss.\n\n'
|
l10n.introText,
|
||||||
'Only a true Streetlifter can stop them. Are you ready to forge your body into a weapon?',
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16, height: 1.5, color: Colors.white),
|
fontSize: 16, height: 1.5, color: Colors.white),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
|
|
@ -77,21 +79,20 @@ class WelcomeScreen extends StatelessWidget {
|
||||||
const SizedBox(height: 48),
|
const SizedBox(height: 48),
|
||||||
_FeatureItem(
|
_FeatureItem(
|
||||||
icon: Icons.shield,
|
icon: Icons.shield,
|
||||||
title: 'Build Your Armor',
|
title: l10n.featureArmorTitle,
|
||||||
description: 'Progressive overload based on Wendler 5/3/1.',
|
description: l10n.featureArmorDesc,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_FeatureItem(
|
_FeatureItem(
|
||||||
icon: Icons.videogame_asset,
|
icon: Icons.videogame_asset,
|
||||||
title: 'Slay Monsters',
|
title: l10n.featureMonstersTitle,
|
||||||
description:
|
description: l10n.featureMonstersDesc,
|
||||||
'Turn every rep into damage against epic foes.',
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_FeatureItem(
|
_FeatureItem(
|
||||||
icon: Icons.inventory_2,
|
icon: Icons.inventory_2,
|
||||||
title: 'Gather Loot',
|
title: l10n.featureLootTitle,
|
||||||
description: 'Earn XP, level up, and unlock new gear.',
|
description: l10n.featureLootDesc,
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
|
|
@ -100,14 +101,14 @@ class WelcomeScreen extends StatelessWidget {
|
||||||
backgroundColor: AppTheme.primaryColor,
|
backgroundColor: AppTheme.primaryColor,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||||
),
|
),
|
||||||
child: const Text('BEGIN YOUR JOURNEY',
|
child: Text(l10n.beginJourney,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.bold, letterSpacing: 1)),
|
fontWeight: FontWeight.bold, letterSpacing: 1)),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => context.go('/login'),
|
onPressed: () => context.go('/login'),
|
||||||
child: const Text('Already a hero? Login here',
|
child: Text(l10n.loginPrompt,
|
||||||
style: TextStyle(color: Colors.white54)),
|
style: TextStyle(color: Colors.white54)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -139,7 +140,7 @@ class _FeatureItem extends StatelessWidget {
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.primaryColor.withOpacity(0.2),
|
color: AppTheme.primaryColor.withValues(alpha: 0.2),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import 'dart:convert';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:slrpg_app/l10n/app_localizations.dart';
|
||||||
|
import 'package:slrpg_app/src/shared/data/local/tables.dart';
|
||||||
|
|
||||||
import '../../../../core/theme/app_theme.dart';
|
import '../../../../core/theme/app_theme.dart';
|
||||||
|
import '../../../../shared/data/local/app_database.dart';
|
||||||
import '../../../../shared/data/repositories/cycle_repository.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/user_repository.dart';
|
||||||
import '../../../../shared/data/repositories/workout_repository.dart';
|
import '../../../../shared/data/repositories/workout_repository.dart';
|
||||||
import '../../../../shared/domain/entities/exercise.dart';
|
import '../../../../shared/domain/entities/exercise.dart';
|
||||||
|
|
@ -24,7 +24,7 @@ class StatsScreen extends ConsumerStatefulWidget {
|
||||||
class _StatsScreenState extends ConsumerState<StatsScreen> {
|
class _StatsScreenState extends ConsumerState<StatsScreen> {
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
|
||||||
String _selectedExercise = 'squat'; // squat, pullup, dip
|
String _selectedExercise = 'squat';
|
||||||
String _selectedRange = '3m'; // 1m, 3m, 1y, all
|
String _selectedRange = '3m'; // 1m, 3m, 1y, all
|
||||||
List<StatsDataPoint> _chartData = [];
|
List<StatsDataPoint> _chartData = [];
|
||||||
bool _isChartLoading = true;
|
bool _isChartLoading = true;
|
||||||
|
|
@ -55,19 +55,15 @@ class _StatsScreenState extends ConsumerState<StatsScreen> {
|
||||||
for (var workout in allWorkouts) {
|
for (var workout in allWorkouts) {
|
||||||
if (workout.completedAt == null) continue;
|
if (workout.completedAt == null) continue;
|
||||||
|
|
||||||
List<dynamic> exercisesJson = [];
|
final exercisesList = workout.exercises;
|
||||||
try {
|
|
||||||
exercisesJson = jsonDecode(workout.exercisesJson);
|
|
||||||
} catch (e) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
double max1RM = 0.0;
|
double max1RM = 0.0;
|
||||||
double sessionVolume = 0.0;
|
double sessionVolume = 0.0;
|
||||||
bool foundExercise = false;
|
bool foundExercise = false;
|
||||||
double trainingMax = 0.0;
|
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);
|
final exercise = Exercise.fromJson(exJson);
|
||||||
|
|
||||||
if (exercise.exerciseId == _selectedExercise) {
|
if (exercise.exerciseId == _selectedExercise) {
|
||||||
|
|
@ -147,12 +143,10 @@ class _StatsScreenState extends ConsumerState<StatsScreen> {
|
||||||
try {
|
try {
|
||||||
final cycleRepo = ref.read(cycleRepositoryProvider);
|
final cycleRepo = ref.read(cycleRepositoryProvider);
|
||||||
|
|
||||||
final oldTMs =
|
final oldTMs = currentCycle.trainingMaxes;
|
||||||
jsonDecode(currentCycle.trainingMaxesJson) as Map<String, dynamic>;
|
|
||||||
|
|
||||||
final newCycle = await cycleRepo.finishCycle();
|
final newCycle = await cycleRepo.finishCycle();
|
||||||
final newTMs =
|
final newTMs = newCycle.trainingMaxes;
|
||||||
jsonDecode(newCycle.trainingMaxesJson) as Map<String, dynamic>;
|
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
await showDialog(
|
await showDialog(
|
||||||
|
|
@ -183,23 +177,49 @@ class _StatsScreenState extends ConsumerState<StatsScreen> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cycleRepo = ref.watch(cycleRepositoryProvider);
|
final cycleRepo = ref.watch(cycleRepositoryProvider);
|
||||||
|
final userRepo = ref.watch(userRepositoryProvider);
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Statistics & Cycles'),
|
title: Text(l10n.statsTitle),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
onPressed: () => context.go('/hub'),
|
onPressed: () => context.go('/hub'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: FutureBuilder<CycleCollection?>(
|
body: FutureBuilder<List<dynamic>>(
|
||||||
future: cycleRepo.getCurrentCycle(),
|
future: Future.wait([
|
||||||
|
cycleRepo.getCurrentCycle(),
|
||||||
|
userRepo.getLocalUser(),
|
||||||
|
]),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
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(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
|
|
@ -209,55 +229,53 @@ class _StatsScreenState extends ConsumerState<StatsScreen> {
|
||||||
if (currentCycle != null) ...[
|
if (currentCycle != null) ...[
|
||||||
_CurrentCycleCard(
|
_CurrentCycleCard(
|
||||||
cycle: currentCycle,
|
cycle: currentCycle,
|
||||||
|
user: user,
|
||||||
onFinish: _isLoading
|
onFinish: _isLoading
|
||||||
? null
|
? null
|
||||||
: () => _handleFinishCycle(currentCycle),
|
: () => _handleFinishCycle(currentCycle),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
],
|
],
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
'Progress Analysis',
|
l10n.statsProgressAnalysis,
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.titleLarge
|
.titleLarge
|
||||||
?.copyWith(color: AppTheme.textPrimary),
|
?.copyWith(color: AppTheme.textPrimary),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
SingleChildScrollView(
|
SingleChildScrollView(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
_FilterChip(
|
_FilterChip(
|
||||||
label: 'Squat',
|
label: l10n.exerciseSquat,
|
||||||
isSelected: _selectedExercise == 'squat',
|
isSelected: _selectedExercise == 'squat',
|
||||||
onTap: () => _onFilterChanged('squat', _selectedRange),
|
onTap: () => _onFilterChanged('squat', _selectedRange),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_FilterChip(
|
_FilterChip(
|
||||||
label: 'Pull-up',
|
label: getLabel(pullVariant),
|
||||||
isSelected: _selectedExercise == 'pullup',
|
isSelected: _selectedExercise == pullVariant,
|
||||||
onTap: () => _onFilterChanged('pullup', _selectedRange),
|
onTap: () =>
|
||||||
|
_onFilterChanged(pullVariant, _selectedRange),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_FilterChip(
|
_FilterChip(
|
||||||
label: 'Dip',
|
label: getLabel(pushVariant),
|
||||||
isSelected: _selectedExercise == 'dip',
|
isSelected: _selectedExercise == pushVariant,
|
||||||
onTap: () => _onFilterChanged('dip', _selectedRange),
|
onTap: () =>
|
||||||
|
_onFilterChanged(pushVariant, _selectedRange),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
_isChartLoading
|
_isChartLoading
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
height: 250,
|
height: 250,
|
||||||
child: Center(child: CircularProgressIndicator()))
|
child: Center(child: CircularProgressIndicator()))
|
||||||
: ProgressChart(data: _chartData),
|
: ProgressChart(data: _chartData),
|
||||||
|
|
||||||
// (Optional: Range Selector unten drunter '1M', '3M', '1Y'...)
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -269,13 +287,36 @@ class _StatsScreenState extends ConsumerState<StatsScreen> {
|
||||||
|
|
||||||
class _CurrentCycleCard extends StatelessWidget {
|
class _CurrentCycleCard extends StatelessWidget {
|
||||||
final CycleCollection cycle;
|
final CycleCollection cycle;
|
||||||
|
final UserCollection user;
|
||||||
final VoidCallback? onFinish;
|
final VoidCallback? onFinish;
|
||||||
|
|
||||||
const _CurrentCycleCard({required this.cycle, required this.onFinish});
|
const _CurrentCycleCard(
|
||||||
|
{required this.cycle, required this.user, required this.onFinish});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|
@ -287,7 +328,7 @@ class _CurrentCycleCard extends StatelessWidget {
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'CYCLE ${cycle.cycleNumber}',
|
l10n.statsCycleTitle(cycle.cycleNumber),
|
||||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||||
color: AppTheme.primaryColor,
|
color: AppTheme.primaryColor,
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
|
|
@ -297,11 +338,11 @@ class _CurrentCycleCard extends StatelessWidget {
|
||||||
padding:
|
padding:
|
||||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.successColor.withOpacity(0.2),
|
color: AppTheme.successColor.withValues(alpha: 0.2),
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
child: const Text(
|
child: Text(
|
||||||
'ACTIVE',
|
l10n.hubActiveYes,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: AppTheme.successColor,
|
color: AppTheme.successColor,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
|
@ -311,19 +352,25 @@ class _CurrentCycleCard extends StatelessWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
Text('Current Training Maxes (TM)',
|
Text(l10n.statsCurrentTM,
|
||||||
style: Theme.of(context).textTheme.labelLarge),
|
style: Theme.of(context).textTheme.labelLarge),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_StatRow(label: 'Squat', value: '${tms['squat']} kg'),
|
_StatRow(
|
||||||
_StatRow(label: 'Pull-up', value: '${tms['pullup']} kg'),
|
label: l10n.exerciseSquat,
|
||||||
_StatRow(label: 'Dip', value: '${tms['dip']} kg'),
|
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),
|
const SizedBox(height: 32),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: onFinish,
|
onPressed: onFinish,
|
||||||
icon: const Icon(Icons.upgrade),
|
icon: const Icon(Icons.upgrade),
|
||||||
label: const Text('FINISH CYCLE & LEVEL UP'),
|
label: Text(l10n.statsFinishCycle),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppTheme.secondaryColor,
|
backgroundColor: AppTheme.secondaryColor,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
|
|
@ -373,33 +420,40 @@ class _CycleFinishDialog extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: const Text('Dungeon Cleared!'),
|
title: Text(l10n.statsCycleFinishedTitle),
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Text(
|
||||||
'You have defeated the guardians of this cycle. But deeper in the dungeon, stronger foes await...'), // Story
|
l10n.statsCycleFinishedBody,
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
const Text('Your Training Maxes have increased:',
|
Text(l10n.statsTMIncreased,
|
||||||
style: TextStyle(fontWeight: FontWeight.bold)),
|
style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_DiffRow(
|
_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(
|
_DiffRow(
|
||||||
name: 'Pull-up',
|
name: l10n.exercisePullup,
|
||||||
oldVal: oldTMs['pullup'],
|
oldVal: (oldTMs['pullup'] as num).toDouble(),
|
||||||
newVal: newTMs['pullup']),
|
newVal: (newTMs['pullup'] as num).toDouble()),
|
||||||
_DiffRow(name: 'Dip', oldVal: oldTMs['dip'], newVal: newTMs['dip']),
|
_DiffRow(
|
||||||
|
name: l10n.exerciseDip,
|
||||||
|
oldVal: (oldTMs['dip'] as num).toDouble(),
|
||||||
|
newVal: (newTMs['dip'] as num).toDouble()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
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(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: Text(name)),
|
Expanded(child: Text(name)),
|
||||||
Text('${oldVal.toStringAsFixed(1)} → ',
|
Text('${oldVal.toStringAsFixed(2)} → ',
|
||||||
style: const TextStyle(color: Colors.grey)),
|
style: const TextStyle(color: Colors.grey)),
|
||||||
Text(
|
Text(
|
||||||
newVal.toStringAsFixed(1),
|
newVal.toStringAsFixed(2),
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
if (isPositive)
|
if (isPositive)
|
||||||
Text('+${diff.toStringAsFixed(1)}',
|
Text('+${diff.toStringAsFixed(2)}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: AppTheme.successColor, fontWeight: FontWeight.bold))
|
color: AppTheme.successColor, fontWeight: FontWeight.bold))
|
||||||
else
|
else
|
||||||
|
|
@ -458,14 +512,15 @@ class _FilterChip extends StatelessWidget {
|
||||||
label: Text(label),
|
label: Text(label),
|
||||||
selected: isSelected,
|
selected: isSelected,
|
||||||
onSelected: (_) => onTap(),
|
onSelected: (_) => onTap(),
|
||||||
selectedColor: AppTheme.primaryColor.withOpacity(0.2),
|
selectedColor: AppTheme.primaryColor.withValues(alpha: 0.2),
|
||||||
labelStyle: TextStyle(
|
labelStyle: TextStyle(
|
||||||
color: isSelected ? AppTheme.primaryColor : Colors.grey,
|
color: isSelected ? AppTheme.primaryColor : Colors.grey,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
side: BorderSide(
|
side: BorderSide(
|
||||||
color:
|
color: isSelected
|
||||||
isSelected ? AppTheme.primaryColor : Colors.grey.withOpacity(0.3),
|
? AppTheme.primaryColor
|
||||||
|
: Colors.grey.withValues(alpha: 0.3),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ class ProgressChart extends StatelessWidget {
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.surfaceColor,
|
color: AppTheme.surfaceColor,
|
||||||
borderRadius: BorderRadius.circular(16),
|
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(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
|
@ -148,14 +148,12 @@ class ProgressChart extends StatelessWidget {
|
||||||
),
|
),
|
||||||
belowBarData: BarAreaData(
|
belowBarData: BarAreaData(
|
||||||
show: true,
|
show: true,
|
||||||
color: AppTheme.primaryColor.withOpacity(0.1),
|
color: AppTheme.primaryColor.withValues(alpha: 0.1),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
lineTouchData: LineTouchData(
|
lineTouchData: LineTouchData(
|
||||||
touchTooltipData: LineTouchTooltipData(
|
touchTooltipData: LineTouchTooltipData(
|
||||||
// FIX 2: Alte API nutzen (tooltipBgColor statt getTooltipColor)
|
|
||||||
// tooltipBgColor: AppTheme.surfaceColor,
|
|
||||||
getTooltipColor: (touchedSpot) => AppTheme.surfaceColor,
|
getTooltipColor: (touchedSpot) => AppTheme.surfaceColor,
|
||||||
getTooltipItems: (touchedSpots) {
|
getTooltipItems: (touchedSpots) {
|
||||||
return touchedSpots.map((spot) {
|
return touchedSpots.map((spot) {
|
||||||
|
|
|
||||||
|
|
@ -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))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -53,7 +53,7 @@ class EnemyHPBar extends StatelessWidget {
|
||||||
color: Colors.red[900],
|
color: Colors.red[900],
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: AppTheme.errorColor.withOpacity(0.5),
|
color: AppTheme.errorColor.withValues(alpha: 0.5),
|
||||||
width: 2,
|
width: 2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -72,7 +72,7 @@ class EnemyHPBar extends StatelessWidget {
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: AppTheme.errorColor.withOpacity(0.5),
|
color: AppTheme.errorColor.withValues(alpha: 0.5),
|
||||||
blurRadius: 8,
|
blurRadius: 8,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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 'package:flutter/material.dart';
|
||||||
import '../../../../core/theme/app_theme.dart';
|
import '../../../../core/theme/app_theme.dart';
|
||||||
import '../../../../core/constants/asset_paths.dart';
|
import '../../../../core/constants/asset_paths.dart';
|
||||||
|
|
@ -19,6 +188,16 @@ class PlateVisualizer extends StatelessWidget {
|
||||||
return colorValue != null ? Color(colorValue) : Colors.grey;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (plateConfiguration.isEmpty) {
|
if (plateConfiguration.isEmpty) {
|
||||||
|
|
@ -30,7 +209,7 @@ class PlateVisualizer extends StatelessWidget {
|
||||||
Icon(
|
Icon(
|
||||||
isTwoSided ? Icons.fitness_center : Icons.accessibility,
|
isTwoSided ? Icons.fitness_center : Icons.accessibility,
|
||||||
size: 64,
|
size: 64,
|
||||||
color: AppTheme.primaryColor.withOpacity(0.5),
|
color: AppTheme.primaryColor.withValues(alpha: 0.5),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -59,7 +238,7 @@ class PlateVisualizer extends StatelessWidget {
|
||||||
if (isTwoSided) _buildBarbellView() else _buildBeltView(),
|
if (isTwoSided) _buildBarbellView() else _buildBeltView(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
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(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: AppTheme.primaryColor,
|
color: AppTheme.primaryColor,
|
||||||
),
|
),
|
||||||
|
|
@ -137,8 +316,8 @@ class PlateVisualizer extends StatelessWidget {
|
||||||
weight == weight.toInt()
|
weight == weight.toInt()
|
||||||
? '${weight.toInt()}'
|
? '${weight.toInt()}'
|
||||||
: weight.toStringAsFixed(2),
|
: weight.toStringAsFixed(2),
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: _getTextColor(weight),
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
37
lib/src/shared/data/local/app_database.dart
Normal file
37
lib/src/shared/data/local/app_database.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
3657
lib/src/shared/data/local/app_database.g.dart
Normal file
3657
lib/src/shared/data/local/app_database.g.dart
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
|
@ -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
|
|
@ -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
36
lib/src/shared/data/local/converters/json_converter.dart
Normal file
36
lib/src/shared/data/local/converters/json_converter.dart
Normal 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);
|
||||||
|
}
|
||||||
92
lib/src/shared/data/local/tables.dart
Normal file
92
lib/src/shared/data/local/tables.dart
Normal 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};
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,9 @@ class ApiClient {
|
||||||
final FlutterSecureStorage _storage;
|
final FlutterSecureStorage _storage;
|
||||||
final Logger _logger;
|
final Logger _logger;
|
||||||
|
|
||||||
|
bool _isRefreshing = false;
|
||||||
|
final List<Function> _requestsQueue = [];
|
||||||
|
|
||||||
ApiClient({
|
ApiClient({
|
||||||
FlutterSecureStorage? storage,
|
FlutterSecureStorage? storage,
|
||||||
Logger? logger,
|
Logger? logger,
|
||||||
|
|
@ -49,15 +52,117 @@ class ApiClient {
|
||||||
},
|
},
|
||||||
onError: (error, handler) async {
|
onError: (error, handler) async {
|
||||||
if (error.response?.statusCode == 401) {
|
if (error.response?.statusCode == 401) {
|
||||||
_logger.w('Unauthorized - clearing token');
|
final token = await _storage.read(key: AppConstants.keyAuthToken);
|
||||||
await _storage.delete(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);
|
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 {
|
Future<Map<String, dynamic>> login(String email, String password) async {
|
||||||
try {
|
try {
|
||||||
final response = await _dio.post(
|
final response = await _dio.post(
|
||||||
|
|
@ -85,6 +190,7 @@ class ApiClient {
|
||||||
required String password,
|
required String password,
|
||||||
required double bodyweight,
|
required double bodyweight,
|
||||||
required Map<String, dynamic> inventorySettings,
|
required Map<String, dynamic> inventorySettings,
|
||||||
|
Map<String, dynamic>? exerciseVariants,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final response = await _dio.post(
|
final response = await _dio.post(
|
||||||
|
|
@ -97,6 +203,7 @@ class ApiClient {
|
||||||
'level': 1,
|
'level': 1,
|
||||||
'current_bodyweight': bodyweight,
|
'current_bodyweight': bodyweight,
|
||||||
'inventory_settings': inventorySettings,
|
'inventory_settings': inventorySettings,
|
||||||
|
'exercise_variants': exerciseVariants ?? {},
|
||||||
'avatar_config': {
|
'avatar_config': {
|
||||||
'skin_tone': 'medium',
|
'skin_tone': 'medium',
|
||||||
'hair_style': 'short_01',
|
'hair_style': 'short_01',
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,28 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
import '../../../../main.dart';
|
import '../../../../main.dart';
|
||||||
import '../../../core/constants/app_constants.dart';
|
import '../../../core/constants/app_constants.dart';
|
||||||
import '../local/collections/user_collection.dart';
|
import '../local/app_database.dart';
|
||||||
import '../local/collections/cycle_collection.dart';
|
|
||||||
import '../local/collections/workout_collection.dart';
|
|
||||||
import 'api_client.dart';
|
|
||||||
import '../repositories/user_repository.dart';
|
import '../repositories/user_repository.dart';
|
||||||
|
import 'api_client.dart';
|
||||||
|
|
||||||
final syncServiceProvider = Provider<SyncService>((ref) {
|
final syncServiceProvider = Provider<SyncService>((ref) {
|
||||||
final isar = ref.watch(isarProvider);
|
final db = ref.watch(appDatabaseProvider);
|
||||||
final apiClient = ref.watch(apiClientProvider);
|
final apiClient = ref.watch(apiClientProvider);
|
||||||
return SyncService(isar: isar, apiClient: apiClient);
|
return SyncService(db: db, apiClient: apiClient);
|
||||||
});
|
});
|
||||||
|
|
||||||
class SyncService {
|
class SyncService {
|
||||||
final Isar isar;
|
final AppDatabase db;
|
||||||
final ApiClient apiClient;
|
final ApiClient apiClient;
|
||||||
final _storage = const FlutterSecureStorage();
|
final _storage = const FlutterSecureStorage();
|
||||||
bool _isSyncing = false;
|
bool _isSyncing = false;
|
||||||
|
|
||||||
SyncService({required this.isar, required this.apiClient});
|
SyncService({required this.db, required this.apiClient});
|
||||||
|
|
||||||
Future<void> sync() async {
|
Future<void> sync() async {
|
||||||
if (_isSyncing) return;
|
if (_isSyncing) return;
|
||||||
|
|
@ -32,160 +30,206 @@ class SyncService {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
debugPrint('🔄 Starting Sync...');
|
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) {
|
for (var cycle in dirtyCycles) {
|
||||||
try {
|
try {
|
||||||
if (cycle.serverId == null) {
|
if (cycle.serverId == null) {
|
||||||
debugPrint(
|
debugPrint('📤 Pushing new cycle ${cycle.cycleNumber}...');
|
||||||
'📤 Pushing new cycle ${cycle.cycleNumber} to server...');
|
final tmsMap = cycle.trainingMaxes
|
||||||
|
.map((k, v) => MapEntry(k, (v as num).toDouble()));
|
||||||
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};
|
|
||||||
}
|
|
||||||
|
|
||||||
final response = await apiClient.createCycle(tmsMap);
|
final response = await apiClient.createCycle(tmsMap);
|
||||||
final newServerId = response['id'];
|
final newServerId = response['id'];
|
||||||
|
|
||||||
await isar.writeTxn(() async {
|
await db.transaction(() async {
|
||||||
cycle.serverId = newServerId;
|
await (db.update(db.cycles)..where((c) => c.id.equals(cycle.id)))
|
||||||
cycle.isDirty = false;
|
.write(
|
||||||
await isar.cycleCollections.put(cycle);
|
CyclesCompanion(
|
||||||
|
serverId: Value(newServerId),
|
||||||
|
isDirty: const Value(false),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
final oldLocalIdRef = cycle.id.toString();
|
final oldLocalIdRef = cycle.id.toString();
|
||||||
|
await (db.update(db.workouts)
|
||||||
final orphanWorkouts = await isar.workoutCollections
|
..where((w) => w.cycleId.equals(oldLocalIdRef)))
|
||||||
.filter()
|
.write(
|
||||||
.cycleIdEqualTo(oldLocalIdRef)
|
WorkoutsCompanion(
|
||||||
.findAll();
|
cycleId: Value(newServerId),
|
||||||
|
isDirty: const Value(true),
|
||||||
for (var w in orphanWorkouts) {
|
),
|
||||||
w.cycleId = newServerId;
|
);
|
||||||
w.isDirty = true;
|
|
||||||
await isar.workoutCollections.put(w);
|
|
||||||
debugPrint('🔗 Relinked workout ${w.id} to cycle $newServerId');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await isar.writeTxn(() async {
|
await (db.update(db.cycles)..where((c) => c.id.equals(cycle.id)))
|
||||||
cycle.isDirty = false;
|
.write(const CyclesCompanion(isDirty: Value(false)));
|
||||||
await isar.cycleCollections.put(cycle);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ Failed to sync cycle: $e');
|
debugPrint('❌ Failed to sync cycle ${cycle.id}: $e');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final dirtyUser =
|
final dirtyUser = await (db.select(db.users)
|
||||||
await isar.userCollections.filter().isDirtyEqualTo(true).findFirst();
|
..where((u) => u.isDirty.equals(true)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
final dirtyWorkouts = await (db.select(db.workouts)
|
||||||
|
..where((w) => w.isDirty.equals(true)))
|
||||||
|
.get();
|
||||||
|
|
||||||
final dirtyWorkouts =
|
final validWorkouts =
|
||||||
await isar.workoutCollections.filter().isDirtyEqualTo(true).findAll();
|
dirtyWorkouts.where((w) => w.cycleId.length > 5).toList();
|
||||||
|
|
||||||
if (dirtyUser == null && dirtyWorkouts.isEmpty) {
|
final pushData = <String, dynamic>{
|
||||||
debugPrint('✅ Nothing to push.');
|
'workouts': validWorkouts.map((w) {
|
||||||
} else {
|
return {
|
||||||
final pushData = <String, dynamic>{
|
'id': w.serverId,
|
||||||
'workouts': dirtyWorkouts.where((w) {
|
'local_id': w.id,
|
||||||
return w.cycleId.length > 5;
|
'cycle_id': w.cycleId,
|
||||||
}).map((w) {
|
'week': w.week,
|
||||||
return {
|
'day': w.day,
|
||||||
'id': w.serverId,
|
'completed_at': w.completedAt?.toIso8601String(),
|
||||||
'local_id': w.id,
|
'xp_earned': w.xpEarned,
|
||||||
'cycle_id': w.cycleId,
|
'notes': w.notes,
|
||||||
'week': w.week,
|
'exercises': w.exercises,
|
||||||
'day': w.day,
|
};
|
||||||
'completed_at': w.completedAt?.toIso8601String(),
|
}).toList(),
|
||||||
'xp_earned': w.xpEarned,
|
'user_stats': dirtyUser != null
|
||||||
'notes': w.notes,
|
? {
|
||||||
'exercises': jsonDecode(w.exercisesJson),
|
'xp': dirtyUser.xp,
|
||||||
};
|
'level': dirtyUser.level,
|
||||||
}).toList(),
|
'current_bodyweight': dirtyUser.currentBodyweight,
|
||||||
'user_stats': dirtyUser != null
|
'exercise_variants': dirtyUser.exerciseVariants,
|
||||||
? {
|
}
|
||||||
'xp': dirtyUser.xp,
|
: null,
|
||||||
'level': dirtyUser.level,
|
};
|
||||||
'current_bodyweight': dirtyUser.currentBodyweight,
|
|
||||||
|
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) {
|
if (response['pull_data']['workouts'] != null) {
|
||||||
await _storage.write(
|
final pulledWorkouts = response['pull_data']['workouts'] as List;
|
||||||
key: AppConstants.keyLastSync,
|
debugPrint('📥 Pulled ${pulledWorkouts.length} workouts.');
|
||||||
value: response['server_timestamp'],
|
|
||||||
);
|
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');
|
debugPrint('✅ Sync completed successfully');
|
||||||
} catch (e) {
|
} catch (e, stack) {
|
||||||
debugPrint('❌ Sync failed: $e');
|
debugPrint('❌ Sync failed: $e');
|
||||||
|
debugPrint(stack.toString());
|
||||||
} finally {
|
} finally {
|
||||||
_isSyncing = false;
|
_isSyncing = false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,49 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:drift/drift.dart';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import '../local/collections/cycle_collection.dart';
|
import '../local/app_database.dart';
|
||||||
import '../remote/api_client.dart';
|
import '../remote/api_client.dart';
|
||||||
import '../../../../main.dart';
|
import '../../../../main.dart';
|
||||||
import 'user_repository.dart';
|
import 'user_repository.dart';
|
||||||
import '../../../core/constants/app_constants.dart';
|
import '../../../core/constants/app_constants.dart';
|
||||||
import '../local/collections/workout_collection.dart';
|
|
||||||
|
|
||||||
final cycleRepositoryProvider = Provider<CycleRepository>((ref) {
|
final cycleRepositoryProvider = Provider<CycleRepository>((ref) {
|
||||||
final isar = ref.watch(isarProvider);
|
final db = ref.watch(appDatabaseProvider);
|
||||||
final apiClient = ref.watch(apiClientProvider);
|
final apiClient = ref.watch(apiClientProvider);
|
||||||
return CycleRepository(isar: isar, apiClient: apiClient);
|
return CycleRepository(db: db, apiClient: apiClient);
|
||||||
});
|
});
|
||||||
|
|
||||||
class CycleRepository {
|
class CycleRepository {
|
||||||
final Isar isar;
|
final AppDatabase db;
|
||||||
final ApiClient apiClient;
|
final ApiClient apiClient;
|
||||||
|
|
||||||
CycleRepository({required this.isar, required this.apiClient});
|
CycleRepository({required this.db, required this.apiClient});
|
||||||
|
|
||||||
Future<CycleCollection?> getCurrentCycle() async {
|
Future<CycleCollection?> getCurrentCycle() async {
|
||||||
return await isar.cycleCollections
|
return await (db.select(db.cycles)
|
||||||
.filter()
|
..where((c) => c.isActive.equals(true))
|
||||||
.isActiveEqualTo(true)
|
..limit(1))
|
||||||
.findFirst();
|
.getSingleOrNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<CycleCollection>> getAllCycles() async {
|
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 {
|
Future<CycleCollection> createCycle(Map<String, double> trainingMaxes) async {
|
||||||
try {
|
return await db.transaction(() async {
|
||||||
final currentCycle = await getCurrentCycle();
|
final currentCycle = await getCurrentCycle();
|
||||||
if (currentCycle != null) {
|
if (currentCycle != null) {
|
||||||
currentCycle.isActive = false;
|
final updateOld = CyclesCompanion(
|
||||||
currentCycle.endDate = DateTime.now();
|
isActive: const Value(false),
|
||||||
await saveCycle(currentCycle);
|
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();
|
final allCycles = await getAllCycles();
|
||||||
|
|
@ -50,34 +54,44 @@ class CycleRepository {
|
||||||
.reduce((a, b) => a > b ? a : b) +
|
.reduce((a, b) => a > b ? a : b) +
|
||||||
1;
|
1;
|
||||||
|
|
||||||
final userRepo = UserRepository(isar: isar, apiClient: ApiClient());
|
final user = await (db.select(db.users)..limit(1)).getSingleOrNull();
|
||||||
final user = await userRepo.getLocalUser();
|
|
||||||
|
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
throw Exception('No user found for cycle creation');
|
throw Exception('No user found for cycle creation');
|
||||||
}
|
}
|
||||||
|
|
||||||
final newCycle = CycleCollection()
|
final newCycleCompanion = CyclesCompanion(
|
||||||
..userId = user.serverId ?? user.id.toString()
|
userId: Value(user.serverId ?? user.id.toString()),
|
||||||
..cycleNumber = nextNumber
|
cycleNumber: Value(nextNumber),
|
||||||
..startDate = DateTime.now()
|
startDate: Value(DateTime.now()),
|
||||||
..isActive = true
|
isActive: const Value(true),
|
||||||
..trainingMaxesJson = jsonEncode(trainingMaxes)
|
trainingMaxes: Value(trainingMaxes),
|
||||||
..isDirty = true;
|
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 {
|
try {
|
||||||
final response = await apiClient.createCycle(trainingMaxes);
|
final response = await apiClient.createCycle(trainingMaxes);
|
||||||
newCycle.serverId = response['id'];
|
await (db.update(db.cycles)..where((c) => c.id.equals(newId))).write(
|
||||||
newCycle.isDirty = false;
|
CyclesCompanion(
|
||||||
await saveCycle(newCycle);
|
serverId: Value(response['id']),
|
||||||
} catch (e) {}
|
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;
|
return newCycle;
|
||||||
} catch (e, stackTrace) {
|
});
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<CycleCollection> finishCycle() async {
|
Future<CycleCollection> finishCycle() async {
|
||||||
|
|
@ -87,24 +101,26 @@ class CycleRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
final cycleIdRef = currentCycle.serverId ?? currentCycle.id.toString();
|
final cycleIdRef = currentCycle.serverId ?? currentCycle.id.toString();
|
||||||
|
final localCycleId = currentCycle.id.toString();
|
||||||
|
|
||||||
final completedMainWorkouts = await isar.workoutCollections
|
final workoutsQuery = db.select(db.workouts)
|
||||||
.filter()
|
..where((w) {
|
||||||
.weekLessThan(4)
|
final weekCheck = w.week.isSmallerThanValue(4);
|
||||||
.completedAtIsNotNull()
|
final completedCheck = w.completedAt.isNotNull();
|
||||||
.group((q) => q
|
final cycleCheck =
|
||||||
.cycleIdEqualTo(cycleIdRef)
|
w.cycleId.equals(cycleIdRef) | w.cycleId.equals(localCycleId);
|
||||||
.or()
|
return weekCheck & completedCheck & cycleCheck;
|
||||||
.cycleIdEqualTo(currentCycle.id.toString()))
|
});
|
||||||
.count();
|
|
||||||
|
final completedMainWorkouts = (await workoutsQuery.get()).length;
|
||||||
|
|
||||||
if (completedMainWorkouts < 9) {
|
if (completedMainWorkouts < 9) {
|
||||||
final missing = 9 - completedMainWorkouts;
|
final missing = 9 - completedMainWorkouts;
|
||||||
throw Exception(
|
throw Exception(
|
||||||
'Cycle incomplete! You still have $missing workouts left in the main phase (Weeks 1-3). Finish them before leveling up.');
|
'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>{
|
final newTMs = <String, double>{
|
||||||
'squat': (currentTMs['squat'] as num?)?.toDouble() ?? 0.0,
|
'squat': (currentTMs['squat'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
|
@ -112,23 +128,27 @@ class CycleRepository {
|
||||||
'dip': (currentTMs['dip'] as num?)?.toDouble() ?? 0.0,
|
'dip': (currentTMs['dip'] as num?)?.toDouble() ?? 0.0,
|
||||||
};
|
};
|
||||||
|
|
||||||
final week3Workouts = await isar.workoutCollections
|
final week3Workouts = await (db.select(db.workouts)
|
||||||
.filter()
|
..where((w) {
|
||||||
.weekEqualTo(3)
|
final weekCheck = w.week.equals(3);
|
||||||
.group((q) => q
|
final cycleCheck =
|
||||||
.cycleIdEqualTo(cycleIdRef)
|
w.cycleId.equals(cycleIdRef) | w.cycleId.equals(localCycleId);
|
||||||
.or()
|
return weekCheck & cycleCheck;
|
||||||
.cycleIdEqualTo(currentCycle.id.toString()))
|
}))
|
||||||
.findAll();
|
.get();
|
||||||
|
|
||||||
bool checkSuccess(String exerciseId) {
|
bool checkSuccess(String exerciseId) {
|
||||||
for (var workout in week3Workouts) {
|
for (var workout in week3Workouts) {
|
||||||
try {
|
try {
|
||||||
final exercises = jsonDecode(workout.exercisesJson) as List;
|
final exercises = workout.exercises;
|
||||||
for (var ex in exercises) {
|
|
||||||
|
for (var exData in exercises) {
|
||||||
|
final ex = exData as Map<String, dynamic>;
|
||||||
|
|
||||||
if (ex['exerciseId'] == exerciseId) {
|
if (ex['exerciseId'] == exerciseId) {
|
||||||
final sets = ex['sets'] as List;
|
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) {
|
if (s['isAmrap'] == true) {
|
||||||
final reps = s['repsActual'] as int? ?? 0;
|
final reps = s['repsActual'] as int? ?? 0;
|
||||||
if (reps >= 1) {
|
if (reps >= 1) {
|
||||||
|
|
@ -170,33 +190,23 @@ class CycleRepository {
|
||||||
try {
|
try {
|
||||||
await apiClient.finishCycle(currentCycle.serverId!);
|
await apiClient.finishCycle(currentCycle.serverId!);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Fehler ignorieren, wird später gesynct
|
// Fehler ignorieren
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return await createCycle(newTMs);
|
return await createCycle(newTMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> saveCycle(CycleCollection cycle) async {
|
Future<Map<String, double>> getCurrentTrainingMaxesAsync() async {
|
||||||
cycle.updatedAt = DateTime.now();
|
final cycle = await getCurrentCycle();
|
||||||
await isar.writeTxn(() async {
|
|
||||||
await isar.cycleCollections.put(cycle);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, double> getCurrentTrainingMaxes() {
|
|
||||||
final cycle =
|
|
||||||
isar.cycleCollections.filter().isActiveEqualTo(true).findFirstSync();
|
|
||||||
|
|
||||||
if (cycle != null) {
|
if (cycle != null) {
|
||||||
final tms = jsonDecode(cycle.trainingMaxesJson);
|
final tms = cycle.trainingMaxes;
|
||||||
return {
|
return {
|
||||||
'squat': (tms['squat'] as num?)?.toDouble() ?? 0.0,
|
'squat': (tms['squat'] as num?)?.toDouble() ?? 0.0,
|
||||||
'pullup': (tms['pullup'] as num?)?.toDouble() ?? 0.0,
|
'pullup': (tms['pullup'] as num?)?.toDouble() ?? 0.0,
|
||||||
'dip': (tms['dip'] as num?)?.toDouble() ?? 0.0,
|
'dip': (tms['dip'] as num?)?.toDouble() ?? 0.0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {'squat': 0.0, 'pullup': 0.0, 'dip': 0.0};
|
return {'squat': 0.0, 'pullup': 0.0, 'dip': 0.0};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,62 +1,76 @@
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:isar/isar.dart';
|
|
||||||
import 'dart:convert';
|
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/app_database.dart';
|
||||||
import '../local/collections/user_collection.dart';
|
|
||||||
import '../local/collections/workout_collection.dart';
|
|
||||||
import '../remote/api_client.dart';
|
import '../remote/api_client.dart';
|
||||||
import '../../../../main.dart';
|
import '../../../../main.dart';
|
||||||
|
import '../../../core/constants/app_constants.dart';
|
||||||
|
|
||||||
final userRepositoryProvider = Provider<UserRepository>((ref) {
|
final userRepositoryProvider = Provider<UserRepository>((ref) {
|
||||||
final isar = ref.watch(isarProvider);
|
final db = ref.watch(appDatabaseProvider);
|
||||||
final apiClient = ref.watch(apiClientProvider);
|
final apiClient = ref.watch(apiClientProvider);
|
||||||
return UserRepository(isar: isar, apiClient: apiClient);
|
return UserRepository(db: db, apiClient: apiClient);
|
||||||
});
|
});
|
||||||
|
|
||||||
final apiClientProvider = Provider<ApiClient>((ref) => ApiClient());
|
final apiClientProvider = Provider<ApiClient>((ref) => ApiClient());
|
||||||
|
|
||||||
class UserRepository {
|
class UserRepository {
|
||||||
final Isar isar;
|
final AppDatabase db;
|
||||||
final ApiClient apiClient;
|
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 {
|
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 {
|
Future<void> saveLocalUser(UserCollection user) async {
|
||||||
user.updatedAt = DateTime.now();
|
final companion = user.toCompanion(true).copyWith(
|
||||||
await isar.writeTxn(() async {
|
updatedAt: Value(DateTime.now()),
|
||||||
await isar.userCollections.put(user);
|
);
|
||||||
});
|
await db.into(db.users).insertOnConflictUpdate(companion);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateXP(int xpToAdd) async {
|
Future<void> updateXP(int xpToAdd) async {
|
||||||
final user = await getLocalUser();
|
final user = await getLocalUser();
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
user.xp += xpToAdd;
|
final newXp = user.xp + xpToAdd;
|
||||||
user.isDirty = true;
|
final companion = UsersCompanion(
|
||||||
await saveLocalUser(user);
|
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 {
|
Future<void> updateLevel(int newLevel) async {
|
||||||
final user = await getLocalUser();
|
final user = await getLocalUser();
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
user.level = newLevel;
|
final companion = UsersCompanion(
|
||||||
user.isDirty = true;
|
level: Value(newLevel),
|
||||||
await saveLocalUser(user);
|
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 {
|
Future<void> updateBodyweight(double bodyweight) async {
|
||||||
final user = await getLocalUser();
|
final user = await getLocalUser();
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
user.currentBodyweight = bodyweight;
|
final companion = UsersCompanion(
|
||||||
user.isDirty = true;
|
currentBodyweight: Value(bodyweight),
|
||||||
await saveLocalUser(user);
|
isDirty: const Value(true),
|
||||||
|
updatedAt: Value(DateTime.now()),
|
||||||
|
);
|
||||||
|
await (db.update(db.users)..where((u) => u.id.equals(user.id)))
|
||||||
|
.write(companion);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.updateBodyweight(bodyweight);
|
await apiClient.updateBodyweight(bodyweight);
|
||||||
|
|
@ -67,9 +81,13 @@ class UserRepository {
|
||||||
Future<void> updateInventory(Map<String, dynamic> inventory) async {
|
Future<void> updateInventory(Map<String, dynamic> inventory) async {
|
||||||
final user = await getLocalUser();
|
final user = await getLocalUser();
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
user.inventorySettingsJson = jsonEncode(inventory);
|
final companion = UsersCompanion(
|
||||||
user.isDirty = true;
|
inventorySettings: Value(inventory),
|
||||||
await saveLocalUser(user);
|
isDirty: const Value(true),
|
||||||
|
updatedAt: Value(DateTime.now()),
|
||||||
|
);
|
||||||
|
await (db.update(db.users)..where((u) => u.id.equals(user.id)))
|
||||||
|
.write(companion);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.updateInventory(inventory);
|
await apiClient.updateInventory(inventory);
|
||||||
|
|
@ -79,21 +97,7 @@ class UserRepository {
|
||||||
|
|
||||||
Future<UserCollection> login(String email, String password) async {
|
Future<UserCollection> login(String email, String password) async {
|
||||||
final response = await apiClient.login(email, password);
|
final response = await apiClient.login(email, password);
|
||||||
|
return _saveUserFromApi(response['record']);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<UserCollection> register({
|
Future<UserCollection> register({
|
||||||
|
|
@ -101,6 +105,7 @@ class UserRepository {
|
||||||
required String password,
|
required String password,
|
||||||
required double bodyweight,
|
required double bodyweight,
|
||||||
required Map<String, dynamic> inventorySettings,
|
required Map<String, dynamic> inventorySettings,
|
||||||
|
Map<String, dynamic>? exerciseVariants,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final response = await apiClient.register(
|
final response = await apiClient.register(
|
||||||
|
|
@ -108,51 +113,79 @@ class UserRepository {
|
||||||
password: password,
|
password: password,
|
||||||
bodyweight: bodyweight,
|
bodyweight: bodyweight,
|
||||||
inventorySettings: inventorySettings,
|
inventorySettings: inventorySettings,
|
||||||
|
exerciseVariants: exerciseVariants,
|
||||||
);
|
);
|
||||||
|
|
||||||
final record = response['record'] ?? response;
|
final record = response['record'] ?? response;
|
||||||
|
var user = await _saveUserFromApi(record);
|
||||||
|
|
||||||
final user = UserCollection()
|
if (exerciseVariants != null && exerciseVariants.isNotEmpty) {
|
||||||
..serverId = record['id']?.toString()
|
final serverVariants = user.exerciseVariants;
|
||||||
..email = record['email']?.toString() ?? email
|
if (serverVariants == null || serverVariants.isEmpty) {
|
||||||
..xp = (record['xp'] as num?)?.toInt() ?? 0
|
final companion = user.toCompanion(true).copyWith(
|
||||||
..level = (record['level'] as num?)?.toInt() ?? 1
|
exerciseVariants: Value(exerciseVariants),
|
||||||
..currentBodyweight =
|
isDirty: const Value(true),
|
||||||
(record['current_bodyweight'] as num?)?.toDouble() ?? bodyweight
|
updatedAt: Value(DateTime.now()),
|
||||||
..inventorySettingsJson =
|
);
|
||||||
jsonEncode(record['inventory_settings'] ?? inventorySettings)
|
await db.into(db.users).insertOnConflictUpdate(companion);
|
||||||
..avatarConfigJson = jsonEncode(record['avatar_config'] ??
|
|
||||||
{
|
|
||||||
'skin_tone': 'medium',
|
|
||||||
'hair_style': 'short_01',
|
|
||||||
'clothing': 'basic_tee',
|
|
||||||
'unlocked_items': ['basic_tee'],
|
|
||||||
})
|
|
||||||
..lastSyncAt = DateTime.now();
|
|
||||||
|
|
||||||
await saveLocalUser(user);
|
user = (await (db.select(db.users)
|
||||||
|
..where((u) => u.id.equals(user.id)))
|
||||||
|
.getSingle());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.login(email, password);
|
await apiClient.login(email, password);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
} catch (e, stackTrace) {
|
} catch (e) {
|
||||||
rethrow;
|
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 {
|
Future<void> logout() async {
|
||||||
await apiClient.logout();
|
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() {
|
Future<Map<String, dynamic>> getInventorySettingsAsync() async {
|
||||||
final user = isar.userCollections.where().findFirstSync();
|
final user = await getLocalUser();
|
||||||
if (user?.inventorySettingsJson != null) {
|
if (user?.inventorySettings != null) {
|
||||||
return jsonDecode(user!.inventorySettingsJson!);
|
return user!.inventorySettings!;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
'bar_weight': 20.0,
|
'bar_weight': 20.0,
|
||||||
|
|
@ -161,14 +194,14 @@ class UserRepository {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
List<double> getAvailablePlates() {
|
Future<List<double>> getAvailablePlates() async {
|
||||||
final inventory = getInventorySettings();
|
final inventory = await getInventorySettingsAsync();
|
||||||
final plates = inventory['plates'] as List?;
|
final plates = inventory['plates'] as List?;
|
||||||
return plates?.map((e) => (e as num).toDouble()).toList() ?? [];
|
return plates?.map((e) => (e as num).toDouble()).toList() ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
double getBarWeight() {
|
Future<double> getBarWeight() async {
|
||||||
final inventory = getInventorySettings();
|
final inventory = await getInventorySettingsAsync();
|
||||||
return (inventory['bar_weight'] as num?)?.toDouble() ?? 20.0;
|
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.");
|
"Server connection required to reset progress. Please try again when online.");
|
||||||
}
|
}
|
||||||
|
|
||||||
user.xp = 0;
|
final companion = UsersCompanion(
|
||||||
user.level = 1;
|
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 db.delete(db.cycles).go();
|
||||||
|
await db.delete(db.workouts).go();
|
||||||
await isar.writeTxn(() async {
|
|
||||||
await isar.userCollections.put(user);
|
|
||||||
|
|
||||||
await isar.cycleCollections.clear();
|
|
||||||
await isar.workoutCollections.clear();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,44 @@
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:drift/drift.dart';
|
||||||
import 'dart:convert';
|
import '../local/app_database.dart';
|
||||||
|
|
||||||
import '../local/collections/workout_collection.dart';
|
|
||||||
import '../remote/api_client.dart';
|
import '../remote/api_client.dart';
|
||||||
import '../../../../main.dart';
|
import '../../../../main.dart';
|
||||||
import 'user_repository.dart';
|
import 'user_repository.dart';
|
||||||
|
|
||||||
final workoutRepositoryProvider = Provider<WorkoutRepository>((ref) {
|
final workoutRepositoryProvider = Provider<WorkoutRepository>((ref) {
|
||||||
final isar = ref.watch(isarProvider);
|
final db = ref.watch(appDatabaseProvider);
|
||||||
final apiClient = ref.watch(apiClientProvider);
|
final apiClient = ref.watch(apiClientProvider);
|
||||||
return WorkoutRepository(isar: isar, apiClient: apiClient);
|
return WorkoutRepository(db: db, apiClient: apiClient);
|
||||||
});
|
});
|
||||||
|
|
||||||
class WorkoutRepository {
|
class WorkoutRepository {
|
||||||
final Isar isar;
|
final AppDatabase db;
|
||||||
final ApiClient apiClient;
|
final ApiClient apiClient;
|
||||||
|
|
||||||
WorkoutRepository({required this.isar, required this.apiClient});
|
WorkoutRepository({required this.db, required this.apiClient});
|
||||||
|
|
||||||
Future<List<WorkoutCollection>> getAllWorkouts() async {
|
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 {
|
Future<List<WorkoutCollection>> getWorkoutsForCycle(String cycleId) async {
|
||||||
return await isar.workoutCollections
|
return await (db.select(db.workouts)
|
||||||
.filter()
|
..where((w) => w.cycleId.equals(cycleId)))
|
||||||
.cycleIdEqualTo(cycleId)
|
.get();
|
||||||
.findAll();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<WorkoutCollection>> getCompletedWorkouts(String userId) async {
|
Future<List<WorkoutCollection>> getCompletedWorkouts(String userId) async {
|
||||||
return await isar.workoutCollections
|
return await (db.select(db.workouts)
|
||||||
.filter()
|
..where((w) => w.userId.equals(userId) & w.completedAt.isNotNull()))
|
||||||
.userIdEqualTo(userId)
|
.get();
|
||||||
.completedAtIsNotNull()
|
|
||||||
.findAll();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> saveWorkout(WorkoutCollection workout) async {
|
Future<void> saveWorkout(WorkoutCollection workout) async {
|
||||||
workout.updatedAt = DateTime.now();
|
final companion = workout.toCompanion(true).copyWith(
|
||||||
workout.isDirty = true;
|
updatedAt: Value(DateTime.now()),
|
||||||
await isar.writeTxn(() async {
|
isDirty: const Value(true),
|
||||||
await isar.workoutCollections.put(workout);
|
);
|
||||||
});
|
await db.into(db.workouts).insertOnConflictUpdate(companion);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<WorkoutCollection> createWorkout({
|
Future<WorkoutCollection> createWorkout({
|
||||||
|
|
@ -51,27 +46,42 @@ class WorkoutRepository {
|
||||||
required String cycleId,
|
required String cycleId,
|
||||||
required int week,
|
required int week,
|
||||||
required int day,
|
required int day,
|
||||||
required String exercisesJson,
|
required List<dynamic> exercises,
|
||||||
}) async {
|
}) async {
|
||||||
final workout = WorkoutCollection()
|
final companion = WorkoutsCompanion(
|
||||||
..userId = userId
|
userId: Value(userId),
|
||||||
..cycleId = cycleId
|
cycleId: Value(cycleId),
|
||||||
..week = week
|
week: Value(week),
|
||||||
..day = day
|
day: Value(day),
|
||||||
..exercisesJson = exercisesJson
|
exercises: Value(exercises),
|
||||||
..scheduledDate = DateTime.now();
|
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);
|
final id = await db.into(db.workouts).insert(companion);
|
||||||
return workout;
|
return await (db.select(db.workouts)..where((w) => w.id.equals(id)))
|
||||||
|
.getSingle();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> completeWorkout(
|
Future<void> completeWorkout(
|
||||||
WorkoutCollection workout, {
|
WorkoutCollection workout, {
|
||||||
required int xpEarned,
|
required int xpEarned,
|
||||||
}) async {
|
}) async {
|
||||||
workout.completedAt = DateTime.now();
|
final companion = WorkoutsCompanion(
|
||||||
workout.xpEarned = xpEarned;
|
id: Value(workout.id),
|
||||||
await saveWorkout(workout);
|
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({
|
Future<WorkoutCollection?> getWorkoutByWeekDay({
|
||||||
|
|
@ -80,16 +90,23 @@ class WorkoutRepository {
|
||||||
required int week,
|
required int week,
|
||||||
required int day,
|
required int day,
|
||||||
}) async {
|
}) async {
|
||||||
return await isar.workoutCollections
|
return await (db.select(db.workouts)
|
||||||
.filter()
|
..where((w) {
|
||||||
.weekEqualTo(week)
|
final weekDayCheck = w.week.equals(week) & w.day.equals(day);
|
||||||
.dayEqualTo(day)
|
|
||||||
.group((q) {
|
Expression<bool> cycleCheck = w.cycleId.equals(cycleId);
|
||||||
var query = q.cycleIdEqualTo(cycleId);
|
if (localCycleId != null) {
|
||||||
if (localCycleId != null) {
|
cycleCheck = cycleCheck | w.cycleId.equals(localCycleId);
|
||||||
query = query.or().cycleIdEqualTo(localCycleId);
|
}
|
||||||
}
|
|
||||||
return query;
|
return weekDayCheck & cycleCheck;
|
||||||
}).findFirst();
|
})
|
||||||
|
..limit(1))
|
||||||
|
.getSingleOrNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<WorkoutCollection?> getWorkoutById(int id) async {
|
||||||
|
return await (db.select(db.workouts)..where((w) => w.id.equals(id)))
|
||||||
|
.getSingleOrNull();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,15 @@ part 'exercise.freezed.dart';
|
||||||
part 'exercise.g.dart';
|
part 'exercise.g.dart';
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
class Exercise with _$Exercise {
|
abstract class Exercise with _$Exercise {
|
||||||
const factory Exercise({
|
const factory Exercise({
|
||||||
required String exerciseId,
|
required String exerciseId,
|
||||||
required String exerciseName,
|
required String exerciseName,
|
||||||
@Default(0.0) double bodyweightAtSession,
|
@Default(0.0) double bodyweightAtSession,
|
||||||
@Default([]) List<WorkoutSet> sets,
|
@Default([]) List<WorkoutSet> sets,
|
||||||
|
int? intervalSeconds,
|
||||||
}) = _Exercise;
|
}) = _Exercise;
|
||||||
|
|
||||||
factory Exercise.fromJson(Map<String, dynamic> json) =>
|
factory Exercise.fromJson(Map<String, dynamic> json) =>
|
||||||
_$ExerciseFromJson(json);
|
_$ExerciseFromJson(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,12 @@ mixin _$Exercise {
|
||||||
double get bodyweightAtSession => throw _privateConstructorUsedError;
|
double get bodyweightAtSession => throw _privateConstructorUsedError;
|
||||||
List<WorkoutSet> get sets => throw _privateConstructorUsedError;
|
List<WorkoutSet> get sets => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// Serializes this Exercise to a JSON map.
|
||||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
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 =>
|
$ExerciseCopyWith<Exercise> get copyWith =>
|
||||||
throw _privateConstructorUsedError;
|
throw _privateConstructorUsedError;
|
||||||
}
|
}
|
||||||
|
|
@ -53,6 +57,8 @@ class _$ExerciseCopyWithImpl<$Res, $Val extends Exercise>
|
||||||
// ignore: unused_field
|
// ignore: unused_field
|
||||||
final $Res Function($Val) _then;
|
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')
|
@pragma('vm:prefer-inline')
|
||||||
@override
|
@override
|
||||||
$Res call({
|
$Res call({
|
||||||
|
|
@ -105,6 +111,8 @@ class __$$ExerciseImplCopyWithImpl<$Res>
|
||||||
_$ExerciseImpl _value, $Res Function(_$ExerciseImpl) _then)
|
_$ExerciseImpl _value, $Res Function(_$ExerciseImpl) _then)
|
||||||
: super(_value, _then);
|
: super(_value, _then);
|
||||||
|
|
||||||
|
/// Create a copy of Exercise
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@pragma('vm:prefer-inline')
|
@pragma('vm:prefer-inline')
|
||||||
@override
|
@override
|
||||||
$Res call({
|
$Res call({
|
||||||
|
|
@ -182,12 +190,14 @@ class _$ExerciseImpl implements _Exercise {
|
||||||
const DeepCollectionEquality().equals(other._sets, _sets));
|
const DeepCollectionEquality().equals(other._sets, _sets));
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonKey(ignore: true)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(runtimeType, exerciseId, exerciseName,
|
int get hashCode => Object.hash(runtimeType, exerciseId, exerciseName,
|
||||||
bodyweightAtSession, const DeepCollectionEquality().hash(_sets));
|
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
|
@override
|
||||||
@pragma('vm:prefer-inline')
|
@pragma('vm:prefer-inline')
|
||||||
_$$ExerciseImplCopyWith<_$ExerciseImpl> get copyWith =>
|
_$$ExerciseImplCopyWith<_$ExerciseImpl> get copyWith =>
|
||||||
|
|
@ -219,8 +229,11 @@ abstract class _Exercise implements Exercise {
|
||||||
double get bodyweightAtSession;
|
double get bodyweightAtSession;
|
||||||
@override
|
@override
|
||||||
List<WorkoutSet> get sets;
|
List<WorkoutSet> get sets;
|
||||||
|
|
||||||
|
/// Create a copy of Exercise
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@override
|
@override
|
||||||
@JsonKey(ignore: true)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
_$$ExerciseImplCopyWith<_$ExerciseImpl> get copyWith =>
|
_$$ExerciseImplCopyWith<_$ExerciseImpl> get copyWith =>
|
||||||
throw _privateConstructorUsedError;
|
throw _privateConstructorUsedError;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ part 'training_maxes.freezed.dart';
|
||||||
part 'training_maxes.g.dart';
|
part 'training_maxes.g.dart';
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
class TrainingMaxes with _$TrainingMaxes {
|
abstract class TrainingMaxes with _$TrainingMaxes {
|
||||||
const factory TrainingMaxes({
|
const factory TrainingMaxes({
|
||||||
@Default(0.0) double squat,
|
@Default(0.0) double squat,
|
||||||
@Default(0.0) double pullup,
|
@Default(0.0) double pullup,
|
||||||
|
|
@ -14,4 +14,3 @@ class TrainingMaxes with _$TrainingMaxes {
|
||||||
factory TrainingMaxes.fromJson(Map<String, dynamic> json) =>
|
factory TrainingMaxes.fromJson(Map<String, dynamic> json) =>
|
||||||
_$TrainingMaxesFromJson(json);
|
_$TrainingMaxesFromJson(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,12 @@ mixin _$TrainingMaxes {
|
||||||
double get pullup => throw _privateConstructorUsedError;
|
double get pullup => throw _privateConstructorUsedError;
|
||||||
double get dip => throw _privateConstructorUsedError;
|
double get dip => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// Serializes this TrainingMaxes to a JSON map.
|
||||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
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 =>
|
$TrainingMaxesCopyWith<TrainingMaxes> get copyWith =>
|
||||||
throw _privateConstructorUsedError;
|
throw _privateConstructorUsedError;
|
||||||
}
|
}
|
||||||
|
|
@ -49,6 +53,8 @@ class _$TrainingMaxesCopyWithImpl<$Res, $Val extends TrainingMaxes>
|
||||||
// ignore: unused_field
|
// ignore: unused_field
|
||||||
final $Res Function($Val) _then;
|
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')
|
@pragma('vm:prefer-inline')
|
||||||
@override
|
@override
|
||||||
$Res call({
|
$Res call({
|
||||||
|
|
@ -92,6 +98,8 @@ class __$$TrainingMaxesImplCopyWithImpl<$Res>
|
||||||
_$TrainingMaxesImpl _value, $Res Function(_$TrainingMaxesImpl) _then)
|
_$TrainingMaxesImpl _value, $Res Function(_$TrainingMaxesImpl) _then)
|
||||||
: super(_value, _then);
|
: super(_value, _then);
|
||||||
|
|
||||||
|
/// Create a copy of TrainingMaxes
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@pragma('vm:prefer-inline')
|
@pragma('vm:prefer-inline')
|
||||||
@override
|
@override
|
||||||
$Res call({
|
$Res call({
|
||||||
|
|
@ -150,11 +158,13 @@ class _$TrainingMaxesImpl implements _TrainingMaxes {
|
||||||
(identical(other.dip, dip) || other.dip == dip));
|
(identical(other.dip, dip) || other.dip == dip));
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonKey(ignore: true)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(runtimeType, squat, pullup, dip);
|
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
|
@override
|
||||||
@pragma('vm:prefer-inline')
|
@pragma('vm:prefer-inline')
|
||||||
_$$TrainingMaxesImplCopyWith<_$TrainingMaxesImpl> get copyWith =>
|
_$$TrainingMaxesImplCopyWith<_$TrainingMaxesImpl> get copyWith =>
|
||||||
|
|
@ -183,8 +193,11 @@ abstract class _TrainingMaxes implements TrainingMaxes {
|
||||||
double get pullup;
|
double get pullup;
|
||||||
@override
|
@override
|
||||||
double get dip;
|
double get dip;
|
||||||
|
|
||||||
|
/// Create a copy of TrainingMaxes
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@override
|
@override
|
||||||
@JsonKey(ignore: true)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
_$$TrainingMaxesImplCopyWith<_$TrainingMaxesImpl> get copyWith =>
|
_$$TrainingMaxesImplCopyWith<_$TrainingMaxesImpl> get copyWith =>
|
||||||
throw _privateConstructorUsedError;
|
throw _privateConstructorUsedError;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ part 'workout_set.freezed.dart';
|
||||||
part 'workout_set.g.dart';
|
part 'workout_set.g.dart';
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
class WorkoutSet with _$WorkoutSet {
|
abstract class WorkoutSet with _$WorkoutSet {
|
||||||
const factory WorkoutSet({
|
const factory WorkoutSet({
|
||||||
@Default(1) int setNumber,
|
@Default(1) int setNumber,
|
||||||
@Default(0) int targetPercentage,
|
@Default(0) int targetPercentage,
|
||||||
|
|
@ -20,4 +20,3 @@ class WorkoutSet with _$WorkoutSet {
|
||||||
factory WorkoutSet.fromJson(Map<String, dynamic> json) =>
|
factory WorkoutSet.fromJson(Map<String, dynamic> json) =>
|
||||||
_$WorkoutSetFromJson(json);
|
_$WorkoutSetFromJson(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,12 @@ mixin _$WorkoutSet {
|
||||||
bool get completed => throw _privateConstructorUsedError;
|
bool get completed => throw _privateConstructorUsedError;
|
||||||
int? get rpe => throw _privateConstructorUsedError;
|
int? get rpe => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// Serializes this WorkoutSet to a JSON map.
|
||||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
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 =>
|
$WorkoutSetCopyWith<WorkoutSet> get copyWith =>
|
||||||
throw _privateConstructorUsedError;
|
throw _privateConstructorUsedError;
|
||||||
}
|
}
|
||||||
|
|
@ -64,6 +68,8 @@ class _$WorkoutSetCopyWithImpl<$Res, $Val extends WorkoutSet>
|
||||||
// ignore: unused_field
|
// ignore: unused_field
|
||||||
final $Res Function($Val) _then;
|
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')
|
@pragma('vm:prefer-inline')
|
||||||
@override
|
@override
|
||||||
$Res call({
|
$Res call({
|
||||||
|
|
@ -146,6 +152,8 @@ class __$$WorkoutSetImplCopyWithImpl<$Res>
|
||||||
_$WorkoutSetImpl _value, $Res Function(_$WorkoutSetImpl) _then)
|
_$WorkoutSetImpl _value, $Res Function(_$WorkoutSetImpl) _then)
|
||||||
: super(_value, _then);
|
: super(_value, _then);
|
||||||
|
|
||||||
|
/// Create a copy of WorkoutSet
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@pragma('vm:prefer-inline')
|
@pragma('vm:prefer-inline')
|
||||||
@override
|
@override
|
||||||
$Res call({
|
$Res call({
|
||||||
|
|
@ -272,7 +280,7 @@ class _$WorkoutSetImpl implements _WorkoutSet {
|
||||||
(identical(other.rpe, rpe) || other.rpe == rpe));
|
(identical(other.rpe, rpe) || other.rpe == rpe));
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonKey(ignore: true)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(
|
int get hashCode => Object.hash(
|
||||||
runtimeType,
|
runtimeType,
|
||||||
|
|
@ -286,7 +294,9 @@ class _$WorkoutSetImpl implements _WorkoutSet {
|
||||||
completed,
|
completed,
|
||||||
rpe);
|
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
|
@override
|
||||||
@pragma('vm:prefer-inline')
|
@pragma('vm:prefer-inline')
|
||||||
_$$WorkoutSetImplCopyWith<_$WorkoutSetImpl> get copyWith =>
|
_$$WorkoutSetImplCopyWith<_$WorkoutSetImpl> get copyWith =>
|
||||||
|
|
@ -333,8 +343,11 @@ abstract class _WorkoutSet implements WorkoutSet {
|
||||||
bool get completed;
|
bool get completed;
|
||||||
@override
|
@override
|
||||||
int? get rpe;
|
int? get rpe;
|
||||||
|
|
||||||
|
/// Create a copy of WorkoutSet
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@override
|
@override
|
||||||
@JsonKey(ignore: true)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
_$$WorkoutSetImplCopyWith<_$WorkoutSetImpl> get copyWith =>
|
_$$WorkoutSetImplCopyWith<_$WorkoutSetImpl> get copyWith =>
|
||||||
throw _privateConstructorUsedError;
|
throw _privateConstructorUsedError;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,35 @@ import 'dart:math';
|
||||||
import '../entities/workout_set.dart';
|
import '../entities/workout_set.dart';
|
||||||
import '../../../core/constants/app_constants.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 {
|
class WendlerCalculator {
|
||||||
static const Map<int, List<double>> weekPercentages = {
|
static const Map<int, List<double>> weekPercentages = {
|
||||||
|
|
@ -34,7 +62,9 @@ class WendlerCalculator {
|
||||||
final rounded = _roundWeight(targetTotal, exerciseType);
|
final rounded = _roundWeight(targetTotal, exerciseType);
|
||||||
|
|
||||||
double plateWeight = 0;
|
double plateWeight = 0;
|
||||||
if (exerciseType != ExerciseType.squat) {
|
if (exerciseType != ExerciseType.squat ||
|
||||||
|
exerciseType != ExerciseType.row ||
|
||||||
|
exerciseType != ExerciseType.bench) {
|
||||||
plateWeight = max(0, rounded - currentBodyweight);
|
plateWeight = max(0, rounded - currentBodyweight);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,8 +81,38 @@ class WendlerCalculator {
|
||||||
return sets;
|
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) {
|
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.squatRoundingStep
|
||||||
: AppConstants.calisthenicsRoundingStep;
|
: AppConstants.calisthenicsRoundingStep;
|
||||||
return (weight / step).floor() * step;
|
return (weight / step).floor() * step;
|
||||||
|
|
@ -86,7 +146,9 @@ class WendlerCalculator {
|
||||||
final rounded = _roundWeight(targetTotal, exerciseType);
|
final rounded = _roundWeight(targetTotal, exerciseType);
|
||||||
|
|
||||||
double plateWeight = 0;
|
double plateWeight = 0;
|
||||||
if (exerciseType != ExerciseType.squat) {
|
if (exerciseType != ExerciseType.squat ||
|
||||||
|
exerciseType != ExerciseType.row ||
|
||||||
|
exerciseType != ExerciseType.bench) {
|
||||||
plateWeight = max(0, rounded - currentBodyweight);
|
plateWeight = max(0, rounded - currentBodyweight);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
148
lib/src/shared/domain/models/exercise_guide.dart
Normal file
148
lib/src/shared/domain/models/exercise_guide.dart
Normal 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),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
432
pubspec.lock
432
pubspec.lock
|
|
@ -5,26 +5,26 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: _fe_analyzer_shared
|
name: _fe_analyzer_shared
|
||||||
sha256: ae92f5d747aee634b87f89d9946000c2de774be1d6ac3e58268224348cd0101a
|
sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "61.0.0"
|
version: "91.0.0"
|
||||||
analyzer:
|
analyzer:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: analyzer
|
name: analyzer
|
||||||
sha256: ea3d8652bda62982addfd92fdc2d0214e5f82e43325104990d4f4c4a2a313562
|
sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.13.0"
|
version: "8.4.1"
|
||||||
analyzer_plugin:
|
analyzer_buffer:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: analyzer_plugin
|
name: analyzer_buffer
|
||||||
sha256: c1d5f167683de03d5ab6c3b53fc9aeefc5d59476e7810ba7bbddff50c6f4392d
|
sha256: aba2f75e63b3135fd1efaa8b6abefe1aa6e41b6bd9806221620fa48f98156033
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.11.2"
|
version: "0.1.11"
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -41,6 +41,62 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.13.0"
|
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:
|
boolean_selector:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -53,18 +109,18 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: build
|
name: build
|
||||||
sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0"
|
sha256: c1668065e9ba04752570ad7e038288559d1e2ca5c6d0131c0f5f55e39e777413
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.1"
|
version: "4.0.3"
|
||||||
build_config:
|
build_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: build_config
|
name: build_config
|
||||||
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
|
sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
version: "1.2.0"
|
||||||
build_daemon:
|
build_daemon:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -73,30 +129,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.1"
|
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:
|
build_runner:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: build_runner
|
name: build_runner
|
||||||
sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d"
|
sha256: "110c56ef29b5eb367b4d17fc79375fa8c18a6cd7acd92c05bb3986c17a079057"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.13"
|
version: "2.10.4"
|
||||||
build_runner_core:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: build_runner_core
|
|
||||||
sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "7.3.2"
|
|
||||||
built_collection:
|
built_collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -145,6 +185,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.0"
|
||||||
|
charcode:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: charcode
|
||||||
|
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.0"
|
||||||
checked_yaml:
|
checked_yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -153,6 +201,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.4"
|
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:
|
clock:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -185,6 +249,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.2"
|
version: "3.1.2"
|
||||||
|
coverage:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: coverage
|
||||||
|
sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.15.0"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -201,30 +273,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.8"
|
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:
|
dart_style:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: dart_style
|
name: dart_style
|
||||||
sha256: "1efa911ca7086affd35f463ca2fc1799584fb6aa89883cf0af8e3664d6a02d55"
|
sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.2"
|
version: "3.1.3"
|
||||||
dartx:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: dartx
|
|
||||||
sha256: "8b25435617027257d43e6508b5fe061012880ddfdaa75a71d607c3de2a13d244"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.2.0"
|
|
||||||
dio:
|
dio:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -241,6 +297,30 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.1"
|
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:
|
equatable:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -302,6 +382,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.4.1"
|
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:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
|
|
@ -314,58 +402,58 @@ packages:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_riverpod
|
name: flutter_riverpod
|
||||||
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
|
sha256: "38ec6c303e2c83ee84512f5fc2a82ae311531021938e63d7137eccc107bf3c02"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.6.1"
|
version: "3.1.0"
|
||||||
flutter_secure_storage:
|
flutter_secure_storage:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_secure_storage
|
name: flutter_secure_storage
|
||||||
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
|
sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
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:
|
flutter_secure_storage_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_secure_storage_linux
|
name: flutter_secure_storage_linux
|
||||||
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
|
sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.3"
|
version: "3.0.0"
|
||||||
flutter_secure_storage_macos:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: flutter_secure_storage_macos
|
|
||||||
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.1.3"
|
|
||||||
flutter_secure_storage_platform_interface:
|
flutter_secure_storage_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_secure_storage_platform_interface
|
name: flutter_secure_storage_platform_interface
|
||||||
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
|
sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
version: "2.0.1"
|
||||||
flutter_secure_storage_web:
|
flutter_secure_storage_web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_secure_storage_web
|
name: flutter_secure_storage_web
|
||||||
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
|
sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.1"
|
version: "2.1.0"
|
||||||
flutter_secure_storage_windows:
|
flutter_secure_storage_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_secure_storage_windows
|
name: flutter_secure_storage_windows
|
||||||
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
|
sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.2"
|
version: "4.1.0"
|
||||||
flutter_svg:
|
flutter_svg:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -388,18 +476,18 @@ packages:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: freezed
|
name: freezed
|
||||||
sha256: a434911f643466d78462625df76fd9eb13e57348ff43fe1f77bbe909522c67a1
|
sha256: "13065f10e135263a4f5a4391b79a8efc5fb8106f8dd555a9e49b750b45393d77"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.5.2"
|
version: "3.2.3"
|
||||||
freezed_annotation:
|
freezed_annotation:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: freezed_annotation
|
name: freezed_annotation
|
||||||
sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2
|
sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.4"
|
version: "3.1.0"
|
||||||
frontend_server_client:
|
frontend_server_client:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -420,18 +508,18 @@ packages:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: go_router
|
name: go_router
|
||||||
sha256: c92d18e1fe994cb06d48aa786c46b142a5633067e8297cff6b5a3ac742620104
|
sha256: eff94d2a6fc79fa8b811dde79c7549808c2346037ee107a1121b4a644c745f2a
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "17.0.0"
|
version: "17.0.1"
|
||||||
google_fonts:
|
google_fonts:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: google_fonts
|
name: google_fonts
|
||||||
sha256: "517b20870220c48752eafa0ba1a797a092fb22df0d89535fd9991e86ee2cdd9c"
|
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.2"
|
version: "6.3.3"
|
||||||
graphs:
|
graphs:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -480,38 +568,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.5"
|
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:
|
js:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: js
|
name: js
|
||||||
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.7"
|
version: "0.7.2"
|
||||||
json_annotation:
|
json_annotation:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -524,10 +588,10 @@ packages:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: json_serializable
|
name: json_serializable
|
||||||
sha256: ea1432d167339ea9b5bb153f0571d0039607a873d6e04e0117af043f14a1fd4b
|
sha256: c5b2ee75210a0f263c6c7b9eeea80553dbae96ea1bf57f02484e806a3ffdffa3
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.8.0"
|
version: "6.11.2"
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -608,6 +672,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
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:
|
octo_image:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -744,38 +824,46 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.0"
|
version: "1.5.0"
|
||||||
|
recase:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: recase
|
||||||
|
sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.0"
|
||||||
riverpod:
|
riverpod:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: riverpod
|
name: riverpod
|
||||||
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
|
sha256: "16ff608d21e8ea64364f2b7c049c94a02ab81668f78845862b6e88b71dd4935a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.6.1"
|
version: "3.1.0"
|
||||||
riverpod_analyzer_utils:
|
riverpod_analyzer_utils:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: riverpod_analyzer_utils
|
name: riverpod_analyzer_utils
|
||||||
sha256: "8b71f03fc47ae27d13769496a1746332df4cec43918aeba9aff1e232783a780f"
|
sha256: "947b05d04c52a546a2ac6b19ef2a54b08520ff6bdf9f23d67957a4c8df1c3bc0"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.5.1"
|
version: "1.0.0-dev.8"
|
||||||
riverpod_annotation:
|
riverpod_annotation:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: riverpod_annotation
|
name: riverpod_annotation
|
||||||
sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8
|
sha256: cc1474bc2df55ec3c1da1989d139dcef22cd5e2bd78da382e867a69a8eca2e46
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.6.1"
|
version: "4.0.0"
|
||||||
riverpod_generator:
|
riverpod_generator:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: riverpod_generator
|
name: riverpod_generator
|
||||||
sha256: d451608bf17a372025fc36058863737636625dfdb7e3cbf6142e0dfeb366ab22
|
sha256: e43b1537229cc8f487f09b0c20d15dba840acbadcf5fc6dad7ad5e8ab75950dc
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.0"
|
version: "4.0.0+1"
|
||||||
rxdart:
|
rxdart:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -788,18 +876,18 @@ packages:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: shared_preferences
|
name: shared_preferences
|
||||||
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
|
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.5.3"
|
version: "2.5.4"
|
||||||
shared_preferences_android:
|
shared_preferences_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shared_preferences_android
|
name: shared_preferences_android
|
||||||
sha256: "46a46fd64659eff15f4638bbe19de43f9483f0e0bf024a9fb6b3582064bacc7b"
|
sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.17"
|
version: "2.4.18"
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -848,14 +936,30 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.2"
|
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:
|
shelf_web_socket:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shelf_web_socket
|
name: shelf_web_socket
|
||||||
sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67
|
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.1"
|
version: "3.0.0"
|
||||||
shimmer:
|
shimmer:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -873,18 +977,34 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: source_gen
|
name: source_gen
|
||||||
sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832"
|
sha256: "07b277b67e0096c45196cbddddf2d8c6ffc49342e88bf31d460ce04605ddac75"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.0"
|
version: "4.1.1"
|
||||||
source_helper:
|
source_helper:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: source_helper
|
name: source_helper
|
||||||
sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c"
|
sha256: "6a3c6cc82073a8797f8c4dc4572146114a39652851c157db37e964d9c7038723"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
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:
|
source_span:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -933,6 +1053,30 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.0"
|
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:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -989,6 +1133,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.2"
|
version: "1.2.2"
|
||||||
|
test:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: test
|
||||||
|
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.26.3"
|
||||||
test_api:
|
test_api:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -997,22 +1149,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.7"
|
version: "0.7.7"
|
||||||
time:
|
test_core:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: time
|
name: test_core
|
||||||
sha256: "370572cf5d1e58adcb3e354c47515da3f7469dac3a95b447117e728e7be6f461"
|
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.5"
|
version: "0.6.12"
|
||||||
timing:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: timing
|
|
||||||
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.2"
|
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1073,10 +1217,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: watcher
|
name: watcher
|
||||||
sha256: "592ab6e2892f67760543fb712ff0177f4ec76c031f02f5b4ff8d3fc5eb9fb61a"
|
sha256: f52385d4f73589977c80797e60fe51014f7f2b957b5e9a62c3f6ada439889249
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.4"
|
version: "1.2.0"
|
||||||
web:
|
web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1101,6 +1245,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.3"
|
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:
|
win32:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1125,14 +1277,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.6.1"
|
version: "6.6.1"
|
||||||
xxh3:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: xxh3
|
|
||||||
sha256: "399a0438f5d426785723c99da6b16e136f4953fb1e9db0bf270bd41dd4619916"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.2.0"
|
|
||||||
yaml:
|
yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
32
pubspec.yaml
32
pubspec.yaml
|
|
@ -1,22 +1,27 @@
|
||||||
name: slrpg_app
|
name: slrpg_app
|
||||||
description: Streetlifting RPG - Gamified Training App
|
description: Streetlifting RPG - Gamified Training App
|
||||||
publish_to: 'none'
|
publish_to: "none"
|
||||||
version: 1.0.0+1
|
version: 1.0.0+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.2.0 <4.0.0'
|
sdk: ">=3.2.0 <4.0.0"
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
flutter_localizations:
|
||||||
|
sdk: flutter
|
||||||
|
audioplayers: ^6.0.0
|
||||||
|
flutter_dotenv: ^5.1.0
|
||||||
|
|
||||||
# State Management
|
# State Management
|
||||||
flutter_riverpod: ^2.5.1
|
flutter_riverpod: ^3.1.0
|
||||||
riverpod_annotation: ^2.3.5
|
riverpod_annotation: ^4.0.0
|
||||||
|
|
||||||
# Local Database
|
# Local Database
|
||||||
isar: ^3.1.0+1
|
drift: ^2.16.0
|
||||||
isar_flutter_libs: ^3.1.0+1
|
drift_flutter: ^0.2.8
|
||||||
|
sqlite3_flutter_libs: ^0.5.20
|
||||||
path_provider: ^2.1.3
|
path_provider: ^2.1.3
|
||||||
|
|
||||||
# Networking
|
# Networking
|
||||||
|
|
@ -24,7 +29,7 @@ dependencies:
|
||||||
pretty_dio_logger: ^1.3.1
|
pretty_dio_logger: ^1.3.1
|
||||||
|
|
||||||
# Storage
|
# Storage
|
||||||
flutter_secure_storage: ^9.0.0
|
flutter_secure_storage: ^10.0.0
|
||||||
shared_preferences: ^2.2.3
|
shared_preferences: ^2.2.3
|
||||||
|
|
||||||
# UI Components
|
# UI Components
|
||||||
|
|
@ -36,7 +41,7 @@ dependencies:
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
intl: ^0.20.2
|
intl: ^0.20.2
|
||||||
freezed_annotation: ^2.4.1
|
freezed_annotation: ^3.1.0
|
||||||
json_annotation: ^4.9.0
|
json_annotation: ^4.9.0
|
||||||
equatable: ^2.0.5
|
equatable: ^2.0.5
|
||||||
logger: ^2.3.0
|
logger: ^2.3.0
|
||||||
|
|
@ -51,13 +56,14 @@ dev_dependencies:
|
||||||
|
|
||||||
# Code Generation
|
# Code Generation
|
||||||
build_runner: ^2.4.9
|
build_runner: ^2.4.9
|
||||||
riverpod_generator: ^2.4.0
|
riverpod_generator: ^4.0.0+1
|
||||||
isar_generator: ^3.1.0+1
|
drift_dev: ^2.16.0
|
||||||
freezed: ^2.5.2
|
freezed: ^3.2.3
|
||||||
json_serializable: ^6.8.0
|
json_serializable: ^6.8.0
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
generate: true
|
||||||
|
|
||||||
assets:
|
assets:
|
||||||
- assets/images/
|
- assets/images/
|
||||||
|
|
@ -66,6 +72,10 @@ flutter:
|
||||||
- assets/images/plates/
|
- assets/images/plates/
|
||||||
- assets/images/enemies/
|
- assets/images/enemies/
|
||||||
- assets/images/backgrounds/
|
- assets/images/backgrounds/
|
||||||
|
- assets/audio/
|
||||||
|
- .env
|
||||||
|
- .env.development
|
||||||
|
- .env.production
|
||||||
|
|
||||||
# fonts:
|
# fonts:
|
||||||
# - family: PixelFont
|
# - family: PixelFont
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue