A Kotlin Multiplatform starter template with simplified database and preferences layers.
- β EasyDB - Simplified SQLDelight with auto-generated Flow-based queries
- β EasyPrefs - Property delegate pattern for DataStore (no more boilerplate!)
- β Network Layer - Ktor client with retry logic and Route pattern
- β Material 3 Theme - Complete theme system with colors, typography, spacing
- β Form Validation - Reusable validators (Email, Required, MinLength, etc.)
- β Authentication - Complete auth flow (Login, SignUp, OTP, Password Reset)
- β Tab Navigation - Decompose Pages navigation with bottom tabs
- β Home Screen - Feed and Explore tabs
- β Settings Screen - Account settings, theme, notifications
- β Profile Screen - View and edit user profile
- β Android - Full app with MainActivity (builds successfully)
- β iOS Frameworks - All modules compile for iOS (iosX64, iosArm64, iosSimulatorArm64)
- β iOS App - SwiftUI app with ComposeUIViewController wrapper
core/
common/ # β
Utilities (Resource, Exception, Coroutine helpers)
database/ # β
EasyDB wrapper + SQLDelight
datastore/ # β
EasyPrefs + AppSettings
network/ # β
Ktor client with retry logic
ui/
theme/ # β
Material 3 theme (colors, typography, spacing, shapes)
form/ # β
Form validation (Email, Required, MinLength, etc.)
feature/
auth/ # β
Login, SignUp, OTP, Password Reset screens
home/ # β
Tab navigation (Feed, Explore tabs)
settings/ # β
Settings screen (account, theme, notifications)
profile/ # β
Profile screen (view/edit)
shared/ # β
App entry, Decompose navigation, Koin DI
app-android/ # β
Android app (MainActivity, MainApplication)
app-ios-compose/ # β
iOS app (SwiftUI + ComposeUIViewController)
scripts/ # β
Utility scripts (package rename, etc.)
Standard SQLDelight requires:
- Manual mapper functions between entities and domain models
- Verbose Flow conversion with dispatchers
- Repetitive
.asFlow().mapToOneOrNull(Dispatchers.IO).distinctUntilChanged()
EasyDB provides:
EasyRepository<T, ID>interface for standard CRUD- Extension functions that handle Flow conversion automatically
- Clean separation between database entities and domain models
1. Define your schema (User.sq):
CREATE TABLE user (
id INTEGER NOT NULL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
displayName TEXT,
avatarUrl TEXT,
createdAt INTEGER NOT NULL
);
upsert:
INSERT OR REPLACE INTO user(id, email, displayName, avatarUrl, createdAt)
VALUES (?, ?, ?, ?, ?);
selectById:
SELECT * FROM user WHERE id = ?;
selectAll:
SELECT * FROM user;
deleteById:
DELETE FROM user WHERE id = ?;2. Define your domain model:
data class User(
val id: Int,
val email: String,
val displayName: String?,
val avatarUrl: String?,
val createdAt: Instant
)3. Implement EasyRepository:
class UserRepository(
private val database: AppDatabase
) : EasyRepository<User, Int> {
private val queries = database.userQueries
override fun observe(): Flow<List<User>> =
queries.selectAll()
.asListFlow() // β EasyDB extension
.mapToDomain { it.toDomain() } // β EasyDB extension
override fun observeById(id: Int): Flow<User?> =
queries.selectById(id.toLong())
.asOneOrNullFlow() // β EasyDB extension
.mapToDomain { it.toDomain() }
override suspend fun upsert(entity: User) = withIoContext {
queries.upsert(/* ... */)
}
override suspend fun delete(id: Int) = withIoContext {
queries.deleteById(id.toLong())
}
override suspend fun clear() = withIoContext {
queries.deleteAll()
}
}4. Use in your code:
// Observe all users reactively
userRepository.observe()
.collect { users ->
println("Users: $users")
}
// Observe a single user
userRepository.observeById(123)
.collect { user ->
println("User: $user")
}
// Insert or update
userRepository.upsert(user)
// Delete
userRepository.delete(123)| Extension | What it does |
|---|---|
.asListFlow() |
Query β Flow<List> with IO dispatcher + deduplication |
.asOneOrNullFlow() |
Query β Flow<T?> with IO dispatcher + deduplication |
.asOneFlow() |
Query β Flow with IO dispatcher + deduplication |
.mapToDomain(mapper) |
Map entities to domain models in a Flow |
Standard DataStore requires:
- Abstract base class with bidirectional mapping
- Manual
read()andwrite()functions for each property - Keys defined separately from properties
EasyPrefs uses property delegates:
- Define preferences as properties with
bydelegates - Type-safe access without manual mapping
- Automatic persistence
1. Define your settings:
class AppSettings(dataStore: DataStore<Preferences>) : EasyPrefs(dataStore) {
var isOnboarded by boolPref("onboarded", false)
var fcmToken by stringPref("fcm_token", null)
var themeMode by intPref("theme_mode", 0)
var shouldShowWelcomeScreen by boolPref("show_welcome", true)
// Observe all settings as a Flow
fun observe(): Flow<AppSettingsState> = /* ... */
}2. Use in your code:
// Read
val isOnboarded = appSettings.isOnboarded
// Write
appSettings.isOnboarded = true
// Observe reactively
appSettings.observe()
.collect { state ->
println("Settings: $state")
}| Delegate | Type | Nullable |
|---|---|---|
boolPref(key, default) |
Boolean | No |
stringPref(key, default) |
String? | Yes |
intPref(key, default) |
Int | No |
longPref(key, default) |
Long | No |
floatPref(key, default) |
Float | No |
doublePref(key, default) |
Double | No |
sealed interface Resource<out T> {
data object Loading : Resource<Nothing>
data class Success<out T>(val data: T) : Resource<T>
data class Error(val throwable: Throwable) : Resource<Nothing>
}
// Create a resource flow
resourceFlow {
apiService.fetchData()
}.collect(
onLoading = { showLoading() },
onSuccess = { data -> showData(data) },
onError = { error -> showError(error) }
)// Execute on IO dispatcher
withIoContext {
database.query()
}
// Execute on Main dispatcher
withMainContext {
updateUI()
}
// App-level scope
val appScope: AppScope = get()
appScope.launch {
// Survives configuration changes
}// NetworkCallException with form errors
catch (e: NetworkCallException) {
val emailError = e.formErrors["email"]
showFormError("email", emailError)
}Core dependencies:
- Kotlin 2.1.21
- Compose Multiplatform 1.8.2
- Decompose 3.2.0 (navigation)
- Koin 3.5.6 (DI)
- Ktor 2.3.12 (networking)
- SQLDelight 2.0.1 (database)
- DataStore 1.1.1 (preferences)
- Coil 3.0.4 (image loading)
Removed from original:
- Firebase dependencies (can add back if needed)
- Dating-specific features
- Paging library
- Camera/MLKit
- Moko permissions
Android:
./gradlew :app-android:assembleDebugiOS Frameworks:
./gradlew :feature:home:iosSimulatorArm64TestiOS App:
- Open
app-ios-compose/app-ios-compose.xcodeprojin Xcode (needs to be created) - Or use
xcodebuildto build from command line - Run on simulator or device
Note: The Xcode project file needs to be created manually. See iOS Setup below.
β Completed (Phases 1-4):
- Core infrastructure (network, database, datastore, UI)
- Authentication flow (SignIn, SignUp, OTP, Password Reset)
- Navigation with Decompose (Stack + Pages)
- Home, Settings, Profile screens
- Android app module
- iOS framework compilation
β³ Next Steps (Phase 5):
- Create iOS app module (
iosApp/with SwiftUI wrapper) - Add GitHub Actions CI/CD workflows
- Gradle scaffolding tasks (
newFeature,newEntity) - Migration documentation
See the handoff document for detailed implementation notes.
The iOS app module is created but requires Xcode project setup.
app-ios-compose/
βββ app-ios-compose/
β βββ iOSApp.swift # SwiftUI app entry point
β βββ RootView.swift # Compose wrapper
β βββ AppDelegate.swift # App lifecycle
β βββ Assets.xcassets/ # Icons and launch screen
β βββ Info.plist # App configuration
βββ Configuration/ # Build settings
-
Create Xcode Project:
cd app-ios-compose # Create new iOS App project in Xcode # Target: iOS 15.0+ # Interface: SwiftUI # Bundle ID: com.starter.kmp
-
Link shared.framework:
- Build Settings β Framework Search Paths β Add
../shared/build/bin/iosSimulatorArm64/debugFramework - General β Frameworks, Libraries β Add
shared.framework
- Build Settings β Framework Search Paths β Add
-
Build iOS Frameworks:
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64
-
Run:
- Select target device/simulator
- Build and run (βR)
Flow:
iOSApp (SwiftUI) β AppDelegate β AppComponent β RootViewController.kt β Compose App
Files:
iOSApp.swift- SwiftUI app entry, initializes KoinAppDelegate.swift- Creates AppComponent with lifecycleRootView.swift- Wraps UIViewController in SwiftUIRootViewController.kt- Wraps Compose in UIViewController
./gradlew renamePackage -Ppackage=com.yourcompany.yourappUpdates all Kotlin files, imports, manifests, build files, and directory structure.
Then:
- Update iOS bundle ID in Xcode (if using iOS)
./gradlew clean- Rebuild and verify
Android:
Edit app-android/src/main/res/values/strings.xml:
<string name="app_name">Your App Name</string>iOS:
Edit app-ios-compose/app-ios-compose/Info.plist:
<key>CFBundleDisplayName</key>
<string>Your App Name</string>Built on Ktor with automatic retry logic and type-safe routes.
- Retry Logic: Exponential backoff (3 retries, 500ms β 2s delays)
- Route Pattern: Type-safe sealed interface for API endpoints
- Request Handler: Unified error handling with NetworkCallException
- Logging: Kermit integration for debugging
// Define routes
sealed interface Route {
data class SignIn(val email: String, val password: String) : Route
data class GetProfile(val userId: Int) : Route
}
// Make requests with automatic retries
requestWithRetry {
client.post("/auth/signin") {
setBody(SignInRequest(email, password))
}
}Complete authentication flow with mock services ready to connect to your API.
- SignIn - Email/password login with form validation
- SignUp - Registration with password confirmation
- Forgot Password - Email-based password reset
- Reset Password - OTP verification + new password
- Verify OTP - 6-digit code verification
- Form validation with real-time feedback
- Loading states with Resource
- Error handling with form field errors
- Navigation between auth screens
- Decompose component architecture
All auth screens use AuthService with mock responses (1 second delay). Replace with real API calls:
class AuthService {
fun signIn(email: String, password: String): Flow<Resource<AuthResult>> = resourceFlow {
// TODO: Replace with actual API call
val response = client.post("/auth/signin") { ... }
AuthResult(token = response.token, userId = response.userId, ...)
}
}Built with Decompose for type-safe, multiplatform navigation.
- Stack Navigation: For screens (SignIn β SignUp β Home)
- Pages Navigation: For tabs (Feed β Explore)
- Type-safe serializable routes
- Back button handling
- Deep linking support
- State preservation across config changes
sealed interface ScreenConfig {
@Serializable data object SignIn : ScreenConfig
@Serializable data object SignUp : ScreenConfig
@Serializable data class VerifyOtp(val userId: Int, val email: String) : ScreenConfig
@Serializable data object Home : ScreenConfig
@Serializable data object Settings : ScreenConfig
@Serializable data object Profile : ScreenConfig
}Reusable validator system with real-time feedback.
EmailValidator- RFC 5322 email validationRequiredValidator- Non-empty checkMinLengthValidator(n)- Minimum character countMaxLengthValidator(n)- Maximum character countContainsSymbolValidator- At least one symbolContainsDigitValidator- At least one digitContainsUppercaseValidator- At least one uppercase letterMustMatchValidator(field)- Password confirmation
Form(
email to listOf(RequiredValidator(), EmailValidator()),
password to listOf(
RequiredValidator(),
MinLengthValidator(8),
ContainsUppercaseValidator(),
ContainsDigitValidator()
),
passwordConfirmation to listOf(
RequiredValidator(),
MustMatchValidator("password")
)
) { isValid ->
SubmitButton(
text = "Sign Up",
enabled = isValid,
onClick = { /* ... */ }
)
}This is a KMP starter template. Feel free to customize for your needs!
TBD