Skip to content

quantipixels/itokuto-kmp-starter

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

1 Commit
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

KMP Starter Project

A Kotlin Multiplatform starter template with simplified database and preferences layers.

Features

Core Infrastructure

  • βœ… 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.)

Features

  • βœ… 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

Platforms

  • βœ… Android - Full app with MainActivity (builds successfully)
  • βœ… iOS Frameworks - All modules compile for iOS (iosX64, iosArm64, iosSimulatorArm64)
  • βœ… iOS App - SwiftUI app with ComposeUIViewController wrapper

Project Structure

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

πŸš€ EasyDB - Simplified SQLDelight

The Problem

Standard SQLDelight requires:

  • Manual mapper functions between entities and domain models
  • Verbose Flow conversion with dispatchers
  • Repetitive .asFlow().mapToOneOrNull(Dispatchers.IO).distinctUntilChanged()

The Solution

EasyDB provides:

  • EasyRepository<T, ID> interface for standard CRUD
  • Extension functions that handle Flow conversion automatically
  • Clean separation between database entities and domain models

Example

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)

EasyDB Extensions

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

🎯 EasyPrefs - DataStore Made Easy

The Problem

Standard DataStore requires:

  • Abstract base class with bidirectional mapping
  • Manual read() and write() functions for each property
  • Keys defined separately from properties

The Solution

EasyPrefs uses property delegates:

  • Define preferences as properties with by delegates
  • Type-safe access without manual mapping
  • Automatic persistence

Example

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

Available Delegates

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

πŸ§ͺ Common Utilities

Resource - Async Operation State

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

Coroutine Helpers

// 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
}

Network Exception Handling

// NetworkCallException with form errors
catch (e: NetworkCallException) {
    val emailError = e.formErrors["email"]
    showFormError("email", emailError)
}

πŸ“¦ Dependencies

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

πŸ—οΈ Getting Started

Build and Run

Android:

./gradlew :app-android:assembleDebug

iOS Frameworks:

./gradlew :feature:home:iosSimulatorArm64Test

iOS App:

  1. Open app-ios-compose/app-ios-compose.xcodeproj in Xcode (needs to be created)
  2. Or use xcodebuild to build from command line
  3. Run on simulator or device

Note: The Xcode project file needs to be created manually. See iOS Setup below.

Project Status

βœ… 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.


πŸ“± iOS Setup

The iOS app module is created but requires Xcode project setup.

Structure

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

Setup Steps

  1. 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
  2. Link shared.framework:

    • Build Settings β†’ Framework Search Paths β†’ Add ../shared/build/bin/iosSimulatorArm64/debugFramework
    • General β†’ Frameworks, Libraries β†’ Add shared.framework
  3. Build iOS Frameworks:

    ./gradlew :shared:linkDebugFrameworkIosSimulatorArm64
  4. Run:

    • Select target device/simulator
    • Build and run (⌘R)

iOS Architecture

Flow:

iOSApp (SwiftUI) β†’ AppDelegate β†’ AppComponent β†’ RootViewController.kt β†’ Compose App

Files:

  • iOSApp.swift - SwiftUI app entry, initializes Koin
  • AppDelegate.swift - Creates AppComponent with lifecycle
  • RootView.swift - Wraps UIViewController in SwiftUI
  • RootViewController.kt - Wraps Compose in UIViewController

πŸ”§ Customization

Change Package Name

./gradlew renamePackage -Ppackage=com.yourcompany.yourapp

Updates all Kotlin files, imports, manifests, build files, and directory structure.

Then:

  1. Update iOS bundle ID in Xcode (if using iOS)
  2. ./gradlew clean
  3. Rebuild and verify

Change App Name

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>

🌐 Network Layer

Built on Ktor with automatic retry logic and type-safe routes.

Features

  • 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

Example

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

πŸ” Authentication

Complete authentication flow with mock services ready to connect to your API.

Screens

  • 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

Features

  • Form validation with real-time feedback
  • Loading states with Resource
  • Error handling with form field errors
  • Navigation between auth screens
  • Decompose component architecture

Mock Service

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, ...)
    }
}

🧭 Navigation

Built with Decompose for type-safe, multiplatform navigation.

Navigation Patterns

  • Stack Navigation: For screens (SignIn β†’ SignUp β†’ Home)
  • Pages Navigation: For tabs (Feed ↔ Explore)

Features

  • Type-safe serializable routes
  • Back button handling
  • Deep linking support
  • State preservation across config changes

Routes

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
}

βœ… Form Validation

Reusable validator system with real-time feedback.

Available Validators

  • EmailValidator - RFC 5322 email validation
  • RequiredValidator - Non-empty check
  • MinLengthValidator(n) - Minimum character count
  • MaxLengthValidator(n) - Maximum character count
  • ContainsSymbolValidator - At least one symbol
  • ContainsDigitValidator - At least one digit
  • ContainsUppercaseValidator - At least one uppercase letter
  • MustMatchValidator(field) - Password confirmation

Example

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 = { /* ... */ }
    )
}

🀝 Contributing

This is a KMP starter template. Feel free to customize for your needs!

πŸ“„ License

TBD

About

KMP starter project

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors