Last updated: 2026-02-16
This document describes the architecture of the VaultStadio frontend built with Compose Multiplatform.
The frontend uses Decompose for navigation and component lifecycle management, with per-screen ViewModels for state management.
┌─────────────────────────────────────────────────────────────────┐
│ App.kt (Entry Point) │
│ │ │
│ ┌───────┴───────┐ │
│ │ RootComponent │ │
│ └───────┬───────┘ │
│ │ │
│ ┌─────────────┴─────────────┐ │
│ │ │ │
│ ┌────────┴────────┐ ┌─────────┴────────┐ │
│ │ AuthComponent │ │ MainComponent │ │
│ │ (AuthViewModel)│ │ (Sidebar + Stack)│ │
│ └─────────────────┘ └─────────┬────────┘ │
│ │ │
│ ┌──────────────────────────┼──────────────────┐ │
│ │ │ │ │
│ ┌────────┴───────┐ ┌───────────┴──────┐ ┌─────┴──┐ │
│ │ FilesComponent │ │ SettingsComponent│ │ Admin │ │
│ │ (FilesViewModel)│ │ (SettingsViewModel)│ │Component││
│ └────────────────┘ └──────────────────┘ └────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Repositories │
│ (AuthRepository, StorageRepository, etc.) │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Shared API Client │
│ (VaultStadioApi) │
└─────────────────────────────────────────────────────────────────┘
│
▼
Backend (Ktor)
compose-frontend/composeApp/src/commonMain/kotlin/com/vaultstadio/app/
├── navigation/ # Decompose navigation
│ ├── RootComponent.kt # Auth/Main split
│ ├── RootContent.kt # Root composable
│ └── MainDestination.kt # Navigation destinations
├── feature/ # Feature modules
│ ├── auth/
│ │ ├── AuthComponent.kt
│ │ ├── AuthViewModel.kt
│ │ └── AuthContent.kt
│ ├── files/
│ │ ├── FilesComponent.kt
│ │ ├── FilesViewModel.kt
│ │ └── FilesContent.kt
│ ├── main/
│ │ ├── MainComponent.kt
│ │ └── MainContent.kt
│ ├── admin/
│ ├── ai/
│ ├── sync/
│ ├── federation/
│ ├── collaboration/
│ └── ...
├── ui/
│ ├── components/ # Reusable UI components
│ │ ├── MainSidebar.kt
│ │ ├── Breadcrumbs.kt
│ │ ├── EmptyState.kt
│ │ └── ...
│ ├── screens/ # Legacy screens (being migrated)
│ └── theme/
│ └── Theme.kt
├── i18n/
│ ├── Strings.kt # String resources
│ └── StringExtensions.kt # Shorthand properties
└── platform/ # Platform-specific code
Each feature has a Component that manages lifecycle and creates ViewModels:
// feature/files/FilesComponent.kt
interface FilesComponent {
val viewModel: FilesViewModel
val mode: MainComponent.FilesMode
}
class DefaultFilesComponent(
componentContext: ComponentContext,
storageRepository: StorageRepository,
apiBaseUrl: String,
override val mode: MainComponent.FilesMode
) : FilesComponent, ComponentContext by componentContext {
private val scope = coroutineScope(Dispatchers.Main + SupervisorJob())
override val viewModel = FilesViewModel(
storageRepository = storageRepository,
scope = scope,
apiBaseUrl = apiBaseUrl,
mode = mode
)
}Each screen has its own ViewModel with focused responsibilities:
// feature/files/FilesViewModel.kt
class FilesViewModel(
private val storageRepository: StorageRepository,
private val scope: CoroutineScope,
private val apiBaseUrl: String,
private val mode: MainComponent.FilesMode
) {
// State
var items by mutableStateOf<List<StorageItem>>(emptyList())
var isLoading by mutableStateOf(false)
var error by mutableStateOf<String?>(null)
// Actions
fun loadItems() {
scope.launch {
isLoading = true
when (val result = storageRepository.getItems(currentFolderId)) {
is ApiResult.Success -> items = result.data
is ApiResult.Error -> error = result.message
}
isLoading = false
}
}
}Navigation uses Decompose's ChildStack for type-safe navigation:
// navigation/RootComponent.kt
interface RootComponent {
val stack: Value<ChildStack<*, Child>>
sealed class Child {
data class Auth(val component: AuthComponent) : Child()
data class Main(val component: MainComponent) : Child()
}
}
// Usage in RootContent.kt
@Composable
fun RootContent(component: RootComponent) {
Children(stack = component.stack) { child ->
when (val instance = child.instance) {
is RootComponent.Child.Auth -> AuthContent(instance.component)
is RootComponent.Child.Main -> MainContent(instance.component)
}
}
}Navigation destinations for the main authenticated area:
enum class MainDestination {
// Core file management
FILES, RECENT, STARRED, TRASH,
// Sharing
SHARED, SHARED_WITH_ME,
// User
SETTINGS, PROFILE,
// Admin
ADMIN, ACTIVITY, PLUGINS,
// Advanced features
AI, SYNC, FEDERATION, COLLABORATION, VERSION_HISTORY
}Navigation is handled by MainComponent:
// In MainComponent
fun navigateTo(destination: MainDestination) {
val config = when (destination) {
MainDestination.FILES -> Config.Files(FilesMode.ALL)
MainDestination.SETTINGS -> Config.Settings
// ...
}
navigation.push(config)
NavDestination.AI -> AIScreen(...)
// ...
}Components follow a consistent pattern:
@Composable
fun MyComponent(
// Data (state)
data: DataType,
isLoading: Boolean,
// Event handlers (callbacks)
onAction: () -> Unit,
onItemClick: (Item) -> Unit,
// Optional modifier (always last)
modifier: Modifier = Modifier
) {
// Component implementation
}Platform-specific code uses Kotlin's expect/actual mechanism:
// commonMain - Declaration
expect class LargeSelectedFile {
val name: String
val size: Long
val mimeType: String
suspend fun readChunk(start: Long, end: Long): ByteArray
}
expect suspend fun openFilePicker(multiple: Boolean): List<SelectedFile>
expect fun openDownloadUrl(url: String)
expect fun initializeDragDrop(onFilesDropped: (List<SelectedFile>) -> Unit)// desktopMain - JVM Implementation
actual class LargeSelectedFile(private val file: File) {
actual val name: String = file.name
actual val size: Long = file.length()
actual val mimeType: String = // detect from extension
actual suspend fun readChunk(start: Long, end: Long): ByteArray {
RandomAccessFile(file, "r").use { raf ->
raf.seek(start)
val buffer = ByteArray((end - start).toInt())
raf.readFully(buffer)
return buffer
}
}
}// wasmJsMain - Web Implementation
actual class LargeSelectedFile(private val jsFile: File) {
actual val name: String = jsFile.name
actual val size: Long = jsFile.size.toLong()
actual suspend fun readChunk(start: Long, end: Long): ByteArray {
val blob = jsFile.slice(start.toInt(), end.toInt())
// Convert blob to ByteArray using JavaScript APIs
}
}User Action → Composable → ViewModel → API Client → Backend
↑ │
└─────────────────────────┘
State Update
- User navigates to FILES
MainContentshowsFilesContent(via Decompose child stack)FilesContentcallsviewModel.navigateToFolder(null)on the screen'sFilesViewModelFilesViewModelusesstorageRepository.getItems()(API client)- API returns data
- ViewModel updates
itemsstate - Compose recomposes the screen with new data
-
List Screens (FilesScreen, StarredScreen, TrashScreen)
- Display items in grid or list view
- Support selection and batch operations
-
Form Screens (LoginScreen, ProfileScreen, SettingsScreen)
- User input and configuration
- Validation and submission
-
Feature Screens (AIScreen, SyncScreen, FederationScreen)
- Complex multi-section layouts
- Real-time updates
-
Detail Screens (VersionHistoryScreen, CollaborationScreen)
- Item-specific views
- Context-dependent actions
@Composable
fun FeatureScreen(
// State
data: FeatureData,
isLoading: Boolean,
// Lifecycle
onLoad: () -> Unit,
// Actions
onAction1: () -> Unit,
onAction2: (String) -> Unit,
modifier: Modifier = Modifier
) {
// Load data on first composition
LaunchedEffect(Unit) {
onLoad()
}
Scaffold(
topBar = { /* ... */ },
floatingActionButton = { /* ... */ }
) { padding ->
when {
isLoading -> LoadingIndicator()
data.isEmpty -> EmptyState()
else -> ContentList(data)
}
}
}Material 3 theming with light/dark mode support:
@Composable
fun VaultStadioTheme(
darkTheme: Boolean = ThemeSettings.isDarkMode,
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) darkColorScheme() else lightColorScheme()
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}Strings are provided via CompositionLocal:
val LocalStrings = staticCompositionLocalOf<StringResources> { EnglishStrings }
@Composable
fun App() {
CompositionLocalProvider(
LocalStrings provides Strings.getStrings(Strings.currentLanguage)
) {
// App content
}
}
// Usage
@Composable
fun MyComponent() {
val strings = LocalStrings.current
Text(strings.myString)
}Errors are handled at the ViewModel level (each screen has its own ViewModel):
class FilesViewModel(...) {
var error by mutableStateOf<String?>(null)
fun loadItems() {
scope.launch {
val result = storageRepository.getItems(folderId)
when (result) {
is ApiResult.Success -> { /* update items */ }
is ApiResult.Error -> error = result.message
is ApiResult.NetworkError -> error = result.message
}
}
}
fun clearError() {
error = null
}
}Errors are displayed via Snackbar or dialog in the root or screen composable.
Keep state in ViewModel, not in components.
Use remember for expensive calculations:
val filteredItems = remember(items, filter) {
items.filter { it.matches(filter) }
}Use LazyColumn/LazyGrid for lists:
LazyVerticalGrid(columns = GridCells.Adaptive(180.dp)) {
items(items) { item ->
FileItem(item)
}
}Use thumbnails and progressive loading for images.
- Unit Tests: Test ViewModel logic and data transformations
- Component Tests: Test component behavior with mocked data
- Integration Tests: Test full flows with API mocking
composeApp/commonMain (domain models, API client, UI, ViewModel)
↑
androidApp / iosApp / desktopMain / wasmJsMain