Skip to main content

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; hold StateFlow state 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

ModuleRegistered components
ContextModulePlatform context (Android: ApplicationContext)
NetworkModuleTwo Ktor client instances: @AuthHttpClient (authenticated), @PublicHttpClient
DataModuleAll repository implementations (scanned via @ComponentScan)
ViewModelModuleAll ViewModels (scanned via @ComponentScan)
NativeModulePlatform-specific: notification handler, icon switcher

Initialisation

Android (MyApp.kt):

class MyApp : Application() {
override fun onCreate() {
super.onCreate()
startKoin { modules(AppModule) }
}
}

iOS (iOSApp.swiftiosMain/KoinInit.kt):

@InitKoin
fun initKoin() = startKoin { modules(AppModule) }

Koin annotations used

AnnotationScope
@SingleSingleton — one instance for the app lifetime
@FactoryNew instance on every injection
@Module + @ComponentScanAuto-registers all annotated classes in a package

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:

SchemeExampleDestination
Universal Linkhttps://get.theassociationapp.com.au/register/ABC123Register screen
Custom schemecreative://my-url.com/register/ABC123Register 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:

ClientUsed forAuth header
@AuthHttpClientAll authenticated API callsBearer {accessToken}
@PublicHttpClientLogin, register, password resetNone

Token auto-refresh

The @AuthHttpClient includes a Mutex-guarded token refresh interceptor:

  1. Attach the current access token to each request
  2. If a 401 is returned, acquire a refresh lock (prevents duplicate refresh calls)
  3. Call the refresh endpoint using @PublicHttpClient
  4. Store the new tokens in KVault
  5. Retry the original request with the new access token
  6. 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

KeyContent
auth_tokenJWT access token
refresh_tokenJWT refresh token
token_issued_atToken issuance timestamp (ms)
token_expires_inToken lifetime (seconds)
fcm_tokenFirebase Cloud Messaging token
default_org_idSelected organisation ID
brandingSerialised organisation branding JSON
onboarding_completeBoolean 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.

StepDetail
1. LoginBackend returns organizationContext.branding.iconName
2. StoreSaved to KVault under branding key
3. ApplyOn app launch, AppIconVariant enum matches iconName to a registered icon variant
4. SwitchAndroid uses PackageManager.setComponentEnabledSetting(); iOS calls UIApplication.setAlternateIconName() via a Swift bridge

Registered variants:

iconNameAndroid aliasiOS asset
defaultMainActivityIconDefaultPrimary AppIcon
pfiaaMainActivityIconPfiaaAppIconPfiaa

Crash Reporting & Analytics

ToolPlatformsWhat it captures
Firebase CrashlyticsAndroid & iOSUnhandled exceptions, ANRs
NSExceptionKtiOS onlyKotlin exceptions propagated through the Swift boundary
Firebase AnalyticsAndroid & iOSScreen views, events
Kotzilla SDKAndroid & iOSPerformance, 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.