mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-07-04 13:23:13 +02:00
feat: add Flutter workout app (StrongLifts replacement)
Full-featured workout tracker with session persistence, auto A/B cycling, warmup weights (4/5 of target), settings weight stepper, history + progress graph, HTTP sync server, and crash-safe active session resume. Removed per-set break timers per user preference. Dropped audioplayers and vibration dependencies; updated permission_handler to 12.x to eliminate two of three KGP build warnings (shared_preferences_android is an upstream issue). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d9d7c9b322
commit
da269c537a
43
stronglift_replacement/design.md
Normal file
43
stronglift_replacement/design.md
Normal file
@ -0,0 +1,43 @@
|
||||
Why:
|
||||
Stronglift app on my rooted device stopped working, we need a new app for tracking workout
|
||||
on that note please disable screen locker functionality untill app is ready to go and fully tested functionally
|
||||
|
||||
Functional Requirements:
|
||||
Tracks workouts (current {sets}x{reps}x{weight (in kg)}):
|
||||
Workout A:
|
||||
Dumbbell Lunge 5x12x7.5
|
||||
Dumbbell Bench Press 5x12x22.5
|
||||
Dumbbell Row 4x6x22.5
|
||||
Dumbbell Curl 3x12x12.5
|
||||
|
||||
Workout B:
|
||||
Dumbbell Romanian Deadlift 5x7x27.5
|
||||
Dummbbell Overhead Press 5x12x7.5
|
||||
Dumbbell Bench Press 5x12x22.5
|
||||
Situp 3x30x10
|
||||
|
||||
|
||||
Exercisees succeded means that the user was able to do ALL sets with ALL reps
|
||||
Automatically increases weight (in increments of 2.5kg) or number of reps (if maximum weight of 27.5 kg was reached for given exercise (see Dumbbell Romanian Deadlift) Situp has maximum weight of 10kg)
|
||||
if user succeeded in doign this exercise in a continous way for between 1-5 days in a row (selectable by user unless max weight reached in which case 27.5 kg should always be chosen)
|
||||
automatically decreases weight (in decrements of 2.5kg) if user failed to do the exercise for 1-5 days in a row (selectable by user)
|
||||
automatically decreases weight if user had a break from using the app
|
||||
Tracks how much time workout took -> Fully automatically, user CANNOT set it manually
|
||||
Adds optional warmup exercises (With weight equal 2/3 of target weight (rounding DOWN to nearest increment of 2.5kg) and always having 5 reps exactly and exactly one set) before each exercise (after warmup 3 minutes break too)
|
||||
adds breaks between exercises (3 minutes if exercise succeeded and 5 minutes if it failed)
|
||||
The user selects exercise as done by tapping on a circle with number of reps for this exercise, exercises are organized in rows where one row = one full set of one exercise, tapping on a circle again means that
|
||||
the user failed to do the exercise and it decreases the rep by "1" tappign again further reduces this count, if user holds finger over the specific circle they can reset the state of this circle (which should
|
||||
cancel any failed/succeed state)
|
||||
shows history of workouts and a graph for showing progress
|
||||
Crucial: The app should be able to comunicate with this pc (arch linux) and inform it if the user had succeed to do the exercises and transfer full info about todays workout to the pc, crucialy:
|
||||
time and how many sets reps and weight was done, change screen locker if needed
|
||||
The app should be capable of working in the background without any problem and display status notifications allowing user to click on "done rep" from the status bar
|
||||
After break time is over app should play a sound and vibrate the phone and generally point the user attention towards the app
|
||||
|
||||
Technical Requirements:
|
||||
App should work on rooted and unrooted phones with minimum android version of at least 12
|
||||
Full test coverage (100%) (but first check the functionality and if the functionality fully works and is approved by user THEN start writing ANY tests at all please)
|
||||
I connected an unrooted phone with adb on to the pc use it for testing
|
||||
|
||||
Nice to have:
|
||||
make it work on both desktops (linux only is fine) and android phones (just android is fine)
|
||||
26
stronglift_replacement/dfesign_v2.md
Normal file
26
stronglift_replacement/dfesign_v2.md
Normal file
@ -0,0 +1,26 @@
|
||||
This is a continouation from design.md file with what is left to be done and what new ideas came to me since the last time arranged in order of importance
|
||||
|
||||
Crucial (max 1 feature):
|
||||
If user starts workout and later either exit the app completely or clicks the arrow in upper left the workout gets reseted completely, all progress is lost this is very bad
|
||||
once user starts workout only by tapping finish and confriming that they INDEED finished workout should end it OR if user clicks and confirms RESET button, NOTHING ELSE
|
||||
|
||||
High (max 2 features):
|
||||
adds breaks between REPS (3 minutes if REP succeeded (as in all reps were done) and 5 minutes if it failed) <-- currently app ads breaks between SETS which wrong
|
||||
After break time (after REPS) is over app should play a sound and vibrate the phone and generally point the user attention towards the app
|
||||
|
||||
Mid (max 3 features):
|
||||
change warmup exercises weight from 2/3 to 3/4 of target weight
|
||||
Warmups should be a selectable circles in a separate screen, optional but still interactive and after doing them give the user a 3 minute break ALSO
|
||||
The app should change between workout A and B AUTOMATICALLY, no user interaction if last workout done was "A" the next one should be "B" and then "A" and so on...
|
||||
|
||||
Low (max 4 features):
|
||||
If set is finished user cannot modify reps on this set for some reason -> this is a bug user should ALWAYS be able to modify ANY reps in ANY exercise
|
||||
shows history of workouts and a graph for showing progress
|
||||
The app should be capable of working in the background without any problem and display status notifications allowing user to click on "done rep" from the status bar
|
||||
automatically decreases weight if user had a break from using the app <-- not sure if implemented (maybe implemented but did not have a change to check it add fallback manual setting of weights by user)
|
||||
|
||||
|
||||
Technical Requirements:
|
||||
App should work on rooted and unrooted phones with minimum android version of at least 12
|
||||
Full test coverage (100%) (but first check the functionality and if the functionality fully works and is approved by user THEN start writing ANY tests at all please)
|
||||
I connected an unrooted phone with adb on to the pc use it for testing
|
||||
45
stronglift_replacement/workout_app/.gitignore
vendored
Normal file
45
stronglift_replacement/workout_app/.gitignore
vendored
Normal file
@ -0,0 +1,45 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
/coverage/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
30
stronglift_replacement/workout_app/.metadata
Normal file
30
stronglift_replacement/workout_app/.metadata
Normal file
@ -0,0 +1,30 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "559ffa3f75e7402d65a8def9c28389a9b2e6fe42"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||
- platform: android
|
||||
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
20
stronglift_replacement/workout_app/CONTEXT.md
Normal file
20
stronglift_replacement/workout_app/CONTEXT.md
Normal file
@ -0,0 +1,20 @@
|
||||
# Workout App Context
|
||||
|
||||
## Current Task
|
||||
Implementing design_v2.md improvements.
|
||||
|
||||
## Key Decisions
|
||||
- Active session persisted to SQLite on every tap so force-kill is safe
|
||||
- PopScope removed — back button returns to home, workout continues in DB
|
||||
- Auto-resume: HomeScreen auto-navigates to workout on first load if session exists
|
||||
|
||||
## Deferred / Not Yet Implemented
|
||||
- **Background notifications + break sound when phone is sleeping**: Dart timers
|
||||
suspend when app is backgrounded. Needs a native Android foreground service
|
||||
(e.g. `flutter_foreground_task` or custom Kotlin service) to keep the break
|
||||
countdown running and fire the alert even when the screen is off.
|
||||
File as a separate task before marking the app "done."
|
||||
|
||||
## Next Steps
|
||||
- Clarify user intent on "no break between sets" (contradicts design_v2.md which
|
||||
explicitly requested per-set breaks)
|
||||
17
stronglift_replacement/workout_app/README.md
Normal file
17
stronglift_replacement/workout_app/README.md
Normal file
@ -0,0 +1,17 @@
|
||||
# workout_app
|
||||
|
||||
A new Flutter project.
|
||||
|
||||
## Getting Started
|
||||
|
||||
This project is a starting point for a Flutter application.
|
||||
|
||||
A few resources to get you started if this is your first Flutter project:
|
||||
|
||||
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
|
||||
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
|
||||
|
||||
For help getting started with Flutter development, view the
|
||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||
samples, guidance on mobile development, and a full API reference.
|
||||
28
stronglift_replacement/workout_app/analysis_options.yaml
Normal file
28
stronglift_replacement/workout_app/analysis_options.yaml
Normal file
@ -0,0 +1,28 @@
|
||||
# This file configures the analyzer, which statically analyzes Dart code to
|
||||
# check for errors, warnings, and lints.
|
||||
#
|
||||
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||
# invoked from the command line by running `flutter analyze`.
|
||||
|
||||
# The following line activates a set of recommended lints for Flutter apps,
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
# included above or to enable additional rules. A list of all available lints
|
||||
# and their documentation is published at https://dart.dev/lints.
|
||||
#
|
||||
# Instead of disabling a lint rule for the entire project in the
|
||||
# section below, it can also be suppressed for a single line of code
|
||||
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||
# producing the lint.
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
14
stronglift_replacement/workout_app/android/.gitignore
vendored
Normal file
14
stronglift_replacement/workout_app/android/.gitignore
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/to/reference-keystore
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
@ -0,0 +1,45 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.kuhy.workout_app"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.kuhy.workout_app"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@ -0,0 +1,55 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Storage permission for writing workout JSON to /sdcard/ for PC sync -->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="29" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<!-- Wake lock keeps timer running in background -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<!-- Required to bind a server socket for the HTTP fallback sync endpoint -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<application
|
||||
android:label="workout_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>
|
||||
@ -0,0 +1,5 @@
|
||||
package com.kuhy.workout_app
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 544 B |
Binary file not shown.
|
After Width: | Height: | Size: 442 B |
Binary file not shown.
|
After Width: | Height: | Size: 721 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
24
stronglift_replacement/workout_app/android/build.gradle.kts
Normal file
24
stronglift_replacement/workout_app/android/build.gradle.kts
Normal file
@ -0,0 +1,24 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build")
|
||||
.get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
# This newDsl flag was added by the Flutter template
|
||||
android.newDsl=false
|
||||
# This builtInKotlin flag was added by the Flutter template
|
||||
android.builtInKotlin=false
|
||||
5
stronglift_replacement/workout_app/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
stronglift_replacement/workout_app/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip
|
||||
@ -0,0 +1,26 @@
|
||||
pluginManagement {
|
||||
val flutterSdkPath =
|
||||
run {
|
||||
val properties = java.util.Properties()
|
||||
file("local.properties").inputStream().use { properties.load(it) }
|
||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||
flutterSdkPath
|
||||
}
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "9.0.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
31
stronglift_replacement/workout_app/lib/main.dart
Normal file
31
stronglift_replacement/workout_app/lib/main.dart
Normal file
@ -0,0 +1,31 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:workout_app/screens/home_screen.dart';
|
||||
import 'package:workout_app/services/http_server_service.dart';
|
||||
import 'package:workout_app/services/storage_service.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await StorageService.init();
|
||||
await HttpServerService.instance.start();
|
||||
runApp(const WorkoutApp());
|
||||
}
|
||||
|
||||
class WorkoutApp extends StatelessWidget {
|
||||
const WorkoutApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Workout Tracker',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: Colors.indigo,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const HomeScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
61
stronglift_replacement/workout_app/lib/models/exercise.dart
Normal file
61
stronglift_replacement/workout_app/lib/models/exercise.dart
Normal file
@ -0,0 +1,61 @@
|
||||
/// Core domain model for a single exercise definition and its current progression state.
|
||||
library;
|
||||
|
||||
const double kDefaultMaxWeight = 27.5;
|
||||
const double kWeightIncrement = 2.5;
|
||||
|
||||
class Exercise {
|
||||
const Exercise({
|
||||
required this.name,
|
||||
required this.sets,
|
||||
required this.reps,
|
||||
required this.weight,
|
||||
this.maxWeight = kDefaultMaxWeight,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final int sets;
|
||||
final int reps;
|
||||
final double weight;
|
||||
|
||||
/// Weight cap beyond which reps increase instead of weight.
|
||||
final double maxWeight;
|
||||
|
||||
/// Warmup weight: 4/5 of target weight, rounded DOWN to nearest 2.5 kg.
|
||||
double get warmupWeight {
|
||||
final raw = weight * 4.0 / 5.0;
|
||||
return (raw / kWeightIncrement).floor() * kWeightIncrement;
|
||||
}
|
||||
|
||||
Exercise copyWith({
|
||||
String? name,
|
||||
int? sets,
|
||||
int? reps,
|
||||
double? weight,
|
||||
double? maxWeight,
|
||||
}) {
|
||||
return Exercise(
|
||||
name: name ?? this.name,
|
||||
sets: sets ?? this.sets,
|
||||
reps: reps ?? this.reps,
|
||||
weight: weight ?? this.weight,
|
||||
maxWeight: maxWeight ?? this.maxWeight,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'name': name,
|
||||
'sets': sets,
|
||||
'reps': reps,
|
||||
'weight': weight,
|
||||
'maxWeight': maxWeight,
|
||||
};
|
||||
|
||||
factory Exercise.fromJson(Map<String, dynamic> json) => Exercise(
|
||||
name: json['name'] as String,
|
||||
sets: json['sets'] as int,
|
||||
reps: json['reps'] as int,
|
||||
weight: (json['weight'] as num).toDouble(),
|
||||
maxWeight: (json['maxWeight'] as num?)?.toDouble() ?? kDefaultMaxWeight,
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
/// All set results for one exercise in a workout session.
|
||||
library;
|
||||
|
||||
import 'package:workout_app/models/exercise.dart';
|
||||
import 'package:workout_app/models/set_result.dart';
|
||||
|
||||
class ExerciseResult {
|
||||
const ExerciseResult({
|
||||
required this.exercise,
|
||||
required this.sets,
|
||||
});
|
||||
|
||||
final Exercise exercise;
|
||||
final List<SetResult> sets;
|
||||
|
||||
/// True when every set was fully completed.
|
||||
bool get succeeded => sets.isNotEmpty && sets.every((s) => s.succeeded);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'name': exercise.name,
|
||||
'targetSets': exercise.sets,
|
||||
'targetReps': exercise.reps,
|
||||
'targetWeight': exercise.weight,
|
||||
'sets': sets.map((s) => s.toJson()).toList(),
|
||||
'succeeded': succeeded,
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
/// Result of a single set during a workout session.
|
||||
library;
|
||||
|
||||
class SetResult {
|
||||
const SetResult({
|
||||
required this.targetReps,
|
||||
required this.doneReps,
|
||||
required this.weight,
|
||||
});
|
||||
|
||||
final int targetReps;
|
||||
|
||||
/// How many reps the user actually completed (may be < targetReps on failure).
|
||||
final int doneReps;
|
||||
|
||||
final double weight;
|
||||
|
||||
/// True when the user completed every target rep.
|
||||
bool get succeeded => doneReps >= targetReps;
|
||||
|
||||
SetResult copyWith({int? doneReps}) => SetResult(
|
||||
targetReps: targetReps,
|
||||
doneReps: doneReps ?? this.doneReps,
|
||||
weight: weight,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'targetReps': targetReps,
|
||||
'doneReps': doneReps,
|
||||
'weight': weight,
|
||||
'succeeded': succeeded,
|
||||
};
|
||||
|
||||
factory SetResult.fromJson(Map<String, dynamic> json) => SetResult(
|
||||
targetReps: json['targetReps'] as int,
|
||||
doneReps: json['doneReps'] as int,
|
||||
weight: (json['weight'] as num).toDouble(),
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
/// Static workout plans A and B with their default exercise configurations.
|
||||
library;
|
||||
|
||||
import 'package:workout_app/models/exercise.dart';
|
||||
|
||||
/// Situp has a lower max weight cap.
|
||||
const double kSitupMaxWeight = 10.0;
|
||||
|
||||
final workoutA = [
|
||||
const Exercise(name: 'Dumbbell Lunge', sets: 5, reps: 12, weight: 7.5),
|
||||
const Exercise(name: 'Dumbbell Bench Press', sets: 5, reps: 12, weight: 22.5),
|
||||
const Exercise(name: 'Dumbbell Row', sets: 4, reps: 6, weight: 22.5),
|
||||
const Exercise(name: 'Dumbbell Curl', sets: 3, reps: 12, weight: 12.5),
|
||||
];
|
||||
|
||||
final workoutB = [
|
||||
const Exercise(
|
||||
name: 'Dumbbell Romanian Deadlift',
|
||||
sets: 5,
|
||||
reps: 7,
|
||||
weight: 27.5,
|
||||
),
|
||||
const Exercise(
|
||||
name: 'Dumbbell Overhead Press',
|
||||
sets: 5,
|
||||
reps: 12,
|
||||
weight: 7.5,
|
||||
),
|
||||
const Exercise(name: 'Dumbbell Bench Press', sets: 5, reps: 12, weight: 22.5),
|
||||
const Exercise(
|
||||
name: 'Situp',
|
||||
sets: 3,
|
||||
reps: 30,
|
||||
weight: 10.0,
|
||||
maxWeight: kSitupMaxWeight,
|
||||
),
|
||||
];
|
||||
@ -0,0 +1,37 @@
|
||||
/// A completed workout session — serialised to JSON for PC sync.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'package:workout_app/models/exercise_result.dart';
|
||||
|
||||
class WorkoutSession {
|
||||
const WorkoutSession({
|
||||
required this.workoutType,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
required this.exercises,
|
||||
});
|
||||
|
||||
/// 'A' or 'B'.
|
||||
final String workoutType;
|
||||
final DateTime startTime;
|
||||
final DateTime endTime;
|
||||
final List<ExerciseResult> exercises;
|
||||
|
||||
Duration get duration => endTime.difference(startTime);
|
||||
|
||||
/// True when every exercise succeeded.
|
||||
bool get fullySucceeded => exercises.every((e) => e.succeeded);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'workout_type': workoutType,
|
||||
'date': startTime.toIso8601String().substring(0, 10),
|
||||
'start_time': startTime.toIso8601String(),
|
||||
'end_time': endTime.toIso8601String(),
|
||||
'duration_seconds': duration.inSeconds,
|
||||
'succeeded': fullySucceeded,
|
||||
'exercises': exercises.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
|
||||
String toJsonString() => const JsonEncoder.withIndent(' ').convert(toJson());
|
||||
}
|
||||
@ -0,0 +1,307 @@
|
||||
/// History screen: past workout list with per-exercise weight progress chart.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:workout_app/services/storage_service.dart';
|
||||
|
||||
class HistoryScreen extends StatefulWidget {
|
||||
const HistoryScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HistoryScreen> createState() => _HistoryScreenState();
|
||||
}
|
||||
|
||||
class _HistoryScreenState extends State<HistoryScreen> {
|
||||
List<Map<String, dynamic>> _rows = [];
|
||||
bool _loading = true;
|
||||
String? _selectedExercise;
|
||||
List<String> _exerciseNames = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final rows = await StorageService.instance.getWorkoutHistory();
|
||||
final names = <String>{};
|
||||
for (final row in rows) {
|
||||
final json =
|
||||
jsonDecode(row['json'] as String) as Map<String, dynamic>;
|
||||
for (final ex in (json['exercises'] as List)) {
|
||||
names.add((ex as Map<String, dynamic>)['name'] as String);
|
||||
}
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_rows = rows;
|
||||
_exerciseNames = names.toList();
|
||||
_selectedExercise =
|
||||
_exerciseNames.isNotEmpty ? _exerciseNames.first : null;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDuration(int secs) {
|
||||
final m = (secs ~/ 60).toString().padLeft(2, '0');
|
||||
final s = (secs % 60).toString().padLeft(2, '0');
|
||||
return '${secs ~/ 3600 > 0 ? '${secs ~/ 3600}h ' : ''}${m}m ${s}s';
|
||||
}
|
||||
|
||||
/// Extract (date, weight) points for the selected exercise from history.
|
||||
List<(DateTime, double)> _buildChartPoints(String exerciseName) {
|
||||
final points = <(DateTime, double)>[];
|
||||
for (final row in _rows.reversed) {
|
||||
final json =
|
||||
jsonDecode(row['json'] as String) as Map<String, dynamic>;
|
||||
for (final ex in (json['exercises'] as List)) {
|
||||
final m = ex as Map<String, dynamic>;
|
||||
if (m['name'] == exerciseName) {
|
||||
final date = DateTime.tryParse(row['date'] as String);
|
||||
final weight = (m['targetWeight'] as num?)?.toDouble();
|
||||
if (date != null && weight != null) {
|
||||
points.add((date, weight));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.grey.shade900,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.grey.shade800,
|
||||
title: const Text('History', style: TextStyle(color: Colors.white)),
|
||||
iconTheme: const IconThemeData(color: Colors.white),
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _rows.isEmpty
|
||||
? const Center(
|
||||
child: Text(
|
||||
'No workouts yet.',
|
||||
style: TextStyle(color: Colors.white54),
|
||||
),
|
||||
)
|
||||
: ListView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
children: [
|
||||
if (_selectedExercise != null) ...[
|
||||
_ExercisePicker(
|
||||
names: _exerciseNames,
|
||||
selected: _selectedExercise!,
|
||||
onChanged: (v) =>
|
||||
setState(() => _selectedExercise = v),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_WeightChart(
|
||||
points: _buildChartPoints(_selectedExercise!),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
const Text(
|
||||
'SESSIONS',
|
||||
style: TextStyle(
|
||||
color: Colors.white54,
|
||||
fontSize: 11,
|
||||
letterSpacing: 1.3,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
..._rows.map((row) => _SessionTile(
|
||||
row: row,
|
||||
formatDuration: _formatDuration,
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sub-widgets ────────────────────────────────────────────────────────────────
|
||||
|
||||
class _ExercisePicker extends StatelessWidget {
|
||||
const _ExercisePicker({
|
||||
required this.names,
|
||||
required this.selected,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final List<String> names;
|
||||
final String selected;
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DropdownButton<String>(
|
||||
value: selected,
|
||||
dropdownColor: Colors.grey.shade800,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
underline: const SizedBox(),
|
||||
isExpanded: true,
|
||||
items: names
|
||||
.map(
|
||||
(n) => DropdownMenuItem(
|
||||
value: n,
|
||||
child: Text(n, style: const TextStyle(color: Colors.white)),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
if (v != null) onChanged(v);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WeightChart extends StatelessWidget {
|
||||
const _WeightChart({required this.points});
|
||||
|
||||
final List<(DateTime, double)> points;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (points.length < 2) {
|
||||
return Container(
|
||||
height: 100,
|
||||
alignment: Alignment.center,
|
||||
child: const Text(
|
||||
'Not enough data',
|
||||
style: TextStyle(color: Colors.white38),
|
||||
),
|
||||
);
|
||||
}
|
||||
return SizedBox(
|
||||
height: 120,
|
||||
child: CustomPaint(
|
||||
painter: _ChartPainter(points),
|
||||
size: Size.infinite,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChartPainter extends CustomPainter {
|
||||
_ChartPainter(this.points);
|
||||
|
||||
final List<(DateTime, double)> points;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final minW = points.map((p) => p.$2).reduce(min);
|
||||
final maxW = points.map((p) => p.$2).reduce(max);
|
||||
final minMs = points.first.$1.millisecondsSinceEpoch.toDouble();
|
||||
final maxMs = points.last.$1.millisecondsSinceEpoch.toDouble();
|
||||
final wRange = maxW - minW;
|
||||
final tRange = maxMs - minMs;
|
||||
|
||||
double xOf(DateTime t) =>
|
||||
tRange == 0 ? size.width / 2 :
|
||||
(t.millisecondsSinceEpoch - minMs) / tRange * (size.width - 16) + 8;
|
||||
double yOf(double w) =>
|
||||
wRange == 0 ? size.height / 2 :
|
||||
(1 - (w - minW) / wRange) * (size.height - 16) + 8;
|
||||
|
||||
final linePaint = Paint()
|
||||
..color = Colors.indigoAccent
|
||||
..strokeWidth = 2
|
||||
..style = PaintingStyle.stroke;
|
||||
final dotPaint = Paint()
|
||||
..color = Colors.indigoAccent
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final path = Path()
|
||||
..moveTo(xOf(points.first.$1), yOf(points.first.$2));
|
||||
for (final p in points.skip(1)) {
|
||||
path.lineTo(xOf(p.$1), yOf(p.$2));
|
||||
}
|
||||
canvas.drawPath(path, linePaint);
|
||||
|
||||
for (final p in points) {
|
||||
canvas.drawCircle(Offset(xOf(p.$1), yOf(p.$2)), 4, dotPaint);
|
||||
}
|
||||
|
||||
// Label min/max weight
|
||||
final tp = TextPainter(textDirection: TextDirection.ltr);
|
||||
void drawLabel(String text, Offset offset) {
|
||||
tp
|
||||
..text = TextSpan(
|
||||
text: text,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 10),
|
||||
)
|
||||
..layout()
|
||||
..paint(canvas, offset);
|
||||
}
|
||||
drawLabel('${maxW}kg', Offset(8, 0));
|
||||
drawLabel('${minW}kg', Offset(8, size.height - 14));
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_ChartPainter old) => old.points != points;
|
||||
}
|
||||
|
||||
class _SessionTile extends StatelessWidget {
|
||||
const _SessionTile({
|
||||
required this.row,
|
||||
required this.formatDuration,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> row;
|
||||
final String Function(int) formatDuration;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final succeeded = (row['succeeded'] as int) == 1;
|
||||
final type = row['workout_type'] as String;
|
||||
final date = row['date'] as String;
|
||||
final dur = formatDuration(row['duration_seconds'] as int);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade800,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: succeeded ? Colors.green.shade800 : Colors.red.shade900,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
succeeded ? Icons.check_circle : Icons.cancel,
|
||||
color: succeeded ? Colors.greenAccent : Colors.redAccent,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Workout $type · $date',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
dur,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
310
stronglift_replacement/workout_app/lib/screens/home_screen.dart
Normal file
310
stronglift_replacement/workout_app/lib/screens/home_screen.dart
Normal file
@ -0,0 +1,310 @@
|
||||
/// Home screen: auto-resumes an active session, shows done-today status.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:workout_app/models/exercise.dart';
|
||||
import 'package:workout_app/screens/history_screen.dart';
|
||||
import 'package:workout_app/screens/settings_screen.dart';
|
||||
import 'package:workout_app/screens/workout_screen.dart';
|
||||
import 'package:workout_app/services/http_server_service.dart';
|
||||
import 'package:workout_app/services/storage_service.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
List<Exercise>? _exercises;
|
||||
String _nextType = 'A';
|
||||
List<String> _serverAddresses = [];
|
||||
bool _loading = true;
|
||||
bool _doneToday = false;
|
||||
Map<String, dynamic>? _savedSession;
|
||||
|
||||
/// True after the first load auto-navigated to an in-progress workout,
|
||||
/// so returning from workout does not auto-navigate again.
|
||||
bool _hasAutoResumed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final storage = StorageService.instance;
|
||||
final nextType = await storage.getNextWorkoutType();
|
||||
final exercises = await storage.getCurrentExercises(nextType);
|
||||
final saved = await storage.loadActiveSession();
|
||||
final addrs = await HttpServerService.instance.localAddresses;
|
||||
final lastDate = await storage.getLastWorkoutDate();
|
||||
final today = DateTime.now();
|
||||
final doneToday = lastDate != null &&
|
||||
lastDate.year == today.year &&
|
||||
lastDate.month == today.month &&
|
||||
lastDate.day == today.day;
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_nextType = nextType;
|
||||
_exercises = exercises;
|
||||
_serverAddresses = addrs;
|
||||
_savedSession = saved;
|
||||
_doneToday = doneToday;
|
||||
_loading = false;
|
||||
});
|
||||
|
||||
// Auto-resume active session on first load (app launch).
|
||||
if (saved != null && !_hasAutoResumed) {
|
||||
_hasAutoResumed = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _openWorkout(resume: true);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openWorkout({bool resume = false}) async {
|
||||
final storage = StorageService.instance;
|
||||
Map<String, dynamic>? savedState;
|
||||
String type = _nextType;
|
||||
List<Exercise> exercises = _exercises!;
|
||||
|
||||
if (resume && _savedSession != null) {
|
||||
savedState = _savedSession;
|
||||
final savedType = savedState!['workoutType'] as String? ?? _nextType;
|
||||
type = savedType;
|
||||
exercises = await storage.getCurrentExercises(savedType);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => WorkoutScreen(
|
||||
workoutType: type,
|
||||
exercises: exercises,
|
||||
savedState: savedState,
|
||||
),
|
||||
),
|
||||
);
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.grey.shade900,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.grey.shade800,
|
||||
title: const Text(
|
||||
'Workout Tracker',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.history, color: Colors.white),
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(builder: (_) => const HistoryScreen()),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings, color: Colors.white),
|
||||
onPressed: () async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const SettingsScreen(),
|
||||
),
|
||||
);
|
||||
_load();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_WorkoutCard(
|
||||
type: _nextType,
|
||||
exercises: _exercises!,
|
||||
doneToday: _doneToday,
|
||||
hasActiveSession: _savedSession != null,
|
||||
onStart: () => _openWorkout(resume: false),
|
||||
onResume: () => _openWorkout(resume: true),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_ServerAddressTile(addresses: _serverAddresses),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sub-widgets ────────────────────────────────────────────────────────────────
|
||||
|
||||
class _WorkoutCard extends StatelessWidget {
|
||||
const _WorkoutCard({
|
||||
required this.type,
|
||||
required this.exercises,
|
||||
required this.doneToday,
|
||||
required this.hasActiveSession,
|
||||
required this.onStart,
|
||||
required this.onResume,
|
||||
});
|
||||
|
||||
final String type;
|
||||
final List<Exercise> exercises;
|
||||
final bool doneToday;
|
||||
final bool hasActiveSession;
|
||||
final VoidCallback onStart;
|
||||
final VoidCallback onResume;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
color: Colors.grey.shade800,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (doneToday && !hasActiveSession) ...[
|
||||
const Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: Colors.greenAccent, size: 18),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'Done for today!',
|
||||
style: TextStyle(
|
||||
color: Colors.greenAccent,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Next: Workout $type — tomorrow',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
Text(
|
||||
hasActiveSession
|
||||
? 'Workout $type in progress'
|
||||
: 'Next: Workout $type',
|
||||
style: TextStyle(
|
||||
color: hasActiveSession ? Colors.orangeAccent : Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
...exercises.map(
|
||||
(e) => Text(
|
||||
'${e.name} ${e.sets}×${e.reps}×${e.weight}kg',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 13),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
if (hasActiveSession)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.orange.shade800,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
onPressed: onResume,
|
||||
child: const Text(
|
||||
'Resume Workout',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (!doneToday)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.indigo,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
onPressed: onStart,
|
||||
child: Text(
|
||||
'Start Workout $type',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ServerAddressTile extends StatelessWidget {
|
||||
const _ServerAddressTile({required this.addresses});
|
||||
|
||||
final List<String> addresses;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final lines = addresses.isEmpty
|
||||
? ['Server not started']
|
||||
: addresses.map((ip) => '$ip:$kWorkoutServerPort').toList();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade800,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'HTTP sync (no ADB needed)',
|
||||
style: TextStyle(
|
||||
color: Colors.white54,
|
||||
fontSize: 11,
|
||||
letterSpacing: 1.1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
...lines.map(
|
||||
(line) => Text(
|
||||
line,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,334 @@
|
||||
/// Settings screen: per-exercise streak thresholds and manual weight overrides.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:workout_app/models/exercise.dart';
|
||||
import 'package:workout_app/models/workout_plan.dart';
|
||||
import 'package:workout_app/services/storage_service.dart';
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
List<ExerciseState>? _states;
|
||||
bool _loading = true;
|
||||
bool _saving = false;
|
||||
|
||||
final Map<String, int> _successThresholds = {};
|
||||
final Map<String, int> _failThresholds = {};
|
||||
final Map<String, double> _weights = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final states = await StorageService.instance.getAllExerciseStates();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_states = states;
|
||||
for (final s in states) {
|
||||
_successThresholds[s.name] = s.successThreshold;
|
||||
_failThresholds[s.name] = s.failThreshold;
|
||||
_weights[s.name] = s.weight;
|
||||
}
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
setState(() => _saving = true);
|
||||
final storage = StorageService.instance;
|
||||
for (final s in _states!) {
|
||||
await storage.setExerciseThresholds(
|
||||
s.name,
|
||||
successThreshold: _successThresholds[s.name]!,
|
||||
failThreshold: _failThresholds[s.name]!,
|
||||
);
|
||||
final newWeight = _weights[s.name] ?? s.weight;
|
||||
if ((newWeight - s.weight).abs() > 0.001) {
|
||||
await storage.setExerciseWeight(s.name, newWeight);
|
||||
}
|
||||
}
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
List<String> get _orderedNames {
|
||||
final seen = <String>{};
|
||||
return [...workoutA, ...workoutB]
|
||||
.map((e) => e.name)
|
||||
.where(seen.add)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.grey.shade900,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.grey.shade800,
|
||||
title: const Text('Settings', style: TextStyle(color: Colors.white)),
|
||||
iconTheme: const IconThemeData(color: Colors.white),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: (_loading || _saving) ? null : _save,
|
||||
child: const Text('Save', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
const _SectionHeader('WEIGHTS'),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Override current working weight. '
|
||||
'Resets streak counters. Rounded to 2.5 kg.',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
..._orderedNames.map((name) {
|
||||
final w = _weights[name];
|
||||
if (w == null) return const SizedBox.shrink();
|
||||
return _WeightRow(
|
||||
name: name,
|
||||
weight: w,
|
||||
onChanged: (v) => setState(() => _weights[name] = v),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 20),
|
||||
const _SectionHeader('PROGRESSION THRESHOLDS'),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Consecutive successes (↑) or failures (↓) '
|
||||
'before weight changes.',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
..._orderedNames.map((name) {
|
||||
final sThresh = _successThresholds[name] ?? 3;
|
||||
final fThresh = _failThresholds[name] ?? 2;
|
||||
return _ExerciseThresholdCard(
|
||||
name: name,
|
||||
successThreshold: sThresh,
|
||||
failThreshold: fThresh,
|
||||
onSuccessChanged: (v) =>
|
||||
setState(() => _successThresholds[name] = v),
|
||||
onFailChanged: (v) =>
|
||||
setState(() => _failThresholds[name] = v),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader(this.text);
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
color: Colors.white54,
|
||||
fontSize: 11,
|
||||
letterSpacing: 1.4,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WeightRow extends StatelessWidget {
|
||||
const _WeightRow({
|
||||
required this.name,
|
||||
required this.weight,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final double weight;
|
||||
final ValueChanged<double> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
name,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 13),
|
||||
),
|
||||
),
|
||||
_StepperButton(
|
||||
icon: Icons.remove,
|
||||
onTap: () => onChanged(
|
||||
(weight - kWeightIncrement).clamp(0.0, 999.0),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Text(
|
||||
'${weight}kg',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
_StepperButton(
|
||||
icon: Icons.add,
|
||||
onTap: () => onChanged(weight + kWeightIncrement),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StepperButton extends StatelessWidget {
|
||||
const _StepperButton({required this.icon, required this.onTap});
|
||||
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade700,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(icon, color: Colors.white, size: 18),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ExerciseThresholdCard extends StatelessWidget {
|
||||
const _ExerciseThresholdCard({
|
||||
required this.name,
|
||||
required this.successThreshold,
|
||||
required this.failThreshold,
|
||||
required this.onSuccessChanged,
|
||||
required this.onFailChanged,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final int successThreshold;
|
||||
final int failThreshold;
|
||||
final ValueChanged<int> onSuccessChanged;
|
||||
final ValueChanged<int> onFailChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade800,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_ThresholdRow(
|
||||
label: '↑ Increase after N successes',
|
||||
value: successThreshold,
|
||||
color: Colors.green,
|
||||
onChanged: onSuccessChanged,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_ThresholdRow(
|
||||
label: '↓ Decrease after N failures',
|
||||
value: failThreshold,
|
||||
color: Colors.red,
|
||||
onChanged: onFailChanged,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThresholdRow extends StatelessWidget {
|
||||
const _ThresholdRow({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.color,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final int value;
|
||||
final Color color;
|
||||
final ValueChanged<int> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
for (int i = 1; i <= 5; i++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: GestureDetector(
|
||||
onTap: () => onChanged(i),
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: i == value ? color : Colors.grey.shade700,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'$i',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,321 @@
|
||||
/// Active workout screen: warmup, back-button protection,
|
||||
/// and crash-safe session persistence.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:workout_app/models/exercise.dart';
|
||||
import 'package:workout_app/models/exercise_result.dart';
|
||||
import 'package:workout_app/models/set_result.dart';
|
||||
import 'package:workout_app/models/workout_session.dart';
|
||||
import 'package:workout_app/services/storage_service.dart';
|
||||
import 'package:workout_app/services/sync_service.dart';
|
||||
import 'package:workout_app/widgets/exercise_tile.dart';
|
||||
import 'package:workout_app/widgets/workout_summary_dialog.dart';
|
||||
|
||||
class WorkoutScreen extends StatefulWidget {
|
||||
const WorkoutScreen({
|
||||
super.key,
|
||||
required this.workoutType,
|
||||
required this.exercises,
|
||||
this.savedState,
|
||||
});
|
||||
|
||||
final String workoutType;
|
||||
final List<Exercise> exercises;
|
||||
|
||||
/// Non-null when resuming a previously interrupted session.
|
||||
final Map<String, dynamic>? savedState;
|
||||
|
||||
@override
|
||||
State<WorkoutScreen> createState() => _WorkoutScreenState();
|
||||
}
|
||||
|
||||
class _WorkoutScreenState extends State<WorkoutScreen> {
|
||||
late List<List<bool>> _tapped;
|
||||
late List<List<int>> _doneReps;
|
||||
late List<bool> _warmupTapped;
|
||||
late DateTime _startTime;
|
||||
late Timer _elapsedTimer;
|
||||
Duration _elapsed = Duration.zero;
|
||||
|
||||
final _sync = SyncService();
|
||||
bool _finished = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final saved = widget.savedState;
|
||||
if (saved != null) {
|
||||
_restoreFromSaved(saved);
|
||||
} else {
|
||||
_initFresh();
|
||||
}
|
||||
_elapsedTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
setState(() => _elapsed = DateTime.now().difference(_startTime));
|
||||
});
|
||||
}
|
||||
|
||||
void _initFresh() {
|
||||
_startTime = DateTime.now();
|
||||
_tapped = List.generate(
|
||||
widget.exercises.length,
|
||||
(i) => List.filled(widget.exercises[i].sets, false),
|
||||
);
|
||||
_doneReps = List.generate(
|
||||
widget.exercises.length,
|
||||
(i) => List.filled(widget.exercises[i].sets, widget.exercises[i].reps),
|
||||
);
|
||||
_warmupTapped = List.filled(widget.exercises.length, false);
|
||||
}
|
||||
|
||||
void _restoreFromSaved(Map<String, dynamic> s) {
|
||||
_startTime = DateTime.fromMillisecondsSinceEpoch(s['startTimeMs'] as int);
|
||||
_tapped = (s['tapped'] as List)
|
||||
.map((row) => (row as List).cast<bool>())
|
||||
.toList();
|
||||
_doneReps = (s['doneReps'] as List)
|
||||
.map((row) => (row as List).cast<int>())
|
||||
.toList();
|
||||
_warmupTapped = (s['warmupTapped'] as List).cast<bool>();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_elapsedTimer.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ── Persistence ────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _saveActiveSession() async {
|
||||
await StorageService.instance.saveActiveSession({
|
||||
'workoutType': widget.workoutType,
|
||||
'startTimeMs': _startTime.millisecondsSinceEpoch,
|
||||
'tapped': _tapped,
|
||||
'doneReps': _doneReps,
|
||||
'warmupTapped': _warmupTapped,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
String _formatDuration(Duration d) {
|
||||
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
return '${d.inHours > 0 ? '${d.inHours}:' : ''}$m:$s';
|
||||
}
|
||||
|
||||
bool get _allSetsCompleted => _tapped.every((row) => row.every((t) => t));
|
||||
|
||||
// ── Interaction ────────────────────────────────────────────────────────────
|
||||
|
||||
void _tapCircle(int exIdx, int setIdx) {
|
||||
if (_finished) return;
|
||||
setState(() {
|
||||
if (!_tapped[exIdx][setIdx]) {
|
||||
_tapped[exIdx][setIdx] = true;
|
||||
} else {
|
||||
// Subsequent taps decrement reps (records actual reps done).
|
||||
_doneReps[exIdx][setIdx] =
|
||||
(_doneReps[exIdx][setIdx] - 1).clamp(0, 999);
|
||||
}
|
||||
});
|
||||
_saveActiveSession();
|
||||
}
|
||||
|
||||
void _tapWarmup(int exIdx) {
|
||||
if (_finished || _warmupTapped[exIdx]) return;
|
||||
setState(() => _warmupTapped[exIdx] = true);
|
||||
_saveActiveSession();
|
||||
}
|
||||
|
||||
void _resetCircle(int exIdx, int setIdx) {
|
||||
if (_finished) return;
|
||||
setState(() {
|
||||
_tapped[exIdx][setIdx] = false;
|
||||
_doneReps[exIdx][setIdx] = widget.exercises[exIdx].reps;
|
||||
});
|
||||
_saveActiveSession();
|
||||
}
|
||||
|
||||
// ── Finish / Reset ─────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _confirmFinish() async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
backgroundColor: Colors.grey.shade900,
|
||||
title: const Text(
|
||||
'Finish workout?',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel', style: TextStyle(color: Colors.white70)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child:
|
||||
const Text('Finish', style: TextStyle(color: Colors.greenAccent)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) await _finishWorkout();
|
||||
}
|
||||
|
||||
Future<void> _confirmReset() async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
backgroundColor: Colors.grey.shade900,
|
||||
title: const Text(
|
||||
'Reset workout?',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
content: const Text(
|
||||
'All progress will be lost.',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child:
|
||||
const Text('Cancel', style: TextStyle(color: Colors.white70)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child:
|
||||
const Text('Reset', style: TextStyle(color: Colors.redAccent)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) {
|
||||
await StorageService.instance.clearActiveSession();
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _finishWorkout() async {
|
||||
_elapsedTimer.cancel();
|
||||
setState(() => _finished = true);
|
||||
|
||||
final endTime = DateTime.now();
|
||||
final results = <ExerciseResult>[];
|
||||
|
||||
for (int i = 0; i < widget.exercises.length; i++) {
|
||||
final ex = widget.exercises[i];
|
||||
results.add(ExerciseResult(
|
||||
exercise: ex,
|
||||
sets: List.generate(
|
||||
ex.sets,
|
||||
(s) => SetResult(
|
||||
targetReps: ex.reps,
|
||||
doneReps: _tapped[i][s] ? _doneReps[i][s] : 0,
|
||||
weight: ex.weight,
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
final session = WorkoutSession(
|
||||
workoutType: widget.workoutType,
|
||||
startTime: _startTime,
|
||||
endTime: endTime,
|
||||
exercises: results,
|
||||
);
|
||||
|
||||
final storage = StorageService.instance;
|
||||
await storage.saveSession(
|
||||
date: _startTime.toIso8601String().substring(0, 10),
|
||||
workoutType: widget.workoutType,
|
||||
durationSeconds: session.duration.inSeconds,
|
||||
succeeded: session.fullySucceeded,
|
||||
json: session.toJsonString(),
|
||||
);
|
||||
|
||||
final lastDate = await storage.getLastWorkoutDate() ?? _startTime;
|
||||
await storage.applyProgression(
|
||||
succeededExercises: {
|
||||
for (int i = 0; i < widget.exercises.length; i++)
|
||||
widget.exercises[i].name: results[i].succeeded,
|
||||
},
|
||||
lastWorkoutDate: lastDate,
|
||||
);
|
||||
await storage.setLastWorkoutType(widget.workoutType);
|
||||
await storage.clearActiveSession();
|
||||
|
||||
final syncResult = await _sync.writeWorkoutResult(session);
|
||||
|
||||
if (!mounted) return;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => WorkoutSummaryDialog(
|
||||
session: session,
|
||||
syncResult: syncResult,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Build ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
// canPop: true → back navigates home, workout stays in DB
|
||||
// ignore: avoid_redundant_argument_values
|
||||
canPop: true,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.grey.shade900,
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
backgroundColor: Colors.grey.shade800,
|
||||
title: Text(
|
||||
'Workout ${widget.workoutType} · ${_formatDuration(_elapsed)}',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
actions: [
|
||||
if (!_finished)
|
||||
TextButton(
|
||||
onPressed: () => _confirmReset(),
|
||||
child: const Text(
|
||||
'Reset',
|
||||
style: TextStyle(color: Colors.redAccent),
|
||||
),
|
||||
),
|
||||
if (!_finished)
|
||||
TextButton(
|
||||
onPressed: _allSetsCompleted ? _confirmFinish : null,
|
||||
child: Text(
|
||||
'Finish',
|
||||
style: TextStyle(
|
||||
color:
|
||||
_allSetsCompleted ? Colors.greenAccent : Colors.grey,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView.separated(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: widget.exercises.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (_, i) => ExerciseTile(
|
||||
exercise: widget.exercises[i],
|
||||
tapped: _tapped[i],
|
||||
doneReps: _doneReps[i],
|
||||
warmupTapped: _warmupTapped[i],
|
||||
onTapCircle: (s) => _tapCircle(i, s),
|
||||
onLongPressCircle: (s) => _resetCircle(i, s),
|
||||
onTapWarmup: () => _tapWarmup(i),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,104 @@
|
||||
/// Tiny HTTP server that exposes the latest workout JSON on the local network.
|
||||
///
|
||||
/// Purpose: allows the PC to verify the workout even when USB-debugging /
|
||||
/// ADB is not available. The PC scans for port [kWorkoutServerPort] on the
|
||||
/// local subnet and GETs /workout.
|
||||
///
|
||||
/// Security note: this only serves workout data and only on the local
|
||||
/// network. No authentication is needed for a home-network use case.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:workout_app/services/sync_service.dart';
|
||||
|
||||
/// Port the HTTP server listens on. Must match the constant on the PC side.
|
||||
const int kWorkoutServerPort = 8765;
|
||||
|
||||
class HttpServerService {
|
||||
HttpServerService._();
|
||||
static final HttpServerService instance = HttpServerService._();
|
||||
|
||||
HttpServer? _server;
|
||||
|
||||
/// The most recent workout JSON string (updated after each finished workout).
|
||||
String? _latestJson;
|
||||
|
||||
/// Returns all non-loopback IPv4 addresses the server is reachable on.
|
||||
Future<List<String>> get localAddresses async {
|
||||
final addrs = <String>[];
|
||||
for (final iface in await NetworkInterface.list()) {
|
||||
for (final addr in iface.addresses) {
|
||||
if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) {
|
||||
addrs.add(addr.address);
|
||||
}
|
||||
}
|
||||
}
|
||||
return addrs;
|
||||
}
|
||||
|
||||
void updateLatestWorkout(String json) => _latestJson = json;
|
||||
|
||||
Future<void> start() async {
|
||||
if (_server != null) return; // already running
|
||||
await _loadFromDisk();
|
||||
try {
|
||||
_server = await HttpServer.bind(InternetAddress.anyIPv4, kWorkoutServerPort);
|
||||
_serve();
|
||||
} on SocketException {
|
||||
// Port already in use or binding failed — not fatal.
|
||||
_server = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// On startup, try to load the last saved workout JSON from disk so the
|
||||
/// HTTP endpoint is populated even before the next workout is completed.
|
||||
Future<void> _loadFromDisk() async {
|
||||
final candidates = <String>[kSyncFilePath];
|
||||
try {
|
||||
final dir = await getExternalStorageDirectory();
|
||||
if (dir != null) candidates.add('${dir.path}/workout_result.json');
|
||||
} on Exception {
|
||||
// Ignore; the /sdcard path is tried first.
|
||||
}
|
||||
for (final path in candidates) {
|
||||
final file = File(path);
|
||||
if (await file.exists()) {
|
||||
try {
|
||||
_latestJson = await file.readAsString();
|
||||
return;
|
||||
} on IOException {
|
||||
// Try next path.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _serve() async {
|
||||
final server = _server;
|
||||
if (server == null) return;
|
||||
await for (final req in server) {
|
||||
if (req.method == 'GET' && req.uri.path == '/workout') {
|
||||
if (_latestJson != null) {
|
||||
req.response
|
||||
..statusCode = HttpStatus.ok
|
||||
..headers.contentType = ContentType.json
|
||||
..write(_latestJson);
|
||||
} else {
|
||||
req.response.statusCode = HttpStatus.notFound;
|
||||
req.response.write('{"error":"no workout data yet"}');
|
||||
}
|
||||
} else {
|
||||
req.response.statusCode = HttpStatus.notFound;
|
||||
req.response.write('{"error":"not found"}');
|
||||
}
|
||||
await req.response.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
await _server?.close(force: true);
|
||||
_server = null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,364 @@
|
||||
/// Persistent storage for exercise progression state using SQLite.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:workout_app/models/exercise.dart';
|
||||
import 'package:workout_app/models/workout_plan.dart';
|
||||
|
||||
/// Per-exercise progression state stored in SQLite.
|
||||
class ExerciseState {
|
||||
ExerciseState({
|
||||
required this.name,
|
||||
required this.weight,
|
||||
required this.reps,
|
||||
required this.successStreak,
|
||||
required this.failStreak,
|
||||
required this.maxWeight,
|
||||
required this.successThreshold,
|
||||
required this.failThreshold,
|
||||
});
|
||||
|
||||
final String name;
|
||||
double weight;
|
||||
int reps;
|
||||
int successStreak;
|
||||
int failStreak;
|
||||
final double maxWeight;
|
||||
int successThreshold;
|
||||
int failThreshold;
|
||||
}
|
||||
|
||||
class StorageService {
|
||||
StorageService._();
|
||||
static StorageService? _instance;
|
||||
static StorageService get instance => _instance!;
|
||||
|
||||
late Database _db;
|
||||
|
||||
static Future<StorageService> init() async {
|
||||
if (_instance != null) return _instance!;
|
||||
final svc = StorageService._();
|
||||
await svc._open();
|
||||
_instance = svc;
|
||||
return svc;
|
||||
}
|
||||
|
||||
Future<void> _open() async {
|
||||
final dbPath = p.join(await getDatabasesPath(), 'workout_app.db');
|
||||
_db = await openDatabase(
|
||||
dbPath,
|
||||
version: 3,
|
||||
onCreate: _createSchema,
|
||||
onUpgrade: _migrateSchema,
|
||||
);
|
||||
await _seedDefaultsIfNeeded();
|
||||
}
|
||||
|
||||
Future<void> _createSchema(Database db, int version) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE exercise_state (
|
||||
name TEXT PRIMARY KEY,
|
||||
weight REAL NOT NULL,
|
||||
reps INTEGER NOT NULL,
|
||||
success_streak INTEGER NOT NULL DEFAULT 0,
|
||||
fail_streak INTEGER NOT NULL DEFAULT 0,
|
||||
max_weight REAL NOT NULL,
|
||||
success_threshold INTEGER NOT NULL DEFAULT 3,
|
||||
fail_threshold INTEGER NOT NULL DEFAULT 2
|
||||
)
|
||||
''');
|
||||
await db.execute('''
|
||||
CREATE TABLE workout_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT NOT NULL,
|
||||
workout_type TEXT NOT NULL,
|
||||
duration_seconds INTEGER NOT NULL,
|
||||
succeeded INTEGER NOT NULL,
|
||||
json TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
await db.execute('''
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
await db.execute('''
|
||||
CREATE TABLE active_session (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
json TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
}
|
||||
|
||||
Future<void> _migrateSchema(
|
||||
Database db,
|
||||
int oldVersion,
|
||||
int newVersion,
|
||||
) async {
|
||||
if (oldVersion < 2) {
|
||||
await db.execute(
|
||||
'ALTER TABLE exercise_state ADD COLUMN success_threshold INTEGER NOT NULL DEFAULT 3',
|
||||
);
|
||||
await db.execute(
|
||||
'ALTER TABLE exercise_state ADD COLUMN fail_threshold INTEGER NOT NULL DEFAULT 2',
|
||||
);
|
||||
}
|
||||
if (oldVersion < 3) {
|
||||
await db.execute(
|
||||
'CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE TABLE IF NOT EXISTS active_session '
|
||||
'(id INTEGER PRIMARY KEY CHECK (id = 1), json TEXT NOT NULL)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _seedDefaultsIfNeeded() async {
|
||||
for (final ex in [...workoutA, ...workoutB]) {
|
||||
final rows = await _db.query(
|
||||
'exercise_state',
|
||||
where: 'name = ?',
|
||||
whereArgs: [ex.name],
|
||||
);
|
||||
if (rows.isEmpty) {
|
||||
await _db.insert('exercise_state', {
|
||||
'name': ex.name,
|
||||
'weight': ex.weight,
|
||||
'reps': ex.reps,
|
||||
'success_streak': 0,
|
||||
'fail_streak': 0,
|
||||
'max_weight': ex.maxWeight,
|
||||
'success_threshold': 3,
|
||||
'fail_threshold': 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────────────
|
||||
|
||||
Future<String?> _getSetting(String key) async {
|
||||
final rows = await _db.query(
|
||||
'settings',
|
||||
where: 'key = ?',
|
||||
whereArgs: [key],
|
||||
);
|
||||
return rows.isEmpty ? null : rows.first['value'] as String;
|
||||
}
|
||||
|
||||
Future<void> _setSetting(String key, String value) async {
|
||||
await _db.insert(
|
||||
'settings',
|
||||
{'key': key, 'value': value},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> getNextWorkoutType() async {
|
||||
final last = await _getSetting('last_workout_type');
|
||||
return last == 'A' ? 'B' : 'A';
|
||||
}
|
||||
|
||||
Future<void> setLastWorkoutType(String type) async {
|
||||
await _setSetting('last_workout_type', type);
|
||||
}
|
||||
|
||||
// ── Active session (crash / exit recovery) ─────────────────────────────────
|
||||
|
||||
Future<void> saveActiveSession(Map<String, dynamic> data) async {
|
||||
await _db.insert(
|
||||
'active_session',
|
||||
{'id': 1, 'json': jsonEncode(data)},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> loadActiveSession() async {
|
||||
final rows = await _db.query('active_session', where: 'id = 1');
|
||||
if (rows.isEmpty) return null;
|
||||
return jsonDecode(rows.first['json'] as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<void> clearActiveSession() async {
|
||||
await _db.delete('active_session', where: 'id = 1');
|
||||
}
|
||||
|
||||
// ── Exercise state ─────────────────────────────────────────────────────────
|
||||
|
||||
Future<ExerciseState?> getExerciseState(String name) async {
|
||||
final rows = await _db.query(
|
||||
'exercise_state',
|
||||
where: 'name = ?',
|
||||
whereArgs: [name],
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
final r = rows.first;
|
||||
return ExerciseState(
|
||||
name: r['name'] as String,
|
||||
weight: r['weight'] as double,
|
||||
reps: r['reps'] as int,
|
||||
successStreak: r['success_streak'] as int,
|
||||
failStreak: r['fail_streak'] as int,
|
||||
maxWeight: r['max_weight'] as double,
|
||||
successThreshold: r['success_threshold'] as int? ?? 3,
|
||||
failThreshold: r['fail_threshold'] as int? ?? 2,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<ExerciseState>> getAllExerciseStates() async {
|
||||
final allNames = [...workoutA, ...workoutB].map((e) => e.name).toSet();
|
||||
final states = <ExerciseState>[];
|
||||
for (final name in allNames) {
|
||||
final s = await getExerciseState(name);
|
||||
if (s != null) states.add(s);
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
Future<void> setExerciseThresholds(
|
||||
String name, {
|
||||
required int successThreshold,
|
||||
required int failThreshold,
|
||||
}) async {
|
||||
await _db.update(
|
||||
'exercise_state',
|
||||
{
|
||||
'success_threshold': successThreshold,
|
||||
'fail_threshold': failThreshold,
|
||||
},
|
||||
where: 'name = ?',
|
||||
whereArgs: [name],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setExerciseWeight(String name, double weight) async {
|
||||
await _db.update(
|
||||
'exercise_state',
|
||||
{'weight': weight, 'success_streak': 0, 'fail_streak': 0},
|
||||
where: 'name = ?',
|
||||
whereArgs: [name],
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<Exercise>> getCurrentExercises(String workoutType) async {
|
||||
final template = workoutType == 'A' ? workoutA : workoutB;
|
||||
final result = <Exercise>[];
|
||||
for (final ex in template) {
|
||||
final state = await getExerciseState(ex.name);
|
||||
if (state == null) {
|
||||
result.add(ex);
|
||||
} else {
|
||||
result.add(ex.copyWith(weight: state.weight, reps: state.reps));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> applyProgression({
|
||||
required Map<String, bool> succeededExercises,
|
||||
required DateTime lastWorkoutDate,
|
||||
}) async {
|
||||
final daysSince = DateTime.now().difference(lastWorkoutDate).inDays;
|
||||
final hadBreak = daysSince > 7;
|
||||
|
||||
for (final entry in succeededExercises.entries) {
|
||||
final state = await getExerciseState(entry.key);
|
||||
if (state == null) continue;
|
||||
|
||||
if (hadBreak) {
|
||||
final newWeight =
|
||||
(state.weight - kWeightIncrement).clamp(0.0, state.maxWeight);
|
||||
await _db.update(
|
||||
'exercise_state',
|
||||
{'weight': newWeight, 'success_streak': 0, 'fail_streak': 0},
|
||||
where: 'name = ?',
|
||||
whereArgs: [entry.key],
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.value) {
|
||||
final newStreak = state.successStreak + 1;
|
||||
final shouldProgress = newStreak >= state.successThreshold;
|
||||
double newWeight = state.weight;
|
||||
int newReps = state.reps;
|
||||
|
||||
if (shouldProgress) {
|
||||
if (state.weight >= state.maxWeight) {
|
||||
newReps = state.reps + 1;
|
||||
} else {
|
||||
newWeight =
|
||||
(state.weight + kWeightIncrement).clamp(0.0, state.maxWeight);
|
||||
}
|
||||
}
|
||||
|
||||
await _db.update(
|
||||
'exercise_state',
|
||||
{
|
||||
'weight': newWeight,
|
||||
'reps': newReps,
|
||||
'success_streak': shouldProgress ? 0 : newStreak,
|
||||
'fail_streak': 0,
|
||||
},
|
||||
where: 'name = ?',
|
||||
whereArgs: [entry.key],
|
||||
);
|
||||
} else {
|
||||
final newStreak = state.failStreak + 1;
|
||||
final shouldRegress = newStreak >= state.failThreshold;
|
||||
final newWeight = shouldRegress
|
||||
? (state.weight - kWeightIncrement).clamp(0.0, state.maxWeight)
|
||||
: state.weight;
|
||||
|
||||
await _db.update(
|
||||
'exercise_state',
|
||||
{
|
||||
'weight': newWeight,
|
||||
'fail_streak': shouldRegress ? 0 : newStreak,
|
||||
'success_streak': 0,
|
||||
},
|
||||
where: 'name = ?',
|
||||
whereArgs: [entry.key],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveSession({
|
||||
required String date,
|
||||
required String workoutType,
|
||||
required int durationSeconds,
|
||||
required bool succeeded,
|
||||
required String json,
|
||||
}) async {
|
||||
await _db.insert('workout_history', {
|
||||
'date': date,
|
||||
'workout_type': workoutType,
|
||||
'duration_seconds': durationSeconds,
|
||||
'succeeded': succeeded ? 1 : 0,
|
||||
'json': json,
|
||||
});
|
||||
}
|
||||
|
||||
Future<DateTime?> getLastWorkoutDate() async {
|
||||
final rows = await _db.rawQuery(
|
||||
'SELECT date FROM workout_history ORDER BY date DESC LIMIT 1',
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return DateTime.tryParse(rows.first['date'] as String);
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getWorkoutHistory({
|
||||
int limit = 60,
|
||||
}) async {
|
||||
return _db.rawQuery(
|
||||
'SELECT date, workout_type, duration_seconds, succeeded, json '
|
||||
'FROM workout_history ORDER BY date DESC LIMIT ?',
|
||||
[limit],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
/// Writes workout result JSON to external storage (ADB) and the in-app HTTP server.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:workout_app/models/workout_session.dart';
|
||||
import 'package:workout_app/services/http_server_service.dart';
|
||||
|
||||
/// Path on the phone's external storage where the PC reads workout data via ADB.
|
||||
const String kSyncFilePath = '/sdcard/workout_result.json';
|
||||
|
||||
class SyncService {
|
||||
/// Writes [session] as JSON to external storage and updates the HTTP server.
|
||||
///
|
||||
/// Falls back to app-external directory if /sdcard/ is not writable.
|
||||
Future<SyncResult> writeWorkoutResult(WorkoutSession session) async {
|
||||
final json = session.toJsonString();
|
||||
|
||||
// Always update the in-app HTTP server so the PC can read via WiFi.
|
||||
HttpServerService.instance.updateLatestWorkout(json);
|
||||
|
||||
// Try the primary path first (/sdcard/ — ADB-accessible without root).
|
||||
try {
|
||||
final file = File(kSyncFilePath);
|
||||
await file.writeAsString(json);
|
||||
return SyncResult(success: true, path: kSyncFilePath);
|
||||
} on Exception {
|
||||
// Fallback: app-specific external directory (still ADB accessible).
|
||||
}
|
||||
|
||||
try {
|
||||
final dir = await getExternalStorageDirectory();
|
||||
if (dir != null) {
|
||||
final file = File('${dir.path}/workout_result.json');
|
||||
await file.writeAsString(json);
|
||||
return SyncResult(success: true, path: file.path);
|
||||
}
|
||||
} on Exception {
|
||||
// Fallback failed.
|
||||
}
|
||||
|
||||
return SyncResult(success: false, path: null, error: 'No writable external path');
|
||||
}
|
||||
}
|
||||
|
||||
class SyncResult {
|
||||
const SyncResult({required this.success, required this.path, this.error});
|
||||
|
||||
final bool success;
|
||||
final String? path;
|
||||
final String? error;
|
||||
}
|
||||
@ -0,0 +1,160 @@
|
||||
/// Card widget for a single exercise showing warmup and main-set rep circles.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:workout_app/models/exercise.dart';
|
||||
import 'package:workout_app/widgets/rep_circle.dart';
|
||||
|
||||
class ExerciseTile extends StatelessWidget {
|
||||
const ExerciseTile({
|
||||
super.key,
|
||||
required this.exercise,
|
||||
required this.tapped,
|
||||
required this.doneReps,
|
||||
required this.warmupTapped,
|
||||
required this.onTapCircle,
|
||||
required this.onLongPressCircle,
|
||||
required this.onTapWarmup,
|
||||
});
|
||||
|
||||
final Exercise exercise;
|
||||
|
||||
/// tapped[setIdx] - whether each main set circle has been tapped.
|
||||
final List<bool> tapped;
|
||||
|
||||
/// doneReps[setIdx] - how many reps recorded for each set.
|
||||
final List<int> doneReps;
|
||||
|
||||
final bool warmupTapped;
|
||||
|
||||
final void Function(int setIdx) onTapCircle;
|
||||
final void Function(int setIdx) onLongPressCircle;
|
||||
final VoidCallback onTapWarmup;
|
||||
|
||||
bool get _allCompleted => tapped.every((t) => t);
|
||||
|
||||
bool get _allSucceeded =>
|
||||
_allCompleted && doneReps.every((r) => r >= exercise.reps);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Color headerColor = Colors.grey.shade800;
|
||||
if (_allCompleted) {
|
||||
headerColor =
|
||||
_allSucceeded ? Colors.green.shade800 : Colors.red.shade900;
|
||||
}
|
||||
|
||||
return Card(
|
||||
color: headerColor,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header row: name + target
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
exercise.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${exercise.sets}×${exercise.reps}×${exercise.weight}kg',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Warmup row
|
||||
_WarmupRow(
|
||||
warmupWeight: exercise.warmupWeight,
|
||||
tapped: warmupTapped,
|
||||
onTap: onTapWarmup,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
// Main set circles
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: List.generate(
|
||||
exercise.sets,
|
||||
(s) => RepCircle(
|
||||
targetReps: exercise.reps,
|
||||
doneReps: doneReps[s],
|
||||
tapped: tapped[s],
|
||||
onTap: () => onTapCircle(s),
|
||||
onLongPress: () => onLongPressCircle(s),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WarmupRow extends StatelessWidget {
|
||||
const _WarmupRow({
|
||||
required this.warmupWeight,
|
||||
required this.tapped,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final double warmupWeight;
|
||||
final bool tapped;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Warmup 1×5×',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 12),
|
||||
),
|
||||
Text(
|
||||
'${warmupWeight}kg',
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
GestureDetector(
|
||||
onTap: tapped ? null : onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: tapped ? Colors.teal : Colors.transparent,
|
||||
border: Border.all(
|
||||
color: tapped ? Colors.teal : Colors.white38,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(
|
||||
tapped ? Icons.check : Icons.fitness_center,
|
||||
color: tapped ? Colors.white : Colors.white38,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
tapped ? 'done' : 'optional',
|
||||
style: TextStyle(
|
||||
color: tapped ? Colors.tealAccent : Colors.white30,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
106
stronglift_replacement/workout_app/lib/widgets/rep_circle.dart
Normal file
106
stronglift_replacement/workout_app/lib/widgets/rep_circle.dart
Normal file
@ -0,0 +1,106 @@
|
||||
/// Tappable circle widget representing one set of an exercise.
|
||||
///
|
||||
/// States:
|
||||
/// neutral – white, shows target reps, not yet acted upon
|
||||
/// success – green, shows target reps (full set done)
|
||||
/// partial – orange, shows how many reps were actually done (< target)
|
||||
/// failed – red, shows 0 (all reps deducted)
|
||||
///
|
||||
/// Interaction:
|
||||
/// single tap → neutral→success, success→partial(-1 rep), partial→partial(-1 rep),
|
||||
/// failed stays failed
|
||||
/// long press → reset to neutral
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum RepCircleState { neutral, success, partial, failed }
|
||||
|
||||
class RepCircle extends StatelessWidget {
|
||||
const RepCircle({
|
||||
super.key,
|
||||
required this.targetReps,
|
||||
required this.doneReps,
|
||||
required this.tapped,
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
});
|
||||
|
||||
final int targetReps;
|
||||
|
||||
/// Reps currently registered (may be < targetReps if user tapped multiple times).
|
||||
final int doneReps;
|
||||
|
||||
/// Whether this circle has been tapped at all (distinguishes neutral from success).
|
||||
final bool tapped;
|
||||
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onLongPress;
|
||||
|
||||
RepCircleState get _state {
|
||||
if (!tapped) return RepCircleState.neutral;
|
||||
if (doneReps >= targetReps) return RepCircleState.success;
|
||||
if (doneReps > 0) return RepCircleState.partial;
|
||||
return RepCircleState.failed;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = _state;
|
||||
final Color bg;
|
||||
final Color fg;
|
||||
final String label;
|
||||
|
||||
switch (state) {
|
||||
case RepCircleState.neutral:
|
||||
bg = Colors.white;
|
||||
fg = Colors.black87;
|
||||
label = '$targetReps';
|
||||
case RepCircleState.success:
|
||||
bg = Colors.green;
|
||||
fg = Colors.white;
|
||||
label = '$targetReps';
|
||||
case RepCircleState.partial:
|
||||
bg = Colors.orange;
|
||||
fg = Colors.white;
|
||||
label = '$doneReps';
|
||||
case RepCircleState.failed:
|
||||
bg = Colors.red;
|
||||
fg = Colors.white;
|
||||
label = '0';
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
child: Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: bg,
|
||||
border: Border.all(
|
||||
color: state == RepCircleState.neutral ? Colors.grey.shade400 : bg,
|
||||
width: 2,
|
||||
),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Colors.black26,
|
||||
blurRadius: 4,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: fg,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
/// Dialog shown after a workout is finished, summarising results.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:workout_app/models/workout_session.dart';
|
||||
import 'package:workout_app/services/sync_service.dart';
|
||||
|
||||
class WorkoutSummaryDialog extends StatelessWidget {
|
||||
const WorkoutSummaryDialog({
|
||||
super.key,
|
||||
required this.session,
|
||||
required this.syncResult,
|
||||
});
|
||||
|
||||
final WorkoutSession session;
|
||||
final SyncResult syncResult;
|
||||
|
||||
String _fmt(Duration d) {
|
||||
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
return '${d.inHours > 0 ? '${d.inHours}h ' : ''}${m}m ${s}s';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final succeeded = session.fullySucceeded;
|
||||
return AlertDialog(
|
||||
backgroundColor: Colors.grey.shade900,
|
||||
title: Text(
|
||||
succeeded ? 'Workout Complete! 💪' : 'Workout Done',
|
||||
style: TextStyle(
|
||||
color: succeeded ? Colors.greenAccent : Colors.orangeAccent,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Duration: ${_fmt(session.duration)}',
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...session.exercises.map(
|
||||
(e) => Text(
|
||||
'${e.exercise.name}: ${e.succeeded ? "✓" : "✗"}',
|
||||
style: TextStyle(
|
||||
color: e.succeeded ? Colors.greenAccent : Colors.redAccent,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
syncResult.success
|
||||
? 'Saved to ${syncResult.path}'
|
||||
: 'Sync failed: ${syncResult.error}',
|
||||
style: TextStyle(
|
||||
color: syncResult.success ? Colors.white54 : Colors.redAccent,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).popUntil((r) => r.isFirst);
|
||||
},
|
||||
child: const Text(
|
||||
'Back to Home',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
562
stronglift_replacement/workout_app/pubspec.lock
Normal file
562
stronglift_replacement/workout_app/pubspec.lock
Normal file
@ -0,0 +1,562 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: clock
|
||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
code_assets:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: "67cf6d84013f9c601e42a6f8a6b74c4c0d9dc1a1619d775f2b28b732d3551b85"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: a41af4e8fc687cd6d33de9751eb936c8c0204ebe2bcb6c15ecf707504bf47f31
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
jni:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni
|
||||
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
jni_flutter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni_flutter
|
||||
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.19"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.4.1"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_config
|
||||
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
path:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.5"
|
||||
path_provider_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.1"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.0"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_linux
|
||||
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_platform_interface
|
||||
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
path_provider_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_windows
|
||||
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
permission_handler:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: permission_handler
|
||||
sha256: ca045d03615023c08ccdb297aad46a9198193666039ddd36d4d85fd0b1864b98
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.0.2"
|
||||
permission_handler_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_android
|
||||
sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "13.0.1"
|
||||
permission_handler_apple:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_apple
|
||||
sha256: "447c18bc3c5fdea5c3039f042b2b365fd51e3634f5f6e269ed22c1f00071addc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.4.8"
|
||||
permission_handler_html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_html
|
||||
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.3+5"
|
||||
permission_handler_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_platform_interface
|
||||
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.3.0"
|
||||
permission_handler_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_windows
|
||||
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: platform
|
||||
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.6"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
record_use:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_use
|
||||
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.5"
|
||||
shared_preferences_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.23"
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_foundation
|
||||
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.6"
|
||||
shared_preferences_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_linux
|
||||
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shared_preferences_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_platform_interface
|
||||
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
shared_preferences_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_web
|
||||
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
shared_preferences_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_windows
|
||||
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
sqflite:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqflite
|
||||
sha256: "564cfed0746fe53140c23b70b308e045c3b31f17778f2f326ccb7d804ea0250a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2+1"
|
||||
sqflite_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_android
|
||||
sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2+3"
|
||||
sqflite_common:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_common
|
||||
sha256: "1581ffbf7a0e333b380d6a30737d78516b826cb35beb7fb0bf8a3ea0c678b465"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.8"
|
||||
sqflite_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_darwin
|
||||
sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
sqflite_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_platform_interface
|
||||
sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: synchronized
|
||||
sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.0+1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.2.0"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xdg_directories
|
||||
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yaml
|
||||
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.12.0 <4.0.0"
|
||||
flutter: ">=3.38.4"
|
||||
25
stronglift_replacement/workout_app/pubspec.yaml
Normal file
25
stronglift_replacement/workout_app/pubspec.yaml
Normal file
@ -0,0 +1,25 @@
|
||||
name: workout_app
|
||||
description: "Workout tracker replacing StrongLifts"
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.0
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
path: ^1.9.1
|
||||
sqflite: ^2.4.2
|
||||
path_provider: ^2.1.5
|
||||
shared_preferences: ^2.5.3
|
||||
permission_handler: ^12.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
2
stronglift_replacement/workout_app/test/widget_test.dart
Normal file
2
stronglift_replacement/workout_app/test/widget_test.dart
Normal file
@ -0,0 +1,2 @@
|
||||
// Tests are written after the user approves functionality per project rules.
|
||||
void main() {}
|
||||
Loading…
Reference in New Issue
Block a user