This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
- Do NOT invent or hallucinate information - Always verify facts using official documentation
- Use web search when needed - Consult official Kotlin Multiplatform and Ktor documentation for implementation details
- Ask questions if unclear - If requirements are ambiguous or you're unsure about an approach, ask the user for clarification before proceeding
- Follow established patterns - Use the MVVM architecture and repository pattern already implemented in this project
- Code comments must be in English - All technical comments, documentation (KDoc), and code-level explanations must be written in English. User-facing strings in the UI (button labels, messages, etc.) can remain in Spanish for the target audience
This is a Kotlin Multiplatform (KMP) project using Compose Multiplatform for shared UI across Android, iOS, Desktop (JVM), and Web (Wasm/JS) platforms. The project implements a MVVM (Model-View-ViewModel) architecture with Ktor Client for API communication.
./gradlew :composeApp:assembleDebug # Build debug APK
./gradlew :composeApp:run # Run on emulator/device./gradlew :composeApp:run # Run desktop application# Wasm target (recommended, modern browsers)
./gradlew :composeApp:wasmJsBrowserDevelopmentRun
# JavaScript target (legacy, older browser support)
./gradlew :composeApp:jsBrowserDevelopmentRunWhen adding or modifying dependencies that have npm transitive dependencies (like Krossbow, Ktor WebSockets, etc.), you must manually update the yarn.lock file for web targets. This is intentional behavior by Gradle for version control safety and build reproducibility.
Required commands after dependency changes:
./gradlew kotlinUpgradeYarnLock # For JS target
./gradlew kotlinWasmUpgradeYarnLock # For WASM targetWhen to run these tasks:
- After adding new dependencies in
gradle/libs.versions.toml - After updating versions of existing dependencies
- When you see the build error:
"Lock file was changed. Run the kotlinUpgradeYarnLock task"
Why this is manual:
- Ensures developers are aware of npm dependency changes
- Maintains build reproducibility across environments
- Allows code review of dependency changes in version control
- Prevents accidental dependency updates
Important: Always commit the updated yarn.lock file(s) along with your code changes. The lock files are tracked in Git to ensure consistent builds across all developers and CI/CD environments.
Open the iosApp/ directory in Xcode or use the IDE run configuration.
The project supports multiple environments configured in util/Environment.kt:
- DEV:
https://inverapi-dev.apptolast.com - PROD:
https://inverapi-prod.apptolast.com
To switch environments, modify Environment.current in util/Environment.kt.
- DEV: https://inverapi-dev.apptolast.com/swagger-ui/index.html
- PROD: https://inverapi-prod.apptolast.com/swagger-ui/index.html
Retrieves recent greenhouse sensor messages.
Response: Array of GreenhouseMessage objects
[{
"timestamp": "2025-11-11T21:54:17.336Z",
"sensor01": 0.1,
"sensor02": 0.1,
"setpoint01": 0.1,
"setpoint02": 0.1,
"setpoint03": 0.1,
"greenhouseId": "string",
"rawPayload": "string",
}]Publishes a custom MQTT message to the greenhouse.
Query Parameters:
topic(string): MQTT topic (default: "GREENHOUSE/RESPONSE")qos(integer): Quality of Service: 0, 1, or 2 (default: 0)
Request Body: GreenhouseMessage object (same structure as above)
Authentication: No authentication required for current endpoints
This project follows MVVM (Model-View-ViewModel) architecture as recommended by Google for Kotlin Multiplatform:
presentation/
├── ui/ # Composable UI (View)
│ └── App.kt
└── viewmodel/ # ViewModels
└── GreenhouseViewModel.kt
domain/
└── repository/ # Repository interfaces
└── GreenhouseRepository.kt
data/
├── model/ # Data models (DTOs)
│ └── GreenhouseMessage.kt
├── remote/ # Network layer
│ ├── api/ # API service definitions
│ │ └── GreenhouseApiService.kt
│ └── KtorClient.kt # Ktor HTTP client configuration
└── repository/ # Repository implementations
└── GreenhouseRepositoryImpl.kt
util/
├── Environment.kt # Multi-environment configuration
└── DateTimeProvider.kt # Expect/actual for platform-specific timestamps
composeApp/src/
├── commonMain/ # Shared code for all platforms
│ ├── kotlin/ # Common Kotlin code (MVVM architecture)
│ └── composeResources/ # Shared resources (images, strings, etc.)
├── androidMain/ # Android-specific code
├── iosMain/ # iOS-specific code (Kotlin)
├── jvmMain/ # Desktop-specific code
├── jsMain/ # JavaScript target code
├── wasmJsMain/ # WebAssembly target code
└── commonTest/ # Shared test code
The project uses Ktor Client for HTTP communication:
- Configuration:
data/remote/KtorClient.kt - Features: Content negotiation (JSON), Logging, Serialization
- Engines: OkHttp (Android/JVM), Darwin (iOS)
Use the expect/actual pattern for platform-specific implementations:
- Define
expectdeclarations incommonMain/(e.g.,Platform.kt) - Provide
actualimplementations in platform-specific source sets (e.g.,Platform.android.kt,Platform.ios.kt)
- Models: Add to
data/model/ - API Services: Add to
data/remote/api/ - Repositories: Interface in
domain/repository/, implementation indata/repository/ - ViewModels: Add to
presentation/viewmodel/ - UI: Add Composable functions in
presentation/ui/ - Platform-specific: Add to respective platform source sets (androidMain, iosMain, etc.)
- Kotlin: 2.2.20
- Compose Multiplatform: 1.9.1
- Android minSdk: 24, targetSdk: 36
- Ktor: 3.0.3
- Kotlinx Serialization: 1.8.0
- Centralized dependency management
- Defines all platform targets
- Configures sourceSets and dependencies
- Android namespace:
com.apptolast.greenhousefronts
- JVM max memory: 4GB (
-Xmx4g) - Configuration cache enabled
The UI connects to GreenhouseViewModel and displays:
- Sensor data from the most recent API message (sensor01 or setpoint01)
- Greenhouse ID from the last message
- OutlinedTextField for user input (setpoint value)
- Button "Enviar" to publish setpoint to MQTT via API
- Loading state with CircularProgressIndicator
- Error/Success messages using Snackbar
State is managed using StateFlow in the ViewModel and collected in the UI with collectAsState().
- ViewModel initialization → Calls
loadRecentMessages() - Repository → Calls
GreenhouseApiService.getRecentMessages() - Ktor Client → Makes HTTP GET request
- StateFlow updates → UI recomposes with new data
- User clicks "Enviar" →
publishSetpoint()called - Repository → Calls
GreenhouseApiService.publishMessage() - Success → Reloads messages and shows confirmation
- All code comments and technical documentation must be in English
- User-facing UI strings (button labels, messages) are in Spanish for the target audience
- Project uses Material Design 3 for consistent UI
- Compose Multiplatform enables write-once UI code across all platforms
- Network calls are made with Ktor Client (not Retrofit)
- All API communication goes through the Repository pattern
- ViewModels use Kotlin Coroutines and StateFlow for reactive state management
This project uses Koin 4.1.0 as the dependency injection framework for managing object creation and lifecycle across all platforms.
- Multiplatform Support: Official support for all KMP targets (Android, iOS, Desktop, Web)
- Lightweight: No code generation or reflection, just Kotlin DSL
- Compose Integration: First-class support for Compose Multiplatform with
koinViewModel() - Easy Testing: Simple to provide fake implementations for unit tests
- Google Best Practices: Follows MVVM architecture recommendations with constructor injection
di/
├── KoinInitializer.kt # Koin startup configuration
├── DataModule.kt # Data layer dependencies (HttpClient, API, Repository)
├── DomainModule.kt # Domain layer dependencies (use cases)
├── PresentationModule.kt # Presentation layer dependencies (ViewModels)
└── PlatformModule.kt # Platform-specific dependencies (expect/actual)
[versions]
koin-bom = "4.1.1"
[libraries]
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin-bom" }
koin-core = { module = "io.insert-koin:koin-core" }
koin-compose = { module = "io.insert-koin:koin-compose" }
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel" }
koin-compose-viewmodel-navigation = { module = "io.insert-koin:koin-compose-viewmodel-navigation" }
koin-android = { module = "io.insert-koin:koin-android" }
koin-test = { module = "io.insert-koin:koin-test" }commonMain.dependencies {
implementation(project.dependencies.platform(libs.koin.bom))
implementation(libs.koin.core)
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
implementation(libs.koin.compose.viewmodel.navigation)
}
androidMain.dependencies {
implementation(libs.koin.android)
}
commonTest.dependencies {
implementation(libs.koin.test)
}Provides network and repository dependencies:
val dataModule = module {
// HttpClient singleton
single { createHttpClient() }
// StompClient singleton
single { createStompClient() }
// API Service with constructor injection
singleOf(::GreenhouseApiService)
// WebSocket Client with constructor injection
singleOf(::StompWebSocketClient)
// Repository Implementation bound to interface
singleOf(::GreenhouseRepositoryImpl) bind GreenhouseRepository::class
}Key Points:
singlecreates a singleton (one instance for app lifetime)singleOf(::ClassName)is concise syntax for constructor injectionbindallows injecting by interface type- Koin automatically resolves constructor dependencies with
get()
Provides ViewModels with lifecycle management:
val presentationModule = module {
// ViewModel with lifecycle-aware scope
viewModelOf(::GreenhouseViewModel)
}Key Points:
viewModelOfcreates a ViewModel-scoped instance- Automatically handles lifecycle and configuration changes
- Repository is auto-injected via constructor
Starting with Koin 4.1+, the API has been simplified. Use koinViewModel() for **all scenarios
**, including Navigation Compose:
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun App() {
val navController = rememberNavController()
NavHost(navController, startDestination = LoginRoute) {
composable<HomeRoute> {
// koinViewModel() automatically handles Navigation integration
val viewModel: GreenhouseViewModel = koinViewModel()
HomeScreen(viewModel = viewModel)
}
}
}Important Note: koinNavViewModel() is DEPRECATED in Koin 4.1+. The functionality has been
integrated into koinViewModel() thanks to lifecycle library updates. Always use koinViewModel()
now.
Automatic Features in koinViewModel() (Koin 4.1+):
- ✅ NavBackStackEntry integration (when used inside NavHost)
- ✅ Automatic SavedStateHandle support
- ✅ Navigation argument injection
- ✅ Lifecycle-aware scoping
For ViewModels with Navigation Arguments:
// ViewModel with navigation arguments
class DetailViewModel(
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
val itemId: String = savedStateHandle.get<String>("itemId") ?: ""
}
// Usage in NavHost
composable("detail/{itemId}") {
val viewModel: DetailViewModel = koinViewModel() // Arguments auto-injected
DetailScreen(viewModel)
}For Shared ViewModels Across Navigation Destinations:
NavHost(
navController = navController,
startDestination = "screenA",
route = "parentRoute" // Important: Define parent route
) {
composable("screenA") { backStackEntry ->
val parentEntry = remember(backStackEntry) {
navController.getBackStackEntry("parentRoute")
}
val sharedViewModel: SharedViewModel = koinViewModel(
viewModelStoreOwner = parentEntry // Scope to parent
)
ScreenA(sharedViewModel)
}
composable("screenB") { backStackEntry ->
val parentEntry = remember(backStackEntry) {
navController.getBackStackEntry("parentRoute")
}
val sharedViewModel: SharedViewModel = koinViewModel(
viewModelStoreOwner = parentEntry // Same instance
)
ScreenB(sharedViewModel)
}
}All dependencies use constructor injection (no field injection):
// ViewModel receives Repository
class GreenhouseViewModel(
private val repository: GreenhouseRepository // Koin injects
) : ViewModel()
// Repository receives API service and WebSocket client
class GreenhouseRepositoryImpl(
private val apiService: GreenhouseApiService, // Koin injects
private val webSocketClient: StompWebSocketClient // Koin injects
) : GreenhouseRepository
// API Service receives HttpClient
class GreenhouseApiService(
private val httpClient: HttpClient // Koin injects
)Created GreenhouseApplication class to initialize Koin:
class GreenhouseApplication : Application() {
override fun onCreate() {
super.onCreate()
initKoin {
androidLogger() // Enable Android logging
androidContext(this@GreenhouseApplication) // Provide context
}
}
}Registered in AndroidManifest.xml:
<application
android:name=".GreenhouseApplication"
...>Initialize in iOSApp.swift:
import ComposeApp
@main
struct iOSApp: App {
init() {
KoinInitializerKt.doInitKoin()
}
// ...
}Initialize in main.kt:
fun main() {
initKoin()
application {
Window(...) {
App()
}
}
}Initialize in main.kt:
fun main() {
initKoin()
ComposeViewport {
App()
}
}| Scope | Usage | Lifecycle |
|---|---|---|
single |
Singletons (HttpClient, Repositories, API services) | App lifetime |
factory |
Short-lived objects (use cases) | Created on each injection |
viewModelOf |
ViewModels | Survives configuration changes |
- Constructor Injection Only: Never use field injection
- Interface-Based Design: Depend on abstractions (
GreenhouseRepositoryinterface, notGreenhouseRepositoryImpl) - Single Responsibility: Each class should depend only on what it needs
- Module Organization: Separate by architectural layers (data, domain, presentation)
- Platform Modules: Use
expect/actualfor platform-specific dependencies
- Create the use case class:
class GetRecentMessagesUseCase(
private val repository: GreenhouseRepository
) {
suspend operator fun invoke(): Result<List<GreenhouseMessage>> {
return repository.getRecentMessages()
}
}- Add to
DomainModule.kt:
val domainModule = module {
factory { GetRecentMessagesUseCase(get()) }
}- Inject into ViewModel:
class GreenhouseViewModel(
private val getRecentMessages: GetRecentMessagesUseCase // Koin injects
) : ViewModel()Use fake implementations for testing:
class FakeGreenhouseRepository : GreenhouseRepository {
var shouldReturnError = false
var fakeMessages = emptyList<GreenhouseMessage>()
override suspend fun getRecentMessages(): Result<List<GreenhouseMessage>> {
return if (shouldReturnError) {
Result.failure(Exception("Test error"))
} else {
Result.success(fakeMessages)
}
}
}
// In test
class GreenhouseViewModelTest : KoinTest {
@Before
fun setup() {
startKoin {
modules(testModule)
}
}
@After
fun teardown() {
stopKoin()
}
companion object {
private val testModule = module {
single<GreenhouseRepository> { FakeGreenhouseRepository() }
viewModelOf(::GreenhouseViewModel)
}
}
}Cause: Starting Koin multiple times
Solution: Only call initKoin() once at Application/App entry point, never in Activities or Composables
Cause: Class not defined in any module
Solution: Add the class to the appropriate module (DataModule, DomainModule, or PresentationModule)
Cause: Class A depends on Class B, which depends on Class A
Solution: Refactor to remove circular dependency or use a third mediator class
- Official Documentation: https://insert-koin.io
- KMP Guide: https://insert-koin.io/docs/reference/koin-mp/kmp/
- Compose Integration: https://insert-koin.io/docs/reference/koin-compose/compose/
- GitHub: https://github.com/InsertKoinIO/koin
The expect/actual mechanism enables accessing platform-specific APIs when:
- No multiplatform library exists - The functionality is not available through official KMP libraries
- Factory functions - Need to return platform-specific implementations
- Inheriting platform classes - Must extend existing platform-specific base classes
- Direct native API access - Require direct access to platform APIs for performance or features
Official Recommendation: Prefer interfaces over expect/actual in most cases.
❌ DO NOT use expect/actual if:
- A multiplatform library already exists (e.g., kotlinx-datetime, kotlinx-coroutines)
- An interface would be sufficient
- You can use dependency injection with interfaces
- Standard Kotlin constructs solve the problem
✅ Interfaces are better because:
- Allow multiple implementations per platform
- Make testing easier with fake/mock implementations
- More flexible and standard Kotlin approach
- Avoid Beta feature limitations
The project uses expect/actual for getCurrentTimestamp() in util/DateTimeProvider.kt:
// commonMain/util/DateTimeProvider.kt
expect fun getCurrentTimestamp(): String
// androidMain/util/DateTimeProvider.android.kt
actual fun getCurrentTimestamp(): String {
return kotlin.time.Clock.System.now().toString()
}
// iosMain/util/DateTimeProvider.ios.kt
actual fun getCurrentTimestamp(): String {
// iOS uses Foundation NSDate directly for better platform integration
val formatter = NSISO8601DateFormatter()
return formatter.stringFromDate(NSDate())
}Note: Most platforms use kotlin.time.Clock from the Kotlin standard library. iOS uses Foundation's NSDate directly for optimal platform integration.
- Declaration Location:
expectincommonMain,actualin each platform source set - Same Package: Both must be in the identical package
- Matching Signatures: Names, parameters, and return types must match exactly
- No Implementation in Expect: Expected declarations cannot contain implementation code
- All Platforms: Every platform must provide an
actualimplementation
- Validates all declarations during compilation
- Merges expected and actual declarations
- Ensures signature consistency across platforms
- Generates one declaration with appropriate implementation per platform
When encountering functionality without multiplatform support:
-
Search for Official KMP Libraries
- Check JetBrains kotlinx.* libraries first
- Search Maven Central for "kmp-" or "kmm-" prefixed libraries
- Verify library supports all your target platforms
-
Verify Library Documentation
- Read official documentation to confirm multiplatform support
- Check GitHub releases for latest stable versions
- Review platform compatibility matrix
-
Test Library Integration
- Add dependency to
commonMain - Sync Gradle and verify no errors
- Test compilation for each platform target
- Add dependency to
-
Implement Expect/Actual as Last Resort
- Only when no suitable multiplatform library exists
- Document the decision and alternatives evaluated
- Create expect declaration in
commonMain - Provide actual implementations for each platform
- Use platform-native APIs (e.g., NSDate for iOS, java.time for JVM)
-
Document the Implementation
- Add comments explaining why expect/actual was necessary
- Reference any GitHub issues or documentation consulted
- Note future migration path if library becomes available
- Verify First: Always search for existing multiplatform solutions before implementing expect/actual
- Use Web Search: When unsure, search official Kotlin and library documentation
- Ask Questions: If requirements are unclear, ask for clarification rather than guessing
- Document Decisions: Explain why expect/actual was chosen over alternatives
- Keep It Simple: Minimize the surface area of platform-specific code
- Test All Platforms: Verify implementation works on every target platform
Expected/actual classes are in Beta status - migration steps may be required in future Kotlin versions. Suppress warnings if needed:
freeCompilerArgs.add("-Xexpect-actual-classes")- Official Documentation: https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-expect-actual.html
- Kotlin Language Docs: https://kotlinlang.org/docs/multiplatform-expect-actual.html
- Connect to Platform APIs: https://kotlinlang.org/docs/multiplatform-connect-to-apis.html
When implementing new features or troubleshooting, consult these official resources:
- Official Guide: https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html
- Compose Multiplatform: https://www.jetbrains.com/compose-multiplatform/
- KMP Architecture: https://kotlinlang.org/docs/multiplatform-mobile-understand-project-structure.html
- Official Documentation: https://ktor.io/docs/client-create-multiplatform-application.html
- Ktor Client Setup: https://ktor.io/docs/client-create-new-application.html
- Content Negotiation: https://ktor.io/docs/serialization-client.html
- Compose Documentation: https://developer.android.com/jetpack/compose
- ViewModel Guide: https://developer.android.com/topic/libraries/architecture/viewmodel
- StateFlow: https://developer.android.com/kotlin/flow/stateflow-and-sharedflow
This project uses a custom Material Design 3 theme with a dark-first aesthetic and neon green accents, designed specifically for greenhouse monitoring dashboards.
The theming system is located in presentation/ui/theme/ and consists of:
presentation/ui/theme/
├── Color.kt # Color palette definitions (light & dark schemes)
├── Font.kt # Custom font family definitions
├── Type.kt # Typography scale (Material 3)
└── Theme.kt # Main GreenhouseTheme composable
The app uses a dark-first design with:
- Background: Almost black with subtle blue tint (
#0F1419) - Surface: Dark gray for cards and elevated elements (
#1A1E23) - Primary: Neon green for emphasis and actions (
#00E676- Material Green A400) - Surface Variant: Dark green-tinted surfaces for greenhouse branding (
#1E3A34) - Tertiary: Bright teal for humidity and variety (
#4ECDC4)
// Primary colors - Neon green for main actions
primary = Color(0xFF00E676) // Bright neon green
onPrimary = Color(0xFF003300) // Very dark green for text
primaryContainer = Color(0xFF1E3A34) // Dark green-gray for containers
onPrimaryContainer = Color(0xFFB2DFDB) // Light teal for text
// Background & Surface
background = Color(0xFF0F1419) // Almost black with blue tint
surface = Color(0xFF1A1E23) // Dark gray for cards
surfaceVariant = Color(0xFF1E3A34) // Dark green-tinted surface
onBackground = Color(0xFFE6E1E5) // Light gray for text
onSurface = Color(0xFFE6E1E5) // Light gray for text
// Tertiary - Teal accent
tertiary = Color(0xFF4ECDC4) // Bright teal (humidity displays)
onTertiary = Color(0xFF002020) // Very dark teal
tertiaryContainer = Color(0xFF1A3635) // Dark teal-grayAlways use MaterialTheme.colorScheme - never hardcode colors:
// ✅ CORRECT - Uses theme colors
Text(
text = "Temperature",
color = MaterialTheme.colorScheme.onSurface
)
Button(
onClick = { },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
Text("Action")
}
// ❌ WRONG - Hardcoded color
Text(
text = "Temperature",
color = Color(0xFFFFFFFF) // Don't do this!
)| Role | Usage | Example Components |
|---|---|---|
primary |
Main brand color, primary actions | FABs, prominent buttons, active states |
primaryContainer |
Tinted backgrounds | Cards with emphasis, chips |
secondary |
Less prominent actions | Secondary buttons, filter chips |
tertiary |
Contrasting accents | Humidity cards, special highlights |
surface |
Backgrounds for components | Cards, dialogs, sheets |
surfaceVariant |
Alternative surfaces | Input fields, inactive chips |
outline |
Borders and dividers | TextField borders, dividers |
error |
Error states and warnings | Error messages, validation failures |
If you need custom colors beyond Material 3 roles:
- Define in
Color.ktas private values - Add to the appropriate ColorScheme
- Document the usage clearly
// In Color.kt
private val CustomGreenHighlight = Color(0xFF4CAF50)
internal val DarkColorScheme = darkColorScheme(
// ... existing colors
// Use surfaceTint or other flexible roles for custom colors
)The app uses composable typography to support custom fonts easily.
Current State: Using system default font (FontFamily.Default)
To add custom fonts: See Custom Fonts section below.
The app follows Material 3's 5-category type scale:
| Category | Sizes | Usage | Font Weight |
|---|---|---|---|
| Display | Large (57sp), Medium (45sp), Small (36sp) | Large, expressive text (hero sections) | Bold/Normal |
| Headline | Large (32sp), Medium (28sp), Small (24sp) | Page titles, emphasis | SemiBold/Medium |
| Title | Large (22sp), Medium (16sp), Small (14sp) | Section titles, list items | SemiBold/Medium |
| Body | Large (16sp), Medium (14sp), Small (12sp) | Main content, paragraphs | Normal |
| Label | Large (14sp), Medium (12sp), Small (11sp) | Buttons, labels, captions | Medium |
Always use MaterialTheme.typography:
// Page title
Text(
text = "Dashboard",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface
)
// Section heading
Text(
text = "Sensor Readings",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
// Body text
Text(
text = "Current temperature is 22°C",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Button text
Button(onClick = { }) {
Text(
text = "Save",
style = MaterialTheme.typography.labelLarge
)
}The project uses a composable font system that makes swapping fonts easy:
- Font.kt: Defines
appFontFamily()composable - Type.kt: Uses
appFontFamily()for all text styles - Theme.kt: Calls
appTypography()composable
Currently using: FontFamily.Default (system font)
Follow these steps to add a custom font like Inter, Roboto, or Geist Sans:
- Visit Google Fonts: https://fonts.google.com
- Recommended font: Inter (https://fonts.google.com/specimen/Inter)
- Optimized for screens and dashboards
- Excellent legibility at all sizes
- Modern, professional aesthetic
- Download at least 4 weights:
- Regular (400)
- Medium (500)
- SemiBold (600)
- Bold (700)
mkdir -p composeApp/src/commonMain/composeResources/fontCopy the .ttf files to the font directory with lowercase, underscore-separated names:
composeApp/src/commonMain/composeResources/font/
├── inter_regular.ttf
├── inter_medium.ttf
├── inter_semibold.ttf
└── inter_bold.ttf
Naming rules:
- Use lowercase only
- Use underscores instead of spaces
- Include font name and weight (e.g.,
roboto_bold.ttf)
./gradlew buildThis generates resource accessors in greenhousefronts.composeapp.generated.resources.Res.font.*
Uncomment and update the custom font code in presentation/ui/theme/Font.kt:
import org.jetbrains.compose.resources.Font
import greenhousefronts.composeapp.generated.resources.Res
import greenhousefronts.composeapp.generated.resources.inter_regular
import greenhousefronts.composeapp.generated.resources.inter_medium
import greenhousefronts.composeapp.generated.resources.inter_semibold
import greenhousefronts.composeapp.generated.resources.inter_bold
@Composable
fun appFontFamily(): FontFamily {
return FontFamily(
Font(Res.font.inter_regular, FontWeight.Normal),
Font(Res.font.inter_medium, FontWeight.Medium),
Font(Res.font.inter_semibold, FontWeight.SemiBold),
Font(Res.font.inter_bold, FontWeight.Bold)
)
}./gradlew build
./gradlew :composeApp:run # Test on Desktop or your platformTo change from Inter to another font (e.g., Roboto):
- Download new font files from Google Fonts
- Place in
composeResources/font/with descriptive names (e.g.,roboto_regular.ttf) - Update only Font.kt:
@Composable
fun appFontFamily(): FontFamily {
return FontFamily(
Font(Res.font.roboto_regular, FontWeight.Normal),
Font(Res.font.roboto_medium, FontWeight.Medium),
Font(Res.font.roboto_bold, FontWeight.Bold)
)
}- Rebuild:
./gradlew build - No changes needed in Type.kt or Theme.kt - they automatically use the new font!
| Font | Best For | Key Strength | Download |
|---|---|---|---|
| Inter | General UI, dashboards | Screen-optimized clarity | Google Fonts |
| Roboto | Data tables, numbers | Tabular figures | Google Fonts |
| Geist Sans | Developer tools | Modern tech aesthetic | Vercel |
| Manrope | Dense information | Space efficiency | Google Fonts |
| DM Sans | Small text | Small-scale legibility | Google Fonts |
In Compose Multiplatform (unlike Android-only Jetpack Compose), **Font() is a @Composable function **:
- FontFamily must be created inside
@Composablefunctions - Typography must be created inside
@Composablefunctions - Cannot define fonts as top-level
valproperties
This is why appFontFamily() and appTypography() are functions, not values.
When creating new UI screens, follow these best practices:
@Composable
fun MyNewScreen() {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(16.dp)
) {
// Page title
Text(
text = "Screen Title",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface
)
// Content card
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
// Card content
}
// Primary action button
Button(
onClick = { },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
Text("Action", style = MaterialTheme.typography.labelLarge)
}
}
}Inputs with icons:
OutlinedTextField(
value = value,
onValueChange = { value = it },
label = { Text("Label") },
leadingIcon = {
Icon(
imageVector = Icons.Default.Person,
contentDescription = "Icon",
tint = MaterialTheme.colorScheme.primary
)
},
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline
)
)Cards with emphasis:
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
// Emphasized content
}Buttons:
// Primary action
Button(
onClick = { },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) { Text("Primary Action") }
// Secondary action
OutlinedButton(onClick = { }) {
Text("Secondary Action")
}
// Tertiary/text action
TextButton(onClick = { }) {
Text("Cancel", color = MaterialTheme.colorScheme.primary)
}Use multiples of 4dp for spacing:
val spacing = object {
val extraSmall = 4.dp
val small = 8.dp
val medium = 16.dp
val large = 24.dp
val extraLarge = 32.dp
}
Column(
modifier = Modifier.padding(spacing.medium),
verticalArrangement = Arrangement.spacedBy(spacing.small)
) {
// Content with consistent spacing
}The app uses consistent corner radii:
- Small components (chips, small cards):
8.dpor12.dp - Medium components (buttons, text fields):
12.dp - Large components (cards, dialogs):
16.dpor24.dp
Card(
shape = RoundedCornerShape(16.dp)
) { /* ... */ }
Button(
shape = RoundedCornerShape(12.dp)
) { /* ... */ }Use Material Icons for consistency:
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
Icon(
imageVector = Icons.Default.Home,
contentDescription = "Home",
tint = MaterialTheme.colorScheme.primary
)The app is dark-first, but supports light theme as a fallback.
- Desaturated colors in dark mode: Primary green (
#00E676) is bright in dark theme, but would use darker green (#1B5E20) in light theme - Sufficient contrast: Always ensure text has adequate contrast against backgrounds
- Avoid pure black: Use dark grays (
#0F1419) instead of#000000for better visual comfort - Elevation with tints: Use slight color tints instead of shadows for elevation in dark theme
To test light theme during development:
@Preview
@Composable
fun MyScreenPreviewLight() {
GreenhouseTheme(darkTheme = false) {
MyScreen()
}
}
@Preview
@Composable
fun MyScreenPreviewDark() {
GreenhouseTheme(darkTheme = true) {
MyScreen()
}
}When creating reusable components, follow this pattern:
/**
* Reusable sensor card component showing current readings.
*
* @param title The sensor name (e.g., "Temperature")
* @param value The current reading
* @param unit The measurement unit (e.g., "°C")
* @param icon The icon to display
* @param accentColor Optional accent color (defaults to primary)
* @param onClick Optional click handler
*/
@Composable
fun SensorCard(
title: String,
value: String,
unit: String,
icon: ImageVector,
accentColor: Color = MaterialTheme.colorScheme.primary,
onClick: (() -> Unit)? = null
) {
Card(
modifier = Modifier
.fillMaxWidth()
.then(
if (onClick != null) {
Modifier.clickable(onClick = onClick)
} else {
Modifier
}
),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = title,
tint = accentColor,
modifier = Modifier.size(40.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(
text = title,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = value,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = unit,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}The app implements edge-to-edge design with adaptive status bar icon colors that automatically adjust based on the current theme (dark/light mode). This ensures optimal visibility of system status icons in all scenarios.
The status bar management uses an expect/actual pattern for platform-specific implementations:
presentation/ui/theme/
├── Theme.kt # Common theme with ConfigureSystemUI() expect declaration
├── Theme.android.kt # Android implementation using WindowCompat
├── Theme.ios.kt # iOS implementation (placeholder)
├── Theme.jvm.kt # Desktop (no-op)
├── Theme.js.kt # Web JS (no-op)
└── Theme.wasmJs.kt # Web Wasm (no-op)
MainActivity.kt configures edge-to-edge with SystemBarStyle.auto():
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// Configure edge-to-edge with auto-adjusting status bar icons
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.auto(
lightScrim = Color.TRANSPARENT,
darkScrim = Color.TRANSPARENT
),
navigationBarStyle = SystemBarStyle.auto(
lightScrim = Color.TRANSPARENT,
darkScrim = Color.TRANSPARENT
)
)
super.onCreate(savedInstanceState)
setContent { App() }
}
}Theme.android.kt uses WindowCompat.getInsetsController() to explicitly set icon appearance:
@Composable
actual fun ConfigureSystemUI(darkTheme: Boolean) {
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
val insetsController = WindowCompat.getInsetsController(window, view)
// isAppearanceLightStatusBars = true → dark icons (for light backgrounds)
// isAppearanceLightStatusBars = false → light icons (for dark backgrounds)
insetsController.isAppearanceLightStatusBars = !darkTheme
insetsController.isAppearanceLightNavigationBars = !darkTheme
}
}
}| Theme Mode | Background | Icon Color | Configuration |
|---|---|---|---|
| Dark | Dark | White | isAppearanceLightStatusBars = false |
| Light | Light | Dark | isAppearanceLightStatusBars = true |
- Automatic Adjustment: Icons automatically change color when theme switches
- Edge-to-Edge: Full-screen content with transparent status/navigation bars
- No API Checks:
WindowCompat.getInsetsController()works across all Android API levels - Multi-Platform: Each platform has appropriate implementation via expect/actual
Problem: Status bar icons invisible in dark mode (dark icons on dark background)
Solution:
- Ensure
enableEdgeToEdge()is called withSystemBarStyle.auto()in MainActivity - Verify
ConfigureSystemUI(darkTheme)is called from GreenhouseTheme - Check that
isAppearanceLightStatusBars = !darkTheme(inverted logic)
Problem: Icons don't update when switching between light/dark mode
Solution: Ensure GreenhouseTheme receives the correct darkTheme parameter and calls
ConfigureSystemUI(darkTheme) every time it recomposes.
The iOS implementation currently does nothing but can be extended to use UIKit:
// TODO: Implement iOS status bar configuration
// Use UIApplication.shared.statusBarStyle or preferredStatusBarStyle
// Configure for light content in dark mode, dark content in light mode- Edge-to-Edge: https://developer.android.com/develop/ui/compose/system/system-bars
- SystemBarStyle: Part of
androidx.activity:activity-compose:1.8.0+ - WindowCompat: Part of
androidx.core:core-ktx
- Always provide contentDescription for icons and images
- Use semantic colors (error for errors, not red)
- Minimum touch targets: 48dp × 48dp for interactive elements
- Text contrast: Ensure sufficient contrast ratios (4.5:1 for body text, 3:1 for large text)
- Status bar visibility: Ensure adequate contrast between status bar icons and app background
- Material Design 3: https://m3.material.io
- Material 3 Color System: https://m3.material.io/styles/color/overview
- Material 3 Typography: https://m3.material.io/styles/typography/overview
- Compose Material 3: https://developer.android.com/develop/ui/compose/designsystems/material3
- Google Fonts: https://fonts.google.com
- Edge-to-Edge Design: https://developer.android.com/develop/ui/compose/system/system-bars