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:
Krzysztof kuhy Rudnicki 2026-05-31 16:23:46 +02:00
parent d9d7c9b322
commit da269c537a
45 changed files with 3420 additions and 0 deletions

View 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)

View 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

View 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

View 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'

View 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)

View 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.

View 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

View 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

View File

@ -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 = "../.."
}

View File

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

View File

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

View File

@ -0,0 +1,5 @@
package com.kuhy.workout_app
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

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

View File

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

View File

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

View File

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

View File

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

View 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)
}

View File

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

View 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

View File

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

View 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(),
);
}
}

View 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,
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 neutralsuccess, successpartial(-1 rep), partialpartial(-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,
),
),
),
);
}
}

View File

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

View 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"

View 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

View File

@ -0,0 +1,2 @@
// Tests are written after the user approves functionality per project rules.
void main() {}