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 975d331..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 @@ -10,45 +10,31 @@ 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.foundation.layout.safeDrawingPadding 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.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.MainViewModel -import nl.jacobras.cloudbridge.model.CloudItem -import nl.jacobras.cloudbridge.model.UserInfo +import nl.jacobras.cloudbridge.demo.ui.DemoScreen +import nl.jacobras.cloudbridge.demo.ui.DemoViewModel +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 class MainActivity : ComponentActivity() { - private val viewModel = MainViewModel() + private val viewModel = DemoViewModel() private var authenticatingProvider: Provider? = null private val scope = CoroutineScope(Dispatchers.Main) @@ -57,7 +43,7 @@ class MainActivity : ComponentActivity() { activity = this, onSuccess = { token -> DemoSettings.googleDriveToken = token - viewModel.refresh(viewModel.googleDrive) + viewModel.updateTokens() }, onDenied = {}, onFailure = { error -> @@ -109,23 +95,23 @@ 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 DemoSettings.dropboxToken = token - viewModel.refresh(viewModel.dropbox) + viewModel.updateTokens() } 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 DemoSettings.oneDriveToken = token - viewModel.refresh(oneDriveService) + viewModel.updateTokens() } } } @@ -134,128 +120,41 @@ class MainActivity : ComponentActivity() { @OptIn(ExperimentalMaterial3Api::class) @Composable private fun DemoApp( - viewModel: MainViewModel, + viewModel: DemoViewModel, launchAuth: (Provider, String) -> Unit, onAuthenticateGoogleDrive: () -> Unit ) { - val files by viewModel.files.collectAsState() - val userInfo by viewModel.userInfo.collectAsState() - val serviceErrors by viewModel.serviceErrors.collectAsState() - - 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( - DROPBOX_CLIENT_ID, + service.authenticate( + BuildConfig.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( - ONEDRIVE_CLIENT_ID, + service.authenticate( + BuildConfig.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)) } - } - } + }, + modifier = Modifier.safeDrawingPadding() + ) } 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 f693277..f4e3b28 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 { @@ -10,20 +11,15 @@ 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") + jvm("desktop") { + compilerOptions { + jvmTarget = JvmTarget.JVM_21 + } + } 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) @@ -37,9 +33,34 @@ 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.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..e26d81f --- /dev/null +++ b/demo/desktop/src/desktopMain/kotlin/nl/jacobras/cloudbridge/demo/desktop/main.kt @@ -0,0 +1,82 @@ +package nl.jacobras.cloudbridge.demo.desktop + +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +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.BuildConfig +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() } + 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 = viewModel, + onAuthenticate = { service -> + when (service) { + is DropboxService -> { + val url = service.authenticate( + authServer = localServer, + clientId = BuildConfig.DROPBOX_CLIENT_ID, + onSuccess = { token -> + DemoSettings.dropboxToken = token + viewModel.updateTokens() + } + ) + uriHandler.openUri(url) + } + is GoogleDriveService -> { + val url = service.authenticate( + authServer = localServer, + clientId = BuildConfig.GOOGLE_DRIVE_CLIENT_ID, + clientSecret = BuildConfig.GOOGLE_DRIVE_CLIENT_SECRET, + onSuccess = { token -> + DemoSettings.googleDriveToken = token + viewModel.updateTokens() + } + ) + uriHandler.openUri(url) + } + is OneDriveService -> { + val url = service.authenticate( + authServer = localServer, + clientId = BuildConfig.ONEDRIVE_CLIENT_ID, + onSuccess = { token -> + DemoSettings.oneDriveToken = token + viewModel.updateTokens() + } + ) + uriHandler.openUri(url) + } + } + } + ) + } +} \ 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..4250cc0 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) @@ -12,8 +15,13 @@ 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 { + jvmTarget = JvmTarget.JVM_21 + } } - jvm("desktop") js { browser() } wasmJs { browser() } @@ -21,11 +29,14 @@ kotlin { commonMain.dependencies { api(projects.library) api(libs.androidx.viewmodel) + 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) @@ -43,5 +54,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/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/composeResources/drawable/ic_dropbox.png b/demo/shared/src/commonMain/composeResources/drawable/ic_dropbox.png new file mode 100644 index 0000000..55b95af Binary files /dev/null and b/demo/shared/src/commonMain/composeResources/drawable/ic_dropbox.png differ 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 0000000..f448ed7 Binary files /dev/null and b/demo/shared/src/commonMain/composeResources/drawable/ic_google_drive.png differ 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 0000000..f1af091 Binary files /dev/null and b/demo/shared/src/commonMain/composeResources/drawable/ic_one_drive.png differ 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 new file mode 100644 index 0000000..3e1b2d2 --- /dev/null +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoScreen.kt @@ -0,0 +1,72 @@ +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, + onFinishAuthOnWeb: (CloudService) -> Unit = {}, + modifier: Modifier = Modifier +) { + val navigator = rememberListDetailPaneScaffoldNavigator() + val userInfos by viewModel.userInfos.collectAsState() + val selectedService by viewModel.selectedService.collectAsState() + val scope = rememberCoroutineScope() + + // TODO: create folder + create file + path filter + + ListDetailPaneScaffold( + modifier = modifier, + directive = navigator.scaffoldDirective, + value = navigator.scaffoldValue, + listPane = { + ServicesList( + services = viewModel.services.associateWith { service -> userInfos[service] }, + selectedService = selectedService, + onClick = { + viewModel.select(it) + scope.launch { + navigator.navigateTo(ListDetailPaneScaffoldRole.Detail) + } + } + ) + }, + detailPane = { + selectedService?.let { service -> + DetailPane( + service = service, + userInfo = userInfos[service], + onAuthenticateClick = { onAuthenticate(service) }, + onDeauthenticateClick = { viewModel.deauthenticate(service) }, + onFinishAuthOnWeb = { onFinishAuthOnWeb(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/DemoViewModel.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoViewModel.kt new file mode 100644 index 0000000..c84362b --- /dev/null +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DemoViewModel.kt @@ -0,0 +1,86 @@ +package nl.jacobras.cloudbridge.demo.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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 +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() + val googleDrive = CloudBridge.googleDrive() + val oneDrive = CloudBridge.oneDrive() + val services = listOf(dropbox, googleDrive, oneDrive) + + internal val userInfos: StateFlow> + field = MutableStateFlow(emptyMap()) + + internal val selectedService: StateFlow + field = MutableStateFlow(null) + val path = MutableStateFlow(FolderPath("/")) + + private var loadServiceDetailsJob: Job? = null + + init { + updateTokens() + } + + fun updateTokens() { + dropbox.setToken(DemoSettings.dropboxToken) + googleDrive.setToken(DemoSettings.googleDriveToken) + oneDrive.setToken(DemoSettings.oneDriveToken) + } + + internal fun select(service: CloudService) { + loadServiceDetailsJob?.cancel() + loadServiceDetailsJob = null + selectedService.update { service } + + if (service.isAuthenticated()) { + loadServiceDetailsJob = viewModelScope.launch { + try { + val userInfo = service.getUserInfo() + userInfos.update { + val map = it.toMutableMap() + map[service] = userInfo + map + } + } catch (_: CloudServiceException) { + // NOOP + } + } + } + } + + 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 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..0ce92d9 --- /dev/null +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/DetailPane.kt @@ -0,0 +1,181 @@ +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.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 +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.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) +@Composable +internal fun DetailPane( + service: CloudService, + userInfo: UserInfo?, + onAuthenticateClick: () -> Unit, + onDeauthenticateClick: () -> Unit, + onFinishAuthOnWeb: () -> Unit, + onBackClick: () -> Unit +) { + val viewModel = remember(service) { ServiceViewModel(service) } + val files by viewModel.files.collectAsState() + val error by viewModel.error.collectAsState() + + val selectedItem by viewModel.selectedItem.collectAsState() + val content by viewModel.content.collectAsState() + + var pendingCreate by remember { mutableStateOf(null) } + + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { + Text(text = buildString { + append(service.name) + + if (userInfo?.emailAddress != null) { + append(" (${userInfo.emailAddress})") + } + }) + }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Close") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent + ), + actions = { + if (!service.isAuthenticated()) { + Button(onClick = onAuthenticateClick) { + Text("Sign in") + } + } else { + Button(onClick = onDeauthenticateClick) { + Text("Sign out") + } + } + }, + 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)) { + if (error.isNotBlank()) { + Text( + text = error, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error + ) + Spacer(Modifier.height(16.dp)) + } + + if (isWeb && !service.isAuthenticated()) { + Button(onClick = onFinishAuthOnWeb) { + Text("Finish auth") + } + Spacer(Modifier.height(16.dp)) + } + + FilesList( + modifier = Modifier.fillMaxSize(), + files = files, + onItemClick = viewModel::selectItem + ) + } + } + + 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/FilesList.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/FilesList.kt new file mode 100644 index 0000000..c9339d3 --- /dev/null +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/FilesList.kt @@ -0,0 +1,30 @@ +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.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import nl.jacobras.cloudbridge.model.CloudItem + +@Composable +internal fun FilesList( + files: List, + 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/MainViewModel.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/MainViewModel.kt deleted file mode 100644 index 6147c75..0000000 --- a/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/MainViewModel.kt +++ /dev/null @@ -1,80 +0,0 @@ -package nl.jacobras.cloudbridge.demo.ui - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import nl.jacobras.cloudbridge.CloudBridge -import nl.jacobras.cloudbridge.CloudService -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() { - val dropbox = CloudBridge.dropbox() - val googleDrive = CloudBridge.googleDrive() - val oneDrive = CloudBridge.oneDrive() - - val files = MutableStateFlow>>(emptyMap()) - val userInfo = MutableStateFlow>(emptyMap()) - val serviceErrors = MutableStateFlow>(emptyMap()) - - val path = MutableStateFlow(FolderPath("/")) - - init { - updateTokens() - refresh(dropbox) - refresh(googleDrive) - refresh(oneDrive) - } - - fun updateTokens() { - dropbox.setToken(DemoSettings.dropboxToken) - googleDrive.setToken(DemoSettings.googleDriveToken) - 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 - } - } -} \ No newline at end of file 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/ServiceViewModel.kt b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServiceViewModel.kt new file mode 100644 index 0000000..2bc394c --- /dev/null +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServiceViewModel.kt @@ -0,0 +1,105 @@ +package nl.jacobras.cloudbridge.demo.ui + +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.CloudItemId +import nl.jacobras.cloudbridge.model.FolderPath +import nl.jacobras.cloudbridge.model.asFilePath +import nl.jacobras.cloudbridge.model.asFolderPath + +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("") + + val selectedItem: StateFlow + field = MutableStateFlow(null) + val content: 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() } + } + } + + 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 { "" } + } + + 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 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..6a008c8 --- /dev/null +++ b/demo/shared/src/commonMain/kotlin/nl/jacobras/cloudbridge/demo/ui/ServicesList.kt @@ -0,0 +1,75 @@ +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 nl.jacobras.cloudbridge.model.UserInfo +import org.jetbrains.compose.resources.painterResource + +@Composable +internal fun ServicesList( + services: Map, + selectedService: CloudService?, + onClick: (CloudService) -> Unit +) { + Column { + for ((service, userInfo) in services) { + Service( + service = service, + userInfo = userInfo, + selected = selectedService == service, + onClick = { onClick(service) } + ) + } + } +} + +@Composable +private fun Service( + service: CloudService, + userInfo: UserInfo?, + 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 (userInfo?.emailAddress != null) { + append(" (${userInfo.emailAddress})") + } + }, + fontWeight = if (selected) { + FontWeight.Medium + } else { + FontWeight.Normal + } + ) + } +} \ No newline at end of file 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/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 88fe311..d02e91d 100644 --- a/demo/web/src/webMain/kotlin/main.kt +++ b/demo/web/src/webMain/kotlin/main.kt @@ -1,54 +1,24 @@ @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.BuildConfig 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.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.demo.ui.DemoScreen +import nl.jacobras.cloudbridge.demo.ui.DemoViewModel +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 +33,62 @@ private class KermitLogger : nl.jacobras.cloudbridge.logging.Logger { fun main() { CloudBridge.logger = KermitLogger() - ComposeViewport { - val viewModel = remember { MainViewModel() } - 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 viewModel = remember { DemoViewModel() } 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( - clientId = "nw5f95uw77yrz3j", + DemoScreen( + viewModel = viewModel, + onAuthenticate = { service -> + when (service) { + is DropboxService -> { + val uri = service.authenticate( + clientId = BuildConfig.DROPBOX_CLIENT_ID, redirectUri = "http://localhost:8080" ) window.location.href = uri - }, - finishAuth = { - val token = dropboxService.completeAuthentication( - clientId = "nw5f95uw77yrz3j", + } + is GoogleDriveService -> { + val uri = service.authenticate( + clientId = BuildConfig.GOOGLE_DRIVE_CLIENT_ID, 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( - clientId = "218224394553-hd5j48a5uk9mjec0oq38ctijmpfq0krm.apps.googleusercontent.com", + window.location.href = uri + } + is OneDriveService -> { + val uri = service.authenticate( + clientId = BuildConfig.ONEDRIVE_CLIENT_ID, 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( - clientId = "40916102-96a6-46ca-929e-90cc62c3be9a", + } + } + }, + onFinishAuthOnWeb = { service -> + when (service) { + is DropboxService -> scope.launch { + val token = service.completeAuthentication( + clientId = BuildConfig.DROPBOX_CLIENT_ID, redirectUri = "http://localhost:8080" ) - window.location.href = uri - }, - finishAuth = { - val token = oneDriveService.completeAuthentication( - clientId = "40916102-96a6-46ca-929e-90cc62c3be9a", + 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 = BuildConfig.ONEDRIVE_CLIENT_ID, 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 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 { 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