From 017e27feb439c767371f7cdf129edb4f5d22f4df Mon Sep 17 00:00:00 2001 From: Jacob Ras Date: Wed, 17 Jun 2026 23:28:12 +0200 Subject: [PATCH 01/15] Share demo screen UI --- .../jacobras/cloudbridge/demo/MainActivity.kt | 6 +- demo/desktop/build.gradle.kts | 14 +- .../jacobras/cloudbridge/demo/desktop/main.kt | 100 ++++++++++++ .../nl/jacobras/cloudbridge/demo/main.kt | 142 ------------------ demo/shared/build.gradle.kts | 13 +- .../composeResources/drawable/ic_dropbox.png | Bin 0 -> 27094 bytes .../drawable/ic_google_drive.png | Bin 0 -> 16254 bytes .../drawable/ic_one_drive.png | Bin 0 -> 16202 bytes .../cloudbridge/demo/ui/DemoScreen.kt | 71 +++++++++ .../ui/{MainViewModel.kt => DemoViewModel.kt} | 53 ++++++- .../cloudbridge/demo/ui/DetailPane.kt | 93 ++++++++++++ .../jacobras/cloudbridge/demo/ui/FilesList.kt | 34 +++++ .../cloudbridge/demo/ui/ServiceViewModel.kt | 6 + .../cloudbridge/demo/ui/ServiceWithInfo.kt | 12 ++ .../cloudbridge/demo/ui/ServicesList.kt | 72 +++++++++ demo/web/src/webMain/kotlin/main.kt | 4 +- gradle/libs.versions.toml | 4 + library/build.gradle.kts | 9 +- 18 files changed, 480 insertions(+), 153 deletions(-) create mode 100644 demo/desktop/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/desktop/main.kt delete mode 100644 demo/desktop/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/main.kt create mode 100644 demo/shared/src/commonMain/composeResources/drawable/ic_dropbox.png create mode 100644 demo/shared/src/commonMain/composeResources/drawable/ic_google_drive.png create mode 100644 demo/shared/src/commonMain/composeResources/drawable/ic_one_drive.png create mode 100644 demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoScreen.kt rename demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/{MainViewModel.kt => DemoViewModel.kt} (56%) create mode 100644 demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DetailPane.kt create mode 100644 demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/FilesList.kt create mode 100644 demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServiceViewModel.kt create mode 100644 demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServiceWithInfo.kt create mode 100644 demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServicesList.kt diff --git a/demo/android/src/main/kotlin/nl/jacobras/cloudbridge/demo/MainActivity.kt b/demo/android/src/main/kotlin/nl/jacobras/cloudbridge/demo/MainActivity.kt index 975d331..75f893d 100644 --- a/demo/android/src/main/kotlin/nl/jacobras/cloudbridge/demo/MainActivity.kt +++ b/demo/android/src/main/kotlin/nl/jacobras/cloudbridge/demo/MainActivity.kt @@ -37,7 +37,7 @@ import kotlinx.coroutines.launch import nl.jacobras.cloudbridge.CloudService import nl.jacobras.cloudbridge.demo.persistence.DemoSettings import nl.jacobras.cloudbridge.demo.ui.FileRow -import nl.jacobras.cloudbridge.demo.ui.MainViewModel +import nl.jacobras.cloudbridge.demo.ui.DemoViewModel import nl.jacobras.cloudbridge.model.CloudItem import nl.jacobras.cloudbridge.model.UserInfo import nl.jacobras.cloudbridge.service.dropbox.authenticate @@ -48,7 +48,7 @@ import nl.jacobras.cloudbridge.service.onedrive.completeAuthentication class MainActivity : ComponentActivity() { - private val viewModel = MainViewModel() + private val viewModel = DemoViewModel() private var authenticatingProvider: Provider? = null private val scope = CoroutineScope(Dispatchers.Main) @@ -134,7 +134,7 @@ class MainActivity : ComponentActivity() { @OptIn(ExperimentalMaterial3Api::class) @Composable private fun DemoApp( - viewModel: MainViewModel, + viewModel: DemoViewModel, launchAuth: (Provider, String) -> Unit, onAuthenticateGoogleDrive: () -> Unit ) { diff --git a/demo/desktop/build.gradle.kts b/demo/desktop/build.gradle.kts index f693277..2f53472 100644 --- a/demo/desktop/build.gradle.kts +++ b/demo/desktop/build.gradle.kts @@ -1,6 +1,7 @@ @file:Suppress("OPT_IN_USAGE") import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import java.util.Properties plugins { @@ -16,13 +17,20 @@ val localProps = Properties().apply { } kotlin { - jvm("desktop") + jvm("desktop") { + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + } + } sourceSets { named("desktopMain") { buildConfig { packageName("nl.jacobras.cloudbridge.demo") - buildConfigField("DRIVE_DESKTOP_SECRET", localProps.getProperty("driveDesktopSecret") ?: "") + buildConfigField( + "DRIVE_DESKTOP_SECRET", + localProps.getProperty("driveDesktopSecret") ?: "" + ) } dependencies { implementation(projects.demo.shared) @@ -39,7 +47,7 @@ kotlin { compose.desktop { application { - mainClass = "nl.jacobras.cloudbridge.demo.MainKt" + mainClass = "nl.jacobras.cloudbridge.demo.desktop.MainKt" nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) diff --git a/demo/desktop/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/desktop/main.kt b/demo/desktop/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/desktop/main.kt new file mode 100644 index 0000000..de205e6 --- /dev/null +++ b/demo/desktop/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/desktop/main.kt @@ -0,0 +1,100 @@ +package nl.jacobras.cloudbridge.demo.desktop + +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState +import nl.jacobras.cloudbridge.demo.DesktopMainBuildConfig +import nl.jacobras.cloudbridge.demo.persistence.DemoSettings +import nl.jacobras.cloudbridge.demo.ui.DemoScreen +import nl.jacobras.cloudbridge.demo.ui.DemoViewModel +import nl.jacobras.cloudbridge.persistence.LocalAuthenticationServer +import nl.jacobras.cloudbridge.service.dropbox.DropboxService +import nl.jacobras.cloudbridge.service.dropbox.authenticate +import nl.jacobras.cloudbridge.service.googledrive.GoogleDriveService +import nl.jacobras.cloudbridge.service.googledrive.authenticate +import nl.jacobras.cloudbridge.service.onedrive.OneDriveService +import nl.jacobras.cloudbridge.service.onedrive.authenticate + +fun main() = application { + val viewModel = remember { DemoViewModel() } + + var showSuccessDialog by remember { mutableStateOf(false) } + val localServer = remember { LocalAuthenticationServer() } + + DisposableEffect(Unit) { + onDispose { + localServer.stop() + } + } + + Window( + onCloseRequest = ::exitApplication, + title = "CloudBridge Demo", + state = rememberWindowState(size = DpSize(1000.dp, 600.dp)) + ) { + val uriHandler = LocalUriHandler.current + + DemoScreen( + viewModel = DemoViewModel(), + onAuthenticate = { service -> + when (service) { + is DropboxService -> { + val url = service.authenticate( + authServer = localServer, + clientId = "nw5f95uw77yrz3j", + onSuccess = { token -> + DemoSettings.dropboxToken = token + showSuccessDialog = true + viewModel.refresh(service) + } + ) + uriHandler.openUri(url) + } + is GoogleDriveService -> { + val url = service.authenticate( + authServer = localServer, + clientId = "218224394553-ls5llp4qcqlem66ovl0rp871jlq47m21.apps.googleusercontent.com", + clientSecret = DesktopMainBuildConfig.DRIVE_DESKTOP_SECRET, + onSuccess = { token -> + DemoSettings.googleDriveToken = token + showSuccessDialog = true + viewModel.refresh(service) + } + ) + uriHandler.openUri(url) + } + is OneDriveService -> { + val url = service.authenticate( + authServer = localServer, + clientId = "40916102-96a6-46ca-929e-90cc62c3be9a", + onSuccess = { token -> + DemoSettings.oneDriveToken = token + showSuccessDialog = true + viewModel.refresh(service) + } + ) + uriHandler.openUri(url) + } + } + }, + onDeauthenticate = { service -> + when (service) { + is DropboxService -> { + DemoSettings.dropboxToken = null + viewModel.refresh(service) + } + is GoogleDriveService -> DemoSettings.googleDriveToken = null + is OneDriveService -> DemoSettings.oneDriveToken = null + } + } + ) + } +} \ No newline at end of file diff --git a/demo/desktop/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/main.kt b/demo/desktop/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/main.kt deleted file mode 100644 index 4cb19c2..0000000 --- a/demo/desktop/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/main.kt +++ /dev/null @@ -1,142 +0,0 @@ -package nl.jacobras.cloudbridge.demo - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.window.Window -import androidx.compose.ui.window.application -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import nl.jacobras.cloudbridge.CloudService -import nl.jacobras.cloudbridge.auth.CloudAccessToken -import nl.jacobras.cloudbridge.demo.persistence.DemoSettings -import nl.jacobras.cloudbridge.demo.ui.FileRow -import nl.jacobras.cloudbridge.demo.ui.MainViewModel -import nl.jacobras.cloudbridge.persistence.LocalAuthenticationServer -import nl.jacobras.cloudbridge.service.dropbox.authenticate -import nl.jacobras.cloudbridge.service.googledrive.authenticate -import nl.jacobras.cloudbridge.service.onedrive.authenticate -import kotlin.time.Duration.Companion.milliseconds - -fun main() = application { - val viewModel = remember { MainViewModel() } - - var obtainedToken by remember { mutableStateOf(null) } - var selectedService by remember { mutableStateOf(viewModel.dropbox) } - - var showSuccessDialog by remember { mutableStateOf(false) } - val localServer = remember { LocalAuthenticationServer() } - - val allFiles by viewModel.files.collectAsState() - val files = allFiles[selectedService] ?: emptyList() - - val allServiceErrors by viewModel.serviceErrors.collectAsState() - val error = allServiceErrors[selectedService] ?: "" - - DisposableEffect(Unit) { - onDispose { - localServer.stop() - } - } - - Window( - onCloseRequest = ::exitApplication, - title = "CloudBridge Demo", - ) { - if (showSuccessDialog) { - AlertDialog( - onDismissRequest = { showSuccessDialog = false }, - confirmButton = { - Button(onClick = { showSuccessDialog = false }) { - Text("OK") - } - }, - text = { SelectionContainer { Text("Success! Token: $obtainedToken") } } - ) - } - - val uriHandler = LocalUriHandler.current - val scope = rememberCoroutineScope() - - fun openDelayed(url: String) = scope.launch { - delay(300.milliseconds) - uriHandler.openUri(url) - } - - Column { - Text("Sign in with: (opens browser)") - Row { - val dropboxService = viewModel.dropbox - Button(onClick = { - val url = dropboxService.authenticate( - authServer = localServer, - clientId = "nw5f95uw77yrz3j", - onSuccess = { - obtainedToken = it - selectedService = dropboxService - DemoSettings.dropboxToken = it - showSuccessDialog = true - viewModel.refresh(dropboxService) - } - ) - openDelayed(url) - }) { Text("Dropbox") } - Button(onClick = { - val googleDriveService = viewModel.googleDrive - val url = googleDriveService.authenticate( - authServer = localServer, - clientId = "218224394553-ls5llp4qcqlem66ovl0rp871jlq47m21.apps.googleusercontent.com", - clientSecret = DesktopMainBuildConfig.DRIVE_DESKTOP_SECRET, - onSuccess = { - obtainedToken = it - selectedService = googleDriveService - DemoSettings.googleDriveToken = it - showSuccessDialog = true - viewModel.refresh(googleDriveService) - } - ) - openDelayed(url) - }) { Text("Google Drive") } - Button(onClick = { - val oneDriveService = viewModel.oneDrive - val url = oneDriveService.authenticate( - authServer = localServer, - clientId = "40916102-96a6-46ca-929e-90cc62c3be9a", - onSuccess = { - obtainedToken = it - selectedService = oneDriveService - DemoSettings.oneDriveToken = it - showSuccessDialog = true - viewModel.refresh(oneDriveService) - } - ) - openDelayed(url) - }) { Text("OneDrive") } - } - - if (error.isNotEmpty()) { - Text( - text = error, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error - ) - } - - for (file in files) { - FileRow(file, onClick = {}) - } - } - } -} \ No newline at end of file diff --git a/demo/shared/build.gradle.kts b/demo/shared/build.gradle.kts index 1fbff2c..a518306 100644 --- a/demo/shared/build.gradle.kts +++ b/demo/shared/build.gradle.kts @@ -1,5 +1,8 @@ @file:Suppress("OPT_IN_USAGE") +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + + plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.android.kotlin.multiplatform.library) @@ -13,7 +16,11 @@ kotlin { compileSdk = libs.versions.android.compileSdk.get().toInt() minSdk = libs.versions.android.minSdk.get().toInt() } - jvm("desktop") + jvm("desktop") { + compilerOptions { + jvmTarget = JvmTarget.JVM_21 + } + } js { browser() } wasmJs { browser() } @@ -21,6 +28,9 @@ kotlin { commonMain.dependencies { api(projects.library) api(libs.androidx.viewmodel) + api(libs.compose.adaptive) + api(libs.compose.adaptive.layout) + api(libs.compose.adaptive.navigation) api(libs.compose.foundation) api(libs.compose.material3) api(libs.compose.runtime) @@ -43,5 +53,6 @@ kotlin { compilerOptions { optIn.add("kotlin.time.ExperimentalTime") + freeCompilerArgs.add("-Xexplicit-backing-fields") } } \ No newline at end of file diff --git a/demo/shared/src/commonMain/composeResources/drawable/ic_dropbox.png b/demo/shared/src/commonMain/composeResources/drawable/ic_dropbox.png new file mode 100644 index 0000000000000000000000000000000000000000..55b95af6ea70e58dba002d696b526b6370ad8567 GIT binary patch literal 27094 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 P5u~SWgf7;)5%K>3{V%n^ literal 0 HcmV?d00001 diff --git a/demo/shared/src/commonMain/composeResources/drawable/ic_google_drive.png b/demo/shared/src/commonMain/composeResources/drawable/ic_google_drive.png new file mode 100644 index 0000000000000000000000000000000000000000..f448ed727550519c880ced7980736cbf8afbb26d GIT binary patch literal 16254 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\IP^oj+8pRDag(=%nWrDcr6cigB&5YtvA7a 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 literal 0 HcmV?d00001 diff --git a/demo/shared/src/commonMain/composeResources/drawable/ic_one_drive.png b/demo/shared/src/commonMain/composeResources/drawable/ic_one_drive.png new file mode 100644 index 0000000000000000000000000000000000000000..f1af091e11ad7505e035426cc311a5b3c2c3b4f7 GIT binary patch literal 16202 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 C@;itC literal 0 HcmV?d00001 diff --git a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoScreen.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoScreen.kt new file mode 100644 index 0000000..5f1c263 --- /dev/null +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoScreen.kt @@ -0,0 +1,71 @@ +package nl.jacobras.cloudbridge.demo.ui + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole +import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import nl.jacobras.cloudbridge.CloudService + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +fun DemoScreen( + viewModel: DemoViewModel, + onAuthenticate: (CloudService) -> Unit, + onDeauthenticate: (CloudService) -> Unit +) { + val navigator = rememberListDetailPaneScaffoldNavigator() + val selectedService by viewModel.selectedService.collectAsState() + val scope = rememberCoroutineScope() + + val files by viewModel.files.collectAsState() + val serviceErrors by viewModel.serviceErrors.collectAsState() + + ListDetailPaneScaffold( + directive = navigator.scaffoldDirective, + value = navigator.scaffoldValue, + listPane = { + ServicesList( + services = viewModel.services, + selectedService = selectedService?.service, + onClick = { + viewModel.select(it) + scope.launch { + navigator.navigateTo(ListDetailPaneScaffoldRole.Detail) + } + } + ) + }, + detailPane = { + selectedService?.let { info -> + val service = info.service + DetailPane( + info = info, + files = files[service] ?: emptyList(), + error = serviceErrors[service] ?: "", + onAuthenticateClick = { onAuthenticate(service) }, + onDeauthenticateClick = { onDeauthenticate(service) }, + onBackClick = { + viewModel.deselect() + scope.launch { + navigator.navigateTo(ListDetailPaneScaffoldRole.List) + } + } + ) + } ?: Text( + modifier = Modifier.padding(8.dp), + text = "Select a service from the list" + ) + }, + paneExpansionDragHandle = { _ -> VerticalDivider() } + ) +} \ No newline at end of file diff --git a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/MainViewModel.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoViewModel.kt similarity index 56% rename from demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/MainViewModel.kt rename to demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoViewModel.kt index 6147c75..89c2351 100644 --- a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/MainViewModel.kt +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoViewModel.kt @@ -2,27 +2,55 @@ package nl.jacobras.cloudbridge.demo.ui import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import cloudbridge.demo.shared.generated.resources.Res +import cloudbridge.demo.shared.generated.resources.ic_dropbox +import cloudbridge.demo.shared.generated.resources.ic_google_drive +import cloudbridge.demo.shared.generated.resources.ic_one_drive +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import nl.jacobras.cloudbridge.CloudBridge import nl.jacobras.cloudbridge.CloudService +import nl.jacobras.cloudbridge.CloudServiceException import nl.jacobras.cloudbridge.demo.persistence.DemoSettings import nl.jacobras.cloudbridge.model.CloudItem import nl.jacobras.cloudbridge.model.FolderPath import nl.jacobras.cloudbridge.model.UserInfo -class MainViewModel : ViewModel() { +class DemoViewModel : ViewModel() { val dropbox = CloudBridge.dropbox() val googleDrive = CloudBridge.googleDrive() val oneDrive = CloudBridge.oneDrive() + internal val services = listOf( + ServiceWithInfo( + service = dropbox, + logo = Res.drawable.ic_dropbox, + name = "Dropbox" + ), + ServiceWithInfo( + service = googleDrive, + logo = Res.drawable.ic_google_drive, + name = "Google Drive" + ), + ServiceWithInfo( + service = oneDrive, + logo = Res.drawable.ic_one_drive, + name = "OneDrive" + ) + ) val files = MutableStateFlow>>(emptyMap()) val userInfo = MutableStateFlow>(emptyMap()) val serviceErrors = MutableStateFlow>(emptyMap()) + internal val selectedService: StateFlow + field = MutableStateFlow(null) val path = MutableStateFlow(FolderPath("/")) + private var loadServiceDetailsJob : Job?=null + init { updateTokens() refresh(dropbox) @@ -77,4 +105,27 @@ class MainViewModel : ViewModel() { map } } + + internal fun select(info: ServiceWithInfo) { + loadServiceDetailsJob?.cancel() + loadServiceDetailsJob = null + selectedService.update { info } + + val service = info.service.takeIf { it.isAuthenticated() } ?: return + loadServiceDetailsJob = viewModelScope.launch { + try { + val userInfo = service.getUserInfo() + val userName = userInfo.name + if (userName != null) { + selectedService.update { info.copy(userName = userName) } + } + } catch (_: CloudServiceException) { + // NOOP + } + } + } + + internal fun deselect() { + selectedService.update { null } + } } \ No newline at end of file diff --git a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DetailPane.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DetailPane.kt new file mode 100644 index 0000000..789d883 --- /dev/null +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DetailPane.kt @@ -0,0 +1,93 @@ +package nl.jacobras.cloudbridge.demo.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import nl.jacobras.cloudbridge.model.CloudItem + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun DetailPane( + info: ServiceWithInfo, + files: List, + error: String, + onAuthenticateClick: () -> Unit, + onDeauthenticateClick: () -> Unit, + onBackClick: () -> Unit +) { + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { + Text(text = buildString { + append(info.name) + + if (info.emailAddress.isNotBlank()) { + append(" (${info.emailAddress})") + } + }) + }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Close") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent + ), + actions = { + val service = info.service + if (!service.isAuthenticated()) { + Button(onClick = onAuthenticateClick) { + Text("Sign in") + } + } else { + Button(onClick = onDeauthenticateClick) { + Text("Sign out") + } + } + }, + modifier = Modifier.padding(end = 8.dp) + ) + }, + containerColor = Color.Transparent + ) { + Column(modifier = Modifier.padding(it)) { + if (error.isNotBlank()) { + Text( + text = error, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error + ) + Spacer(Modifier.height(16.dp)) + } + + FilesList( + modifier = Modifier.fillMaxSize(), + serviceWithInfo = info, + files = files, + onBackClick = onBackClick, + onItemClick = { + // TODO + } + ) + } + } +} \ No newline at end of file diff --git a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/FilesList.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/FilesList.kt new file mode 100644 index 0000000..a08a943 --- /dev/null +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/FilesList.kt @@ -0,0 +1,34 @@ +package nl.jacobras.cloudbridge.demo.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import nl.jacobras.cloudbridge.model.CloudItem + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun FilesList( + serviceWithInfo: ServiceWithInfo, + files: List, + onBackClick: () -> Unit, + onItemClick: (CloudItem) -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier.verticalScroll(rememberScrollState())) { + for (item in files) { + FileRow( + item = item, + onClick = { onItemClick(item) } + ) + if (item != files.last()) { + HorizontalDivider(Modifier.padding(vertical = 4.dp)) + } + } + } +} \ No newline at end of file diff --git a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServiceViewModel.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServiceViewModel.kt new file mode 100644 index 0000000..b7b80bd --- /dev/null +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServiceViewModel.kt @@ -0,0 +1,6 @@ +package nl.jacobras.cloudbridge.demo.ui + +internal class ServiceViewModel { + + // TODO: files, error etc. +} \ No newline at end of file diff --git a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServiceWithInfo.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServiceWithInfo.kt new file mode 100644 index 0000000..7da9360 --- /dev/null +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServiceWithInfo.kt @@ -0,0 +1,12 @@ +package nl.jacobras.cloudbridge.demo.ui + +import nl.jacobras.cloudbridge.CloudService +import org.jetbrains.compose.resources.DrawableResource + +internal data class ServiceWithInfo( + val service: CloudService, + val logo: DrawableResource, + val name: String, + val userName: String = "", + val emailAddress: String = "" +) \ No newline at end of file diff --git a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServicesList.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServicesList.kt new file mode 100644 index 0000000..f94843b --- /dev/null +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServicesList.kt @@ -0,0 +1,72 @@ +package nl.jacobras.cloudbridge.demo.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import nl.jacobras.cloudbridge.CloudService +import org.jetbrains.compose.resources.painterResource + +@Composable +internal fun ServicesList( + services: List, + selectedService: CloudService?, + onClick: (ServiceWithInfo) -> Unit +) { + Column { + for (info in services) { + Service( + service = info, + selected = selectedService == info.service, + onClick = { onClick(info) } + ) + } + } +} + +@Composable +private fun Service( + service: ServiceWithInfo, + selected: Boolean, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(8.dp, 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + modifier = Modifier.size(32.dp), + painter = painterResource(service.logo), + contentDescription = null + ) + Spacer(Modifier.width(8.dp)) + Text( + text = buildString { + append(service.name) + + if (service.userName.isNotBlank()) { + append(" (${service.userName})") + } + }, + fontWeight = if (selected) { + FontWeight.Medium + } else { + FontWeight.Normal + } + ) + } +} \ No newline at end of file diff --git a/demo/web/src/webMain/kotlin/main.kt b/demo/web/src/webMain/kotlin/main.kt index 88fe311..1121236 100644 --- a/demo/web/src/webMain/kotlin/main.kt +++ b/demo/web/src/webMain/kotlin/main.kt @@ -37,7 +37,7 @@ import nl.jacobras.cloudbridge.CloudServiceException import nl.jacobras.cloudbridge.auth.CloudAccessToken import nl.jacobras.cloudbridge.demo.persistence.DemoSettings import nl.jacobras.cloudbridge.demo.ui.FileRow -import nl.jacobras.cloudbridge.demo.ui.MainViewModel +import nl.jacobras.cloudbridge.demo.ui.DemoViewModel import nl.jacobras.cloudbridge.model.CloudFolder import nl.jacobras.cloudbridge.model.CloudItem import nl.jacobras.cloudbridge.model.CloudItemId @@ -65,7 +65,7 @@ fun main() { ComposeViewport { - val viewModel = remember { MainViewModel() } + val viewModel = remember { DemoViewModel() } val dropboxService = viewModel.dropbox val googleDriveService = viewModel.googleDrive val oneDriveService = viewModel.oneDrive diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1ba6ea5..1b2ee53 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,6 +6,7 @@ android-targetSdk = "36" androidx-activityCompose = "1.9.3" androidx-browser = "1.10.0" compose = "1.11.1" +compose-adaptive = "1.3.0-beta02" kotlin = "2.4.0" # https://github.com/JetBrains/kotlin/releases kotlin-coroutines = "1.11.0" kotlin-serialization = "1.9.0" @@ -19,6 +20,9 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose", ver androidx-browser = { module = "androidx.browser:browser", version.ref = "androidx-browser" } androidx-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", version = "2.10.0" } assertK = { module = "com.willowtreeapps.assertk:assertk", version = "0.28.1" } +compose-adaptive = { module = "org.jetbrains.compose.material3.adaptive:adaptive", version.ref = "compose-adaptive" } +compose-adaptive-layout = { module = "org.jetbrains.compose.material3.adaptive:adaptive-layout", version.ref = "compose-adaptive" } +compose-adaptive-navigation = { module = "org.jetbrains.compose.material3.adaptive:adaptive-navigation", version.ref = "compose-adaptive" } compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose" } compose-material3 = { module = "org.jetbrains.compose.material3:material3", version = "1.9.0" } compose-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "compose" } diff --git a/library/build.gradle.kts b/library/build.gradle.kts index e7a9808..dce3ced 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -1,5 +1,8 @@ @file:Suppress("OPT_IN_USAGE") +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + + plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.android.kotlin.multiplatform.library) @@ -19,7 +22,11 @@ kotlin { minSdk = libs.versions.android.minSdk.get().toInt() } js { browser() } - jvm("desktop") + jvm("desktop") { + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + } + } wasmJs { browser { testTask { From cb4ead12f424e3a369246df3b0f34ba0c07dc032 Mon Sep 17 00:00:00 2001 From: Jacob Ras Date: Thu, 18 Jun 2026 19:20:22 +0200 Subject: [PATCH 02/15] Extract service details into `ServiceViewModel` --- .../cloudbridge/demo/ui/DemoScreen.kt | 5 --- .../cloudbridge/demo/ui/DetailPane.kt | 12 ++++--- .../jacobras/cloudbridge/demo/ui/FilesList.kt | 2 -- .../cloudbridge/demo/ui/ServiceViewModel.kt | 36 +++++++++++++++++-- 4 files changed, 41 insertions(+), 14 deletions(-) diff --git a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoScreen.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoScreen.kt index 5f1c263..3e08af0 100644 --- a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoScreen.kt +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoScreen.kt @@ -27,9 +27,6 @@ fun DemoScreen( val selectedService by viewModel.selectedService.collectAsState() val scope = rememberCoroutineScope() - val files by viewModel.files.collectAsState() - val serviceErrors by viewModel.serviceErrors.collectAsState() - ListDetailPaneScaffold( directive = navigator.scaffoldDirective, value = navigator.scaffoldValue, @@ -50,8 +47,6 @@ fun DemoScreen( val service = info.service DetailPane( info = info, - files = files[service] ?: emptyList(), - error = serviceErrors[service] ?: "", onAuthenticateClick = { onAuthenticate(service) }, onDeauthenticateClick = { onDeauthenticate(service) }, onBackClick = { diff --git a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DetailPane.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DetailPane.kt index 789d883..08adfd5 100644 --- a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DetailPane.kt +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DetailPane.kt @@ -17,21 +17,25 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import nl.jacobras.cloudbridge.model.CloudItem @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun DetailPane( info: ServiceWithInfo, - files: List, - error: String, onAuthenticateClick: () -> Unit, onDeauthenticateClick: () -> Unit, onBackClick: () -> Unit ) { + val viewModel = remember(info.service) { ServiceViewModel(info.service) } + val files by viewModel.files.collectAsState() + val error by viewModel.error.collectAsState() + Scaffold( topBar = { CenterAlignedTopAppBar( @@ -81,9 +85,7 @@ internal fun DetailPane( FilesList( modifier = Modifier.fillMaxSize(), - serviceWithInfo = info, files = files, - onBackClick = onBackClick, onItemClick = { // TODO } diff --git a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/FilesList.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/FilesList.kt index a08a943..576398f 100644 --- a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/FilesList.kt +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/FilesList.kt @@ -14,9 +14,7 @@ import nl.jacobras.cloudbridge.model.CloudItem @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun FilesList( - serviceWithInfo: ServiceWithInfo, files: List, - onBackClick: () -> Unit, onItemClick: (CloudItem) -> Unit, modifier: Modifier = Modifier ) { diff --git a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServiceViewModel.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServiceViewModel.kt index b7b80bd..04fea7a 100644 --- a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServiceViewModel.kt +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServiceViewModel.kt @@ -1,6 +1,38 @@ package nl.jacobras.cloudbridge.demo.ui -internal class ServiceViewModel { +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import nl.jacobras.cloudbridge.CloudService +import nl.jacobras.cloudbridge.CloudServiceException +import nl.jacobras.cloudbridge.model.CloudItem +import nl.jacobras.cloudbridge.model.FolderPath +import nl.jacobras.cloudbridge.model.asFolderPath - // TODO: files, error etc. +internal class ServiceViewModel( + private val service: CloudService +) : ViewModel() { + val path: StateFlow + field = MutableStateFlow("".asFolderPath()) + val files: StateFlow> + field = MutableStateFlow(emptyList()) + val error: StateFlow + field = MutableStateFlow("") + + init { + refresh() + } + + private fun refresh() = viewModelScope.launch { + try { + val items = service.listFiles(path.value) + files.update { items } + error.update { "" } + } catch (e: CloudServiceException) { + error.update { e.toString() } + } + } } \ No newline at end of file From b439b18eb4a1d7fdc06c8391e7641909bd20c794 Mon Sep 17 00:00:00 2001 From: Jacob Ras Date: Thu, 18 Jun 2026 19:36:24 +0200 Subject: [PATCH 03/15] Add file detail dialog to desktop demo --- .../cloudbridge/demo/ui/DetailPane.kt | 16 ++++-- .../cloudbridge/demo/ui/FileDetailDialog.kt | 53 +++++++++++++++++++ .../cloudbridge/demo/ui/ServiceViewModel.kt | 23 ++++++++ .../jacobras/cloudbridge/model/CloudItemId.kt | 4 +- 4 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/FileDetailDialog.kt diff --git a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DetailPane.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DetailPane.kt index 08adfd5..85bb450 100644 --- a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DetailPane.kt +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DetailPane.kt @@ -36,6 +36,9 @@ internal fun DetailPane( val files by viewModel.files.collectAsState() val error by viewModel.error.collectAsState() + val selectedItem by viewModel.selectedItem.collectAsState() + val content by viewModel.content.collectAsState() + Scaffold( topBar = { CenterAlignedTopAppBar( @@ -83,12 +86,19 @@ internal fun DetailPane( Spacer(Modifier.height(16.dp)) } + if (content.isNotEmpty() && selectedItem != null) { + FileDetailDialog( + service = info.service, + content = content, + item = selectedItem!!, + onDismiss = { viewModel.deselectItem() } + ) + } + FilesList( modifier = Modifier.fillMaxSize(), files = files, - onItemClick = { - // TODO - } + onItemClick = viewModel::selectItem ) } } diff --git a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/FileDetailDialog.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/FileDetailDialog.kt new file mode 100644 index 0000000..0caa9f6 --- /dev/null +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/FileDetailDialog.kt @@ -0,0 +1,53 @@ +package nl.jacobras.cloudbridge.demo.ui + +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import co.touchlab.kermit.Logger +import kotlinx.coroutines.launch +import nl.jacobras.cloudbridge.CloudService +import nl.jacobras.cloudbridge.CloudServiceException +import nl.jacobras.cloudbridge.model.CloudItem + +@Composable +internal fun FileDetailDialog( + service: CloudService, + content: String, + item: CloudItem, + onDismiss: () -> Unit +) { + // TODO: load file content in here + + val scope = rememberCoroutineScope() + if (content.isNotEmpty()) { + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + Button(onClick = { + scope.launch { + try { + service.delete(item.id) + onDismiss() + } catch (e: CloudServiceException) { + Logger.e(e) { "Failed to delete ${item.name}" } + } + } + }) { Text("Delete") } + }, + dismissButton = { Button(onClick = onDismiss) { Text("Close") } }, + title = { + SelectionContainer { + Text("${item.name} (${item.id})") + } + }, + text = { + SelectionContainer { + Text(content) + } + } + ) + } +} \ No newline at end of file diff --git a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServiceViewModel.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServiceViewModel.kt index 04fea7a..5ba7f86 100644 --- a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServiceViewModel.kt +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServiceViewModel.kt @@ -22,6 +22,11 @@ internal class ServiceViewModel( val error: StateFlow field = MutableStateFlow("") + val selectedItem: StateFlow + field = MutableStateFlow(null) + val content: StateFlow + field = MutableStateFlow("") + init { refresh() } @@ -35,4 +40,22 @@ internal class ServiceViewModel( error.update { e.toString() } } } + + fun selectItem(item: CloudItem) { + selectedItem.update { item } + viewModelScope.launch { + try { + val items = service.downloadFile(item.id) + content.update { items } + error.update { "" } + } catch (e: CloudServiceException) { + error.update { e.toString() } + } + } + } + + fun deselectItem() { + selectedItem.update { null } + content.update { "" } + } } \ No newline at end of file diff --git a/library/src/commonMain/kotlin/nl/jacobras/cloudbridge/model/CloudItemId.kt b/library/src/commonMain/kotlin/nl/jacobras/cloudbridge/model/CloudItemId.kt index 3e09aea..44dedcd 100644 --- a/library/src/commonMain/kotlin/nl/jacobras/cloudbridge/model/CloudItemId.kt +++ b/library/src/commonMain/kotlin/nl/jacobras/cloudbridge/model/CloudItemId.kt @@ -7,4 +7,6 @@ import kotlin.jvm.JvmInline * a path where an ID is expected, and vice versa. */ @JvmInline -public value class CloudItemId(public val value: String) \ No newline at end of file +public value class CloudItemId(public val value: String) { + override fun toString(): String = value +} \ No newline at end of file From 585dd2a14ce18896a34a5a71b3bf4e132f7d2231 Mon Sep 17 00:00:00 2001 From: Jacob Ras Date: Thu, 18 Jun 2026 22:09:50 +0200 Subject: [PATCH 04/15] Remove unnecessary opt-in --- .../kotlin/nl/jacobras/cloudbridge/demo/ui/FilesList.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/FilesList.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/FilesList.kt index 576398f..c9339d3 100644 --- a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/FilesList.kt +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/FilesList.kt @@ -4,14 +4,12 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import nl.jacobras.cloudbridge.model.CloudItem -@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun FilesList( files: List, From bc60b6ce44b1e995044e6407967baff7969175e3 Mon Sep 17 00:00:00 2001 From: Jacob Ras Date: Thu, 18 Jun 2026 22:24:32 +0200 Subject: [PATCH 05/15] Fix duplicate VM mistake --- .../kotlin/nl/jacobras/cloudbridge/demo/desktop/main.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demo/desktop/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/desktop/main.kt b/demo/desktop/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/desktop/main.kt index de205e6..40464ec 100644 --- a/demo/desktop/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/desktop/main.kt +++ b/demo/desktop/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/desktop/main.kt @@ -43,7 +43,7 @@ fun main() = application { val uriHandler = LocalUriHandler.current DemoScreen( - viewModel = DemoViewModel(), + viewModel = viewModel, onAuthenticate = { service -> when (service) { is DropboxService -> { From 6840ab111333867d4cd2c2199063b642655cfb3f Mon Sep 17 00:00:00 2001 From: Jacob Ras Date: Thu, 18 Jun 2026 22:30:47 +0200 Subject: [PATCH 06/15] Remove no longer needed code --- .../jacobras/cloudbridge/demo/desktop/main.kt | 18 ++++-- .../cloudbridge/demo/ui/DemoViewModel.kt | 55 +------------------ 2 files changed, 14 insertions(+), 59 deletions(-) diff --git a/demo/desktop/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/desktop/main.kt b/demo/desktop/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/desktop/main.kt index 40464ec..3cfb056 100644 --- a/demo/desktop/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/desktop/main.kt +++ b/demo/desktop/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/desktop/main.kt @@ -53,7 +53,7 @@ fun main() = application { onSuccess = { token -> DemoSettings.dropboxToken = token showSuccessDialog = true - viewModel.refresh(service) + viewModel.updateTokens() } ) uriHandler.openUri(url) @@ -66,7 +66,7 @@ fun main() = application { onSuccess = { token -> DemoSettings.googleDriveToken = token showSuccessDialog = true - viewModel.refresh(service) + viewModel.updateTokens() } ) uriHandler.openUri(url) @@ -78,7 +78,7 @@ fun main() = application { onSuccess = { token -> DemoSettings.oneDriveToken = token showSuccessDialog = true - viewModel.refresh(service) + viewModel.updateTokens() } ) uriHandler.openUri(url) @@ -89,10 +89,16 @@ fun main() = application { when (service) { is DropboxService -> { DemoSettings.dropboxToken = null - viewModel.refresh(service) + viewModel.updateTokens() + } + is GoogleDriveService -> { + DemoSettings.googleDriveToken = null + viewModel.updateTokens() + } + is OneDriveService -> { + DemoSettings.oneDriveToken = null + viewModel.updateTokens() } - is GoogleDriveService -> DemoSettings.googleDriveToken = null - is OneDriveService -> DemoSettings.oneDriveToken = null } } ) diff --git a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoViewModel.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoViewModel.kt index 89c2351..9257752 100644 --- a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoViewModel.kt +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoViewModel.kt @@ -12,17 +12,15 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import nl.jacobras.cloudbridge.CloudBridge -import nl.jacobras.cloudbridge.CloudService import nl.jacobras.cloudbridge.CloudServiceException import nl.jacobras.cloudbridge.demo.persistence.DemoSettings -import nl.jacobras.cloudbridge.model.CloudItem import nl.jacobras.cloudbridge.model.FolderPath -import nl.jacobras.cloudbridge.model.UserInfo class DemoViewModel : ViewModel() { val dropbox = CloudBridge.dropbox() val googleDrive = CloudBridge.googleDrive() val oneDrive = CloudBridge.oneDrive() + internal val services = listOf( ServiceWithInfo( service = dropbox, @@ -41,21 +39,14 @@ class DemoViewModel : ViewModel() { ) ) - val files = MutableStateFlow>>(emptyMap()) - val userInfo = MutableStateFlow>(emptyMap()) - val serviceErrors = MutableStateFlow>(emptyMap()) - internal val selectedService: StateFlow field = MutableStateFlow(null) val path = MutableStateFlow(FolderPath("/")) - private var loadServiceDetailsJob : Job?=null + private var loadServiceDetailsJob: Job? = null init { updateTokens() - refresh(dropbox) - refresh(googleDrive) - refresh(oneDrive) } fun updateTokens() { @@ -64,48 +55,6 @@ class DemoViewModel : ViewModel() { oneDrive.setToken(DemoSettings.oneDriveToken) } - fun refresh(service: CloudService) = viewModelScope.launch { - updateTokens() - - try { - val info = service.getUserInfo() - userInfo.update { - val map = it.toMutableMap() - map[service] = info - map - } - } catch (_: Exception) { - userInfo.update { - val map = it.toMutableMap() - map[service] = null - map - } - } - - val updated = try { - val files = service.listFiles(path.value) - serviceErrors.update { - val map = it.toMutableMap() - map[service] = "" - map - } - files - } catch (e: Exception) { - serviceErrors.update { - val map = it.toMutableMap() - map[service] = e.toString() - map - } - emptyList() - } - - files.update { - val map = it.toMutableMap() - map[service] = updated - map - } - } - internal fun select(info: ServiceWithInfo) { loadServiceDetailsJob?.cancel() loadServiceDetailsJob = null From 4fccb85fd9f2d1a4ab5ccb58e9edea3e3fb131cd Mon Sep 17 00:00:00 2001 From: Jacob Ras Date: Thu, 18 Jun 2026 22:41:49 +0200 Subject: [PATCH 07/15] Fix account info not showing up --- .../cloudbridge/demo/ui/DemoScreen.kt | 11 ++-- .../cloudbridge/demo/ui/DemoViewModel.kt | 54 +++++++------------ .../cloudbridge/demo/ui/DetailPane.kt | 16 +++--- .../cloudbridge/demo/ui/ServiceExtensions.kt | 31 +++++++++++ .../cloudbridge/demo/ui/ServiceWithInfo.kt | 12 ----- .../cloudbridge/demo/ui/ServicesList.kt | 21 ++++---- 6 files changed, 78 insertions(+), 67 deletions(-) create mode 100644 demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServiceExtensions.kt delete mode 100644 demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServiceWithInfo.kt diff --git a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoScreen.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoScreen.kt index 3e08af0..7171fcb 100644 --- a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoScreen.kt +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoScreen.kt @@ -24,6 +24,7 @@ fun DemoScreen( onDeauthenticate: (CloudService) -> Unit ) { val navigator = rememberListDetailPaneScaffoldNavigator() + val userInfos by viewModel.userInfos.collectAsState() val selectedService by viewModel.selectedService.collectAsState() val scope = rememberCoroutineScope() @@ -32,8 +33,8 @@ fun DemoScreen( value = navigator.scaffoldValue, listPane = { ServicesList( - services = viewModel.services, - selectedService = selectedService?.service, + services = viewModel.services.associateWith { service -> userInfos[service] }, + selectedService = selectedService, onClick = { viewModel.select(it) scope.launch { @@ -43,10 +44,10 @@ fun DemoScreen( ) }, detailPane = { - selectedService?.let { info -> - val service = info.service + selectedService?.let { service -> DetailPane( - info = info, + service = service, + userInfo = userInfos[service], onAuthenticateClick = { onAuthenticate(service) }, onDeauthenticateClick = { onDeauthenticate(service) }, onBackClick = { diff --git a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoViewModel.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoViewModel.kt index 9257752..9d486e8 100644 --- a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoViewModel.kt +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoViewModel.kt @@ -2,45 +2,29 @@ package nl.jacobras.cloudbridge.demo.ui import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import cloudbridge.demo.shared.generated.resources.Res -import cloudbridge.demo.shared.generated.resources.ic_dropbox -import cloudbridge.demo.shared.generated.resources.ic_google_drive -import cloudbridge.demo.shared.generated.resources.ic_one_drive import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import nl.jacobras.cloudbridge.CloudBridge +import nl.jacobras.cloudbridge.CloudService import nl.jacobras.cloudbridge.CloudServiceException import nl.jacobras.cloudbridge.demo.persistence.DemoSettings import nl.jacobras.cloudbridge.model.FolderPath +import nl.jacobras.cloudbridge.model.UserInfo class DemoViewModel : ViewModel() { val dropbox = CloudBridge.dropbox() val googleDrive = CloudBridge.googleDrive() val oneDrive = CloudBridge.oneDrive() + val services = listOf(dropbox, googleDrive, oneDrive) - internal val services = listOf( - ServiceWithInfo( - service = dropbox, - logo = Res.drawable.ic_dropbox, - name = "Dropbox" - ), - ServiceWithInfo( - service = googleDrive, - logo = Res.drawable.ic_google_drive, - name = "Google Drive" - ), - ServiceWithInfo( - service = oneDrive, - logo = Res.drawable.ic_one_drive, - name = "OneDrive" - ) - ) + internal val userInfos: StateFlow> + field = MutableStateFlow(emptyMap()) - internal val selectedService: StateFlow - field = MutableStateFlow(null) + internal val selectedService: StateFlow + field = MutableStateFlow(null) val path = MutableStateFlow(FolderPath("/")) private var loadServiceDetailsJob: Job? = null @@ -55,21 +39,23 @@ class DemoViewModel : ViewModel() { oneDrive.setToken(DemoSettings.oneDriveToken) } - internal fun select(info: ServiceWithInfo) { + internal fun select(service: CloudService) { loadServiceDetailsJob?.cancel() loadServiceDetailsJob = null - selectedService.update { info } + selectedService.update { service } - val service = info.service.takeIf { it.isAuthenticated() } ?: return - loadServiceDetailsJob = viewModelScope.launch { - try { - val userInfo = service.getUserInfo() - val userName = userInfo.name - if (userName != null) { - selectedService.update { info.copy(userName = userName) } + if (service.isAuthenticated()) { + loadServiceDetailsJob = viewModelScope.launch { + try { + val userInfo = service.getUserInfo() + userInfos.update { + val map = it.toMutableMap() + map[service] = userInfo + map + } + } catch (_: CloudServiceException) { + // NOOP } - } catch (_: CloudServiceException) { - // NOOP } } } diff --git a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DetailPane.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DetailPane.kt index 85bb450..e784a9d 100644 --- a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DetailPane.kt +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DetailPane.kt @@ -23,16 +23,19 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import nl.jacobras.cloudbridge.CloudService +import nl.jacobras.cloudbridge.model.UserInfo @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun DetailPane( - info: ServiceWithInfo, + service: CloudService, + userInfo: UserInfo?, onAuthenticateClick: () -> Unit, onDeauthenticateClick: () -> Unit, onBackClick: () -> Unit ) { - val viewModel = remember(info.service) { ServiceViewModel(info.service) } + val viewModel = remember(service) { ServiceViewModel(service) } val files by viewModel.files.collectAsState() val error by viewModel.error.collectAsState() @@ -44,10 +47,10 @@ internal fun DetailPane( CenterAlignedTopAppBar( title = { Text(text = buildString { - append(info.name) + append(service.name) - if (info.emailAddress.isNotBlank()) { - append(" (${info.emailAddress})") + if (userInfo?.emailAddress != null) { + append(" (${userInfo.emailAddress})") } }) }, @@ -60,7 +63,6 @@ internal fun DetailPane( containerColor = Color.Transparent ), actions = { - val service = info.service if (!service.isAuthenticated()) { Button(onClick = onAuthenticateClick) { Text("Sign in") @@ -88,7 +90,7 @@ internal fun DetailPane( if (content.isNotEmpty() && selectedItem != null) { FileDetailDialog( - service = info.service, + service = service, content = content, item = selectedItem!!, onDismiss = { viewModel.deselectItem() } diff --git a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServiceExtensions.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServiceExtensions.kt new file mode 100644 index 0000000..5f51f85 --- /dev/null +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServiceExtensions.kt @@ -0,0 +1,31 @@ +package nl.jacobras.cloudbridge.demo.ui + +import cloudbridge.demo.shared.generated.resources.Res +import cloudbridge.demo.shared.generated.resources.ic_dropbox +import cloudbridge.demo.shared.generated.resources.ic_google_drive +import cloudbridge.demo.shared.generated.resources.ic_one_drive +import nl.jacobras.cloudbridge.CloudService +import nl.jacobras.cloudbridge.service.dropbox.DropboxService +import nl.jacobras.cloudbridge.service.googledrive.GoogleDriveService +import nl.jacobras.cloudbridge.service.onedrive.OneDriveService +import org.jetbrains.compose.resources.DrawableResource + +internal val CloudService.name: String + get() { + return when (this) { + is DropboxService -> "Dropbox" + is GoogleDriveService -> "Google Drive" + is OneDriveService -> "OneDrive" + else -> error("Missing name for $this") + } + } + +internal val CloudService.logo: DrawableResource + get() { + return when (this) { + is DropboxService -> Res.drawable.ic_dropbox + is GoogleDriveService -> Res.drawable.ic_google_drive + is OneDriveService -> Res.drawable.ic_one_drive + else -> error("Missing logo for $this") + } + } \ No newline at end of file diff --git a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServiceWithInfo.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServiceWithInfo.kt deleted file mode 100644 index 7da9360..0000000 --- a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServiceWithInfo.kt +++ /dev/null @@ -1,12 +0,0 @@ -package nl.jacobras.cloudbridge.demo.ui - -import nl.jacobras.cloudbridge.CloudService -import org.jetbrains.compose.resources.DrawableResource - -internal data class ServiceWithInfo( - val service: CloudService, - val logo: DrawableResource, - val name: String, - val userName: String = "", - val emailAddress: String = "" -) \ No newline at end of file diff --git a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServicesList.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServicesList.kt index f94843b..6a008c8 100644 --- a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServicesList.kt +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServicesList.kt @@ -16,20 +16,22 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import nl.jacobras.cloudbridge.CloudService +import nl.jacobras.cloudbridge.model.UserInfo import org.jetbrains.compose.resources.painterResource @Composable internal fun ServicesList( - services: List, + services: Map, selectedService: CloudService?, - onClick: (ServiceWithInfo) -> Unit + onClick: (CloudService) -> Unit ) { Column { - for (info in services) { + for ((service, userInfo) in services) { Service( - service = info, - selected = selectedService == info.service, - onClick = { onClick(info) } + service = service, + userInfo = userInfo, + selected = selectedService == service, + onClick = { onClick(service) } ) } } @@ -37,7 +39,8 @@ internal fun ServicesList( @Composable private fun Service( - service: ServiceWithInfo, + service: CloudService, + userInfo: UserInfo?, selected: Boolean, onClick: () -> Unit ) { @@ -58,8 +61,8 @@ private fun Service( text = buildString { append(service.name) - if (service.userName.isNotBlank()) { - append(" (${service.userName})") + if (userInfo?.emailAddress != null) { + append(" (${userInfo.emailAddress})") } }, fontWeight = if (selected) { From 954f60b13b8d063d9d4b01d7b60b87f98788db34 Mon Sep 17 00:00:00 2001 From: Jacob Ras Date: Thu, 18 Jun 2026 22:43:46 +0200 Subject: [PATCH 08/15] Move deauthenticate logic to VM --- .../jacobras/cloudbridge/demo/desktop/main.kt | 16 --------------- .../cloudbridge/demo/ui/DemoScreen.kt | 5 ++--- .../cloudbridge/demo/ui/DemoViewModel.kt | 20 +++++++++++++++++++ 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/demo/desktop/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/desktop/main.kt b/demo/desktop/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/desktop/main.kt index 3cfb056..0c70e43 100644 --- a/demo/desktop/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/desktop/main.kt +++ b/demo/desktop/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/desktop/main.kt @@ -84,22 +84,6 @@ fun main() = application { uriHandler.openUri(url) } } - }, - onDeauthenticate = { service -> - when (service) { - is DropboxService -> { - DemoSettings.dropboxToken = null - viewModel.updateTokens() - } - is GoogleDriveService -> { - DemoSettings.googleDriveToken = null - viewModel.updateTokens() - } - is OneDriveService -> { - DemoSettings.oneDriveToken = null - viewModel.updateTokens() - } - } } ) } diff --git a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoScreen.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoScreen.kt index 7171fcb..3c95ec8 100644 --- a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoScreen.kt +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoScreen.kt @@ -20,8 +20,7 @@ import nl.jacobras.cloudbridge.CloudService @Composable fun DemoScreen( viewModel: DemoViewModel, - onAuthenticate: (CloudService) -> Unit, - onDeauthenticate: (CloudService) -> Unit + onAuthenticate: (CloudService) -> Unit ) { val navigator = rememberListDetailPaneScaffoldNavigator() val userInfos by viewModel.userInfos.collectAsState() @@ -49,7 +48,7 @@ fun DemoScreen( service = service, userInfo = userInfos[service], onAuthenticateClick = { onAuthenticate(service) }, - onDeauthenticateClick = { onDeauthenticate(service) }, + onDeauthenticateClick = { viewModel.deauthenticate(service) }, onBackClick = { viewModel.deselect() scope.launch { diff --git a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoViewModel.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoViewModel.kt index 9d486e8..c84362b 100644 --- a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoViewModel.kt +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoViewModel.kt @@ -13,6 +13,9 @@ import nl.jacobras.cloudbridge.CloudServiceException import nl.jacobras.cloudbridge.demo.persistence.DemoSettings import nl.jacobras.cloudbridge.model.FolderPath import nl.jacobras.cloudbridge.model.UserInfo +import nl.jacobras.cloudbridge.service.dropbox.DropboxService +import nl.jacobras.cloudbridge.service.googledrive.GoogleDriveService +import nl.jacobras.cloudbridge.service.onedrive.OneDriveService class DemoViewModel : ViewModel() { val dropbox = CloudBridge.dropbox() @@ -63,4 +66,21 @@ class DemoViewModel : ViewModel() { internal fun deselect() { selectedService.update { null } } + + fun deauthenticate(service: CloudService) { + when (service) { + is DropboxService -> { + DemoSettings.dropboxToken = null + updateTokens() + } + is GoogleDriveService -> { + DemoSettings.googleDriveToken = null + updateTokens() + } + is OneDriveService -> { + DemoSettings.oneDriveToken = null + updateTokens() + } + } + } } \ No newline at end of file From b73dd9618b5acb5fa427bae60da1504c5e9592bb Mon Sep 17 00:00:00 2001 From: Jacob Ras Date: Thu, 18 Jun 2026 23:09:01 +0200 Subject: [PATCH 09/15] Reuse new `DemoScreen` on web --- .../jacobras/cloudbridge/demo/desktop/main.kt | 8 - .../cloudbridge/demo/isWeb.android.kt | 3 + .../nl/jacobras/cloudbridge/demo/isWeb.kt | 3 + .../cloudbridge/demo/ui/DemoScreen.kt | 4 +- .../cloudbridge/demo/ui/DetailPane.kt | 9 + .../cloudbridge/demo/isWeb.desktop.kt | 3 + .../nl/jacobras/cloudbridge/demo/isWeb.web.kt | 3 + demo/web/src/webMain/kotlin/main.kt | 353 ++---------------- 8 files changed, 62 insertions(+), 324 deletions(-) create mode 100644 demo/shared/src/androidMain/kotlin/nl/jacobras/cloudbridge/demo/isWeb.android.kt create mode 100644 demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/isWeb.kt create mode 100644 demo/shared/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/isWeb.desktop.kt create mode 100644 demo/shared/src/webMain/kotlin/nl/jacobras/cloudbridge/demo/isWeb.web.kt diff --git a/demo/desktop/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/desktop/main.kt b/demo/desktop/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/desktop/main.kt index 0c70e43..583df2e 100644 --- a/demo/desktop/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/desktop/main.kt +++ b/demo/desktop/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/desktop/main.kt @@ -1,10 +1,7 @@ package nl.jacobras.cloudbridge.demo.desktop import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp @@ -25,8 +22,6 @@ import nl.jacobras.cloudbridge.service.onedrive.authenticate fun main() = application { val viewModel = remember { DemoViewModel() } - - var showSuccessDialog by remember { mutableStateOf(false) } val localServer = remember { LocalAuthenticationServer() } DisposableEffect(Unit) { @@ -52,7 +47,6 @@ fun main() = application { clientId = "nw5f95uw77yrz3j", onSuccess = { token -> DemoSettings.dropboxToken = token - showSuccessDialog = true viewModel.updateTokens() } ) @@ -65,7 +59,6 @@ fun main() = application { clientSecret = DesktopMainBuildConfig.DRIVE_DESKTOP_SECRET, onSuccess = { token -> DemoSettings.googleDriveToken = token - showSuccessDialog = true viewModel.updateTokens() } ) @@ -77,7 +70,6 @@ fun main() = application { clientId = "40916102-96a6-46ca-929e-90cc62c3be9a", onSuccess = { token -> DemoSettings.oneDriveToken = token - showSuccessDialog = true viewModel.updateTokens() } ) diff --git a/demo/shared/src/androidMain/kotlin/nl/jacobras/cloudbridge/demo/isWeb.android.kt b/demo/shared/src/androidMain/kotlin/nl/jacobras/cloudbridge/demo/isWeb.android.kt new file mode 100644 index 0000000..b5c0e85 --- /dev/null +++ b/demo/shared/src/androidMain/kotlin/nl/jacobras/cloudbridge/demo/isWeb.android.kt @@ -0,0 +1,3 @@ +package nl.jacobras.cloudbridge.demo + +internal actual val isWeb: Boolean = false \ No newline at end of file diff --git a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/isWeb.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/isWeb.kt new file mode 100644 index 0000000..28960cc --- /dev/null +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/isWeb.kt @@ -0,0 +1,3 @@ +package nl.jacobras.cloudbridge.demo + +internal expect val isWeb: Boolean \ No newline at end of file diff --git a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoScreen.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoScreen.kt index 3c95ec8..5327c90 100644 --- a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoScreen.kt +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoScreen.kt @@ -20,7 +20,8 @@ import nl.jacobras.cloudbridge.CloudService @Composable fun DemoScreen( viewModel: DemoViewModel, - onAuthenticate: (CloudService) -> Unit + onAuthenticate: (CloudService) -> Unit, + onFinishAuthOnWeb: (CloudService) -> Unit = {} ) { val navigator = rememberListDetailPaneScaffoldNavigator() val userInfos by viewModel.userInfos.collectAsState() @@ -49,6 +50,7 @@ fun DemoScreen( userInfo = userInfos[service], onAuthenticateClick = { onAuthenticate(service) }, onDeauthenticateClick = { viewModel.deauthenticate(service) }, + onFinishAuthOnWeb = { onFinishAuthOnWeb(service) }, onBackClick = { viewModel.deselect() scope.launch { diff --git a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DetailPane.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DetailPane.kt index e784a9d..c56566d 100644 --- a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DetailPane.kt +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DetailPane.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import nl.jacobras.cloudbridge.CloudService +import nl.jacobras.cloudbridge.demo.isWeb import nl.jacobras.cloudbridge.model.UserInfo @OptIn(ExperimentalMaterial3Api::class) @@ -33,6 +34,7 @@ internal fun DetailPane( userInfo: UserInfo?, onAuthenticateClick: () -> Unit, onDeauthenticateClick: () -> Unit, + onFinishAuthOnWeb: () -> Unit, onBackClick: () -> Unit ) { val viewModel = remember(service) { ServiceViewModel(service) } @@ -88,6 +90,13 @@ internal fun DetailPane( Spacer(Modifier.height(16.dp)) } + if (isWeb && !service.isAuthenticated()) { + Button(onClick = onFinishAuthOnWeb) { + Text("Finish auth") + } + Spacer(Modifier.height(16.dp)) + } + if (content.isNotEmpty() && selectedItem != null) { FileDetailDialog( service = service, diff --git a/demo/shared/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/isWeb.desktop.kt b/demo/shared/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/isWeb.desktop.kt new file mode 100644 index 0000000..b5c0e85 --- /dev/null +++ b/demo/shared/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/isWeb.desktop.kt @@ -0,0 +1,3 @@ +package nl.jacobras.cloudbridge.demo + +internal actual val isWeb: Boolean = false \ No newline at end of file diff --git a/demo/shared/src/webMain/kotlin/nl/jacobras/cloudbridge/demo/isWeb.web.kt b/demo/shared/src/webMain/kotlin/nl/jacobras/cloudbridge/demo/isWeb.web.kt new file mode 100644 index 0000000..43c3272 --- /dev/null +++ b/demo/shared/src/webMain/kotlin/nl/jacobras/cloudbridge/demo/isWeb.web.kt @@ -0,0 +1,3 @@ +package nl.jacobras.cloudbridge.demo + +internal actual val isWeb: Boolean = true \ No newline at end of file diff --git a/demo/web/src/webMain/kotlin/main.kt b/demo/web/src/webMain/kotlin/main.kt index 1121236..9d92fb4 100644 --- a/demo/web/src/webMain/kotlin/main.kt +++ b/demo/web/src/webMain/kotlin/main.kt @@ -1,54 +1,23 @@ @file:OptIn(ExperimentalWasmJsInterop::class) -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import androidx.compose.ui.window.ComposeViewport import co.touchlab.kermit.Logger import kotlinx.browser.window import kotlinx.coroutines.launch import nl.jacobras.cloudbridge.CloudBridge -import nl.jacobras.cloudbridge.CloudService -import nl.jacobras.cloudbridge.CloudServiceException -import nl.jacobras.cloudbridge.auth.CloudAccessToken import nl.jacobras.cloudbridge.demo.persistence.DemoSettings -import nl.jacobras.cloudbridge.demo.ui.FileRow +import nl.jacobras.cloudbridge.demo.ui.DemoScreen import nl.jacobras.cloudbridge.demo.ui.DemoViewModel -import nl.jacobras.cloudbridge.model.CloudFolder -import nl.jacobras.cloudbridge.model.CloudItem -import nl.jacobras.cloudbridge.model.CloudItemId -import nl.jacobras.cloudbridge.model.FolderPath -import nl.jacobras.cloudbridge.model.UserInfo -import nl.jacobras.cloudbridge.model.asFilePath -import nl.jacobras.cloudbridge.model.asFolderPath +import nl.jacobras.cloudbridge.service.dropbox.DropboxService import nl.jacobras.cloudbridge.service.dropbox.authenticate import nl.jacobras.cloudbridge.service.dropbox.completeAuthentication +import nl.jacobras.cloudbridge.service.googledrive.GoogleDriveService import nl.jacobras.cloudbridge.service.googledrive.authenticate import nl.jacobras.cloudbridge.service.googledrive.completeAuthentication +import nl.jacobras.cloudbridge.service.onedrive.OneDriveService import nl.jacobras.cloudbridge.service.onedrive.authenticate import nl.jacobras.cloudbridge.service.onedrive.completeAuthentication import kotlin.js.ExperimentalWasmJsInterop @@ -63,308 +32,62 @@ private class KermitLogger : nl.jacobras.cloudbridge.logging.Logger { fun main() { CloudBridge.logger = KermitLogger() - ComposeViewport { val viewModel = remember { DemoViewModel() } - val dropboxService = viewModel.dropbox - val googleDriveService = viewModel.googleDrive - val oneDriveService = viewModel.oneDrive - val allServices = listOf(dropboxService, googleDriveService, oneDriveService) - - val files by viewModel.files.collectAsState() - val serviceErrors by viewModel.serviceErrors.collectAsState() - val userInfo by viewModel.userInfo.collectAsState() - val scope = rememberCoroutineScope() - var pathFilter by remember { mutableStateOf("") } - - Column(Modifier.fillMaxSize().padding(16.dp)) { - Row { - Column(Modifier.weight(1f)) { - var path by remember { mutableStateOf("") } - var content by remember { mutableStateOf("") } - TextField( - value = path, - onValueChange = { path = it }, - label = { Text("Path") } - ) - TextField( - value = content, - onValueChange = { content = it }, - label = { Text("Content") } - ) - Button( - onClick = { - for (service in allServices.filter { it.isAuthenticated() }) { - try { - scope.launch { - service.createFile( - path = path.asFilePath(), - content = content - ) - } - } catch (e: CloudServiceException) { - Logger.e(e) { "Failed to create file" } - } - } - } - ) { Text("Create file") } - } - - Column(Modifier.weight(1f)) { - var id by remember { mutableStateOf("") } - var content by remember { mutableStateOf("") } - TextField( - value = id, - onValueChange = { id = it }, - label = { Text("ID") } - ) - TextField( - value = content, - onValueChange = { content = it }, - label = { Text("Content") } - ) - Button( - onClick = { - for (service in allServices.filter { it.isAuthenticated() }) { - try { - scope.launch { - service.updateFile( - id = CloudItemId(id), - content = content - ) - } - } catch (e: CloudServiceException) { - Logger.e(e) { "Failed to update file" } - } - } - } - ) { Text("Update file") } - } - - Column(Modifier.weight(1f)) { - var name by remember { mutableStateOf("") } - TextField( - value = name, - onValueChange = { name = it }, - label = { Text("Folder name") } - ) - - Button( - onClick = { - for (service in allServices.filter { it.isAuthenticated() }) { - scope.launch { - service.createFolder(path = name.asFolderPath()) - } - } - } - ) { Text("Create folder") } - } - - Column(Modifier.weight(1f)) { - TextField( - value = pathFilter, - onValueChange = { pathFilter = it }, - label = { Text("Path") }, - placeholder = { Text("/") } - ) - } - } - - Spacer(Modifier.height(32.dp)) - - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - CloudServiceColumn( - name = "Dropbox", - service = dropboxService, - files = files[dropboxService] ?: emptyList(), - userInfo = userInfo[dropboxService], - error = serviceErrors[dropboxService] ?: "", - startAuth = { - val uri = dropboxService.authenticate( + DemoScreen( + viewModel = viewModel, + onAuthenticate = { service -> + when (service) { + is DropboxService -> { + val uri = service.authenticate( clientId = "nw5f95uw77yrz3j", redirectUri = "http://localhost:8080" ) window.location.href = uri - }, - finishAuth = { - val token = dropboxService.completeAuthentication( - clientId = "nw5f95uw77yrz3j", - redirectUri = "http://localhost:8080" - ) - DemoSettings.dropboxToken = token - token - }, - pathFilter = pathFilter.asFolderPath(), - onNavigateToFolder = { pathFilter = it.toString() }, - modifier = Modifier.weight(1f) - ) - CloudServiceColumn( - name = "Google Drive", - service = googleDriveService, - files = files[googleDriveService] ?: emptyList(), - userInfo = userInfo[googleDriveService], - error = serviceErrors[googleDriveService] ?: "", - startAuth = { - val uri = googleDriveService.authenticate( + } + is GoogleDriveService -> { + val uri = service.authenticate( clientId = "218224394553-hd5j48a5uk9mjec0oq38ctijmpfq0krm.apps.googleusercontent.com", redirectUri = "http://localhost:8080" ) window.location.href = uri - }, - finishAuth = { - val token = googleDriveService.completeAuthentication() - DemoSettings.googleDriveToken = token - token - }, - pathFilter = pathFilter.asFolderPath(), - onNavigateToFolder = { pathFilter = it.toString() }, - modifier = Modifier.weight(1f) - ) - CloudServiceColumn( - name = "OneDrive", - service = oneDriveService, - files = files[oneDriveService] ?: emptyList(), - userInfo = userInfo[oneDriveService], - error = serviceErrors[oneDriveService] ?: "", - startAuth = { - val uri = oneDriveService.authenticate( + } + is OneDriveService -> { + val uri = service.authenticate( clientId = "40916102-96a6-46ca-929e-90cc62c3be9a", redirectUri = "http://localhost:8080" ) window.location.href = uri - }, - finishAuth = { - val token = oneDriveService.completeAuthentication( + } + } + }, + onFinishAuthOnWeb = { service -> + when (service) { + is DropboxService -> scope.launch { + val token = service.completeAuthentication( + clientId = "nw5f95uw77yrz3j", + redirectUri = "http://localhost:8080" + ) + DemoSettings.dropboxToken = token + viewModel.updateTokens() + } + is GoogleDriveService -> { + val token = service.completeAuthentication() + DemoSettings.googleDriveToken = token + viewModel.updateTokens() + } + is OneDriveService -> scope.launch { + val token = service.completeAuthentication( clientId = "40916102-96a6-46ca-929e-90cc62c3be9a", redirectUri = "http://localhost:8080" ) DemoSettings.oneDriveToken = token - token - }, - pathFilter = pathFilter.asFolderPath(), - onNavigateToFolder = { pathFilter = it.toString() }, - modifier = Modifier.weight(1f) - ) - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun CloudServiceColumn( - name: String, - service: CloudService, - startAuth: () -> Unit, - finishAuth: suspend () -> CloudAccessToken?, - files: List, - userInfo: UserInfo?, - error: String, - pathFilter: FolderPath, - onNavigateToFolder: (FolderPath) -> Unit, - modifier: Modifier = Modifier -) { - val scope = rememberCoroutineScope() - - Column(modifier.verticalScroll(rememberScrollState())) { - Text( - text = name, - style = MaterialTheme.typography.headlineLarge - ) - - if (service.isAuthenticated()) { - if (userInfo != null) { - Text("Authenticated as: $userInfo") - } - - Button(onClick = { - DemoSettings.clear() - window.location.reload() - }) { Text("Log out") } - - if (error.isNotEmpty()) { - Text( - text = error, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error - ) - } - - Text( - text = "Files", - modifier = Modifier.padding(vertical = 8.dp), - style = MaterialTheme.typography.headlineMedium - ) - for (item in files) { - var content by remember { mutableStateOf("") } - if (content.isNotEmpty()) { - AlertDialog( - onDismissRequest = { content = "" }, - confirmButton = { - Button(onClick = { - scope.launch { - try { - service.delete(item.id) - content = "" - } catch (e: CloudServiceException) { - Logger.e(e) { "Failed to delete ${item.name}" } - } - } - }) { Text("Delete") } - }, - dismissButton = { Button(onClick = { content = "" }) { Text("Close") } }, - title = { - SelectionContainer { - Text("${item.name} (${item.id})") - } - }, - text = { - SelectionContainer { - Text(content) - } - } - ) - } - - SelectionContainer { - FileRow( - item = item, - onClick = { - if (item is CloudFolder) { - onNavigateToFolder(item.path) - } else { - scope.launch { - content = try { - service.downloadFile(item.id) - } catch (e: CloudServiceException) { - e.message.toString() - } - } - } - } - ) - } - if (item != files.last()) { - HorizontalDivider(Modifier.padding(vertical = 4.dp)) - } - } - } else { - Button(onClick = { startAuth() }) { - Text("Authenticate with $name") - } - Button(onClick = { - scope.launch { - val token = finishAuth() - if (token != null) { - // Reload page - window.location.href = window.location.origin + window.location.pathname + viewModel.updateTokens() } } - }) { - Text("Finish auth") } - } + ) } } \ No newline at end of file From bcc450b0d9962497fc215b064230cdef76c5858f Mon Sep 17 00:00:00 2001 From: Jacob Ras Date: Thu, 18 Jun 2026 23:12:45 +0200 Subject: [PATCH 10/15] Reuse new `DemoScreen` on Android --- .../jacobras/cloudbridge/demo/MainActivity.kt | 138 +++--------------- demo/shared/build.gradle.kts | 1 + 2 files changed, 21 insertions(+), 118 deletions(-) diff --git a/demo/android/src/main/kotlin/nl/jacobras/cloudbridge/demo/MainActivity.kt b/demo/android/src/main/kotlin/nl/jacobras/cloudbridge/demo/MainActivity.kt index 75f893d..d65e895 100644 --- a/demo/android/src/main/kotlin/nl/jacobras/cloudbridge/demo/MainActivity.kt +++ b/demo/android/src/main/kotlin/nl/jacobras/cloudbridge/demo/MainActivity.kt @@ -10,39 +10,24 @@ import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.browser.customtabs.CustomTabsIntent -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp +import androidx.compose.runtime.remember import androidx.core.net.toUri import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.launch -import nl.jacobras.cloudbridge.CloudService import nl.jacobras.cloudbridge.demo.persistence.DemoSettings -import nl.jacobras.cloudbridge.demo.ui.FileRow +import nl.jacobras.cloudbridge.demo.ui.DemoScreen import nl.jacobras.cloudbridge.demo.ui.DemoViewModel -import nl.jacobras.cloudbridge.model.CloudItem -import nl.jacobras.cloudbridge.model.UserInfo +import nl.jacobras.cloudbridge.service.dropbox.DropboxService import nl.jacobras.cloudbridge.service.dropbox.authenticate import nl.jacobras.cloudbridge.service.dropbox.completeAuthentication import nl.jacobras.cloudbridge.service.googledrive.GoogleDriveAuthenticator +import nl.jacobras.cloudbridge.service.googledrive.GoogleDriveService +import nl.jacobras.cloudbridge.service.onedrive.OneDriveService import nl.jacobras.cloudbridge.service.onedrive.authenticate import nl.jacobras.cloudbridge.service.onedrive.completeAuthentication @@ -57,7 +42,7 @@ class MainActivity : ComponentActivity() { activity = this, onSuccess = { token -> DemoSettings.googleDriveToken = token - viewModel.refresh(viewModel.googleDrive) + viewModel.updateTokens() }, onDenied = {}, onFailure = { error -> @@ -114,7 +99,7 @@ class MainActivity : ComponentActivity() { intentUri = uri ) ?: return@launch DemoSettings.dropboxToken = token - viewModel.refresh(viewModel.dropbox) + viewModel.updateTokens() } Provider.OneDrive -> { @@ -125,7 +110,7 @@ class MainActivity : ComponentActivity() { intentUri = uri ) ?: return@launch DemoSettings.oneDriveToken = token - viewModel.refresh(oneDriveService) + viewModel.updateTokens() } } } @@ -138,119 +123,36 @@ private fun DemoApp( launchAuth: (Provider, String) -> Unit, onAuthenticateGoogleDrive: () -> Unit ) { - val files by viewModel.files.collectAsState() - val userInfo by viewModel.userInfo.collectAsState() - val serviceErrors by viewModel.serviceErrors.collectAsState() + val viewModel = remember { DemoViewModel() } - Scaffold( - modifier = Modifier.fillMaxSize(), - topBar = { TopAppBar(title = { Text("CloudBridge Demo") }) } - ) { innerPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - .verticalScroll(rememberScrollState()) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(24.dp) - ) { - val dropboxService = viewModel.dropbox - ServiceSection( - name = "Dropbox", - service = dropboxService, - userInfo = userInfo[dropboxService], - files = files[dropboxService] ?: emptyList(), - error = serviceErrors[dropboxService] ?: "", - onAuthenticate = { + DemoScreen( + viewModel = viewModel, + onAuthenticate = { service -> + when (service) { + is DropboxService -> { launchAuth( Provider.Dropbox, - viewModel.dropbox.authenticate( + service.authenticate( DROPBOX_CLIENT_ID, REDIRECT_URI ) ) - }, - onLogOut = { - DemoSettings.dropboxToken = null - viewModel.refresh(dropboxService) } - ) - val googleDriveService = viewModel.googleDrive - ServiceSection( - name = "Google Drive", - service = googleDriveService, - userInfo = userInfo[googleDriveService], - files = files[googleDriveService] ?: emptyList(), - error = serviceErrors[googleDriveService] ?: "", - onAuthenticate = onAuthenticateGoogleDrive, - onLogOut = { - DemoSettings.googleDriveToken = null - viewModel.refresh(googleDriveService) + is GoogleDriveService -> { + onAuthenticateGoogleDrive() } - ) - val oneDriveService = viewModel.oneDrive - ServiceSection( - name = "OneDrive", - service = oneDriveService, - userInfo = userInfo[oneDriveService], - files = files[oneDriveService] ?: emptyList(), - error = serviceErrors[oneDriveService] ?: "", - onAuthenticate = { + is OneDriveService -> { launchAuth( Provider.OneDrive, - oneDriveService.authenticate( + service.authenticate( ONEDRIVE_CLIENT_ID, REDIRECT_URI ) ) - }, - onLogOut = { - DemoSettings.oneDriveToken = null - viewModel.refresh(oneDriveService) } - ) - } - } -} - -@Composable -private fun ServiceSection( - name: String, - service: CloudService, - userInfo: UserInfo?, - files: List, - error: String, - onAuthenticate: () -> Unit, - onLogOut: () -> Unit -) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(name, style = MaterialTheme.typography.headlineSmall) - - if (!service.isAuthenticated()) { - Button(onClick = onAuthenticate) { Text("Authenticate with $name") } - return@Column - } - - if (userInfo != null) { - SelectionContainer { Text("Authenticated as: $userInfo") } - } - Button(onClick = onLogOut) { Text("Log out") } - - if (error.isNotEmpty()) { - Text( - text = error, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error - ) - } - - for (item in files) { - FileRow(item = item, onClick = {}) - if (item != files.last()) { - HorizontalDivider(Modifier.padding(vertical = 4.dp)) } } - } + ) } private enum class Provider { Dropbox, OneDrive } diff --git a/demo/shared/build.gradle.kts b/demo/shared/build.gradle.kts index a518306..d3b951d 100644 --- a/demo/shared/build.gradle.kts +++ b/demo/shared/build.gradle.kts @@ -15,6 +15,7 @@ kotlin { namespace = "nl.jacobras.cloudbridge.demo.shared" compileSdk = libs.versions.android.compileSdk.get().toInt() minSdk = libs.versions.android.minSdk.get().toInt() + androidResources { enable = true } } jvm("desktop") { compilerOptions { From 48520bb4758cbf1822fdb4148e09b54fe17f1e89 Mon Sep 17 00:00:00 2001 From: Jacob Ras Date: Fri, 19 Jun 2026 15:37:45 +0200 Subject: [PATCH 11/15] Add create/edit file/folder to demo --- .../jacobras/cloudbridge/demo/desktop/main.kt | 2 + .../cloudbridge/demo/ui/DetailPane.kt | 83 +++++++++++++++++-- .../cloudbridge/demo/ui/EditDialog.kt | 80 ++++++++++++++++++ .../cloudbridge/demo/ui/FileDetailDialog.kt | 53 ------------ .../cloudbridge/demo/ui/ServiceViewModel.kt | 44 ++++++++++ 5 files changed, 200 insertions(+), 62 deletions(-) create mode 100644 demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/EditDialog.kt delete mode 100644 demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/FileDetailDialog.kt diff --git a/demo/desktop/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/desktop/main.kt b/demo/desktop/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/desktop/main.kt index 583df2e..9a7a38c 100644 --- a/demo/desktop/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/desktop/main.kt +++ b/demo/desktop/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/desktop/main.kt @@ -37,6 +37,8 @@ fun main() = application { ) { val uriHandler = LocalUriHandler.current + // TODO: create folder + create file + path filter + DemoScreen( viewModel = viewModel, onAuthenticate = { service -> diff --git a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DetailPane.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DetailPane.kt index c56566d..0ce92d9 100644 --- a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DetailPane.kt +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DetailPane.kt @@ -5,8 +5,12 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.BottomAppBar import androidx.compose.material3.Button import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api @@ -19,12 +23,15 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import nl.jacobras.cloudbridge.CloudService import nl.jacobras.cloudbridge.demo.isWeb +import nl.jacobras.cloudbridge.model.CloudFile import nl.jacobras.cloudbridge.model.UserInfo @OptIn(ExperimentalMaterial3Api::class) @@ -44,6 +51,8 @@ internal fun DetailPane( val selectedItem by viewModel.selectedItem.collectAsState() val content by viewModel.content.collectAsState() + var pendingCreate by remember { mutableStateOf(null) } + Scaffold( topBar = { CenterAlignedTopAppBar( @@ -78,6 +87,23 @@ internal fun DetailPane( modifier = Modifier.padding(end = 8.dp) ) }, + bottomBar = { + if (service.isAuthenticated()) { + BottomAppBar { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button(onClick = { pendingCreate = CreateType.FOLDER }) { + Text("Create folder") + } + Button(onClick = { pendingCreate = CreateType.FILE }) { + Text("Create file") + } + } + } + } + }, containerColor = Color.Transparent ) { Column(modifier = Modifier.padding(it)) { @@ -97,15 +123,6 @@ internal fun DetailPane( Spacer(Modifier.height(16.dp)) } - if (content.isNotEmpty() && selectedItem != null) { - FileDetailDialog( - service = service, - content = content, - item = selectedItem!!, - onDismiss = { viewModel.deselectItem() } - ) - } - FilesList( modifier = Modifier.fillMaxSize(), files = files, @@ -113,4 +130,52 @@ internal fun DetailPane( ) } } + + when (pendingCreate) { + CreateType.FOLDER -> EditDialog( + title = "New folder", + confirmLabel = "Create", + nameEditable = true, + showContent = false, + onConfirm = { name, _ -> + viewModel.createFolder(name) + pendingCreate = null + }, + onDismiss = { pendingCreate = null } + ) + + CreateType.FILE -> EditDialog( + title = "New file", + confirmLabel = "Create", + nameEditable = true, + showContent = true, + onConfirm = { name, content -> + viewModel.createFile(name, content) + pendingCreate = null + }, + onDismiss = { pendingCreate = null } + ) + + null -> Unit + } + + val editingFile = selectedItem + if (editingFile is CloudFile) { + EditDialog( + title = "Edit file", + confirmLabel = "Save", + nameEditable = false, + showContent = true, + initialName = editingFile.name, + initialContent = content, + onConfirm = { _, newContent -> viewModel.updateFile(editingFile.id, newContent) }, + onDismiss = { viewModel.deselectItem() }, + onDelete = { viewModel.delete(editingFile) } + ) + } +} + +private enum class CreateType { + FOLDER, + FILE } \ No newline at end of file diff --git a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/EditDialog.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/EditDialog.kt new file mode 100644 index 0000000..1c7accc --- /dev/null +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/EditDialog.kt @@ -0,0 +1,80 @@ +package nl.jacobras.cloudbridge.demo.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * Dialog used both for creating a folder/file and for editing an existing file. + * + * @param showContent Whether to show the (editable) content field. Off for folders. + * @param nameEditable Whether the name can be changed. Off when editing, since the API + * only allows updating a file's content. + * @param onDelete When non-null, a Delete button is shown (used when editing). + */ +@Composable +internal fun EditDialog( + title: String, + confirmLabel: String, + nameEditable: Boolean, + showContent: Boolean, + initialName: String = "", + initialContent: String = "", + onConfirm: (name: String, content: String) -> Unit, + onDismiss: () -> Unit, + onDelete: (() -> Unit)? = null +) { + var name by remember(initialName) { mutableStateOf(initialName) } + var content by remember(initialContent) { mutableStateOf(initialContent) } + + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (onDelete != null) { + TextButton(onClick = onDelete) { Text("Delete") } + } + Button( + enabled = name.isNotBlank(), + onClick = { onConfirm(name, content) } + ) { Text(confirmLabel) } + } + }, + dismissButton = { Button(onClick = onDismiss) { Text("Cancel") } }, + title = { Text(title) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + readOnly = !nameEditable, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + if (showContent) { + OutlinedTextField( + value = content, + onValueChange = { content = it }, + label = { Text("Content") }, + minLines = 4, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + ) +} \ No newline at end of file diff --git a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/FileDetailDialog.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/FileDetailDialog.kt deleted file mode 100644 index 0caa9f6..0000000 --- a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/FileDetailDialog.kt +++ /dev/null @@ -1,53 +0,0 @@ -package nl.jacobras.cloudbridge.demo.ui - -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import co.touchlab.kermit.Logger -import kotlinx.coroutines.launch -import nl.jacobras.cloudbridge.CloudService -import nl.jacobras.cloudbridge.CloudServiceException -import nl.jacobras.cloudbridge.model.CloudItem - -@Composable -internal fun FileDetailDialog( - service: CloudService, - content: String, - item: CloudItem, - onDismiss: () -> Unit -) { - // TODO: load file content in here - - val scope = rememberCoroutineScope() - if (content.isNotEmpty()) { - AlertDialog( - onDismissRequest = onDismiss, - confirmButton = { - Button(onClick = { - scope.launch { - try { - service.delete(item.id) - onDismiss() - } catch (e: CloudServiceException) { - Logger.e(e) { "Failed to delete ${item.name}" } - } - } - }) { Text("Delete") } - }, - dismissButton = { Button(onClick = onDismiss) { Text("Close") } }, - title = { - SelectionContainer { - Text("${item.name} (${item.id})") - } - }, - text = { - SelectionContainer { - Text(content) - } - } - ) - } -} \ No newline at end of file diff --git a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServiceViewModel.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServiceViewModel.kt index 5ba7f86..2bc394c 100644 --- a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServiceViewModel.kt +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServiceViewModel.kt @@ -9,7 +9,9 @@ import kotlinx.coroutines.launch import nl.jacobras.cloudbridge.CloudService import nl.jacobras.cloudbridge.CloudServiceException import nl.jacobras.cloudbridge.model.CloudItem +import nl.jacobras.cloudbridge.model.CloudItemId import nl.jacobras.cloudbridge.model.FolderPath +import nl.jacobras.cloudbridge.model.asFilePath import nl.jacobras.cloudbridge.model.asFolderPath internal class ServiceViewModel( @@ -58,4 +60,46 @@ internal class ServiceViewModel( selectedItem.update { null } content.update { "" } } + + fun createFolder(name: String) = viewModelScope.launch { + try { + service.createFolder(childPath(name).asFolderPath()) + refresh() + } catch (e: CloudServiceException) { + error.update { e.toString() } + } + } + + fun createFile(name: String, content: String) = viewModelScope.launch { + try { + service.createFile(childPath(name).asFilePath(), content) + refresh() + } catch (e: CloudServiceException) { + error.update { e.toString() } + } + } + + fun updateFile(id: CloudItemId, content: String) = viewModelScope.launch { + try { + service.updateFile(id, content) + deselectItem() + refresh() + } catch (e: CloudServiceException) { + error.update { e.toString() } + } + } + + fun delete(item: CloudItem) = viewModelScope.launch { + try { + service.delete(item.id) + deselectItem() + refresh() + } catch (e: CloudServiceException) { + error.update { e.toString() } + } + } + + private fun childPath(name: String): String { + return if (path.value.isRoot) "/$name" else "${path.value}/$name" + } } \ No newline at end of file From 1227578099c595adec65bcc54a436e2a8fbca78f Mon Sep 17 00:00:00 2001 From: Jacob Ras Date: Thu, 2 Jul 2026 20:23:50 +0200 Subject: [PATCH 12/15] Fix E2E on Android --- .../kotlin/nl/jacobras/cloudbridge/demo/MainActivity.kt | 8 ++++---- .../kotlin/nl/jacobras/cloudbridge/demo/ui/DemoScreen.kt | 4 +++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/demo/android/src/main/kotlin/nl/jacobras/cloudbridge/demo/MainActivity.kt b/demo/android/src/main/kotlin/nl/jacobras/cloudbridge/demo/MainActivity.kt index d65e895..c5fee0e 100644 --- a/demo/android/src/main/kotlin/nl/jacobras/cloudbridge/demo/MainActivity.kt +++ b/demo/android/src/main/kotlin/nl/jacobras/cloudbridge/demo/MainActivity.kt @@ -10,10 +10,11 @@ import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier import androidx.core.net.toUri import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -123,8 +124,6 @@ private fun DemoApp( launchAuth: (Provider, String) -> Unit, onAuthenticateGoogleDrive: () -> Unit ) { - val viewModel = remember { DemoViewModel() } - DemoScreen( viewModel = viewModel, onAuthenticate = { service -> @@ -151,7 +150,8 @@ private fun DemoApp( ) } } - } + }, + modifier = Modifier.safeDrawingPadding() ) } diff --git a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoScreen.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoScreen.kt index 5327c90..7f2be01 100644 --- a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoScreen.kt +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoScreen.kt @@ -21,7 +21,8 @@ import nl.jacobras.cloudbridge.CloudService fun DemoScreen( viewModel: DemoViewModel, onAuthenticate: (CloudService) -> Unit, - onFinishAuthOnWeb: (CloudService) -> Unit = {} + onFinishAuthOnWeb: (CloudService) -> Unit = {}, + modifier: Modifier = Modifier ) { val navigator = rememberListDetailPaneScaffoldNavigator() val userInfos by viewModel.userInfos.collectAsState() @@ -29,6 +30,7 @@ fun DemoScreen( val scope = rememberCoroutineScope() ListDetailPaneScaffold( + modifier = modifier, directive = navigator.scaffoldDirective, value = navigator.scaffoldValue, listPane = { From 75b7a18564b1df7d5b819e518e7a1ea6428d4765 Mon Sep 17 00:00:00 2001 From: Jacob Ras Date: Thu, 2 Jul 2026 20:24:12 +0200 Subject: [PATCH 13/15] Change api() into implementation() on some dependencies --- demo/shared/build.gradle.kts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/demo/shared/build.gradle.kts b/demo/shared/build.gradle.kts index d3b951d..4250cc0 100644 --- a/demo/shared/build.gradle.kts +++ b/demo/shared/build.gradle.kts @@ -29,14 +29,14 @@ kotlin { commonMain.dependencies { api(projects.library) api(libs.androidx.viewmodel) - api(libs.compose.adaptive) - api(libs.compose.adaptive.layout) - api(libs.compose.adaptive.navigation) + implementation(libs.compose.adaptive) + implementation(libs.compose.adaptive.layout) + implementation(libs.compose.adaptive.navigation) api(libs.compose.foundation) api(libs.compose.material3) - api(libs.compose.runtime) - api(libs.compose.ui) - api(libs.kotlin.coroutines.core) + implementation(libs.compose.runtime) + implementation(libs.compose.ui) + implementation(libs.kotlin.coroutines.core) implementation(libs.compose.icons.extended) implementation(libs.compose.resources) implementation(libs.compose.ui.tooling.preview) From 4ee7286ec080167186fcbbfd0375bda9c52dd6a7 Mon Sep 17 00:00:00 2001 From: Jacob Ras Date: Thu, 2 Jul 2026 20:38:32 +0200 Subject: [PATCH 14/15] Move service IDs into local.properties --- demo/android/build.gradle.kts | 23 +++++++++--- .../jacobras/cloudbridge/demo/MainActivity.kt | 13 +++---- demo/desktop/build.gradle.kts | 37 +++++++++++++------ .../jacobras/cloudbridge/demo/desktop/main.kt | 12 +++--- .../cloudbridge/demo/ui/DemoScreen.kt | 2 + demo/web/build.gradle.kts | 26 +++++++++++++ demo/web/src/webMain/kotlin/main.kt | 11 +++--- 7 files changed, 87 insertions(+), 37 deletions(-) diff --git a/demo/android/build.gradle.kts b/demo/android/build.gradle.kts index 31a695f..93ae2f5 100644 --- a/demo/android/build.gradle.kts +++ b/demo/android/build.gradle.kts @@ -6,11 +6,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.compose.compiler) alias(libs.plugins.compose.multiplatform) -} - -val localProps = Properties().apply { - val f = rootProject.file("local.properties") - if (f.exists()) f.inputStream().use { load(it) } + alias(libs.plugins.buildconfig) } dependencies { @@ -35,4 +31,21 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } +} + +val localProps = Properties().apply { + val f = rootProject.file("local.properties") + if (f.exists()) f.inputStream().use { load(it) } +} + +buildConfig { + packageName("nl.jacobras.cloudbridge.demo") + buildConfigField( + "DROPBOX_CLIENT_ID", + localProps.getProperty("dropboxClientId") ?: "" + ) + buildConfigField( + "ONEDRIVE_CLIENT_ID", + localProps.getProperty("onedriveClientId") ?: "" + ) } \ No newline at end of file diff --git a/demo/android/src/main/kotlin/nl/jacobras/cloudbridge/demo/MainActivity.kt b/demo/android/src/main/kotlin/nl/jacobras/cloudbridge/demo/MainActivity.kt index c5fee0e..81ae19b 100644 --- a/demo/android/src/main/kotlin/nl/jacobras/cloudbridge/demo/MainActivity.kt +++ b/demo/android/src/main/kotlin/nl/jacobras/cloudbridge/demo/MainActivity.kt @@ -95,7 +95,7 @@ class MainActivity : ComponentActivity() { when (provider) { Provider.Dropbox -> { val token = viewModel.dropbox.completeAuthentication( - clientId = DROPBOX_CLIENT_ID, + clientId = BuildConfig.DROPBOX_CLIENT_ID, redirectUri = REDIRECT_URI, intentUri = uri ) ?: return@launch @@ -106,7 +106,7 @@ class MainActivity : ComponentActivity() { Provider.OneDrive -> { val oneDriveService = viewModel.oneDrive val token = oneDriveService.completeAuthentication( - clientId = ONEDRIVE_CLIENT_ID, + clientId = BuildConfig.ONEDRIVE_CLIENT_ID, redirectUri = REDIRECT_URI, intentUri = uri ) ?: return@launch @@ -132,7 +132,7 @@ private fun DemoApp( launchAuth( Provider.Dropbox, service.authenticate( - DROPBOX_CLIENT_ID, + BuildConfig.DROPBOX_CLIENT_ID, REDIRECT_URI ) ) @@ -144,7 +144,7 @@ private fun DemoApp( launchAuth( Provider.OneDrive, service.authenticate( - ONEDRIVE_CLIENT_ID, + BuildConfig.ONEDRIVE_CLIENT_ID, REDIRECT_URI ) ) @@ -157,7 +157,4 @@ private fun DemoApp( private enum class Provider { Dropbox, OneDrive } -private const val REDIRECT_URI = "nl.jacobras.cloudbridge.demo://cloudbridge-auth" - -private const val DROPBOX_CLIENT_ID = "nw5f95uw77yrz3j" -private const val ONEDRIVE_CLIENT_ID = "40916102-96a6-46ca-929e-90cc62c3be9a" \ No newline at end of file +private const val REDIRECT_URI = "nl.jacobras.cloudbridge.demo://cloudbridge-auth" \ No newline at end of file diff --git a/demo/desktop/build.gradle.kts b/demo/desktop/build.gradle.kts index 2f53472..3cb7b10 100644 --- a/demo/desktop/build.gradle.kts +++ b/demo/desktop/build.gradle.kts @@ -11,11 +11,6 @@ plugins { alias(libs.plugins.buildconfig) } -val localProps = Properties().apply { - val f = rootProject.file("local.properties") - if (f.exists()) f.inputStream().use { load(it) } -} - kotlin { jvm("desktop") { compilerOptions { @@ -25,13 +20,6 @@ kotlin { sourceSets { named("desktopMain") { - buildConfig { - packageName("nl.jacobras.cloudbridge.demo") - buildConfigField( - "DRIVE_DESKTOP_SECRET", - localProps.getProperty("driveDesktopSecret") ?: "" - ) - } dependencies { implementation(projects.demo.shared) implementation(compose.desktop.currentOs) @@ -45,6 +33,31 @@ kotlin { } } +val localProps = Properties().apply { + val f = rootProject.file("local.properties") + if (f.exists()) f.inputStream().use { load(it) } +} + +buildConfig { + packageName("nl.jacobras.cloudbridge.demo") + buildConfigField( + "DROPBOX_CLIENT_ID", + localProps.getProperty("dropboxClientId") ?: "" + ) + buildConfigField( + "GOOGLE_DRIVE_CLIENT_ID", + localProps.getProperty("googleDriveDesktopClientId") ?: "" + ) + buildConfigField( + "GOOGLE_DRIVE_CLIENT_SECRET", + localProps.getProperty("googleDriveDesktopSecret") ?: "" + ) + buildConfigField( + "ONEDRIVE_CLIENT_ID", + localProps.getProperty("onedriveClientId") ?: "" + ) +} + compose.desktop { application { mainClass = "nl.jacobras.cloudbridge.demo.desktop.MainKt" diff --git a/demo/desktop/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/desktop/main.kt b/demo/desktop/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/desktop/main.kt index 9a7a38c..e26d81f 100644 --- a/demo/desktop/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/desktop/main.kt +++ b/demo/desktop/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/desktop/main.kt @@ -8,7 +8,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState -import nl.jacobras.cloudbridge.demo.DesktopMainBuildConfig +import nl.jacobras.cloudbridge.demo.BuildConfig import nl.jacobras.cloudbridge.demo.persistence.DemoSettings import nl.jacobras.cloudbridge.demo.ui.DemoScreen import nl.jacobras.cloudbridge.demo.ui.DemoViewModel @@ -37,8 +37,6 @@ fun main() = application { ) { val uriHandler = LocalUriHandler.current - // TODO: create folder + create file + path filter - DemoScreen( viewModel = viewModel, onAuthenticate = { service -> @@ -46,7 +44,7 @@ fun main() = application { is DropboxService -> { val url = service.authenticate( authServer = localServer, - clientId = "nw5f95uw77yrz3j", + clientId = BuildConfig.DROPBOX_CLIENT_ID, onSuccess = { token -> DemoSettings.dropboxToken = token viewModel.updateTokens() @@ -57,8 +55,8 @@ fun main() = application { is GoogleDriveService -> { val url = service.authenticate( authServer = localServer, - clientId = "218224394553-ls5llp4qcqlem66ovl0rp871jlq47m21.apps.googleusercontent.com", - clientSecret = DesktopMainBuildConfig.DRIVE_DESKTOP_SECRET, + clientId = BuildConfig.GOOGLE_DRIVE_CLIENT_ID, + clientSecret = BuildConfig.GOOGLE_DRIVE_CLIENT_SECRET, onSuccess = { token -> DemoSettings.googleDriveToken = token viewModel.updateTokens() @@ -69,7 +67,7 @@ fun main() = application { is OneDriveService -> { val url = service.authenticate( authServer = localServer, - clientId = "40916102-96a6-46ca-929e-90cc62c3be9a", + clientId = BuildConfig.ONEDRIVE_CLIENT_ID, onSuccess = { token -> DemoSettings.oneDriveToken = token viewModel.updateTokens() diff --git a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoScreen.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoScreen.kt index 7f2be01..3e1b2d2 100644 --- a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoScreen.kt +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoScreen.kt @@ -29,6 +29,8 @@ fun DemoScreen( val selectedService by viewModel.selectedService.collectAsState() val scope = rememberCoroutineScope() + // TODO: create folder + create file + path filter + ListDetailPaneScaffold( modifier = modifier, directive = navigator.scaffoldDirective, diff --git a/demo/web/build.gradle.kts b/demo/web/build.gradle.kts index 89d4242..ea2e20b 100644 --- a/demo/web/build.gradle.kts +++ b/demo/web/build.gradle.kts @@ -1,9 +1,14 @@ @file:Suppress("OPT_IN_USAGE") +import java.util.Properties +import kotlin.apply + + plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.compose.compiler) alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.buildconfig) } kotlin { @@ -37,4 +42,25 @@ kotlin { compilerOptions { optIn.add("kotlin.time.ExperimentalTime") } +} + +val localProps = Properties().apply { + val f = rootProject.file("local.properties") + if (f.exists()) f.inputStream().use { load(it) } +} + +buildConfig { + packageName("nl.jacobras.cloudbridge.demo") + buildConfigField( + "DROPBOX_CLIENT_ID", + localProps.getProperty("dropboxClientId") ?: "" + ) + buildConfigField( + "GOOGLE_DRIVE_CLIENT_ID", + localProps.getProperty("googleDriveWebClientId") ?: "" + ) + buildConfigField( + "ONEDRIVE_CLIENT_ID", + localProps.getProperty("onedriveClientId") ?: "" + ) } \ No newline at end of file diff --git a/demo/web/src/webMain/kotlin/main.kt b/demo/web/src/webMain/kotlin/main.kt index 9d92fb4..d02e91d 100644 --- a/demo/web/src/webMain/kotlin/main.kt +++ b/demo/web/src/webMain/kotlin/main.kt @@ -8,6 +8,7 @@ import co.touchlab.kermit.Logger import kotlinx.browser.window import kotlinx.coroutines.launch import nl.jacobras.cloudbridge.CloudBridge +import nl.jacobras.cloudbridge.demo.BuildConfig import nl.jacobras.cloudbridge.demo.persistence.DemoSettings import nl.jacobras.cloudbridge.demo.ui.DemoScreen import nl.jacobras.cloudbridge.demo.ui.DemoViewModel @@ -42,21 +43,21 @@ fun main() { when (service) { is DropboxService -> { val uri = service.authenticate( - clientId = "nw5f95uw77yrz3j", + clientId = BuildConfig.DROPBOX_CLIENT_ID, redirectUri = "http://localhost:8080" ) window.location.href = uri } is GoogleDriveService -> { val uri = service.authenticate( - clientId = "218224394553-hd5j48a5uk9mjec0oq38ctijmpfq0krm.apps.googleusercontent.com", + clientId = BuildConfig.GOOGLE_DRIVE_CLIENT_ID, redirectUri = "http://localhost:8080" ) window.location.href = uri } is OneDriveService -> { val uri = service.authenticate( - clientId = "40916102-96a6-46ca-929e-90cc62c3be9a", + clientId = BuildConfig.ONEDRIVE_CLIENT_ID, redirectUri = "http://localhost:8080" ) window.location.href = uri @@ -67,7 +68,7 @@ fun main() { when (service) { is DropboxService -> scope.launch { val token = service.completeAuthentication( - clientId = "nw5f95uw77yrz3j", + clientId = BuildConfig.DROPBOX_CLIENT_ID, redirectUri = "http://localhost:8080" ) DemoSettings.dropboxToken = token @@ -80,7 +81,7 @@ fun main() { } is OneDriveService -> scope.launch { val token = service.completeAuthentication( - clientId = "40916102-96a6-46ca-929e-90cc62c3be9a", + clientId = BuildConfig.ONEDRIVE_CLIENT_ID, redirectUri = "http://localhost:8080" ) DemoSettings.oneDriveToken = token From 24cb734e6376207b6b806ec30a263dc63f08214a Mon Sep 17 00:00:00 2001 From: Jacob Ras Date: Thu, 2 Jul 2026 20:53:39 +0200 Subject: [PATCH 15/15] Set desktop JVM target to 21, like [shared] --- demo/desktop/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demo/desktop/build.gradle.kts b/demo/desktop/build.gradle.kts index 3cb7b10..f4e3b28 100644 --- a/demo/desktop/build.gradle.kts +++ b/demo/desktop/build.gradle.kts @@ -14,7 +14,7 @@ plugins { kotlin { jvm("desktop") { compilerOptions { - jvmTarget = JvmTarget.JVM_17 + jvmTarget = JvmTarget.JVM_21 } }