Introduction
A few weeks ago, I was contacted by my old coach, Dima Kachan, to help him out with an idea for a new training aid software.
Dima is a very successful international gymnastics coach. He has six world championship medals, across five of his athletes and two disciplines. I have been lucky enough to train with him over a period of two years (unfortunately I haven’t won him any medals) and I’ve learned a lot.
A lot of factors goes into becoming a world-class gymnast, like training intensity and recovery. One of the big factors Dima monitors, is total difficulty of skills, performed in a training session. This metric helps measure the physical demands placed on gymnasts based on how difficult skills they perform and varies depending on the training phase in a season.
Currently, Dima uses spreadsheets for tracking difficulty. Athletes manually record the number of repetitions for each skill performed during a session. However, this system is cumbersome, error-prone, and often neglected by athletes. This is where I come in.
This blog will first explain the more overall process I go through in a project like this, followed by a bit of technological decision making.
The Process
When I start new project, I use an iterative process. Rapid prototyping allows me to get a functional version into users’ hands early, ensuring the app meets their needs. Feedback is invaluable, helping me avoid spending time on unnecessary features.
The first step is to figure out the requirements. What does the app need to do? In this case, it need to replace the spreadsheet and make tracking and monitoring training, more dynamic.
After spending some time talking with Dima and looking at the existing approach, the main requirements of the app is:
- Athletes should be able to track each skill they do and on what apparatus
- Athletes should be able to see their difficulty for each session
- Coaches should be able to see which skills the athlete has done during a session
- Coaches should be able to see a weekly or monthly view of what an athlete has done
- Coaches should be able to assign programs (set of skills for a session) to an athlete.
These requirements translated into the app’s core features:
- Tracking: Where users choose their program and record their skills
- Monitoring: Where athlete and coach can monitor completed sessions and difficulty
- Programming: Where the coach can put skills into programs and assign them to athletes
With the features set and confirmed with Dima, I can start developing.
To ensure the app is useful from the start, I prioritise features in this order:
Tracking → Monitoring → Programming
This sequence allows me to collect and review training data early, gradually adding more functionality. Each feature is deployed on GitHub Pages for Dima and his athletes to test, providing immediate feedback. These insights guides refinements before I move on to the next feature.
Technical Insights
Tech Stack
My base tools of choice are Flutter and Supabase. Flutter is a cross-platform frontend framework, which allows me to make apps for both Android, iOS and web. Supabase is my backend which delivers database and authentication, it is open source and it runs on PostgreSQL.
These choices came out of necessity, as I had to implement ML models into a Flutter app, in a previous job for a medical monitoring app. I fell in love with both Flutter and Supabase and have used it since.
I wanted to try out the MVC design pattern, as it seems to be the standard in a lot of companies, but as the app grew, the unfamiliar framework got in the way of quality. The controllers got too bloated and the interplay between the different components wasn’t clear to me.
Because of this, I switch to Clean Architecture, which I’ve worked with in other projects. For me it offers more granularity, as the organisation into individual features makes a lot of sense to me and it ensured that each feature remained independent and testable, simplifying future maintenance and scalability as more users are added to the app. I make use of BLoC for state management as well, which makes my widgets very clean and simple.
Features
With the foundation of tools, architecture, and state management in place, I focus on implementing the core features that will bring the app to life. Each feature is designed to replace the existing spreadsheet workflow while prioritising ease of use and functionality for both athletes and coaches.
Tracking
The main functionality from the spreadsheet, that I replace, is tracking. It needs to be quick and simple, so the athletes wont get their training disturbed, but it just becomes a habit. In order to accomplish this, I come up with two designs. A button layout and a bar layout.
The design needs to fit in adding, subtracting, reseting and editing a large number of skills. As you can see, my solution is a toggle menu for layout and skill name format, allowing the athletes to smoothly go back and forth. This makes it possible for the athlete to customise the experience, while utilising all the features with one tap.
The functionality itself is a simple set of counters stored in BLoC and uploaded to Supabase. The challenge with this is how to format the data. Sessions and Skills are the two main models, but within them, they need to keep track of data for each piece of equipment. Although I don’t believe it’s the best solution, I’ve used a map to solve this. This keeps the data structure compact but may require additional handling for custom equipment in the future.
Session Model:
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:tumblelog/constants.dart';
part 'session_model.g.dart';
@JsonSerializable(explicitToJson: true, fieldRename: FieldRename.snake)
class SessionModel extends Equatable {
final String id;
final String athleteId;
final String athleteName;
final DateTime date;
final double totalDd;
final Map<EquipmentType, double> equipmentDd;
const SessionModel({
required this.id,
required this.athleteId,
required this.date,
this.totalDd = 0,
this.athleteName = 'NoName',
this.equipmentDd = defaultEquipmentDd,
});
// Factory method to create a SessionModel from JSON
factory SessionModel.fromJson(Map<String, dynamic> json) =>
_$SessionModelFromJson(json);
// Method to convert a SessionModel to JSON
Map<String, dynamic> toJson() => _$SessionModelToJson(this);
@override
List<Object> get props =>
[id, athleteId, athleteName, totalDd, date, equipmentDd];
}
Skill Model:
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:tumblelog/constants.dart';
part 'skill_model.g.dart';
@JsonSerializable(explicitToJson: true, fieldRename: FieldRename.snake)
class SkillModel extends Equatable {
final String id;
final String sessionId;
final String name;
final String symbol;
final double difficulty;
final Map<EquipmentType, int> equipmentReps;
const SkillModel({
required this.id,
required this.sessionId,
required this.name,
required this.symbol,
required this.difficulty,
required this.equipmentReps,
});
// Factory method to create a SkillModel from JSON
factory SkillModel.fromJson(Map<String, dynamic> json) =>
_$SkillModelFromJson(json);
// Method to convert a SkillModel to JSON
Map<String, dynamic> toJson() => _$SkillModelToJson(this);
@override
List<Object> get props =>
[id, sessionId, name, symbol, difficulty, equipmentReps];
}
As you can see, I use json_serializable, to automate the transformation from model to json. I do this to avoid a bunch of human errors I’ve made in the past. It adds more complexity up front, but saves me a lot of time down the line.
Monitoring
The next feature is monitoring, which lets the coaches and athletes see their progress. After doing the data ground work in the tracking feature, this is relatively straight forward. The data is just fetched an displayed nicely.
This design lets the coach get an overview of how much work the athlete has done during the week, and make decision for the upcoming training. For a more detailed view, the coach can see the individual session.
Programming
Finally there is the programming part. The goal is to give coaches the option to customise which skills an athlete needs to do in a session and assign this new program to athletes. Four new tables are added to the database:
- Programs: For tracking the specific programs
- SkillLibrary: A database of skills for coaches to choose between
- ProgramSkills: For linking programs to skills
- AthletePrograms: For linking athletes to programs
This setup makes the database flexible and easy to scale, as you can add as many links as needed. The drawback is that queries can be a bit more challenging:
final programsJson = await supabaseClient
.from('programs')
.select(
'*, athlete_programs!inner(), program_skills(skill_library(*))')
.eq('athlete_programs.athlete_id', userId);
The model can also get more bloated, but for me this is worth it, as I can automate conversions.
ProgramModel:
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:tumblelog/core/models/skill_library_model.dart';
part 'program_model.g.dart';
@JsonSerializable(explicitToJson: true, fieldRename: FieldRename.snake)
class ProgramModel extends Equatable {
final String id;
final String name;
final String? creatorId;
@JsonKey(name: 'program_skills', fromJson: _extractSkillsFromProgramSkills)
final List<SkillLibraryModel> skills;
const ProgramModel({
required this.id,
required this.name,
required this.creatorId,
required this.skills,
});
/// Factory method to create a `ProgramModel` from JSON
factory ProgramModel.fromJson(Map<String, dynamic> json) =>
_$ProgramModelFromJson(json);
/// Method to convert `ProgramModel` to JSON
Map<String, dynamic> toJson() => _$ProgramModelToJson(this);
/// Helper method to convert `program_skills` into a list of `SkillLibraryModel`
static List<SkillLibraryModel> _extractSkillsFromProgramSkills(
List<dynamic>? programSkills) {
if (programSkills == null) return [];
return programSkills
.map((e) => SkillLibraryModel.fromJson(e['skill_library']))
.toList();
}
/// Convert `ProgramModel` to a map for database saving
Map<String, dynamic> toProgramsTableMap() {
return {
'id': id,
'name': name,
'creator_id': creatorId,
};
}
/// Map skills to a list of `program_skills` entries
List<Map<String, dynamic>> mapSkillsToProgramSkills(String programId) {
return skills.map((skill) {
return {
'program_id': programId,
'skill_id': skill.id,
};
}).toList();
}
@override
List<Object?> get props => [id, name, creatorId, skills];
}
The end product of the programming pages looks like this:
That is the rough outline of the app. As you can see, I am not a design genius and have gone with either simple or default colors. I have mainly focused on building a functioning prototype.
The Outcome and Lessons Learned
Now that the project is done, it needs to get used. Every time I have implemented a new feature, I have sent it to Dima for him to test with his athletes. The feedback has been very positive, but will the app stand the test of time?
Will the design be simple enough for athletes to seamlessly integrate it into their training routines without distractions or missed entries?
As the app is being used and more data is being recorded, hopefully we will be able to see patterns in training frequency, difficulty and performance or injuries.
I might make a small workshop with the club, to see them use the app and identify pain points.
Refactoring the app to Clean Architecture was a key moment where I focused on applying principles like separation of concerns and DRY. This approach made the app more maintainable and scalable.
As the app continues to be used and evolves, I'm excited to see the insights it brings to Dima and his athletes. This project has been a significant step in my growth as a developer, and I'm looking forward to tackling even more challenging projects in the future.
Top comments (0)