Architecture & Navigation
The Association App mobile client follows Clean Architecture with an MVI (Model-View-Intent) presentation pattern. All product logic and UI is shared across Android and iOS via Kotlin Multiplatform.
Layer Overview
┌──────────────────────────────────────────────┐
│ Presentation Layer │
│ Composable Screens → ViewModels → StateFlow │
├──────────────────────────────────────────────┤
│ Domain Layer │
│ Repository Interfaces + Domain Models │
├──────────────────────────────────────────────┤
│ Data Layer │
│ Repository Impls → Ktor API + KVault Storage │
└──────────────────────────────────────────────┘
Presentation Layer
- Screens (33 Composables) — Stateless UI; collect state from the ViewModel
- ViewModels (18) — Extend
androidx.lifecycle.ViewModel; holdStateFlowstate and process user intents - State pattern — Each ViewModel exposes one immutable state object via
.asStateFlow(); the screen recomposes when state changes
// Typical ViewModel structure
class EventsViewModel(private val repo: EventsRepository) : ViewModel() {
private val _state = MutableStateFlow(EventsState())
val state = _state.asStateFlow()
fun onIntent(intent: EventsIntent) {
viewModelScope.launch { /* mutate _state */ }
}
}
Domain Layer
- Repository interfaces — Contracts that the data layer implements; the presentation layer depends only on these interfaces
- Domain models — Pure Kotlin data classes; no Android/iOS dependencies
Data Layer
- Repository implementations — Single-scoped Koin singletons
- Remote data sources — Ktor HTTP clients that parse JSON into DTOs
- Local storage — KVault for encrypted key-value pairs (tokens, branding, preferences)
- DTOs → Domain mapping — Extension functions convert API response objects to domain models
Dependency Injection (Koin)
The project uses Koin 4.1 with KSP annotation processing to eliminate manual module declarations.
Module structure
| Module | Registered components |
|---|---|
ContextModule | Platform context (Android: ApplicationContext) |
NetworkModule | Two Ktor client instances: @AuthHttpClient (authenticated), @PublicHttpClient |
DataModule | All repository implementations (scanned via @ComponentScan) |
ViewModelModule | All ViewModels (scanned via @ComponentScan) |
NativeModule | Platform-specific: notification handler, icon switcher |
Initialisation
Android (MyApp.kt):
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
startKoin { modules(AppModule) }
}
}
iOS (iOSApp.swift → iosMain/KoinInit.kt):
@InitKoin
fun initKoin() = startKoin { modules(AppModule) }
Koin annotations used
| Annotation | Scope |
|---|---|
@Single | Singleton — one instance for the app lifetime |
@Factory | New instance on every injection |
@Module + @ComponentScan | Auto-registers all annotated classes in a package |
Navigation
Navigation is built on Jetpack Navigation Compose with type-safe serializable routes.
Route hierarchy
NavHost
├── Auth graph
│ ├── Splash
│ ├── Onboarding
│ ├── Login
│ ├── ForgotPassword
│ ├── SignUpRequest
│ ├── Register(code)
│ ├── SetPassword(token, email, firstName, lastName)
│ └── Invitation(code)
│
└── Main graph
├── Home (bottom-nav container)
│ ├── Events tab
│ ├── News tab
│ ├── Home tab
│ ├── Notifications tab
│ └── Profile tab
├── OrganizationSelection
├── EventDetail(eventId)
├── NewsDetail(slug)
├── Documents / DocumentsBrowser
├── Governance / Guidance
├── PdfViewer(id, title)
├── Members
├── EditProfile
├── AccountSettings
├── ChangePassword
├── UpdateEmail
├── About
├── LegalPageDetail(type)
└── ContactUs
Route definitions
Routes are defined as a sealed interface with @Serializable annotations, enabling compile-time safety and Parcelable-free argument passing:
@Serializable
sealed interface Route {
@Serializable data object Login : Route
@Serializable data class EventDetail(val eventId: String) : Route
@Serializable data class SetPassword(
val token: String, val email: String,
val firstName: String, val lastName: String
) : Route
}
Deep linking
The app handles two deep link schemes:
| Scheme | Example | Destination |
|---|---|---|
| Universal Link | https://get.theassociationapp.com.au/register/ABC123 | Register screen |
| Custom scheme | creative://my-url.com/register/ABC123 | Register screen |
Deep links are processed by DeepLinkHandler and NavigationStateManager, which translate incoming URLs into typed Route objects before handing off to the nav controller.
Android — Declared in AndroidManifest.xml as <intent-filter> with BROWSABLE category.
iOS — Declared via Associated Domains entitlement (applinks:get.theassociationapp.com.au) and a custom URL scheme.
Networking (Ktor)
The app uses two named Ktor client instances:
| Client | Used for | Auth header |
|---|---|---|
@AuthHttpClient | All authenticated API calls | Bearer {accessToken} |
@PublicHttpClient | Login, register, password reset | None |
Token auto-refresh
The @AuthHttpClient includes a Mutex-guarded token refresh interceptor:
- Attach the current access token to each request
- If a
401is returned, acquire a refresh lock (prevents duplicate refresh calls) - Call the refresh endpoint using
@PublicHttpClient - Store the new tokens in KVault
- Retry the original request with the new access token
- If refresh fails, clear tokens and navigate to login
Base URL
https://api-dev.theassociationapp.com.au/api ← development
https://api-staging.theassociationapp.com.au/api ← staging
https://api.theassociationapp.com.au/api ← production
Configured in AppConfig.kt (common source set).
Local Storage (KVault)
KVault provides an encrypted key-value store backed by:
- iOS: Keychain Services
- Android: EncryptedSharedPreferences
Keys stored
| Key | Content |
|---|---|
auth_token | JWT access token |
refresh_token | JWT refresh token |
token_issued_at | Token issuance timestamp (ms) |
token_expires_in | Token lifetime (seconds) |
fcm_token | Firebase Cloud Messaging token |
default_org_id | Selected organisation ID |
branding | Serialised organisation branding JSON |
onboarding_complete | Boolean flag |
State Management Pattern
Each feature follows the same pattern:
User action
↓
Screen calls ViewModel.onIntent(intent)
↓
ViewModel processes intent in viewModelScope
↓
ViewModel updates _state (MutableStateFlow)
↓
Screen collects state via collectAsStateWithLifecycle()
↓
Compose recomposition updates the UI
Loading, success, and error states are modelled as fields on the state data class — not as separate sealed classes — keeping state transitions predictable.
Dynamic App Icon (Branding)
Organisations can customise the app icon that appears on a member's home screen. The platform delivers an iconName string as part of the organisation branding payload.
| Step | Detail |
|---|---|
| 1. Login | Backend returns organizationContext.branding.iconName |
| 2. Store | Saved to KVault under branding key |
| 3. Apply | On app launch, AppIconVariant enum matches iconName to a registered icon variant |
| 4. Switch | Android uses PackageManager.setComponentEnabledSetting(); iOS calls UIApplication.setAlternateIconName() via a Swift bridge |
Registered variants:
iconName | Android alias | iOS asset |
|---|---|---|
default | MainActivityIconDefault | Primary AppIcon |
pfiaa | MainActivityIconPfiaa | AppIconPfiaa |
Crash Reporting & Analytics
| Tool | Platforms | What it captures |
|---|---|---|
| Firebase Crashlytics | Android & iOS | Unhandled exceptions, ANRs |
| NSExceptionKt | iOS only | Kotlin exceptions propagated through the Swift boundary |
| Firebase Analytics | Android & iOS | Screen views, events |
| Kotzilla SDK | Android & iOS | Performance, custom events, dSYM symbolication |
On iOS, a Xcode build phase script uploads the .dSYM bundle to Kotzilla after each archive build to enable human-readable stack traces.