From 145135721943b3aa9d34db99f5b3ba965a09f34c Mon Sep 17 00:00:00 2001 From: William Walker <wnwalker@ucsc.edu> Date: Mon, 5 Aug 2024 12:02:10 -0700 Subject: [PATCH] . --- build.gradle.kts | 1 + gradle/libs.versions.toml | 50 ++-- settings.gradle.kts | 2 +- shared/compose/NOTICE | 7 +- shared/compose/build.gradle.kts | 1 + .../composeResources/values/strings.xml | 17 ++ .../its/temerity/shared/ui/LoginScreen.kt | 9 +- .../ucsc/its/temerity/shared/ui/MainScreen.kt | 194 +++++++++----- .../ucsc/its/temerity/shared/ui/RootScreen.kt | 25 +- .../its/temerity/shared/ui/TemerityApp.kt | 12 +- .../its/temerity/shared/ui/UserEditScreen.kt | 253 ++++++++++++++++++ .../shared/ui/elements/CoursesElements.kt | 129 +++++++++ .../shared/ui/elements/UserElements.kt | 60 +++-- .../temerity/shared/ui/elements/Utility.kt | 147 +++++++++- shared/shared/build.gradle.kts | 9 +- .../ucsc/its/temerity/shared/AppSettings.kt | 62 +++-- .../data/repository/PlatformRepository.kt | 105 ++++---- .../temerity/shared/database/AppDatabase.kt | 39 ++- .../temerity/shared/database/TemerityDao.kt | 30 ++- .../its/temerity/shared/di/CommonModule.kt | 12 +- .../{AppStateViewModel.kt => AppViewModel.kt} | 7 +- .../viewmodel/DevicesScreenViewModel.kt | 58 ++-- .../shared/viewmodel/LoginViewModel.kt | 3 +- .../shared/viewmodel/MoleculeViewModel.kt | 33 ++- .../shared/viewmodel/RootViewModel.kt | 37 ++- .../shared/viewmodel/UserEditViewModel.kt | 118 ++++++++ .../shared/data/Actual.StorageModule.kt | 31 +-- .../edu/ucsc/its/temerity/TemerityLibrary.kt | 2 +- .../ucsc/its/temerity/remote/PlatformApi.kt | 4 +- 29 files changed, 1135 insertions(+), 322 deletions(-) create mode 100644 shared/compose/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/ui/UserEditScreen.kt create mode 100644 shared/compose/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/ui/elements/CoursesElements.kt rename shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/viewmodel/{AppStateViewModel.kt => AppViewModel.kt} (95%) create mode 100644 shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/viewmodel/UserEditViewModel.kt diff --git a/build.gradle.kts b/build.gradle.kts index 09a85f9..1f9c63b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,6 +7,7 @@ plugins { alias(libs.plugins.kotlinMultiplatform) apply false alias(libs.plugins.kotlinxSerialization) apply false alias(libs.plugins.jetbrainsCompose) apply false + alias(libs.plugins.composeCompiler) apply false alias(libs.plugins.dokka) apply false alias(libs.plugins.spotless) apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cf0d405..c10566e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,33 +26,32 @@ jetbrainsLifecycle = "2.8.0" androidxRoom = "2.7.0-alpha05" sqlite = "2.5.0-SNAPSHOT" androidxDatastore = "1.1.1" +exposed = "0.53.0" material3WindowSizeClass = "0.5.0" landscapistCoil3 = "2.3.6" composetheme = "1.2.0-alpha" voyager = "1.1.0-beta02" -composables = "1.7.0" -bottomsheet = "0.1.3" -alertKmp = "0.0.7" - dotenv-vault = "0.0.3" +kermit = "2.0.4" +jline = "3.26.3" +appdirs = "1.2.0" +kstore = "0.7.2" kotlinx-dataframe = "0.13.1" klaxon = "5.6" kotest = "5.9.1" -kermit = "2.0.4" +composables = "1.10.0" +bottomsheet = "0.1.3" +alertKmp = "0.0.7" +filekit = "0.7.0" dokka = "1.9.20" spotless = "6.25.0" gitSemVer = "3.1.4" - -jline = "3.25.1" -appdirs = "1.2.2" -kstore = "0.7.2" - minSdk = "24" targetSdk = "34" compileSdk = "34" @@ -72,16 +71,28 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-debug = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-debug", version.ref = "coroutines" } kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "coroutines" } +kotlinx-coroutines-slf4j = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-slf4j", version.ref = "coroutines" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" } ktorfit-lib = { module = "de.jensklingenberg.ktorfit:ktorfit-lib-light", version.ref = "ktorfit" } +kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } +kermit-koin = { module = "co.touchlab:kermit-koin", version.ref = "kermit" } + +jline = { module = "org.jline:jline", version.ref = "jline" } +appdirs = { module = "ca.gosyer:kotlin-multiplatform-appdirs", version.ref = "appdirs" } +kstore = { module = "io.github.xxfast:kstore", version.ref = "kstore" } +kstore-file = { module = "io.github.xxfast:kstore-file", version.ref = "kstore" } + + sandwich = { module = "com.github.skydoves:sandwich", version.ref = "sandwich" } sandwich-ktor = { module = "com.github.skydoves:sandwich-ktor", version.ref = "sandwich" } sandwich-ktorfit = { module = "com.github.skydoves:sandwich-ktorfit", version.ref = "sandwich" } koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } arrow-core = { module = "io.arrow-kt:arrow-core", version.ref = "arrow" } -arrow-fx-coroutines = { module = "io.arrow-kt:arrow-fx-coroutines", version.ref = "arrow" } +arrow-fxCoroutines = { module = "io.arrow-kt:arrow-fx-coroutines", version.ref = "arrow" } +arrow-optics = { module = "io.arrow-kt:arrow-optics", version.ref = "arrow" } +arrow-opticsKspPlugin = { module = "io.arrow-kt:arrow-optics-ksp-plugin", version.ref = "arrow" } mosaic = { module = "com.jakewharton.mosaic:mosaic-runtime", version.ref = "mosaic" } koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koinComposeMultiplatform" } @@ -103,6 +114,8 @@ landscapist-coil3 = { module = "com.github.skydoves:landscapist-coil3", version. composables-core = { module = "com.composables:core", version.ref = "composables" } flexible-bottomsheet = { module = "com.github.skydoves:flexible-bottomsheet", version.ref = "bottomsheet" } alertKmp = { module = "io.github.khubaibkhan4:alert-kmp", version.ref = "alertKmp" } +filekit-core = { group = "io.github.vinceglb", name = "filekit-core", version.ref = "filekit" } +filekit-compose = { group = "io.github.vinceglb", name = "filekit-compose", version.ref = "filekit" } # Shared presenter code deps (some usages are platform-specific) molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" } @@ -111,7 +124,10 @@ jetbrains-navigation-compose = { module = "org.jetbrains.androidx.navigation:nav androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "androidxRoom" } androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "androidxRoom" } sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" } - +exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" } +exposed-crypt = { module = "org.jetbrains.exposed:exposed-crypt", version.ref = "exposed" } +exposed-dao = { module = "org.jetbrains.exposed:exposed-dao", version.ref = "exposed" } +exposed-kotlinDatetime = { module = "org.jetbrains.exposed:exposed-kotlin-datetime", version.ref = "exposed" } # Testing deps dotenv-vault = { module = "com.github.dotenv-org:dotenv-vault-kotlin", version.ref = "dotenv-vault" } @@ -126,20 +142,12 @@ dokka-base = { module = "org.jetbrains.dokka:dokka-base", version.ref = "dokka" android-documentation-plugin = { module = "org.jetbrains.dokka:android-documentation-plugin", version.ref = "dokka" } klaxon = { module = "com.beust:klaxon", version.ref = "klaxon" } -kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } -kermit-koin = { module = "co.touchlab:kermit-koin", version.ref = "kermit" } - -kotlinx-coroutines-slf4j = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-slf4j", version.ref = "coroutines" } - -jline = { module = "org.jline:jline", version.ref = "jline" } -appdirs = { module = "net.harawata:appdirs", version.ref = "appdirs" } -kstore = { module = "io.github.xxfast:kstore", version.ref = "kstore" } -kstore-file = { module = "io.github.xxfast:kstore-file", version.ref = "kstore" } [bundles] ktor = ["ktor-client-logging", "ktor-client-serialization", "kotlinx-serialization-json"] sandwich = ["sandwich", "sandwich-ktor", "sandwich-ktorfit"] voyager = ["voyager-navigator", "voyager-bottomSheetNavigator", "voyager-tabNavigator", "voyager-lifecycle-kmp", "voyager-transitions", "voyager-koin"] +exposed = ["exposed-core", "exposed-crypt", "exposed-dao", "exposed-kotlinDatetime"] [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 2fec6e4..4c61df6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -13,7 +13,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() - maven { url = uri("https://jitpack.io") } + maven { url = uri("https://jitpack.io") } maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") maven("https://maven.pkg.jetbrains.space/kotlin/p/wasm/experimental") maven("https://androidx.dev/storage/compose-compiler/repository") diff --git a/shared/compose/NOTICE b/shared/compose/NOTICE index 7096b0d..7f911eb 100644 --- a/shared/compose/NOTICE +++ b/shared/compose/NOTICE @@ -18,8 +18,11 @@ License: None This product includes software developed by Composable Horizons (Alex Styl) -File(s): ListDetailLayoutWithSearchBar.kt -Description: A material 3 responsive layout built using the canonical list-detail pattern. +File(s): ListDetailLayoutWithSearchBar.kt, UserEditScreen.kt, Utility.kt +Descriptions: + - A material 3 responsive layout built using the canonical list-detail pattern. + - A screen containing a form for editing user details. + - A simple dropdown menu with chevron down icon, basic text items License: Purchased 06/2024 -------------------------------------------- \ No newline at end of file diff --git a/shared/compose/build.gradle.kts b/shared/compose/build.gradle.kts index 9e5b0a4..659c61d 100644 --- a/shared/compose/build.gradle.kts +++ b/shared/compose/build.gradle.kts @@ -53,6 +53,7 @@ kotlin { implementation(libs.composeTheme.material3) implementation(libs.bundles.voyager) implementation(libs.alertKmp) + implementation(libs.composables.core) implementation(libs.dotenv.vault) } diff --git a/shared/compose/src/commonMain/composeResources/values/strings.xml b/shared/compose/src/commonMain/composeResources/values/strings.xml index 1400b07..3d7925d 100644 --- a/shared/compose/src/commonMain/composeResources/values/strings.xml +++ b/shared/compose/src/commonMain/composeResources/values/strings.xml @@ -19,8 +19,25 @@ <string name="app_name">Temerity</string> <string name="login">Login</string> <string name="main">Main</string> + <string name="notifications">Notifications</string> + <string name="notifications_short">Notifs.</string> <string name="devices">Devices</string> <string name="roster">Roster</string> + <string name="courses">Courses</string> <string name="settings">Settings</string> + + <!-- Strings used in the User Screens --> + <string name="user_edit">Edit User Record</string> + + <!-- Action item text --> + <string name="blank">No content</string> + <string name="save">Save</string> + + <!-- Strings used in the Courses Screens --> + <string name="course_code_prefix">Course code: </string> + <string name="term_code_prefix">Term: </string> + <string name="direct_course_link_prefix">Direct course link: </string> + <string name="course_edit">Edit Course</string> + </resources> diff --git a/shared/compose/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/ui/LoginScreen.kt b/shared/compose/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/ui/LoginScreen.kt index 61cf79a..a02b701 100644 --- a/shared/compose/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/ui/LoginScreen.kt +++ b/shared/compose/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/ui/LoginScreen.kt @@ -28,18 +28,17 @@ import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import edu.ucsc.its.temerity.shared.viewmodel.AppStateViewModel +import edu.ucsc.its.temerity.shared.viewmodel.AppViewModel import edu.ucsc.its.temerity.shared.viewmodel.LoginViewModel -import kotlinx.coroutines.ExperimentalCoroutinesApi import org.koin.compose.getKoin import org.koin.compose.viewmodel.koinViewModel import org.koin.core.annotation.KoinExperimentalAPI import org.koin.core.parameter.parametersOf -@OptIn(KoinExperimentalAPI::class, ExperimentalCoroutinesApi::class) +@OptIn(KoinExperimentalAPI::class) @Composable fun LoginScreen( - appStateViewModel: AppStateViewModel = koinViewModel(), + appViewModel: AppViewModel = koinViewModel(), viewModel: LoginViewModel = koinViewModel(), onLogin: () -> Unit, ) { @@ -82,7 +81,7 @@ fun LoginScreen( // ) Spacer(modifier = Modifier.height(16.dp)) Text( - text = getKoin().get<String> { parametersOf("temerity", "1.0.0", "edu.ucsc.its") }, + text = getKoin().get<String> { parametersOf("edu.ucsc.its.temerity") }, ) Spacer(modifier = Modifier.height(16.dp)) Button( diff --git a/shared/compose/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/ui/MainScreen.kt b/shared/compose/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/ui/MainScreen.kt index 353ae85..96af97f 100644 --- a/shared/compose/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/ui/MainScreen.kt +++ b/shared/compose/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/ui/MainScreen.kt @@ -19,9 +19,6 @@ package edu.ucsc.its.temerity.shared.ui import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement @@ -44,6 +41,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.AdUnits import androidx.compose.material.icons.outlined.Contacts import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Group import androidx.compose.material.icons.outlined.Notifications import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.rounded.Clear @@ -80,48 +78,83 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp -import androidx.navigation.compose.NavHost +import androidx.lifecycle.viewModelScope import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.createGraph +import androidx.navigation.toRoute import com.composables.composetheme.ComposeTheme import com.composables.composetheme.material3.colorScheme import edu.ucsc.its.temerity.PlatformClient +import edu.ucsc.its.temerity.remote.Course import edu.ucsc.its.temerity.remote.Device import edu.ucsc.its.temerity.remote.User +import edu.ucsc.its.temerity.shared.ui.MainContentWindow.Courses +import edu.ucsc.its.temerity.shared.ui.MainContentWindow.Devices +import edu.ucsc.its.temerity.shared.ui.MainContentWindow.Notifications +import edu.ucsc.its.temerity.shared.ui.MainContentWindow.Roster +import edu.ucsc.its.temerity.shared.ui.MainContentWindow.Settings +import edu.ucsc.its.temerity.shared.ui.elements.CoursesList import edu.ucsc.its.temerity.shared.ui.elements.DeviceList +import edu.ucsc.its.temerity.shared.ui.elements.NewNavHost import edu.ucsc.its.temerity.shared.ui.elements.UserList +import edu.ucsc.its.temerity.shared.viewmodel.AppViewModel import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import org.dotenv.vault.dotenvVault import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource import org.koin.compose.getKoin +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.annotation.KoinExperimentalAPI import org.koin.core.parameter.parametersOf import temerity.shared.compose.generated.resources.Res +import temerity.shared.compose.generated.resources.courses import temerity.shared.compose.generated.resources.devices import temerity.shared.compose.generated.resources.notifications +import temerity.shared.compose.generated.resources.notifications_short import temerity.shared.compose.generated.resources.roster import temerity.shared.compose.generated.resources.settings -import temerity.shared.compose.generated.resources.user_edit -enum class MainContentWindow(val title: StringResource) { - Notifications(title = Res.string.notifications), - Devices(title = Res.string.devices), - Roster(title = Res.string.roster), - Settings(title = Res.string.settings), +sealed class MainContentWindow { + @Serializable + data object Notifications : MainContentWindow() + + @Serializable + data object Devices : MainContentWindow() + + @Serializable + data object Roster : MainContentWindow() + + @Serializable + data object Courses : MainContentWindow() + + @Serializable + data object Settings : MainContentWindow() } -enum class MainDetailWindow(val title: StringResource) { - UserEditScreen(title = Res.string.user_edit), +sealed class UserDetailView { + @Serializable + data object BlankScreen : UserDetailView() + + @Serializable + data class EditScreen(val user: String) : UserDetailView() } -@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class, KoinExperimentalAPI::class) @Composable fun MainScreen() { + val appViewModel: AppViewModel = koinViewModel() + val appViewModelScope = appViewModel.viewModelScope // Set initial main content pane to "Devices" var navigationIndex by remember { mutableStateOf(1) } - val selectedDeviceIndex by remember { mutableStateOf(0) } - val widthSizeClass = calculateWindowSizeClass().widthSizeClass + val windowSizeClass = calculateWindowSizeClass() + val widthSizeClass = windowSizeClass.widthSizeClass val dotenv = dotenvVault { directory = "../temerity" @@ -129,49 +162,73 @@ fun MainScreen() { val pc: PlatformClient = getKoin().get(parameters = { parametersOf(dotenv["YUJAPROD_API_URL"], dotenv["YUJAPROD_TOKEN"]) }) var devicesList by remember { mutableStateOf(listOf<Device>()) } var usersList by remember { mutableStateOf(listOf<User>()) } + var coursesList by remember { mutableStateOf(listOf<Course>()) } + var searchQuery by remember { mutableStateOf(String()) } LaunchedEffect(pc) { - withContext(IO) { - devicesList = pc.getDevices().sortedBy { it.stationName } - usersList = pc.getUsers().sortedBy { it.lastName } + appViewModelScope.launch { + withContext(IO) { + devicesList = pc.getDevices().sortedBy { it.stationName } + usersList = pc.getUsers().sortedBy { it.lastName } + coursesList = pc.getCourses() + } + } + } + + val userDetailViewController = rememberNavController() + val userDetailViewGraph = userDetailViewController.createGraph( + startDestination = UserDetailView.BlankScreen, + ) { + composable<UserDetailView.BlankScreen> { + Box { + } + } + composable<UserDetailView.EditScreen> { + val args = it.toRoute<UserDetailView.EditScreen>() + val user = Json.decodeFromString<User>(args.user) + Box { + UserEditScreen(user) + } } } val contentPaneNavController = rememberNavController() val contentPaneNavGraph = contentPaneNavController.createGraph( - startDestination = MainContentWindow.Devices.name, + startDestination = Devices, ) { - composable(MainContentWindow.Notifications.name) { + composable<Notifications> { // NotificationsScreen() } - composable(MainContentWindow.Devices.name) { + composable<Devices> { DeviceList( devicesList, ) } - composable(MainContentWindow.Roster.name) { + composable<Roster> { UserList( usersList, - ) + ) { + userDetailViewController.popBackStack() + userDetailViewController.navigate(UserDetailView.EditScreen(Json.encodeToString(it))) + } } - composable(MainContentWindow.Settings.name) { - // SettingsScreen() + composable<Courses> { + CoursesList( + coursesList, + ) { + } } - } - val detailPaneNavController = rememberNavController() - val detailPaneNavGraph = detailPaneNavController.createGraph( - startDestination = MainDetailWindow.UserEditScreen, - ) { - composable(MainDetailWindow.UserEditScreen.name) { - UserEditScreen() + composable<Settings> { + // SettingsScreen() } } val navigationItems = listOf( - mapOf("icon" to Icons.Outlined.Notifications, "label" to "Notifications"), - mapOf("icon" to Icons.Outlined.AdUnits, "label" to "Devices"), - mapOf("icon" to Icons.Outlined.Contacts, "label" to "Roster"), - mapOf("icon" to Icons.Outlined.Settings, "label" to "Settings"), + mapOf("icon" to Icons.Outlined.Notifications, "label" to if (widthSizeClass == WindowWidthSizeClass.Compact) Res.string.notifications_short else Res.string.notifications, "route" to Notifications), + mapOf("icon" to Icons.Outlined.AdUnits, "label" to Res.string.devices, "route" to Devices), + mapOf("icon" to Icons.Outlined.Contacts, "label" to Res.string.roster, "route" to Roster), + mapOf("icon" to Icons.Outlined.Group, "label" to Res.string.courses, "route" to Courses), + mapOf("icon" to Icons.Outlined.Settings, "label" to Res.string.settings, "route" to Settings), ) @Composable @@ -202,14 +259,31 @@ fun MainScreen() { } } + fun filterContent(currentNavDestination: String) { +// when(contentPaneNavController.findDestination(currentNavDestination)) { +// is Devices -> { +// devicesList = devicesList.filter { it.stationName.contains(searchQuery, ignoreCase = true) } +// } +// is Roster -> { +// usersList = usersList.filter { it.loginId.contains(searchQuery, ignoreCase = true) } +// } +// is Courses -> { +// coursesList = coursesList.filter { it.courseCode.contains(searchQuery, ignoreCase = true) } +// } +// +// Notifications -> TODO() +// Settings -> TODO() +// } + } + @Composable fun ContentPanel(modifier: Modifier = Modifier) { - var searchQuery by remember { mutableStateOf("") } Column(modifier) { SearchBar( searchQuery = searchQuery, onSearchQueryChange = { searchQuery = it + contentPaneNavController.currentDestination!!.route?.let { currentNavDestination: String -> filterContent(currentNavDestination) } }, onImeSearch = { /*TODO*/ }, ) @@ -222,18 +296,9 @@ fun MainScreen() { .clip(RoundedCornerShape(18.dp)) .background(ComposeTheme.colorScheme.surface), ) { - NavHost( - navController = contentPaneNavController, - graph = contentPaneNavGraph, - enterTransition = { - fadeIn() + slideInHorizontally(initialOffsetX = { it / 2 }) - }, - exitTransition = { - fadeOut() + slideOutHorizontally(targetOffsetX = { it / 2 }) - }, - popExitTransition = { - fadeOut() + slideOutVertically(targetOffsetY = { -it }) - }, + NewNavHost( + contentPaneNavController, + contentPaneNavGraph, ) } } @@ -249,18 +314,9 @@ fun MainScreen() { .clip(RoundedCornerShape(18.dp)) .background(ComposeTheme.colorScheme.surface), ) { - NavHost( - navController = detailPaneNavController, - graph = detailPaneNavGraph, - enterTransition = { - fadeIn() + slideInHorizontally(initialOffsetX = { it / 2 }) - }, - exitTransition = { - fadeOut() + slideOutHorizontally(targetOffsetX = { it / 2 }) - }, - popExitTransition = { - fadeOut() + slideOutVertically(targetOffsetY = { -it }) - }, + NewNavHost( + navController = userDetailViewController, + navGraph = userDetailViewGraph, ) } } @@ -275,11 +331,13 @@ fun MainScreen() { navigationItems.forEachIndexed { index, _ -> val item = navigationItems[index] val icon = item["icon"] as ImageVector - val label = item["label"] as String - NavigationBarItem(selected = index == navigationIndex, icon = { Icon(icon, null) }, label = { Text(label) }, onClick = { + val label = item["label"] as StringResource + val route = item["route"] as MainContentWindow + NavigationBarItem(selected = index == navigationIndex, icon = { Icon(icon, null) }, label = { Text(stringResource(label)) }, onClick = { if (index != navigationIndex) { navigationIndex = index - contentPaneNavController.navigate(label.lowercase()) + contentPaneNavController.popBackStack() + contentPaneNavController.navigate(route) } }) } @@ -307,16 +365,18 @@ fun MainScreen() { itemsIndexed(navigationItems) { index, _ -> val item = navigationItems[index] val icon = item["icon"] as ImageVector - val label = item["label"] as String + val label = item["label"] as StringResource + val route = item["route"] as MainContentWindow NavigationDrawerItem( modifier = Modifier.padding(horizontal = 16.dp), selected = index == navigationIndex, icon = { Icon(icon, null) }, - label = { Text(label) }, + label = { Text(stringResource(label)) }, onClick = { if (index != navigationIndex) { navigationIndex = index - contentPaneNavController.navigate(label.lowercase()) + contentPaneNavController.popBackStack() + contentPaneNavController.navigate(route) } }, ) diff --git a/shared/compose/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/ui/RootScreen.kt b/shared/compose/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/ui/RootScreen.kt index 1da1cd9..d88363d 100644 --- a/shared/compose/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/ui/RootScreen.kt +++ b/shared/compose/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/ui/RootScreen.kt @@ -21,31 +21,28 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.createGraph -import org.jetbrains.compose.resources.StringResource -import temerity.shared.compose.generated.resources.Res -import temerity.shared.compose.generated.resources.login -import temerity.shared.compose.generated.resources.main +import kotlinx.serialization.Serializable -/** - * enum values to represent different root windows in the application - */ -enum class TemerityWindow(val title: StringResource) { - Login(title = Res.string.login), - Main(title = Res.string.main), +object TemerityWindow { + @Serializable + object Login + + @Serializable + object Main } @Composable fun RootScreen() { val navController = rememberNavController() val navGraph = navController.createGraph( - startDestination = TemerityWindow.Login.name, + startDestination = TemerityWindow.Login, ) { - composable(TemerityWindow.Login.name) { + composable<TemerityWindow.Login> { LoginScreen(onLogin = { - navController.navigate(TemerityWindow.Main.name) + navController.navigate(TemerityWindow.Main) }) } - composable(TemerityWindow.Main.name) { + composable<TemerityWindow.Main> { MainScreen() } } diff --git a/shared/compose/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/ui/TemerityApp.kt b/shared/compose/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/ui/TemerityApp.kt index 89f09bc..97c2c1a 100644 --- a/shared/compose/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/ui/TemerityApp.kt +++ b/shared/compose/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/ui/TemerityApp.kt @@ -21,7 +21,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Surface import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi -import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.composables.composetheme.ComposeTheme @@ -30,7 +29,7 @@ import edu.ucsc.its.temerity.shared.data.repository.PlatformRepository import edu.ucsc.its.temerity.shared.di.initKoin import edu.ucsc.its.temerity.shared.ui.theme.DarkTheme import edu.ucsc.its.temerity.shared.ui.theme.LightTheme -import edu.ucsc.its.temerity.shared.viewmodel.AppStateViewModel +import edu.ucsc.its.temerity.shared.viewmodel.AppViewModel import edu.ucsc.its.temerity.shared.viewmodel.RootViewModel import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel @@ -41,16 +40,13 @@ private val koin = initKoin().koin @OptIn(KoinExperimentalAPI::class, ExperimentalMaterial3WindowSizeClassApi::class) @Composable internal fun TemerityApp( - appStateViewModel: AppStateViewModel = koinViewModel(), + appStateViewModel: AppViewModel = koinViewModel(), rootViewModel: RootViewModel = koinViewModel(), platformRepository: PlatformRepository = koinInject(), ) { - val windowSizeClass = calculateWindowSizeClass() + val appTheme = if (isSystemInDarkTheme()) DarkTheme else LightTheme - @Suppress("PropertyName") - val AppTheme = if (isSystemInDarkTheme()) DarkTheme else LightTheme - - AppTheme { + appTheme { Surface( modifier = Modifier.fillMaxSize(), color = ComposeTheme.colorScheme.surface, diff --git a/shared/compose/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/ui/UserEditScreen.kt b/shared/compose/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/ui/UserEditScreen.kt new file mode 100644 index 0000000..ee2c80d --- /dev/null +++ b/shared/compose/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/ui/UserEditScreen.kt @@ -0,0 +1,253 @@ +/* + * Designed and developed in 2022-2024 by William Walker (wnwalker@ucsc.edu) + * Copyright 2022-2024 The Regents of the University of California. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package edu.ucsc.its.temerity.shared.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Abc +import androidx.compose.material.icons.outlined.Cancel +import androidx.compose.material.icons.outlined.Email +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material.icons.outlined.Save +import androidx.compose.material3.Checkbox +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope +import com.composables.composetheme.ComposeTheme +import com.composables.composetheme.material3.colorScheme +import com.composables.composetheme.material3.typography +import edu.ucsc.its.temerity.remote.User +import edu.ucsc.its.temerity.shared.ui.elements.DropdownMenu +import edu.ucsc.its.temerity.shared.ui.elements.ScrollableContent +import edu.ucsc.its.temerity.shared.viewmodel.UserEditViewModel +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.annotation.KoinExperimentalAPI +import temerity.shared.compose.generated.resources.Res +import temerity.shared.compose.generated.resources.save +import temerity.shared.compose.generated.resources.user_edit + +@OptIn(KoinExperimentalAPI::class) +@Composable +fun UserEditScreen(user: User? = null) { + val focusManager = LocalFocusManager.current + val viewmodel: UserEditViewModel = koinViewModel() + val viewModelScope = viewmodel.viewModelScope + LaunchedEffect(user) { + viewModelScope.launch { viewmodel.loadUser(user) } + } + val userEditState by viewmodel.models.collectAsState() + + Scaffold( + floatingActionButton = { + FloatingActionButton(onClick = { viewModelScope.launch { viewmodel.saveUserEdits() } }) { + Icon(Icons.Outlined.Save, contentDescription = stringResource(Res.string.save)) + } + }, + ) { + ScrollableContent { + item { + Text( + text = stringResource(Res.string.user_edit), + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 8.dp), + color = ComposeTheme.colorScheme.onSurface, + style = ComposeTheme.typography.titleMedium, + ) + } + item { + val fieldName = "firstName" + Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(24.dp)) { + Icon(Icons.Outlined.Person, null) + OutlinedTextField( + modifier = Modifier.weight(1f), + label = { Text("First name") }, + value = userEditState.firstName, + onValueChange = { viewModelScope.launch { viewmodel.updateField(fieldName, it) } }, + singleLine = true, + trailingIcon = { + AnimatedVisibility(visible = userEditState.firstName.isNotBlank(), enter = fadeIn(), exit = fadeOut()) { + IconButton(onClick = { viewModelScope.launch { viewmodel.clearField(fieldName) } }) { + Icon(Icons.Outlined.Cancel, "Clear") + } + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next, capitalization = KeyboardCapitalization.Words), + keyboardActions = KeyboardActions { + focusManager.moveFocus(FocusDirection.Next) + }, + ) + } + } + item { + val fieldName = "lastName" + Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(24.dp)) { + Box(Modifier.size(24.dp)) + OutlinedTextField( + modifier = Modifier.weight(1f), + label = { Text("Last name") }, + value = userEditState.lastName, + onValueChange = { viewModelScope.launch { viewmodel.updateField(fieldName, it) } }, + singleLine = true, + trailingIcon = { + AnimatedVisibility(visible = userEditState.lastName.isNotBlank(), enter = fadeIn(), exit = fadeOut()) { + IconButton(onClick = { viewModelScope.launch { viewmodel.clearField(fieldName) } }) { + Icon(Icons.Outlined.Cancel, "Clear") + } + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next, capitalization = KeyboardCapitalization.Words), + keyboardActions = KeyboardActions { + focusManager.moveFocus(FocusDirection.Next) + }, + ) + } + } + item { Spacer(Modifier.height(4.dp)) } + item { + val fieldName = "loginId" + Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(24.dp)) { + Icon(Icons.Outlined.Abc, null) + OutlinedTextField( + modifier = Modifier.weight(1f), + label = { Text("Login ID") }, + value = userEditState.loginId, + onValueChange = { viewModelScope.launch { viewmodel.updateField(fieldName, it) } }, + trailingIcon = { + AnimatedVisibility(visible = userEditState.loginId.isNotBlank(), enter = fadeIn(), exit = fadeOut()) { + IconButton(onClick = { viewModelScope.launch { viewmodel.clearField(fieldName) } }) { + Icon(Icons.Outlined.Cancel, "Clear") + } + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next), + keyboardActions = KeyboardActions { + focusManager.moveFocus(FocusDirection.Next) + }, + singleLine = true, + ) + } + } + item { Spacer(Modifier.height(4.dp)) } + item { + val fieldName = "emailAddress" + Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(24.dp)) { + Icon(Icons.Outlined.Email, null) + OutlinedTextField( + modifier = Modifier.weight(1f), + label = { Text("Email Address") }, + value = userEditState.emailAddress, + onValueChange = { viewModelScope.launch { viewmodel.updateField(fieldName, it) } }, + trailingIcon = { + AnimatedVisibility(visible = userEditState.emailAddress.isNotBlank(), enter = fadeIn(), exit = fadeOut()) { + IconButton(onClick = { viewModelScope.launch { viewmodel.clearField(fieldName) } }) { + Icon(Icons.Outlined.Cancel, "Clear") + } + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions { + focusManager.clearFocus() + }, + singleLine = true, + ) + } + } + item { Spacer(Modifier.height(4.dp)) } + item { + Text( + text = "Timezone", + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 8.dp), + color = ComposeTheme.colorScheme.onSurface, + style = ComposeTheme.typography.titleMedium, + ) + } + item { + DropdownMenu( + items = listOf( + "America/Los_Angeles", + "America/New_York", + "Canada/East", + ), + onItemSelected = {}, + modifier = Modifier.height(40.dp).fillMaxWidth(), + ) + } + item { + Text(text = "User Role", style = ComposeTheme.typography.titleMedium, modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 8.dp)) + } + item { + DropdownMenu( + items = listOf( + "Student", + "Instructor", + "IT Manager", + ), + onItemSelected = {}, + modifier = Modifier.height(40.dp).fillMaxWidth(), + ) + } + item { + Text( + text = "Extra Options", + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 8.dp), + color = ComposeTheme.colorScheme.onSurface, + style = ComposeTheme.typography.titleMedium, + ) + } + item { + var selected by remember { mutableStateOf(false) } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier.clickable { selected = selected.not() }.fillMaxWidth().padding(16.dp)) { + Checkbox(checked = selected, onCheckedChange = null) + Text(text = "Lock User") + } + } + } + } +} diff --git a/shared/compose/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/ui/elements/CoursesElements.kt b/shared/compose/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/ui/elements/CoursesElements.kt new file mode 100644 index 0000000..2317f3a --- /dev/null +++ b/shared/compose/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/ui/elements/CoursesElements.kt @@ -0,0 +1,129 @@ +/* + * Designed and developed in 2022-2024 by William Walker (wnwalker@ucsc.edu) + * Copyright 2022-2024 The Regents of the University of California. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package edu.ucsc.its.temerity.shared.ui.elements + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withLink +import androidx.compose.ui.unit.dp +import com.composables.composetheme.ComposeTheme +import com.composables.composetheme.material3.colorScheme +import com.composables.composetheme.material3.typography +import edu.ucsc.its.temerity.remote.Course +import org.jetbrains.compose.resources.stringResource +import temerity.shared.compose.generated.resources.Res +import temerity.shared.compose.generated.resources.course_code_prefix +import temerity.shared.compose.generated.resources.course_edit +import temerity.shared.compose.generated.resources.direct_course_link_prefix +import temerity.shared.compose.generated.resources.term_code_prefix + +@Preview +@Composable +fun CoursesListPreview() { + val mockCourseList = listOf( + Course(), + Course(), + Course(), + ) + PreviewThemeWrapper { + CoursesList(courseList = mockCourseList) {} + } +} + +@Composable +fun CoursesList(courseList: List<Course>, onEditClick: (Course) -> Unit) { + ScrollableList(courseList) { + CourseCardRow(course = it, onEditClick = { onEditClick(it) }) + } +} + +@Composable +fun CourseCardRow(course: Course, onEditClick: () -> Unit) { + CardRow( + title = course.courseName, + subtitle = stringResource(Res.string.course_code_prefix) + course.courseCode, + ) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.height(190.dp), + contentPadding = PaddingValues(vertical = 8.dp), + ) { + item { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Column { + Text( + text = stringResource(Res.string.term_code_prefix) + course.courseTerm, + color = ComposeTheme.colorScheme.primary, + style = ComposeTheme.typography.bodyMedium, + ) + Spacer(modifier = Modifier.width(4.dp).background(ComposeTheme.colorScheme.background)) + val directCourseLinkPrefix = stringResource(Res.string.direct_course_link_prefix) + val courseLinkText = remember { + buildAnnotatedString { + append(directCourseLinkPrefix) + withLink( + link = LinkAnnotation.Url( + url = course.directLink, + ), + ) { + append(" ") + append(course.directLink) + } + } + } + Text( + text = courseLinkText, + color = ComposeTheme.colorScheme.primary, + style = ComposeTheme.typography.bodyMedium, + ) + } + IconButton( + onClick = onEditClick, + modifier = Modifier.padding(8.dp), + ) { + Icon(imageVector = Icons.Default.Edit, contentDescription = stringResource(Res.string.course_edit)) + } + } + } + } + } +} diff --git a/shared/compose/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/ui/elements/UserElements.kt b/shared/compose/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/ui/elements/UserElements.kt index 62a4313..8b0ac23 100644 --- a/shared/compose/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/ui/elements/UserElements.kt +++ b/shared/compose/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/ui/elements/UserElements.kt @@ -19,44 +19,54 @@ package edu.ucsc.its.temerity.shared.ui.elements import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.composables.composetheme.ComposeTheme import com.composables.composetheme.material3.colorScheme import com.composables.composetheme.material3.typography import edu.ucsc.its.temerity.remote.User +import org.jetbrains.compose.resources.stringResource +import temerity.shared.compose.generated.resources.Res +import temerity.shared.compose.generated.resources.user_edit @Preview @Composable fun UserListPreview() { - val userList = listOf( + val mockUserList = listOf( User(), User(), User(), ) PreviewThemeWrapper { - UserList(userList = userList) + UserList(userList = mockUserList) {} } } @Composable -fun UserList(userList: List<User>) { +fun UserList(userList: List<User>, onEditClick: (User) -> Unit) { ScrollableList(userList) { - UserCardRow(user = it) + UserCardRow(user = it, onEditClick = { onEditClick(it) }) } } @Composable -fun UserCardRow(user: User) { +fun UserCardRow(user: User, onEditClick: () -> Unit) { CardRow( title = user.loginId, subtitle = "Role: ${user.userType}", @@ -67,17 +77,31 @@ fun UserCardRow(user: User) { contentPadding = PaddingValues(vertical = 8.dp), ) { item { - Text( - text = "Name: ${user.firstName} ${user.lastName}", - color = ComposeTheme.colorScheme.primary, - style = ComposeTheme.typography.bodyMedium, - ) - Spacer(modifier = Modifier.width(4.dp).background(ComposeTheme.colorScheme.background)) - Text( - text = "Login email: ${user.emailAddress}", - color = ComposeTheme.colorScheme.primary, - style = ComposeTheme.typography.bodyMedium, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Column { + Text( + text = "Name: ${user.firstName} ${user.lastName}", + color = ComposeTheme.colorScheme.primary, + style = ComposeTheme.typography.bodyLarge, + ) + Spacer(modifier = Modifier.width(4.dp).background(ComposeTheme.colorScheme.background)) + Text( + text = "Login email: ${user.emailAddress}", + color = ComposeTheme.colorScheme.primary, + style = ComposeTheme.typography.bodyLarge, + ) + } + IconButton( + onClick = onEditClick, + modifier = Modifier.padding(8.dp), + ) { + Icon(imageVector = Icons.Default.Edit, contentDescription = stringResource(Res.string.user_edit)) + } + } } } } diff --git a/shared/compose/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/ui/elements/Utility.kt b/shared/compose/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/ui/elements/Utility.kt index 57ff800..cddcc48 100644 --- a/shared/compose/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/ui/elements/Utility.kt +++ b/shared/compose/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/ui/elements/Utility.kt @@ -19,8 +19,15 @@ package edu.ucsc.its.temerity.shared.ui.elements import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.Image import androidx.compose.foundation.ScrollbarStyle import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState @@ -41,9 +48,12 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.onClick import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.ChevronRight import androidx.compose.material3.CardDefaults @@ -63,12 +73,30 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.toSize +import androidx.navigation.NavGraph +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost import com.composables.composetheme.ComposeTheme import com.composables.composetheme.material3.colorScheme import com.composables.composetheme.material3.typography +import com.composables.core.Menu +import com.composables.core.MenuButton +import com.composables.core.MenuContent +import com.composables.core.MenuItem +import com.composables.core.rememberMenuState import kotlinx.coroutines.launch /** @@ -104,7 +132,7 @@ fun AdaptiveAspectRatioBox( } @Composable -fun <T> ScrollableList(lazyListItems: List<T>, itemLayout: @Composable LazyItemScope.(T) -> Unit) { +fun ScrollableContent(content: LazyListScope.() -> Unit) { val unHoverColor = ComposeTheme.colorScheme.surfaceContainerHighest val hoverColor = ComposeTheme.colorScheme.secondaryContainer val scrollbarStyle = ScrollbarStyle( @@ -132,12 +160,10 @@ fun <T> ScrollableList(lazyListItems: List<T>, itemLayout: @Composable LazyItemS ) .padding(end = 4.dp), state = scrollState, - verticalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 24.dp), ) { - items(lazyListItems) { item: T -> - itemLayout(item) - } + content() } VerticalScrollbar( adapter = rememberScrollbarAdapter(scrollState), @@ -150,6 +176,15 @@ fun <T> ScrollableList(lazyListItems: List<T>, itemLayout: @Composable LazyItemS } } +@Composable +fun <T> ScrollableList(lazyListItems: List<T> = emptyList(), itemLayout: @Composable LazyItemScope.(T) -> Unit) { + ScrollableContent { + items(lazyListItems) { item -> + itemLayout(item) + } + } +} + @Composable fun CardRow( title: String, @@ -180,11 +215,9 @@ fun CardRow( text = title, color = textColor, style = ComposeTheme.typography.titleLarge, - ) - Spacer( - modifier = Modifier - .weight(1f) - .background(spacerColor), + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) IconButton(onClick = { expanded = expanded.not() }) { Icon( @@ -222,3 +255,95 @@ fun CardRow( } } } + +@Composable +fun NewNavHost(navController: NavHostController, navGraph: NavGraph) = NavHost( + navController, + navGraph, + enterTransition = { + fadeIn() + slideInHorizontally(initialOffsetX = { it / 2 }) + }, + exitTransition = { + fadeOut() + slideOutHorizontally(targetOffsetX = { it / 2 }) + }, + popExitTransition = { + fadeOut() + slideOutVertically(targetOffsetY = { -it }) + }, +) + +@Composable +fun ChevronDown(): ImageVector { + val iconColor = ComposeTheme.colorScheme.onSurface + return remember { + ImageVector.Builder( + name = "ChevronDown", + defaultWidth = 16.dp, + defaultHeight = 16.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ).apply { + path( + fill = null, + fillAlpha = 1.0f, + stroke = SolidColor(iconColor), + strokeAlpha = 1.0f, + strokeLineWidth = 2f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + strokeLineMiter = 1.0f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(6f, 9f) + lineToRelative(6f, 6f) + lineToRelative(6f, -6f) + } + }.build() + } +} + +@Composable +fun DropdownMenu( + items: List<String>, + onItemSelected: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val state = rememberMenuState(expanded = false) + var selectedItem by mutableStateOf("Select") + val surfaceColor = ComposeTheme.colorScheme.surface + val textColor = ComposeTheme.colorScheme.onSurface + + Box(modifier = modifier) { + Menu(modifier = Modifier.align(Alignment.TopStart).width(240.dp), state = state) { + val degrees by animateFloatAsState(if (state.expanded) -180f else 0f) + + MenuButton( + Modifier.clip(RoundedCornerShape(6.dp)).background(surfaceColor) + .border(1.dp, Color(0xFFBDBDBD), RoundedCornerShape(6.dp)), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + ) { + BasicText(selectedItem, modifier = Modifier.widthIn(min = 160.dp), style = TextStyle(fontWeight = FontWeight(500), color = textColor)) + Spacer(Modifier.width(4.dp)) + Image(ChevronDown(), null, Modifier.rotate(degrees)) + } + } + + MenuContent( + modifier = Modifier.padding(top = 4.dp).width(320.dp).clip(RoundedCornerShape(6.dp)) + .border(1.dp, Color(0xFFE0E0E0), RoundedCornerShape(6.dp)).background(surfaceColor).padding(4.dp), + exit = fadeOut(), + ) { + items.forEach { + MenuItem(modifier = Modifier.clip(RoundedCornerShape(6.dp)), onClick = { + selectedItem = it + onItemSelected(it) + }) { + BasicText(it, Modifier.fillMaxWidth().padding(vertical = 10.dp, horizontal = 10.dp), style = TextStyle(color = textColor)) + } + } + } + } + } +} diff --git a/shared/shared/build.gradle.kts b/shared/shared/build.gradle.kts index b8405b4..a4fae46 100644 --- a/shared/shared/build.gradle.kts +++ b/shared/shared/build.gradle.kts @@ -41,6 +41,10 @@ plugins { alias(libs.plugins.room) } +dependencies { + ksp(libs.arrow.opticsKspPlugin) +} + kotlin { jvmToolchain(libs.versions.java.get().toInt()) jvm() @@ -59,7 +63,9 @@ kotlin { implementation(libs.koin.composeVm) api(libs.kermit) api(libs.kermit.koin) - + implementation(libs.arrow.core) + implementation(libs.arrow.fxCoroutines) + implementation(libs.arrow.optics) api(libs.kotlinx.serialization.json) api(libs.kstore) api(libs.kstore.file) @@ -70,6 +76,7 @@ kotlin { implementation(libs.androidx.room.runtime) implementation(libs.sqlite.bundled) implementation(libs.datastore.preferences) + implementation(libs.bundles.exposed) } } val jvmMain by getting { diff --git a/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/AppSettings.kt b/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/AppSettings.kt index 71fe669..e0b26c2 100644 --- a/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/AppSettings.kt +++ b/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/AppSettings.kt @@ -1,3 +1,19 @@ +/* + * Designed and developed in 2022-2024 by William Walker (wnwalker@ucsc.edu) + * Copyright 2022-2024 The Regents of the University of California. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package edu.ucsc.its.temerity.shared import androidx.datastore.core.DataStore @@ -10,37 +26,37 @@ import kotlinx.coroutines.flow.map import okio.Path.Companion.toPath fun createDataStore( - producePath: () -> String, + producePath: () -> String, ): DataStore<Preferences> = PreferenceDataStoreFactory.createWithPath( - corruptionHandler = null, - migrations = emptyList(), - produceFile = { producePath().toPath() }, + corruptionHandler = null, + migrations = emptyList(), + produceFile = { producePath().toPath() }, ) class AppSettings(private val dataStore: DataStore<Preferences>) { - val userServiceEndpoint: Flow<String> = dataStore.data.map { preferences -> - preferences[USER_ENDPOINT_URL_SETTING] ?: "" - } + val userServiceEndpoint: Flow<String> = dataStore.data.map { preferences -> + preferences[USER_ENDPOINT_URL_SETTING] ?: "" + } - val userAuthToken: Flow<String> = dataStore.data.map { preferences -> - preferences[USER_AUTH_TOKEN_SETTING] ?: "" - } + val userAuthToken: Flow<String> = dataStore.data.map { preferences -> + preferences[USER_AUTH_TOKEN_SETTING] ?: "" + } - suspend fun updateServiceEndpoint(newEndpointUrl: String) { - dataStore.edit { preferences -> - preferences[USER_ENDPOINT_URL_SETTING] = newEndpointUrl - } + suspend fun updateServiceEndpoint(newEndpointUrl: String) { + dataStore.edit { preferences -> + preferences[USER_ENDPOINT_URL_SETTING] = newEndpointUrl } + } - suspend fun updateUserAuthToken(newToken: String) { - dataStore.edit { preferences -> - preferences[USER_AUTH_TOKEN_SETTING] = newToken - } + suspend fun updateUserAuthToken(newToken: String) { + dataStore.edit { preferences -> + preferences[USER_AUTH_TOKEN_SETTING] = newToken } + } - companion object { - val USER_AUTH_TOKEN_SETTING = stringPreferencesKey("user_auth_token") - val USER_ENDPOINT_URL_SETTING = stringPreferencesKey("user_endpoint_url") - } -} \ No newline at end of file + companion object { + val USER_AUTH_TOKEN_SETTING = stringPreferencesKey("user_auth_token") + val USER_ENDPOINT_URL_SETTING = stringPreferencesKey("user_endpoint_url") + } +} diff --git a/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/data/repository/PlatformRepository.kt b/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/data/repository/PlatformRepository.kt index 803f4da..428fc9f 100644 --- a/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/data/repository/PlatformRepository.kt +++ b/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/data/repository/PlatformRepository.kt @@ -1,3 +1,19 @@ +/* + * Designed and developed in 2022-2024 by William Walker (wnwalker@ucsc.edu) + * Copyright 2022-2024 The Regents of the University of California. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package edu.ucsc.its.temerity.shared.data.repository /* @@ -38,57 +54,56 @@ import org.koin.core.parameter.parametersOf interface PlatformRepositoryInterface { - sealed class CacheFlag(val enable: Boolean) { - data object ENABLE: CacheFlag(true) - data object DISABLE: CacheFlag(false) - } + sealed class CacheFlag(val enable: Boolean) { + data object ENABLE : CacheFlag(true) + data object DISABLE : CacheFlag(false) + } - suspend fun fetchGroups(flag: CacheFlag = DISABLE): List<Group> - suspend fun fetchUsers(flag: CacheFlag = DISABLE): List<User> - suspend fun fetchDevices(flag: CacheFlag = DISABLE): List<Device> + suspend fun fetchGroups(flag: CacheFlag = DISABLE): List<Group> + suspend fun fetchUsers(flag: CacheFlag = DISABLE): List<User> + suspend fun fetchDevices(flag: CacheFlag = DISABLE): List<Device> } class PlatformRepository : KoinComponent, PlatformRepositoryInterface { - private lateinit var platformClient: PlatformClient - private val database : AppDatabase by inject() - private val appSettings: AppSettings by inject() - - private val jobScope = CoroutineScope(Dispatchers.IO + Job()) - var updateInterval = mutableLongStateOf(-1L) - - val logger = Logger.withTag("PlatformRepository") + private lateinit var platformClient: PlatformClient + private val database: AppDatabase by inject() + private val appSettings: AppSettings by inject() - init { - jobScope.launch { - loadDataForAppStart() - } - } - - private suspend fun updateServiceEndpoint(newEndpointUrl: String) { - appSettings.updateServiceEndpoint(newEndpointUrl) - platformClient = get { parametersOf(appSettings.userServiceEndpoint, appSettings.userAuthToken) } - // TODO: Test new PC here - } + private val jobScope = CoroutineScope(Dispatchers.IO + Job()) + var updateInterval = mutableLongStateOf(-1L) - private suspend fun updateUserAuthToken(newToken: String) { - appSettings.updateUserAuthToken(newToken) - platformClient = get { parametersOf(appSettings.userServiceEndpoint, appSettings.userAuthToken) } - // TODO: Test new PC here - } - - private suspend fun loadDataForAppStart() { - - } - - override suspend fun fetchGroups(flag: CacheFlag): List<Group> { - TODO("Not yet implemented") - } - - override suspend fun fetchUsers(flag: CacheFlag): List<User> { - TODO("Not yet implemented") - } + val logger = Logger.withTag("PlatformRepository") - override suspend fun fetchDevices(flag: CacheFlag): List<Device> { - TODO("Not yet implemented") + init { + jobScope.launch { + loadDataForAppStart() } + } + + private suspend fun updateServiceEndpoint(newEndpointUrl: String) { + appSettings.updateServiceEndpoint(newEndpointUrl) + platformClient = get { parametersOf(appSettings.userServiceEndpoint, appSettings.userAuthToken) } + // TODO: Test new PC here + } + + private suspend fun updateUserAuthToken(newToken: String) { + appSettings.updateUserAuthToken(newToken) + platformClient = get { parametersOf(appSettings.userServiceEndpoint, appSettings.userAuthToken) } + // TODO: Test new PC here + } + + private suspend fun loadDataForAppStart() { + } + + override suspend fun fetchGroups(flag: CacheFlag): List<Group> { + TODO("Not yet implemented") + } + + override suspend fun fetchUsers(flag: CacheFlag): List<User> { + TODO("Not yet implemented") + } + + override suspend fun fetchDevices(flag: CacheFlag): List<Device> { + TODO("Not yet implemented") + } } diff --git a/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/database/AppDatabase.kt b/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/database/AppDatabase.kt index 38c1ca5..4c9a3b8 100644 --- a/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/database/AppDatabase.kt +++ b/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/database/AppDatabase.kt @@ -1,3 +1,19 @@ +/* + * Designed and developed in 2022-2024 by William Walker (wnwalker@ucsc.edu) + * Copyright 2022-2024 The Regents of the University of California. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package edu.ucsc.its.temerity.shared.database import androidx.room.Database @@ -9,23 +25,22 @@ import edu.ucsc.its.temerity.remote.Group import edu.ucsc.its.temerity.remote.User import kotlinx.datetime.LocalDateTime -internal const val dbFileName = "temerity.db" +internal const val DB_FILENAME = "temerity.db" @Database(entities = [Group::class, User::class, Device::class], version = 1) @TypeConverters(LocalDateTimeConverter::class) abstract class AppDatabase : RoomDatabase() { - abstract fun temerityDao(): TemerityDao - + abstract fun temerityDao(): TemerityDao } class LocalDateTimeConverter { - @TypeConverter - fun fromTimestamp(value: String?): LocalDateTime? { - return value?.let { LocalDateTime.parse(it) } - } + @TypeConverter + fun fromTimestamp(value: String?): LocalDateTime? { + return value?.let { LocalDateTime.parse(it) } + } - @TypeConverter - fun dateToTimestamp(date: LocalDateTime?): String? { - return date?.toString() - } -} \ No newline at end of file + @TypeConverter + fun dateToTimestamp(date: LocalDateTime?): String? { + return date?.toString() + } +} diff --git a/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/database/TemerityDao.kt b/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/database/TemerityDao.kt index fef61ed..f39d950 100644 --- a/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/database/TemerityDao.kt +++ b/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/database/TemerityDao.kt @@ -1,3 +1,19 @@ +/* + * Designed and developed in 2022-2024 by William Walker (wnwalker@ucsc.edu) + * Copyright 2022-2024 The Regents of the University of California. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package edu.ucsc.its.temerity.shared.database import androidx.room.Dao @@ -10,12 +26,12 @@ import edu.ucsc.its.temerity.remote.User @Dao interface TemerityDao { - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertGroupList(groupList: List<Group>) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertGroupList(groupList: List<Group>) - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertUserList(userList: List<User>) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertUserList(userList: List<User>) - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertDeviceList(deviceList: List<Device>) -} \ No newline at end of file + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertDeviceList(deviceList: List<Device>) +} diff --git a/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/di/CommonModule.kt b/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/di/CommonModule.kt index b10b58d..8ce97fd 100644 --- a/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/di/CommonModule.kt +++ b/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/di/CommonModule.kt @@ -16,15 +16,17 @@ */ package edu.ucsc.its.temerity.shared.di +import ca.gosyer.appdirs.AppDirs import co.touchlab.kermit.Logger import co.touchlab.kermit.koin.kermitLoggerModule -import edu.ucsc.its.temerity.shared.data.storageModule import edu.ucsc.its.temerity.platformClient import edu.ucsc.its.temerity.shared.AppSettings import edu.ucsc.its.temerity.shared.data.repository.PlatformRepository -import edu.ucsc.its.temerity.shared.viewmodel.AppStateViewModel +import edu.ucsc.its.temerity.shared.data.storageModule +import edu.ucsc.its.temerity.shared.viewmodel.AppViewModel import edu.ucsc.its.temerity.shared.viewmodel.LoginViewModel import edu.ucsc.its.temerity.shared.viewmodel.RootViewModel +import edu.ucsc.its.temerity.shared.viewmodel.UserEditViewModel import org.koin.compose.viewmodel.dsl.viewModelOf import org.koin.core.context.startKoin import org.koin.dsl.KoinAppDeclaration @@ -38,9 +40,10 @@ fun initKoin(enableNetworkLogs: Boolean = false, appDeclaration: KoinAppDeclarat } internal fun commonModule(isDebuggingEnabled: Boolean = false) = module { - viewModelOf(::AppStateViewModel) + viewModelOf(::AppViewModel) viewModelOf(::LoginViewModel) viewModelOf(::RootViewModel) + viewModelOf(::UserEditViewModel) single { (serviceEndpoint: String, serviceToken: String) -> platformClient { debugEnabled(isDebuggingEnabled) @@ -50,6 +53,9 @@ internal fun commonModule(isDebuggingEnabled: Boolean = false) = module { } single { PlatformRepository() } single { AppSettings(get()) } + single<String> { (packageName: String) -> + AppDirs("", packageName).getUserConfigDir() + } includes(storageModule(), kermitLoggerModule(Logger.withTag("compose koin context"))) } diff --git a/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/viewmodel/AppStateViewModel.kt b/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/viewmodel/AppViewModel.kt similarity index 95% rename from shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/viewmodel/AppStateViewModel.kt rename to shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/viewmodel/AppViewModel.kt index 7b11602..c251b49 100644 --- a/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/viewmodel/AppStateViewModel.kt +++ b/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/viewmodel/AppViewModel.kt @@ -41,7 +41,7 @@ data class AppState( val userLoggedIn: Boolean = false, ) -class AppStateViewModel : MoleculeViewModel<AppStateEvent, AppState>(), KoinComponent { +class AppViewModel : MoleculeViewModel<AppStateEvent, AppState>(), KoinComponent { private var serviceEndpoint by mutableStateOf("") private var serviceToken by mutableStateOf("") private var userLoggedIn by mutableStateOf(false) @@ -53,9 +53,9 @@ class AppStateViewModel : MoleculeViewModel<AppStateEvent, AppState>(), KoinComp @Composable private fun presentAppState(events: Flow<AppStateEvent>, platformClient: PlatformClient): AppState { - LaunchedEffect(Unit){ + LaunchedEffect(Unit) { events.collect { event -> - when(event) { + when (event) { is AppStateEvent.ServiceEndpointUpdated -> { viewModelScope.launch { serviceEndpoint = event.newEndpoint @@ -78,6 +78,5 @@ class AppStateViewModel : MoleculeViewModel<AppStateEvent, AppState>(), KoinComp } private fun attemptLogin() { - } } diff --git a/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/viewmodel/DevicesScreenViewModel.kt b/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/viewmodel/DevicesScreenViewModel.kt index 86cef55..df1d3cd 100644 --- a/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/viewmodel/DevicesScreenViewModel.kt +++ b/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/viewmodel/DevicesScreenViewModel.kt @@ -1,3 +1,19 @@ +/* + * Designed and developed in 2022-2024 by William Walker (wnwalker@ucsc.edu) + * Copyright 2022-2024 The Regents of the University of California. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package edu.ucsc.its.temerity.shared.viewmodel import androidx.compose.runtime.Composable @@ -11,40 +27,36 @@ import edu.ucsc.its.temerity.remote.Device import kotlinx.coroutines.flow.Flow import org.koin.compose.getKoin -sealed interface DevicesScreenEvent { - -} +sealed interface DevicesScreenEvent data class DevicesScreenState( - val loading: Boolean, - val devices: List<Device> + val loading: Boolean, + val devices: List<Device>, ) class DevicesScreenViewModel() : MoleculeViewModel<DevicesScreenEvent, DevicesScreenState>() { - @Composable - override fun models(events: Flow<DevicesScreenEvent>): DevicesScreenState { - return devicesScreenPresenter(events, getKoin().get()) - } - + @Composable + override fun models(events: Flow<DevicesScreenEvent>): DevicesScreenState { + return devicesScreenPresenter(events, getKoin().get()) + } } @Composable fun devicesScreenPresenter(events: Flow<DevicesScreenEvent>, client: PlatformClient): DevicesScreenState { - var devices: List<Device> by remember { mutableStateOf(emptyList()) } + var devices: List<Device> by remember { mutableStateOf(emptyList()) } - LaunchedEffect(Unit) { - devices = client.getDevices() - } - - LaunchedEffect(Unit) { - events.collect { event -> + LaunchedEffect(Unit) { + devices = client.getDevices() + } - } + LaunchedEffect(Unit) { + events.collect { event -> } + } - return DevicesScreenState( - loading = false, - devices = devices - ) -} \ No newline at end of file + return DevicesScreenState( + loading = false, + devices = devices, + ) +} diff --git a/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/viewmodel/LoginViewModel.kt b/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/viewmodel/LoginViewModel.kt index 7e57f09..e8d7574 100644 --- a/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/viewmodel/LoginViewModel.kt +++ b/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/viewmodel/LoginViewModel.kt @@ -47,14 +47,13 @@ class LoginViewModel : MoleculeViewModel<LoginEvent, LoginState>(), KoinComponen return loginScreenPresenter(events) } - @Composable fun loginScreenPresenter(eventCollector: Flow<LoginEvent>): LoginState { LaunchedEffect(Unit) { eventCollector.collect { event -> when (event) { is LoginEvent.AttemptLogin -> { - viewModelScope.launch(Dispatchers.IO){ + viewModelScope.launch(Dispatchers.IO) { try { // TODO: Update token and refresh platform client injected into repository here. events.tryEmit(LoginEvent.LoginSuccess) diff --git a/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/viewmodel/MoleculeViewModel.kt b/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/viewmodel/MoleculeViewModel.kt index 5a2f87d..b8b257e 100644 --- a/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/viewmodel/MoleculeViewModel.kt +++ b/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/viewmodel/MoleculeViewModel.kt @@ -1,27 +1,24 @@ /* - * Copyright (C) 2022 Square, Inc. + * Designed and developed in 2022-2024 by William Walker (wnwalker@ucsc.edu) + * Copyright 2022-2024 The Regents of the University of California. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. - * - * Adapted from https://github.com/cashapp/molecule/blob/9cedc80ab10193a7c3afecbd5b2cb514aa88f9ff/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/presentationLogic.kt - * Modifications made by William Walker (wnwalker@ucsc.edu) in June 2024 */ package edu.ucsc.its.temerity.shared.viewmodel import androidx.compose.runtime.Composable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import app.cash.molecule.RecompositionMode.ContextClock import app.cash.molecule.RecompositionMode.Immediate import app.cash.molecule.launchMolecule import kotlinx.coroutines.flow.Flow @@ -29,20 +26,20 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.StateFlow abstract class MoleculeViewModel<Event, Model> : ViewModel() { - internal val events = MutableSharedFlow<Event>(extraBufferCapacity = 20) + internal val events = MutableSharedFlow<Event>(extraBufferCapacity = 20) - val models: StateFlow<Model> by lazy(LazyThreadSafetyMode.NONE) { - viewModelScope.launchMolecule(mode = Immediate) { - models(events) - } + val models: StateFlow<Model> by lazy(LazyThreadSafetyMode.NONE) { + viewModelScope.launchMolecule(mode = Immediate) { + models(events) } + } - fun take(event: Event) { - if (!events.tryEmit(event)) { - error("Event buffer overflow.") - } + fun take(event: Event) { + if (!events.tryEmit(event)) { + error("Event buffer overflow.") } + } - @Composable - protected abstract fun models(events: Flow<Event>): Model -} \ No newline at end of file + @Composable + protected abstract fun models(events: Flow<Event>): Model +} diff --git a/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/viewmodel/RootViewModel.kt b/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/viewmodel/RootViewModel.kt index a5018c0..091f4aa 100644 --- a/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/viewmodel/RootViewModel.kt +++ b/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/viewmodel/RootViewModel.kt @@ -1,3 +1,19 @@ +/* + * Designed and developed in 2022-2024 by William Walker (wnwalker@ucsc.edu) + * Copyright 2022-2024 The Regents of the University of California. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package edu.ucsc.its.temerity.shared.viewmodel import androidx.compose.runtime.Composable @@ -7,22 +23,21 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.get sealed class RootEvent { - data object NavigateToDevicesPane : RootEvent() + data object NavigateToDevicesPane : RootEvent() } data class RootState( - val loading: Boolean = false + val loading: Boolean = false, ) class RootViewModel : MoleculeViewModel<RootEvent, RootState>(), KoinComponent { - @Composable - override fun models(events: Flow<RootEvent>): RootState { - return rootPresenter(events, get<PlatformClient>()) - } + @Composable + override fun models(events: Flow<RootEvent>): RootState { + return rootPresenter(events, get<PlatformClient>()) + } - private fun rootPresenter(events: Flow<RootEvent>, client: PlatformClient): RootState { - return RootState(false) - } - -} \ No newline at end of file + private fun rootPresenter(events: Flow<RootEvent>, client: PlatformClient): RootState { + return RootState(false) + } +} diff --git a/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/viewmodel/UserEditViewModel.kt b/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/viewmodel/UserEditViewModel.kt new file mode 100644 index 0000000..173bf0e --- /dev/null +++ b/shared/shared/src/commonMain/kotlin/edu/ucsc/its/temerity/shared/viewmodel/UserEditViewModel.kt @@ -0,0 +1,118 @@ +/* + * Designed and developed in 2022-2024 by William Walker (wnwalker@ucsc.edu) + * Copyright 2022-2024 The Regents of the University of California. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package edu.ucsc.its.temerity.shared.viewmodel + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import edu.ucsc.its.temerity.remote.User +import edu.ucsc.its.temerity.shared.viewmodel.UserEditEvent.ClearField +import edu.ucsc.its.temerity.shared.viewmodel.UserEditEvent.LoadUser +import edu.ucsc.its.temerity.shared.viewmodel.UserEditEvent.Save +import edu.ucsc.its.temerity.shared.viewmodel.UserEditEvent.UpdateField +import kotlinx.coroutines.flow.Flow + +sealed class UserEditEvent { + data class LoadUser(val user: User?) : UserEditEvent() + data object Save : UserEditEvent() + data class ClearField(val fieldName: String) : UserEditEvent() + data class UpdateField(val fieldName: String, val value: String) : UserEditEvent() +} + +data class UserEditState( + var firstName: String = "", + var lastName: String = "", + var loginId: String = "", + var emailAddress: String = "", + val timezone: String = "", + val role: String = "", + val lockUser: Boolean = false, +) + +class UserEditViewModel : MoleculeViewModel<UserEditEvent, UserEditState>() { + @Composable + override fun models(events: Flow<UserEditEvent>): UserEditState { + return userEditPresenter(events) + } + + @Composable + fun userEditPresenter(events: Flow<UserEditEvent>): UserEditState { + var state by remember { mutableStateOf(UserEditState()) } + + LaunchedEffect(Unit) { + events.collect { + when (it) { + is LoadUser -> { + val user = it.user + if (user != null) { + state = state.copy( + firstName = user.firstName, + lastName = user.lastName, + loginId = user.loginId, + emailAddress = user.emailAddress, + timezone = user.timezone, + role = user.userType, + ) + } + } + is Save -> { + // Save the user edits + } + is ClearField -> { + state = updateField(it.fieldName, "", state) + } + + is UpdateField -> { + state = updateField(it.fieldName, it.value, state) + } + } + } + } + return state + } + + private fun updateField(fieldName: String, value: String, state: UserEditState): UserEditState { + return when (fieldName) { + "firstName" -> state.copy(firstName = value) + "lastName" -> state.copy(lastName = value) + "loginId" -> state.copy(loginId = value) + "emailAddress" -> state.copy(emailAddress = value) + "timezone" -> state.copy(timezone = value) + "role" -> state.copy(role = value) + else -> state + } + } + + suspend fun loadUser(user: User?) { + events.emit(LoadUser(user)) + } + + suspend fun clearField(fieldName: String) { + events.emit(ClearField(fieldName)) + } + + suspend fun updateField(fieldName: String, value: String) { + events.emit(UpdateField(fieldName, value)) + } + + suspend fun saveUserEdits() { + events.emit(Save) + } +} diff --git a/shared/shared/src/jvmMain/kotlin/edu/ucsc/its/temerity/shared/data/Actual.StorageModule.kt b/shared/shared/src/jvmMain/kotlin/edu/ucsc/its/temerity/shared/data/Actual.StorageModule.kt index 9256ee8..aec9deb 100644 --- a/shared/shared/src/jvmMain/kotlin/edu/ucsc/its/temerity/shared/data/Actual.StorageModule.kt +++ b/shared/shared/src/jvmMain/kotlin/edu/ucsc/its/temerity/shared/data/Actual.StorageModule.kt @@ -22,43 +22,28 @@ import androidx.room.Room import androidx.sqlite.driver.bundled.BundledSQLiteDriver import edu.ucsc.its.temerity.shared.createDataStore import edu.ucsc.its.temerity.shared.database.AppDatabase -import edu.ucsc.its.temerity.shared.database.dbFileName -import java.io.File -import net.harawata.appdirs.AppDirsFactory -import okio.FileSystem -import okio.Path.Companion.toPath +import edu.ucsc.its.temerity.shared.database.DB_FILENAME import org.koin.core.context.GlobalContext.get import org.koin.core.parameter.parametersOf import org.koin.dsl.module - +import java.io.File internal actual fun storageModule() = module { - single<String> { (packageName: String, version: String, organization: String) -> - getAppDataDir(packageName, version, organization) - } single { dataStore() } single<AppDatabase> { createRoomDatabase() } } -private fun getAppDataDir(packageName: String, version: String, organization: String): String { - val appDataDir = AppDirsFactory.getInstance().getUserDataDir(packageName, version, organization) - val path = appDataDir.toPath() - if (!FileSystem.SYSTEM.exists(path)) { - FileSystem.SYSTEM.createDirectories(path) - } - return appDataDir -} - fun createRoomDatabase(): AppDatabase { - val appDataDir = get().get<String> { parametersOf("edu.ucsc.its.temerity", "1.0", "UCSC") } - val dbFile = File(appDataDir, dbFileName) + val appDataDir = get().get<String> { parametersOf("edu.ucsc.its.temerity") } + val dbFile = File(appDataDir, DB_FILENAME) return Room.databaseBuilder<AppDatabase>( - name = dbFile.absolutePath,) + name = dbFile.absolutePath, + ) .setDriver(BundledSQLiteDriver()) .build() } fun dataStore(): DataStore<Preferences> = createDataStore( - producePath = { "temerity.preferences_pb" } - ) \ No newline at end of file + producePath = { "temerity.preferences_pb" }, + ) diff --git a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/TemerityLibrary.kt b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/TemerityLibrary.kt index 27970fa..95086c3 100644 --- a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/TemerityLibrary.kt +++ b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/TemerityLibrary.kt @@ -27,7 +27,7 @@ import org.koin.dsl.koinApplication * */ internal object TemerityLibraryKoinContext { - internal const val TIMEOUT = 15000L + internal const val TIMEOUT = 120000L private val koinApp = koinApplication { modules(libModule) diff --git a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/remote/PlatformApi.kt b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/remote/PlatformApi.kt index 20f2333..bed4b22 100644 --- a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/remote/PlatformApi.kt +++ b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/remote/PlatformApi.kt @@ -63,12 +63,12 @@ internal interface PlatformApi { @GET("users/{userId}/groups/content_owners") suspend fun getUserGroupsOwned(@Path userId: Long): HttpStatement - @POST("services/media/permission") + @POST("media/permission") suspend fun addFolderPermission( @Body addFolderPermissions: String, ): ApiResponse<HttpResponse> - @POST("services/media/permission") + @POST("media/permission") suspend fun removeFolderPermission( @Body deleteFolderPermissions: String, ): ApiResponse<HttpResponse> -- GitLab