Gainful: A Three-Platform Financial Tracker Built with Kotlin Multiplatform and Compose
From Zero to Three Platforms: Building a Cross-Platform Financial Tracking App with Kotlin Multiplatform + Compose
Make every gain traceable.
Foreword
As a Kotlin developer, I've always wanted to try the full-stack cross-platform solution of Kotlin Multiplatform (KMP) + Compose Multiplatform. I recently completed a personal financial tracking app called Gainful (盈迹), which supports Android, iOS, and Desktop. Today, I'd like to share the thinking and practice throughout the development process.
UI Design: OpenDesign was used to assist with interface design and prototype validation.
Why choose KMP + Compose?
- One set of Kotlin code, shared across three platforms
- High development efficiency with Compose's declarative UI
- Native performance, without the performance loss of WebView solutions
- Active community with continuous investment from JetBrains
Demo
Dashboard
The dashboard page displays daily profit/loss, total profit/loss, portfolio trend charts, and key indicators, giving a clear overview of investment status at a glance.
Holdings Analysis
The holdings page provides investment weight visualization (Treemap chart) and holding details, supporting real-time market data display.
Transaction Records
Transaction records support filtering by type (All/Buy/Sell/Dividend), clearly displaying the details of each transaction.
Settings
The settings page supports customizing rise/fall colors, trading session configuration, CSV import/export, and other features.
Tech Stack Selection
| Category | Technology | Reason for Choice |
|---|---|---|
| Networking | Ktor | Official KMP networking library, type-safe |
| Local Storage | Room | Google's official cross-platform ORM |
| DI | Koin | Lightweight, good KMP support |
| Navigation | Navigation3 | Compose's official navigation solution |
| UI Framework | Compose Multiplatform | Declarative UI, unified across three platforms |
Architecture Design
MVI Data Flow
The project adopts the MVI (Model-View-Intent) architecture, with unidirectional data flow driving UI updates:
User Action → Intent → ViewModel → State → UI
↑ │
└────────────────────────────────────┘
Benefits of this architecture:
- Predictable state, easy to debug
- Unidirectional data flow, clear logic
- Easy to test and maintain
Clean Architecture Layering
Referencing Google's Now in Android project, Clean Architecture layering is adopted:
shared/
├── commonMain/ # Platform-independent code
├── androidMain/ # Android platform implementation
├── iosMain/ # iOS platform implementation
└── jvmMain/ # Desktop platform implementation
core/
├── common/ # Common utilities
├── model/ # Data models
├── ui/ # Common UI components
├── data/ # Repository interfaces
├── database/ # Room database
├── domain/ # UseCase business logic
└── navigation/ # Navigation configuration
feature/
├── dashboard/ # Dashboard
├── holdings/ # Holdings management
├── transactions/ # Transaction records
└── settings/ # Settings
Core Feature Implementation
1. Cross-Platform Database (Room)
The experience of using Room in KMP is excellent, almost identical to native Android:
// Define Entity
@Entity(tableName = "transactions")
data class TransactionEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val stockCode: String,
val stockName: String,
val type: TransactionType,
val quantity: Int,
val price: Double,
val fee: Double,
val timestamp: Long
)
// Define DAO
@Dao
interface TransactionDao {
@Query("SELECT * FROM transactions ORDER BY timestamp DESC")
fun getAllTransactions(): Flow<List<TransactionEntity>>
@Insert
suspend fun insertTransaction(transaction: TransactionEntity)
}
Note: Room in KMP requires the use of BundledSQLiteDriver, and the database file will be packaged into the application.
2. Cross-Platform Networking (Ktor)
Ktor is the standard networking solution for KMP:
// Define API interface
interface StockApi {
suspend fun searchAssets(keyword: String): List<AssetDto>
suspend fun getStockPrice(code: String): StockPriceDto
}
// Implementation
class StockApiImpl(private val client: HttpClient) : StockApi {
override suspend fun searchAssets(keyword: String): List<AssetDto> {
return client.get("api/assets/search") {
parameter("keyword", keyword)
}.body()
}
}
3. Cross-Platform UI (Compose)
Compose Multiplatform achieves a truly unified UI across three platforms:
// Common Composable
@Composable
fun TransactionCard(transaction: Transaction) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = transaction.stockName,
style = MaterialTheme.typography.titleMedium
)
Text(
text = transaction.type.displayName,
color = if (transaction.type == TransactionType.BUY)
Color.Red else Color.Green
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(text = "${transaction.quantity}股 × ${transaction.price}")
}
}
}
4. Internationalization (i18n)
Multi-language support is implemented using Compose Resources:
// strings.xml (Chinese)
<string name="dashboard_title">仪表盘</string>
<string name="total_assets">总资产</string>
// strings-en.xml (English)
<string name="dashboard_title">Dashboard</string>
<string name="total_assets">Total Assets</string>
// Usage
@Composable
fun DashboardScreen() {
Text(text = stringResource(Res.string.dashboard_title))
}
Pitfalls and Solutions During Development
1. Date and Time Handling
Problem: java.time.* is not available in commonMain.
Solution: Use kotlinx-datetime:
// Incorrect
// val now = java.time.LocalDateTime.now()
// Correct
val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
2. iOS Framework Configuration
Problem: iOS requires a static framework and Team ID configuration.
Solution:
// shared/build.gradle.kts
kotlin {
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "Shared"
isStatic = true
}
}
}
3. Compose Resource ID Generation
Problem: Multi-module resource ID conflicts.
Solution: Independent namespaces for each module:
// Correct import method
import gainful.feature.dashboard.generated.resources.Res
import gainful.feature.dashboard.generated.resources.dashboard_title
// Usage
stringResource(Res.string.dashboard_title)
Development Experience Summary
Pros
- High Development Efficiency: One codebase runs on three platforms, saving significant time
- UI Consistency: Compose ensures UI consistency across three platforms
- Type Safety: Kotlin's type system makes code more robust
- Hot Reload: Desktop supports hot reload, providing a good development experience
Points to Note
- Platform-Specific Code: A small amount of platform-specific code is still required for each platform
- Dependency Versions: The KMP ecosystem is still developing rapidly, dependency versions need attention
- Debugging Tools: Compared to native development, debugging tools still have room for improvement
Quick Start
Android
./gradlew :androidApp:assembleDebug
Desktop
./gradlew :desktopApp:run
iOS
- Open
iosApp/iosApp.xcodeproj - Configure
TEAM_ID - Run in Xcode
Project Repository
GitHub: https://github.com/Yoke0/Gainful
Conclusion
Kotlin Multiplatform + Compose Multiplatform is a very promising cross-platform solution. Although there are still some pitfalls to navigate, the overall development experience is already quite good. For developers who want to cover multiple platforms with a single codebase, this is a tech stack worth trying.
If you have any questions, feel free to discuss in the comments!
Tags: #Kotlin #KMP #ComposeMultiplatform #CrossPlatformDevelopment #Android #iOS #Desktop
Top 1 from juejin.cn, machine-translated. The original thread is authoritative.
👍