Table Tennis Tracker is a Kotlin Multiplatform (KMP) application targeting Android, iOS, Desktop (JVM), and Server. It uses Compose Multiplatform for shared UI across platforms and Ktor for the backend server.
./gradlew :composeApp:assembleDebug # Build debug APK
./gradlew :androidApp:installDebug # Install on connected device./gradlew :composeApp:run # Run desktop appOpen /iosApp directory in Xcode and build/run from there, or use the IDE's run configuration.
./gradlew :server:run # Run server locally (port 8080)
./gradlew :server:buildFatJar # Build standalone fat JAR./gradlew test # Run all tests
./gradlew :server:test # Run server tests only
./gradlew :composeApp:test # Run UI tests onlyThe project consists of 4 modules with clear separation of concerns:
Shared Compose UI for Android, iOS, and Desktop. This is a multiplatform library (not an application module).
- Platform targets: Android Library, iOS framework, JVM
- UI Framework: Compose Multiplatform with Material3
- DI: Koin for Compose (
koin-compose,koin-compose-viewmodel) - Navigation: Compose Navigation 3 Multiplatform: https://kotlinlang.org/docs/multiplatform/compose-navigation-3.html
- Database: SQLDelight with platform-specific drivers (Android, iOS native, JVM)
- Entry points:
- Desktop:
composeApp/src/jvmMain/kotlin/xyz/tleskiv/tt/main.kt - iOS:
composeApp/src/iosMain/kotlin/xyz/tleskiv/tt/MainViewController.kt - Android: Via
androidAppmodule's MainActivity
- Desktop:
Important: Android resource handling requires special build task copyComposeResourcesToAndroidResources that
prefixes resources with tabletennistracker.composeapp.generated.resources.
Pattern: MVVM + Clean Architecture with layered separation:
UI Layer (Screens) → ViewModel Layer → Service Layer → Repository Layer → Database
ViewModel Pattern:
- Abstract interface class extending
ViewModelBase(which extendsandroidx.lifecycle.ViewModel) - Concrete implementation class (e.g.,
SessionScreenViewModel+SessionScreenViewModelImpl) - State exposed as immutable
StateFlow, internal state asMutableStateFlow - Koin registration:
viewModelOf(::ImplementationClass) bind InterfaceClass::class - ViewModel injection: Always inject as default parameter value in composables:
// Without parameters: fun MyScreen( onNavigateBack: () -> Unit, viewModel: MyViewModel = koinViewModel() ) // With parameters: fun MyScreen( sessionId: String, onNavigateBack: () -> Unit, viewModel: MyViewModel = koinViewModel { parametersOf(sessionId) } )
- For ViewModels with custom parameter types (not primitives), use lambda registration:
viewModel<MyViewModel> { params -> MyViewModelImpl(params.getOrNull<MyType>(), get()) }
Service Layer:
- Interface + implementation pattern
- Business logic and data transformation (e.g., LocalDateTime ↔ epoch milliseconds)
- Registered as Koin singletons:
single<Interface> { ImplementationClass(get()) }
Repository Layer:
- Interface + implementation pattern
- Data access via SQLDelight queries
- Uses
withContext(ioDispatcher)for suspend functions - Injected with named dispatcher qualifier
- All multi-statement database operations must be transactional using
database.transaction { ... }
Dialogs:
- All dialogs go in
ui/dialogs/package as separate composable functions - Pattern:
@Composable fun XxxDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) - Use Material 3
AlertDialogfor confirmation dialogs - Examples:
DatePickerDialog.kt,DeleteSessionDialog.kt
Android application entry point that depends on composeApp.
- Application class:
TTApplication.kt- Initializes Koin with Android context - MainActivity: Simple ComponentActivity that loads the shared Compose app
- Min SDK 24, Target SDK 36
Ktor backend server (JVM only).
- Framework: Ktor 3.3.3 with Netty engine
- Port: 8080
- DI: Koin for Ktor (
koin-ktor)- Modules defined in
Application.kt - Use
@inject<T>pattern in route handlers
- Modules defined in
- Database: SQLDelight with JdbcSqliteDriver
- Database file:
data/server.db - Schema:
server/src/main/sqldelight/xyz/tleskiv/tt/db/ServerDatabase.sq - Setup:
DatabaseFactory.ktconfigures WAL mode, foreign keys, and optimizations
- Database file:
- Routing: Extension functions on
Routing(seeApplication.kt) - Deployment: Multi-stage Dockerfile with Java 21 runtime, produces fat JAR
Shared data models across all platforms (Android, iOS, Desktop, Server).
- Pattern: Kotlinx serialization-compatible data classes
- Example:
User.kt-@Serializable data class - Purpose: Single source of truth for API contracts and domain models
Koin 4.1.1 is used throughout the project:
- Server: Koin modules defined in
Application.kt, installed viaKoinplugin - Android: Initialized in
TTApplication.ktwith Android context - Compose: Use
koin-compose-viewmodelfor ViewModel injection in Composables
Composable Dependency Injection Rules:
- NEVER use
koinInject<T>()directly in composables - ALWAYS inject dependencies into ViewModels and expose functionality through the ViewModel
- Composables should only receive ViewModels via
koinViewModel()and UI callbacks via parameters
Pattern for adding new Koin modules:
- Define module in appropriate location (Application.kt for server, App.kt for UI)
- Inject dependencies using
@inject<T>()(Ktor routes) or constructor injection in ViewModels
SQLDelight 2.0.2 is configured for both server and client apps:
- Server:
ServerDatabasewith schema atserver/src/main/sqldelight/xyz/tleskiv/tt/db/ServerDatabase.sq - Clients: Platform-specific drivers (Android, iOS native, JVM)
- Queries: Auto-generated from
.sqfiles - Database setup: See
server/src/main/kotlin/xyz/tleskiv/tt/db/DatabaseFactory.ktfor server configuration
To add new tables:
- Add SQL schema to appropriate
.sqfile - Define queries in same file
- Rebuild project to generate Kotlin code
- Access via generated database interface
Uses Compose Navigation 3 with type-safe route definitions:
Route Structure (ui/nav/Routes.kt):
sealed interface TopLevelRoute // Bottom nav destinations with icon + label
├── SessionsRoute // Sessions list (default)
├── AnalyticsRoute // Analytics screen
└── ProfileRoute // Profile screen
data class CreateSessionRoute(val initialDate: LocalDate?) // Modal route
data class SessionDetailsRoute(val sessionId: String) // Modal routeNavigation Components:
TopLevelBackStack<T>- Custom class managing per-tab back stack persistenceNavDisplay- Renders routes with entry decorators for state/ViewModel preservation- Entry decorators:
rememberSaveableStateHolderNavEntryDecorator(),rememberViewModelStoreNavEntryDecorator() - Pass route parameters to screens, not ViewModels (ViewModel is injected as default parameter)
All dependencies are managed via gradle/libs.versions.toml.
Instead of using KMP's expect/actual pattern prefer creating an interface in di.components package in commonMain and adding platform specific implementations,
also add it to Koin injection.
If it's not possible fallback to expect/actual pattern:
- Define
expectdeclaration incommonMain - Provide
actualimplementation in platform-specific source sets (androidMain,iosMain,jvmMain)
- Do not comment on the code unless absolutely necessary.
- Prefer keeping code a single line if less than 120 characters.
- for clock use
kotlin.time.Clock - Use docs folder in the root for any additional functional documentation needed
- Never use
System.currentTimeMillis()in commonApp module, usenowMillisfrom DateTimeUtils instead. - Never nest Scaffolds in composeApp module, use simple Column/Box instead.
- Never inline full package names, always use imports
- In Android instrumentation tests, never hardcode UI strings - use Compose resources via
Res.string.*withrunBlocking { getString(res) } - After modifying Android instrumentation tests, always run them to verify:
./gradlew :androidApp:connectedDebugAndroidTest - In tests, never use hardcoded values inline - always extract values into named variables (e.g.,
val expectedCount = 1instead ofassertEquals(1, list.size)) - Test naming convention:
action_condition_expectedResultusing underscores to separate parts. Examples:addSession_withAllFields_displaysSessionDetailsgetSessionById_withNonExistentId_returnsNulldeleteSession_removesFromDatabase(condition can be omitted if obvious)