
- Android
- Kotlin
- Jetpack Compose
- .NET 10
- PostgreSQL
- Clean Architecture
Dziennik treningu siłowego. Offline. Cykle, 1RM i realne statystyki.
Zrzuty ekranu
Pasek można przewijać w bok. Kliknięcie zrzutu powiększa go — dalej nawigacja strzałkami.Pełny opis
Griff Gym to natywny dziennik treningu siłowego dla Androida, zbudowany wokół jednej zasady: trening nigdy nie czeka na internet.
Każda funkcja treningowa działa bez konta i bez sieci. Bazą danych na telefonie jest jedyne źródło prawdy — konto jest opcjonalne i dokłada kopię w chmurze, nigdy jej nie zastępuje.
PROGRAM LICZONY Z TWOICH LICZB
Po instalacji podajesz trzy maksy — przysiad, wyciskanie leżąc, martwy ciąg — albo wyliczasz je z serii, którą na pewno zrobisz. Na tej podstawie aplikacja buduje sześciotygodniowy cykl oparty na procentach Twoich własnych maksów. Nic nie jest generowane dla kogoś, kto nie podał ani jednej liczby.
PLAN, KTÓRY NIE JEST KALENDARZEM
Program to sekwencja, nie terminarz. Trening dzień wcześniej albo tydzień później niczego nie psuje — aplikacja po prostu przesuwa się na kolejną jednostkę. Tydzień szósty to deload trenowany na 50% maksów danego cyklu.
CYKLE, NIE JEDEN PROGRAM NA ZAWSZE
Po ostatnim treningu szóstego tygodnia otwiera się podsumowanie cyklu. Dla każdego boju osobno decydujesz, czy maks referencyjny idzie w górę o sugerowaną wartość, zostaje bez zmian, czy zmienia się o Twoją własną. Nowy cykl startuje ze świeżym snapshotem tych liczb.
LOGOWANIE SERII BEZ TARCIA
Ekran treningu pokazuje, o co plan prosi, obok tego, co faktycznie zrobiłeś. Każde naciśnięcie klawisza, które da się sparsować, ląduje od razu w bazie — sesja przeżywa ubicie aplikacji między seriami. Po powrocie ekran główny pokazuje TRENING W TOKU i przycisk KONTYNUUJ.
STATYSTYKI, KTÓRE MÓWIĄ PRAWDĘ
Progresja 1RM wielkiej trójki, rekordy osobiste, łączna objętość, kalendarz systematyczności z oznaczonymi rekordami. Szacowany 1RM liczony jest wzorem Epleya z rzeczywiście zalogowanych serii. Maks referencyjny to liczba planistyczna, którą sam wpisałeś — i celowo nigdy nie jest traktowany jak rekord.
KALKULATOR 1RM
Ciężar i powtórzenia zamieniane na szacowany jednorazowy maks, wraz z tabelą procentów treningowych. Możesz też wciągnąć do kalkulatora swoje aktualne maksy jednym przyciskiem.
TWOJE DANE ZOSTAJĄ TWOJE
Tryb lokalny jest domyślny i w pełni funkcjonalny — nic nie opuszcza telefonu. Jeśli założysz konto, historia dostaje trwałą kopię na serwerze, a nowy telefon wraca dokładnie do serii, na której skończyłeś. Konto usuwasz sam, z poziomu aplikacji, w dwóch potwierdzeniach — razem z kopią w chmurze.
DLA KOGO
Dla trenujących siłowo, którzy prowadzą blok oparty na procentach maksów i chcą mieć jeden dziennik zamiast notatnika, arkusza i pamięci.
Bez reklam. Bez subskrypcji. Bez profilowania.
Technologie
Zakres prac: Projekt, architektura, implementacja aplikacji i backendu, wdrożenie
- Kotlin
- Jetpack Compose
- Material 3
- Navigation Compose
- Hilt
- Coroutines
- Flow / StateFlow
- WorkManager
- Gradle Kotlin DSL
- KSP
- Room
- SQLite
- DataStore
- Android Keystore (AES-256-GCM)
- .NET 10
- ASP.NET Core
- EF Core 10
- PostgreSQL 16
- Npgsql
- FluentValidation
- JWT + refresh token rotation
- JUnit
- Turbine
- xUnit v3
- Testcontainers
- Robolectric
- Docker
- docker compose
- Linux VPS
- Reverse proxy + TLS
- Google Play
Realizacja — architektura i technologie
13 min
Realizacja w jednym akapicie
Griff Gym to natywna aplikacja Android do prowadzenia dziennika treningu siłowego, wraz z własnym backendem w .NET, który przechowuje kopię historii treningowej. Całość — od modelu domenowego, przez UI w Compose, po API i wdrożenie w Dockerze — powstała jako jeden projekt prowadzony według Clean Architecture po obu stronach. Poniżej opisuję, jakie decyzje architektoniczne zapadły i dlaczego akurat takie.
Założenie, z którego wynika reszta
Aplikacja treningowa ma jedną własność, której nie da się negocjować: nie wolno jej przegrać danych i nie wolno jej kazać czekać na sieć. Człowiek stoi między seriami z telefonem w ręce, ma trzydzieści sekund i słaby zasięg w piwnicy siłowni.
Stąd wzięła się zasada, która przenika cały projekt: Room jest jedynym źródłem prawdy dla UI — w obu trybach pracy aplikacji. Zalogowanie serii, zakończenie treningu, wystartowanie cyklu — nic z tego nie dotyka sieci po drodze na ekran. Konto nie jest licencją na aplikację, tylko dokłada drugiego konsumenta ścieżki zapisu i proces w tle, który tę kolejkę opróżnia.
Architektura mobilna — moduły, nie pakiety
Clean Architecture bywa deklaracją w README, po której i tak wszystko importuje wszystko. Tutaj granice są realnymi modułami Gradle, więc kompilator ich pilnuje zamiast człowieka:
:app composition root — Application, MainActivity, wejścia Hilt, ikona
:presentation 100% Compose, ViewModele, UiState/UiEvent, nawigacja, motyw
:application use case'y; cała orkiestracja biznesowa, nigdy w ViewModelu
:domain czysty Kotlin: modele, value objecty, reguły, kontrakty repozytoriów
:infrastructure Room, DataStore, Retrofit/OkHttp, WorkManager, implementacje repozytoriówZależności wskazują do wewnątrz:
:app ──────────► :presentation ──► :application ──► :domain
└────────────► :infrastructure ──────────────────► :domainDwie rzeczy robią z tego mechanizm, a nie dobre chęci:
:domaini:applicationsą zwykłymi modułami JVM. Nie mają Android SDK, Compose ani Roomu na ścieżce kompilacji — nie da się ich tam wciągnąć nawet przez przypadek.:presentationnie widzi:infrastructure. Obie strony spotykają się wyłącznie w:app, przez Hilta. ViewModel wywołuje use case, nie DAO, a encja Roomu nigdy nie pojawia się wUiState.
Droga jednego kliknięcia przez warstwy
ekran Compose
│ akcja użytkownika
▼
ViewModel (presentation)
│ invoke(...)
▼
Use case (application)
│ interfejs repozytorium
▼
Implementacja repozytorium (infrastructure)
│ DAO / wywołanie HTTP
▼
Room ◄──────────────────────► silnik synchronizacji
│ Flow<Entity>
▼
ViewModel ──► UiState ──► Compose przerysowujeSilnik synchronizacji stoi obok tej ścieżki, a nie w środku. Czyta i zapisuje Room własnymi DAO i nigdy nie wchodzi w wywołanie use case'a — ekran treningu nie ma pojęcia, czy w ogóle istnieje jakieś konto.
Reguła, która ukształtowała model danych
Plan treningowy jest szablonem. Naciśnięcie START kopiuje go w snapshot sesji: każda zaplanowana seria staje się osobnym wierszem set_log niosącym plannedWeight, plannedReps i plannedRpe. Wyniki lądują w oddzielnych kolumnach actual*.
Konsekwencja jest taka, że późniejsza edycja programu nie może przepisać historii, a sesja pokazuje obok siebie to, o co plan prosił, i to, co faktycznie się wydarzyło. To jedna linijka decyzji w modelu, która oszczędza całą klasę błędów typu „dlaczego mój trening sprzed roku nagle wygląda inaczej”.
Aktywny trening nie żyje w ViewModelu. Każde naciśnięcie klawisza, które daje się sparsować, jest zapisywane wprost do Roomu — dzięki temu sesja przeżywa ubicie procesu między seriami.
Backend — te same granice, inny język
Backend to ASP.NET Core na .NET 10, PostgreSQL 16 przez EF Core, wdrażany Dockerem na Linuksie za reverse proxy terminującym TLS. Clean Architecture w postaci czterech projektów:
GriffGym.Api ────────► GriffGym.Application ────────► GriffGym.Domain
▲ ▲
└──── GriffGym.Infrastructure ─┘Egzekwowanie granicy jest tu równie prymitywne i równie skuteczne, co w module Gradle: `GriffGym.Domain.csproj` nie ma ani jednego `PackageReference`. Domena nie może sięgnąć po ASP.NET Core, EF Core, Npgsql, HTTP ani JWT, bo tych assembly po prostu nie ma na jej ścieżce kompilacji. Reguła nie zależy od tego, czy ktoś ją pamięta.
Use case'y zamiast mediatora
Warstwa aplikacji to jawne klasy — CreateTrainingCycleUseCase, LogSetUseCase — rejestrowane po nazwie. Bez MediatR.
MediatR zarabia na siebie, gdy jest wiele handlerów i realne zachowania przekrojowe warte wstawienia w pipeline. Tutaj use case'ów jest około dwudziestu, a dwie rzeczy przekrojowe — walidacja i mapowanie błędów — mają już pierwszorzędne miejsce w ASP.NET Core: filtr akcji i IExceptionHandler. Mediator kupiłby w zamian warstwę pośrednią i wywołanie Send(object), po którym nie widać, co się uruchamia.
Jest jeszcze efekt uboczny, na którym mi zależało: lista rejestracji to powierzchnia systemu. Jeśli pojawia się nowa rzecz, którą można zrobić z danymi treningowymi, widać ją w diffie, gdzie ktoś musi na nią spojrzeć.
Trzy modele zamiast jednego
model domenowy encja persystencji kontrakt API
WorkoutSession ≠ WorkoutSessionRecord ≠ WorkoutResponseDomena — bogata i samowalidująca. Prywatne settery, metody niosące zachowanie, niezmienniki sprawdzane w konstruktorach.
WorkoutSession.LogSetodmawia zapisu do zakończonej sesji.Persystencja — płaskie worki kolumn, które EF Core umie zapisać, plus metadane synchronizacji. Wszystkie
internal.API — to, co idzie po drucie.
Rozdział kosztuje kod mapujący. Kupuje trzy rzeczy. Po pierwsze: encja EF wystawiona jako JSON nie tylko wycieka nazwy kolumn, ale — gorzej — przyjmuje je na wejściu. To mass assignment z definicji. W tym API żaden kontrakt żądania nie ma pola UserId, więc żadne żądanie nie może stwierdzić, czyje dane zapisuje. Po drugie: v2 API może zmienić kształt, nie ruszając domeny. Po trzecie: metadane synchronizacji żyją na encji persystencji, gdzie stempluje je interceptor, zamiast być czymś, o czym każdy use case musi pamiętać.
Identyfikatory generowane po stronie klienta
Każdy synchronizowany rekord ma klucz główny uuid z ValueGeneratedNever(). To klient wymyśla identyfikator.
To nie jest preferencja stylistyczna. Produkt ma wpisany scenariusz, w którym ktoś używa Griff Gym wyłącznie lokalnie przez pół roku, zbiera cykle i setki sesji, a dopiero potem zakłada konto i wysyła wszystko na serwer. Te wiersze już mają tożsamość. Serwer nadający własne identyfikatory wymusiłby przemapowanie każdego klucza obcego w bazie na telefonie dokładnie w momencie, w którym błąd jest najmniej odwracalny i najsłabiej widoczny.
Stąd zasada: nic w tym modelu nie zakłada, że serwer zobaczył rekord pierwszy. Tam, gdzie identyfikator nadaje serwer, robi to przez Guid.CreateVersion7() — sekwencyjny, więc wstawienia do indeksu klucza głównego pozostają skupione zamiast rozsypywać zapisy po całym B-drzewie, jak robi to v4.
Synchronizacja jest kopią, nie zależnością
Zalogowanie się nie znaczy „włącz synchronizację”. Znaczy „ustal, która z czterech sytuacji zachodzi”, bo pomyłka w tym miejscu niszczy historię treningową. Robi to jeden use case z własną nazwą i własnymi testami — ResolvePostSignInActionUseCase — a nie if schowany w ViewModelu:
| Telefon | Konto | Wynik |
|---|---|---|
| pusty | puste | onboarding — nie ma czego przenosić |
| ma dane | puste | wyślij historię z telefonu (przypadek migracji) |
| pusty | ma dane | odtwórz historię konta na tym telefonie |
| ma dane | ma dane | nie zgaduj — poproś użytkownika o rozstrzygnięcie |
Gdy konto już robi kopię, zalogowanie serii zapisuje się do Roomu natychmiast i oznacza zmianę jako PENDING_UPLOAD. Synchronizacja w tle (WorkManager, NetworkType.CONNECTED, wykładniczy backoff) wypycha ją, gdy jest połączenie. Nic w treningu nigdy nie czeka na sieć.
Stany metadanych synchronizacji są cztery: PENDING_UPLOAD, SYNCED, CONFLICT i FAILED. Dwie reguły chronią dane po obu stronach:
Kopia, która się nie udała, nigdy nie jest zapisywana jako udana. Aplikacja zostaje w trybie lokalnym i tak mówi — nie twierdzi, że istnieje kopia, której nie ma. To najgorszy możliwy tryb awarii, bo użytkownik przestaje się martwić o coś, co go nie chroni.
Jeśli serwer ma nowszą wersję, rekord dostaje
CONFLICT, lokalna kopia zostaje nietknięta i nie jest ponawiany, dopóki ktoś tego nie rozstrzygnie. Nic nie jest po cichu nadpisywane.
Odtworzenie historii na telefonie to jedna transakcja Roomu: ląduje w całości albo baza zostaje dokładnie taka, jaka była. Historyczne cykle są odtwarzane tak, jak zostały zapisane — nigdy przeliczane od dzisiejszych maksów, bo to po cichu przepisałoby to, o co plan prosił kogoś trzy lata temu. Odtworzenie odtwarza historię, nie interpretuje jej na nowo.
Bezpieczeństwo
Refresh token to jedyny sekret, jaki aplikacja przechowuje, i jest traktowany odpowiednio:
Szyfrowany kluczem AES-256-GCM wygenerowanym w Android Keystore, w osobnym pliku DataStore — nigdy jawnie, nigdy w
SharedPreferences. Jeśli szyfrogram stanie się trwale nieodczytywalny (reset do ustawień fabrycznych, przywrócenie kopii na inny sprzęt), jest to traktowane jak „brak sesji”, a nie jak awaria: użytkownik loguje się ponownie, a jego dane w Roomie pozostają nietknięte.Access tokeny są krótkotrwałe, a odświeżanie idzie za muteksem. Kilka żądań, które jednocześnie dostaną 401, wywołuje dokładnie jedno odświeżenie — refresh tokeny rotują i są jednorazowe, więc drugie równoległe odświeżenie przedstawiłoby token już wycofany, co serwer traktuje jako kradzież i unieważnia całą sesję.
Adres API jest ustawieniem builda, nie stałą w kodzie. Build release czyta
GRIFFGYM_API_BASE_URLi odmawia kompilacji, jeśli to nie jest URL po HTTPS — nie ma wartości domyślnej, z którą release mógłby wyjechać przez przypadek.Po stronie serwera nie ma w repozytorium żadnego klucza podpisującego. Walidacja startowa odmawia uruchomienia bez niego, zamiast schodzić na wartość domyślną — źle skonfigurowane wdrożenie pada od razu, zamiast wydawać tokeny podpisane czymś przewidywalnym.
Usuwanie konta, czyli kolejność ma znaczenie
Jedyna nieodwracalna operacja w aplikacji, więc jej kolejność wynika wprost z tego faktu:
Zapytaj serwer i nie rób nic dalej, dopóki nie potwierdzi, że konto zniknęło. Czyszczenie lokalne w trybie offline zniszczyłoby trening na telefonie, zostawiając konto i kopię w chmurze nietknięte — i żaden ekran nie mógłby już zaproponować jednego ani drugiego.
Dopiero po odpowiedzi serwera: anuluj zaplanowane workery synchronizacji, potem wyczyść lokalne dane. Odwrotna kolejność zostawiłaby okno, w którym proces w tle budzi się, czyta na wpół wyczyszczoną bazę i próbuje ją uzgodnić z kontem, którego już nie ma.
Zresetuj tryb pracy do „nierozstrzygnięty”, przywracając instalację do stanu sprzed konta.
Nie ma kolejki „usuń, gdy wróci sieć”. Usunięcie, przy którym nikogo nie było, żeby zobaczyć, że się powiodło, nie jest usunięciem, na które ktokolwiek się zgodził.
Stos technologiczny
| Obszar | Technologie |
|---|---|
| Aplikacja | Kotlin, Jetpack Compose, Material 3, Navigation Compose, Hilt, Coroutines, Flow/StateFlow, WorkManager |
| Dane na urządzeniu | Room, SQLite, DataStore, Android Keystore (AES-256-GCM) |
| Backend | .NET 10, ASP.NET Core, EF Core 10, PostgreSQL 16, Npgsql, FluentValidation, JWT z rotacją refresh tokenów |
| Testy | JUnit, Turbine, Robolectric, xUnit v3, Testcontainers |
| Wdrożenie | Docker, docker compose, Linux VPS, reverse proxy z TLS, Google Play |
Migracje bazy są jawnym krokiem wdrożenia, nie efektem ubocznym startu aplikacji: kilka replik bootujących równocześnie ścigałoby się o tę samą zmianę schematu, a zła migracja położyłaby usługę bez możliwości zatrzymania jej. EnsureCreated() nie występuje nigdzie w projekcie — historia treningowa jest sensem tego systemu i nie jest kasowana dla wygody.
Co z tej realizacji wynika
Clean Architecture bywa oskarżana o ceremonię. W tym projekcie zarobiła na siebie w miejscach, których na starcie nie planowałem: dodanie kont i synchronizacji osiem miesięcy po napisaniu pierwszej wersji offline nie wymagało dotknięcia ani jednego ekranu Compose, bo ekrany nigdy nie wiedziały, skąd biorą dane. Silnik synchronizacji wszedł obok istniejącej ścieżki, a nie w nią.
Druga rzecz, którą bym powtórzył: granice egzekwowane przez system budowania, a nie przez recenzję kodu. Moduł JVM bez Android SDK i .csproj bez ani jednego PackageReference to dwie linijki konfiguracji, które robią więcej dla utrzymania architektury niż jakikolwiek dokument.
I trzecia: tam, gdzie decyzja może zniszczyć dane użytkownika, dostaje własny use case z własną nazwą i własnymi testami. if w ViewModelu to dokładnie to miejsce, w którym taki błąd się chowa.
