From 91c3d3094d7cdf5e94e1e6db9007ba4d0b64fb74 Mon Sep 17 00:00:00 2001 From: Kevin Schildhorn Date: Fri, 8 Dec 2023 13:28:23 -0500 Subject: [PATCH] Adding cache and cleaning up --- .../src/androidMain/res/values/strings.xml | 2 +- iosApp/Configuration/Config.xcconfig | 2 +- shared/build.gradle.kts | 2 + .../ui/shared/SharedImageAndroid.kt | 3 +- .../ui/shared/SharedImageConverter.kt | 29 +++++++ shared/src/commonMain/kotlin/App.kt | 8 +- .../com/kevinschildhorn/fotopresenter/Koin.kt | 4 + .../fotopresenter/data/State.kt | 3 + .../data/datasources/ImageCacheDataSource.kt | 19 +++++ .../repositories/CredentialsRepository.kt | 4 + .../fotopresenter/domain/LogoutUseCase.kt | 19 +++++ .../domain/RetrieveImagesUseCase.kt | 34 +++++++++ .../fotopresenter/extension/ListExtension.kt | 19 +++++ .../ui/compose/DirectoryScreen.kt | 34 +++++++-- .../ui/compose/ImagePreviewOverlay.kt | 76 ++++++++++++++++--- .../ui/compose/directory/DirectoryGrid.kt | 6 +- .../ui/compose/login/LoginCheckbox.kt | 4 +- .../compose/login/LoginPasswordTextField.kt | 31 +++++++- .../fotopresenter/ui/shared/SharedCache.kt | 17 +++++ .../fotopresenter/ui/shared/SharedImage.kt | 19 ----- .../ui/shared/SharedImageConverter.kt | 28 +++++++ .../ui/state/DirectoryScreenState.kt | 29 ++++++- .../ui/state/LoginScreenState.kt | 1 + .../ui/viewmodel/DirectoryViewModel.kt | 69 ++++++++++++++--- .../ui/viewmodel/LoginViewModel.kt | 8 +- .../fotopresenter/domain/LogoutUseCaseTest.kt | 49 ++++++++++++ .../ui/viewmodel/DirectoryViewModelTest.kt | 4 +- .../fotopresenter/ui/shared/SharedImage.kt | 1 + .../ui/shared/SharedImageConverter.kt | 18 +++++ 29 files changed, 475 insertions(+), 67 deletions(-) create mode 100644 shared/src/androidMain/kotlin/com/kevinschildhorn/fotopresenter/ui/shared/SharedImageConverter.kt create mode 100644 shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/data/datasources/ImageCacheDataSource.kt create mode 100644 shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/domain/LogoutUseCase.kt create mode 100644 shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/domain/RetrieveImagesUseCase.kt create mode 100644 shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/extension/ListExtension.kt create mode 100644 shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/shared/SharedCache.kt create mode 100644 shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/shared/SharedImageConverter.kt create mode 100644 shared/src/commonTest/kotlin/com/kevinschildhorn/fotopresenter/domain/LogoutUseCaseTest.kt create mode 100644 shared/src/desktopMain/kotlin/com/kevinschildhorn/fotopresenter/ui/shared/SharedImageConverter.kt diff --git a/androidApp/src/androidMain/res/values/strings.xml b/androidApp/src/androidMain/res/values/strings.xml index 592270bf..741d4431 100644 --- a/androidApp/src/androidMain/res/values/strings.xml +++ b/androidApp/src/androidMain/res/values/strings.xml @@ -1,3 +1,3 @@ - My application + FotoPresenter \ No newline at end of file diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig index 2327a19a..f28f0fe4 100644 --- a/iosApp/Configuration/Config.xcconfig +++ b/iosApp/Configuration/Config.xcconfig @@ -1,3 +1,3 @@ TEAM_ID= BUNDLE_ID=com.kevinschildhorn.fotopresenter -APP_NAME=My application +APP_NAME=Foto Presenter diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index bbf1698f..ddb54951 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -33,6 +33,8 @@ kotlin { implementation(compose.components.resources) implementation("br.com.devsrsouza.compose.icons:eva-icons:1.1.0") + implementation("io.github.reactivecircus.cache4k:cache4k:0.12.0") + implementation("io.insert-koin:koin-core:3.4.0") implementation("androidx.security:security-crypto:1.1.0-alpha06") implementation("co.touchlab:kermit:1.2.2") diff --git a/shared/src/androidMain/kotlin/com/kevinschildhorn/fotopresenter/ui/shared/SharedImageAndroid.kt b/shared/src/androidMain/kotlin/com/kevinschildhorn/fotopresenter/ui/shared/SharedImageAndroid.kt index 86a62a88..ff21da3c 100644 --- a/shared/src/androidMain/kotlin/com/kevinschildhorn/fotopresenter/ui/shared/SharedImageAndroid.kt +++ b/shared/src/androidMain/kotlin/com/kevinschildhorn/fotopresenter/ui/shared/SharedImageAndroid.kt @@ -14,6 +14,5 @@ actual fun getBitmapFromFile(file: File, size: Int): ImageBitmap? { return Bitmap.createScaledBitmap(it, dimensions.first, dimensions.second, false) .asImageBitmap() } - return null -} +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/com/kevinschildhorn/fotopresenter/ui/shared/SharedImageConverter.kt b/shared/src/androidMain/kotlin/com/kevinschildhorn/fotopresenter/ui/shared/SharedImageConverter.kt new file mode 100644 index 00000000..5646bc5a --- /dev/null +++ b/shared/src/androidMain/kotlin/com/kevinschildhorn/fotopresenter/ui/shared/SharedImageConverter.kt @@ -0,0 +1,29 @@ +package com.kevinschildhorn.fotopresenter.ui.shared + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.asImageBitmap +import java.nio.ByteBuffer + +actual object SharedImageConverter { + actual fun convertBytes(byteArray: ByteArray): ImageBitmap { + val bmp = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) + return bmp.asImageBitmap() + } + + actual fun convertImage(imageBitmap: ImageBitmap): ByteArray { + return imageBitmap.asAndroidBitmap().convertToByteArray() + } + + private fun Bitmap.convertToByteArray(): ByteArray { + val size = this.byteCount + val buffer = ByteBuffer.allocate(size) + val bytes = ByteArray(size) + this.copyPixelsToBuffer(buffer) + buffer.rewind() + buffer.get(bytes) + return bytes + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/App.kt b/shared/src/commonMain/kotlin/App.kt index 21381d8f..3a951d83 100644 --- a/shared/src/commonMain/kotlin/App.kt +++ b/shared/src/commonMain/kotlin/App.kt @@ -19,9 +19,15 @@ fun App( when (currentScreen.value) { Screen.LOGIN -> LoginScreen(loginViewModel) { + directoryViewModel.setLoggedIn() currentScreen.value = Screen.DIRECTORY } - Screen.DIRECTORY -> DirectoryScreen(directoryViewModel) + + Screen.DIRECTORY -> + DirectoryScreen(directoryViewModel) { + loginViewModel.setLoggedOut() + currentScreen.value = Screen.LOGIN + } } } } diff --git a/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/Koin.kt b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/Koin.kt index 18c20576..4fd20e16 100644 --- a/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/Koin.kt +++ b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/Koin.kt @@ -11,7 +11,9 @@ import com.kevinschildhorn.fotopresenter.data.repositories.ImageRepository import com.kevinschildhorn.fotopresenter.domain.AutoConnectUseCase import com.kevinschildhorn.fotopresenter.domain.ChangeDirectoryUseCase import com.kevinschildhorn.fotopresenter.domain.ConnectToServerUseCase +import com.kevinschildhorn.fotopresenter.domain.LogoutUseCase import com.kevinschildhorn.fotopresenter.domain.RetrieveDirectoryContentsUseCase +import com.kevinschildhorn.fotopresenter.domain.RetrieveImagesUseCase import com.kevinschildhorn.fotopresenter.domain.SaveCredentialsUseCase import com.kevinschildhorn.fotopresenter.ui.viewmodel.DirectoryViewModel import com.kevinschildhorn.fotopresenter.ui.viewmodel.LoginViewModel @@ -35,6 +37,7 @@ val commonModule = factory { ChangeDirectoryUseCase(get(), baseLogger.withTag("ChangeDirectoryUseCase")) } factory { AutoConnectUseCase(get(), get(), baseLogger.withTag("AutoConnectUseCase")) } factory { SaveCredentialsUseCase(get(), baseLogger.withTag("SaveCredentialsUseCase")) } + factory { LogoutUseCase(get(), get(), baseLogger.withTag("LogoutUseCase")) } factory { RetrieveDirectoryContentsUseCase( get(), @@ -42,6 +45,7 @@ val commonModule = baseLogger.withTag("RetrieveDirectoryContentsUseCase"), ) } + factory { RetrieveImagesUseCase(baseLogger.withTag("RetrieveImagesUseCase")) } // UI single { LoginViewModel(baseLogger.withTag("LoginViewModel"), get()) } diff --git a/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/data/State.kt b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/data/State.kt index 96e86cc5..cf5cf657 100644 --- a/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/data/State.kt +++ b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/data/State.kt @@ -33,6 +33,9 @@ sealed class State { return this } + val value: DATA? + get() = (this as? SUCCESS)?.data + @Composable fun asComposable(modifier: Modifier = Modifier) { when (this) { diff --git a/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/data/datasources/ImageCacheDataSource.kt b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/data/datasources/ImageCacheDataSource.kt new file mode 100644 index 00000000..b2944695 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/data/datasources/ImageCacheDataSource.kt @@ -0,0 +1,19 @@ +package com.kevinschildhorn.fotopresenter.data.datasources + +import androidx.compose.ui.graphics.ImageBitmap +import com.kevinschildhorn.fotopresenter.data.network.NetworkDirectoryDetails +import com.kevinschildhorn.fotopresenter.ui.shared.SharedCache + +class ImageCacheDataSource { + fun getImage(directory: NetworkDirectoryDetails): ImageBitmap? = SharedCache.getImage(directory.cacheId) + + fun saveImage( + directory: NetworkDirectoryDetails, + bitmap: ImageBitmap, + ) { + SharedCache.cacheImage(directory.cacheId, bitmap) + } + + private val NetworkDirectoryDetails.cacheId: String + get() = "$name.$id" +} diff --git a/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/data/repositories/CredentialsRepository.kt b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/data/repositories/CredentialsRepository.kt index 83e9c95c..5227449c 100644 --- a/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/data/repositories/CredentialsRepository.kt +++ b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/data/repositories/CredentialsRepository.kt @@ -31,4 +31,8 @@ class CredentialsRepository( dataSource.sharedFolder = sharedFolder dataSource.shouldAutoConnect = shouldAutoConnect } + + fun clearAutoConnect() { + dataSource.shouldAutoConnect = false + } } diff --git a/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/domain/LogoutUseCase.kt b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/domain/LogoutUseCase.kt new file mode 100644 index 00000000..9ee1dd66 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/domain/LogoutUseCase.kt @@ -0,0 +1,19 @@ +package com.kevinschildhorn.fotopresenter.domain + +import co.touchlab.kermit.Logger +import com.kevinschildhorn.fotopresenter.data.network.NetworkHandler +import com.kevinschildhorn.fotopresenter.data.repositories.CredentialsRepository + +/** +Logging out of App + **/ +class LogoutUseCase( + private val credentialsRepository: CredentialsRepository, + private val networkHandler: NetworkHandler, + private val logger: Logger, +) { + suspend operator fun invoke() { + networkHandler.disconnect() + credentialsRepository.clearAutoConnect() + } +} diff --git a/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/domain/RetrieveImagesUseCase.kt b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/domain/RetrieveImagesUseCase.kt new file mode 100644 index 00000000..79e94b23 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/domain/RetrieveImagesUseCase.kt @@ -0,0 +1,34 @@ +package com.kevinschildhorn.fotopresenter.domain + +import androidx.compose.ui.graphics.ImageBitmap +import co.touchlab.kermit.Logger +import com.kevinschildhorn.fotopresenter.data.ImageDirectory +import com.kevinschildhorn.fotopresenter.data.State +import com.kevinschildhorn.fotopresenter.ui.shared.SharedCache + +/** +Retrieving Directories from Location + **/ +class RetrieveImagesUseCase( + private val logger: Logger, +) { + suspend operator fun invoke( + directory: ImageDirectory, + callback: suspend (State) -> Unit, + ) { + callback(State.LOADING) + SharedCache.getImage(directory.name)?.let { + callback(State.SUCCESS(it)) + } + val imageBitmap: ImageBitmap? = directory.image?.getImageBitmap(400) + + callback( + imageBitmap?.let { + State.SUCCESS(it) + } ?: State.ERROR("No Image Found"), + ) + imageBitmap?.let { + SharedCache.cacheImage(directory.name, it) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/extension/ListExtension.kt b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/extension/ListExtension.kt new file mode 100644 index 00000000..267f395f --- /dev/null +++ b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/extension/ListExtension.kt @@ -0,0 +1,19 @@ +package com.kevinschildhorn.fotopresenter.extension + +fun List.getNextIndex(index: Int): Int { + val nextIndex = index + 1 + return if (nextIndex >= this.count()) { + 0 + } else { + nextIndex + } +} + +fun List.getPreviousIndex(index: Int): Int { + val previousIndex = index - 1 + return if (previousIndex < 0) { + this.count() - 1 + } else { + previousIndex + } +} diff --git a/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/compose/DirectoryScreen.kt b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/compose/DirectoryScreen.kt index fa2239cd..248b7548 100644 --- a/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/compose/DirectoryScreen.kt +++ b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/compose/DirectoryScreen.kt @@ -7,18 +7,25 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import com.kevinschildhorn.fotopresenter.data.State import com.kevinschildhorn.fotopresenter.ui.atoms.Padding +import com.kevinschildhorn.fotopresenter.ui.compose.common.PrimaryButton import com.kevinschildhorn.fotopresenter.ui.compose.directory.DirectoryGrid import com.kevinschildhorn.fotopresenter.ui.viewmodel.DirectoryViewModel @Composable -fun DirectoryScreen(viewModel: DirectoryViewModel) { +fun DirectoryScreen( + viewModel: DirectoryViewModel, + onLogout: () -> Unit, +) { LaunchedEffect(Unit) { viewModel.refreshScreen() } val uiState by viewModel.uiState.collectAsState() + if (!uiState.loggedIn) + { + onLogout() + } uiState.state.asComposable( modifier = Modifier.padding( @@ -26,22 +33,33 @@ fun DirectoryScreen(viewModel: DirectoryViewModel) { vertical = Padding.SMALL.dp, ), ) + PrimaryButton("Logout") { + viewModel.logout() + } DirectoryGrid( uiState.directoryGridState, modifier = Modifier .padding(top = Padding.EXTRA_LARGE.dp), onFolderPressed = { - uiState.selectedImage = State.IDLE viewModel.changeDirectory(it) }, onImageDirectoryPressed = { - uiState.selectedImage = it + viewModel.setSelectedImageById(it) }, ) - if (uiState.selectedImage != State.IDLE) { - ImagePreviewOverlay(uiState.selectedImage) { - uiState.selectedImage = State.IDLE - } + uiState.selectedImage?.let { + ImagePreviewOverlay( + it, + onDismiss = { + viewModel.clearPresentedImage() + }, + onBack = { + viewModel.showPreviousImage() + }, + onForward = { + viewModel.showNextImage() + }, + ) } } diff --git a/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/compose/ImagePreviewOverlay.kt b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/compose/ImagePreviewOverlay.kt index 4d7bb0a2..3c1bf2fb 100644 --- a/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/compose/ImagePreviewOverlay.kt +++ b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/compose/ImagePreviewOverlay.kt @@ -4,28 +4,37 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize +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.material.CircularProgressIndicator -import androidx.compose.material.Text +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Icon import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.unit.dp import com.kevinschildhorn.atomik.color.base.composeColor -import com.kevinschildhorn.fotopresenter.data.State import com.kevinschildhorn.fotopresenter.ui.atoms.FotoColors import com.kevinschildhorn.fotopresenter.ui.atoms.Padding import com.kevinschildhorn.fotopresenter.ui.compose.common.Overlay +import compose.icons.EvaIcons +import compose.icons.evaicons.Fill +import compose.icons.evaicons.fill.ArrowLeft +import compose.icons.evaicons.fill.ArrowRight @Composable fun ImagePreviewOverlay( - imageState: State, + image: ImageBitmap, onDismiss: () -> Unit, + onBack: () -> Unit, + onForward: () -> Unit, ) { val interactionSource = remember { MutableInteractionSource() } @@ -40,12 +49,57 @@ fun ImagePreviewOverlay( onClick = onDismiss, ), ) { - imageState.onSuccess { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.SpaceBetween, + ) { + // imageState.onSuccess { Image( - bitmap = it, + bitmap = image, contentDescription = null, - modifier = Modifier.fillMaxSize().padding(Padding.IMAGE.dp), + modifier = + Modifier + .fillMaxWidth() + .fillMaxHeight(.9f) + .padding(Padding.IMAGE.dp), ) + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(Padding.STANDARD.dp) + .height(44.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Button( + onClick = onBack, + colors = + ButtonDefaults.buttonColors( + backgroundColor = FotoColors.primary.composeColor, + ), + ) { + Icon( + EvaIcons.Fill.ArrowLeft, + contentDescription = null, + tint = FotoColors.surface.composeColor, + ) + } + Button( + onClick = onForward, + colors = + ButtonDefaults.buttonColors( + backgroundColor = FotoColors.primary.composeColor, + ), + ) { + Icon( + EvaIcons.Fill.ArrowRight, + contentDescription = null, + tint = FotoColors.surface.composeColor, + ) + } + } + } + /* }.onLoading { CircularProgressIndicator( modifier = Modifier.width(75.dp).align(Alignment.Center), @@ -53,6 +107,6 @@ fun ImagePreviewOverlay( ) }.onError { Text("Error") - } + }*/ } } diff --git a/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/compose/directory/DirectoryGrid.kt b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/compose/directory/DirectoryGrid.kt index dad3dc10..fd0ad089 100644 --- a/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/compose/directory/DirectoryGrid.kt +++ b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/compose/directory/DirectoryGrid.kt @@ -13,12 +13,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex -import com.kevinschildhorn.fotopresenter.data.State import com.kevinschildhorn.fotopresenter.ui.compose.common.ActionSheet import com.kevinschildhorn.fotopresenter.ui.state.DirectoryGridState import com.kevinschildhorn.fotopresenter.ui.state.FolderDirectoryGridCellState @@ -31,7 +29,7 @@ fun DirectoryGrid( gridSize: Int = 5, modifier: Modifier = Modifier, onFolderPressed: (Int) -> Unit, - onImageDirectoryPressed: (State) -> Unit, + onImageDirectoryPressed: (Int) -> Unit, ) { var actionSheetVisible by remember { mutableStateOf(false) } var contextMenuPhotoId by rememberSaveable { mutableStateOf(null) } @@ -48,7 +46,7 @@ fun DirectoryGrid( .combinedClickable( onClick = { (state as? ImageDirectoryGridCellState)?.let { imageContent -> - onImageDirectoryPressed(imageContent.imageState) + onImageDirectoryPressed(imageContent.id) } ?: run { onFolderPressed(state.id) } diff --git a/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/compose/login/LoginCheckbox.kt b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/compose/login/LoginCheckbox.kt index f56f4d11..6ee1b297 100644 --- a/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/compose/login/LoginCheckbox.kt +++ b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/compose/login/LoginCheckbox.kt @@ -19,13 +19,13 @@ fun LoginCheckbox( ) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End, + horizontalArrangement = horizontalArrangement, modifier = modifier.fillMaxWidth(), ) { Text(title) Checkbox( checked = checked, - onCheckedChange = { onCheckedChange }, + onCheckedChange = onCheckedChange, ) } } diff --git a/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/compose/login/LoginPasswordTextField.kt b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/compose/login/LoginPasswordTextField.kt index 66e8ca1a..6ead248e 100644 --- a/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/compose/login/LoginPasswordTextField.kt +++ b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/compose/login/LoginPasswordTextField.kt @@ -1,13 +1,26 @@ package com.kevinschildhorn.fotopresenter.ui.compose.login +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Icon +import androidx.compose.material.IconButton import androidx.compose.material.OutlinedTextField import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation import com.kevinschildhorn.atomik.atomic.atoms.shape import com.kevinschildhorn.atomik.atomic.atoms.textStyle import com.kevinschildhorn.fotopresenter.ui.atoms.LoginScreenAtoms +import compose.icons.EvaIcons +import compose.icons.evaicons.Fill +import compose.icons.evaicons.fill.Eye +import compose.icons.evaicons.fill.EyeOff @Composable fun LoginPasswordTextField( @@ -17,11 +30,12 @@ fun LoginPasswordTextField( modifier: Modifier = Modifier, ) { val molecule = LoginScreenAtoms.textFieldMolecule + var passwordVisible by rememberSaveable { mutableStateOf(false) } OutlinedTextField( value = value, onValueChange = onValueChange, - visualTransformation = PasswordVisualTransformation(), + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), placeholder = { Text( placeholder, @@ -32,5 +46,20 @@ fun LoginPasswordTextField( shape = molecule.shape, textStyle = molecule.textAtom.textStyle, modifier = modifier, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + trailingIcon = { + val image = + if (passwordVisible) { + EvaIcons.Fill.Eye + } else { + EvaIcons.Fill.EyeOff + } + + val description = if (passwordVisible) "Hide password" else "Show password" + + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon(imageVector = image, description) + } + }, ) } diff --git a/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/shared/SharedCache.kt b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/shared/SharedCache.kt new file mode 100644 index 00000000..c1c17df3 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/shared/SharedCache.kt @@ -0,0 +1,17 @@ +package com.kevinschildhorn.fotopresenter.ui.shared + +import androidx.compose.ui.graphics.ImageBitmap +import io.github.reactivecircus.cache4k.Cache + +object SharedCache { + private val imageCache = Cache.Builder().build() + + fun getImage(id: String): ImageBitmap? = imageCache.get(id) + + fun cacheImage( + id: String, + imageBitmap: ImageBitmap, + ) { + imageCache.put(id, imageBitmap) + } +} diff --git a/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/shared/SharedImage.kt b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/shared/SharedImage.kt index a549381b..b5e38795 100644 --- a/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/shared/SharedImage.kt +++ b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/shared/SharedImage.kt @@ -5,22 +5,3 @@ import androidx.compose.ui.graphics.ImageBitmap expect class SharedImage { fun getImageBitmap(size: Int): ImageBitmap? } - -fun getScaledDimensions( - width: Int, - height: Int, - minSize: Int, -): Pair { - val newWidth: Float - val newHeight: Float - if (height < width) { - newHeight = minSize.toFloat() - val ratio: Float = (newHeight / height) - newWidth = width * ratio - } else { - newWidth = minSize.toFloat() - val ratio: Float = (newWidth / width) - newHeight = height * ratio - } - return Pair(newWidth.toInt(), newHeight.toInt()) -} diff --git a/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/shared/SharedImageConverter.kt b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/shared/SharedImageConverter.kt new file mode 100644 index 00000000..6eae73a1 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/shared/SharedImageConverter.kt @@ -0,0 +1,28 @@ +package com.kevinschildhorn.fotopresenter.ui.shared + +import androidx.compose.ui.graphics.ImageBitmap + +expect object SharedImageConverter { + fun convertBytes(byteArray: ByteArray): ImageBitmap + + fun convertImage(imageBitmap: ImageBitmap): ByteArray +} + +fun getScaledDimensions( + width: Int, + height: Int, + minSize: Int, +): Pair { + val newWidth: Float + val newHeight: Float + if (height < width) { + newHeight = minSize.toFloat() + val ratio: Float = (newHeight / height) + newWidth = width * ratio + } else { + newWidth = minSize.toFloat() + val ratio: Float = (newWidth / width) + newHeight = height * ratio + } + return Pair(newWidth.toInt(), newHeight.toInt()) +} diff --git a/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/state/DirectoryScreenState.kt b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/state/DirectoryScreenState.kt index 122cdabe..39d2a3cb 100644 --- a/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/state/DirectoryScreenState.kt +++ b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/state/DirectoryScreenState.kt @@ -2,11 +2,15 @@ package com.kevinschildhorn.fotopresenter.ui.state import androidx.compose.ui.graphics.ImageBitmap import com.kevinschildhorn.fotopresenter.data.State +import com.kevinschildhorn.fotopresenter.extension.getNextIndex +import com.kevinschildhorn.fotopresenter.extension.getPreviousIndex data class DirectoryScreenState( val currentPath: String = "", var directoryGridState: DirectoryGridState = DirectoryGridState(emptyList(), mutableListOf()), - var selectedImage: State = State.IDLE, + val selectedImageIndex: Int? = null, + val selectedImage: ImageBitmap? = null, + val loggedIn: Boolean = true, override val state: UiState = UiState.IDLE, ) : ScreenState { fun copyImageState( @@ -29,6 +33,29 @@ data class DirectoryScreenState( ), ) } + + fun getImageIndexFromId(id: Int): Int = directoryGridState.imageStates.indexOfFirst { it.id == id } + + fun getImageStateByIndex(): State? = + selectedImageIndex?.let { index -> + directoryGridState.imageStates.getOrNull(index)?.imageState + } + + fun getNextImageIndex(): Int? { + selectedImageIndex?.let { + return directoryGridState.imageStates.getNextIndex(it) + } ?: run { + return null + } + } + + fun getPreviousImageIndex(): Int? { + selectedImageIndex?.let { + return directoryGridState.imageStates.getPreviousIndex(it) + } ?: run { + return null + } + } } data class DirectoryGridState( diff --git a/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/state/LoginScreenState.kt b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/state/LoginScreenState.kt index 053f2482..d3ab2ad5 100644 --- a/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/state/LoginScreenState.kt +++ b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/state/LoginScreenState.kt @@ -33,5 +33,6 @@ data class LoginScreenState( username = username, password = password, sharedFolder = sharedFolder, + shouldAutoConnect = shouldAutoConnect, ) } diff --git a/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/viewmodel/DirectoryViewModel.kt b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/viewmodel/DirectoryViewModel.kt index 2b139e1f..7d62e534 100644 --- a/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/viewmodel/DirectoryViewModel.kt +++ b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/viewmodel/DirectoryViewModel.kt @@ -6,7 +6,9 @@ import com.kevinschildhorn.fotopresenter.data.State import com.kevinschildhorn.fotopresenter.data.network.NetworkDirectoryDetails import com.kevinschildhorn.fotopresenter.data.network.NetworkHandlerException import com.kevinschildhorn.fotopresenter.domain.ChangeDirectoryUseCase +import com.kevinschildhorn.fotopresenter.domain.LogoutUseCase import com.kevinschildhorn.fotopresenter.domain.RetrieveDirectoryContentsUseCase +import com.kevinschildhorn.fotopresenter.domain.RetrieveImagesUseCase import com.kevinschildhorn.fotopresenter.extension.addPath import com.kevinschildhorn.fotopresenter.ui.state.DirectoryGridState import com.kevinschildhorn.fotopresenter.ui.state.DirectoryScreenState @@ -37,6 +39,57 @@ class DirectoryViewModel( updateDirectories() } + fun setLoggedIn() { + _uiState.update { it.copy(loggedIn = true) } + } + + fun logout() { + viewModelScope.launch(Dispatchers.Default) { + val logoutUseCase: LogoutUseCase by inject() + logoutUseCase() + _uiState.update { it.copy(loggedIn = false) } + } + } + //region Image + + fun showPreviousImage() { + val newIndex = _uiState.value.getPreviousImageIndex() + _uiState.update { it.copy(selectedImageIndex = newIndex) } + updateSelectedImage() + } + + fun showNextImage() { + val newIndex = _uiState.value.getNextImageIndex() + _uiState.update { it.copy(selectedImageIndex = newIndex) } + updateSelectedImage() + } + + fun clearPresentedImage() { + _uiState.update { it.copy(selectedImage = null, selectedImageIndex = null) } + } + + fun setSelectedImageById(imageId: Int?) { + var index: Int? = null + imageId?.let { + index = _uiState.value.getImageIndexFromId(it) + } + _uiState.update { it.copy(selectedImageIndex = index) } + updateSelectedImage() + } + + private fun updateSelectedImage() { + val state = + _uiState.value.getImageStateByIndex()?.let { state -> + _uiState.update { it.copy(selectedImage = state.value) } + } ?: run { + _uiState.update { it.copy(selectedImage = null) } + } + } + + //endregion + + //region Directory + fun changeDirectory(id: Int) { _directoryContentsState.value.allDirectories.find { it.id == id }?.let { changeDirectory(it.details) @@ -99,19 +152,9 @@ class DirectoryViewModel( private fun updatePhotos() { _directoryContentsState.value.images.forEach { imageDirectory -> viewModelScope.launch(Dispatchers.Default) { - _uiState.update { - it.copyImageState( - imageDirectory.id, - state = State.LOADING, - ) - } - - val newState = - imageDirectory.image?.getImageBitmap(400)?.let { - State.SUCCESS(it) - } ?: State.ERROR("No Image Found") + val retrieveImagesUseCase: RetrieveImagesUseCase by inject() - viewModelScope.launch(Dispatchers.Main) { + retrieveImagesUseCase(imageDirectory) { newState -> _uiState.update { it.copyImageState( imageDirectory.id, @@ -136,4 +179,6 @@ class DirectoryViewModel( ) }.toMutableList(), ) + + //endregion } diff --git a/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/viewmodel/LoginViewModel.kt b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/viewmodel/LoginViewModel.kt index 9782c1b4..63ca3147 100644 --- a/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/viewmodel/LoginViewModel.kt +++ b/shared/src/commonMain/kotlin/com/kevinschildhorn/fotopresenter/ui/viewmodel/LoginViewModel.kt @@ -88,12 +88,16 @@ class LoginViewModel( } } + fun setLoggedOut() { + _uiState.update { it.copy(state = UiState.IDLE) } + } + private fun attemptAutoLogin() { logger.i { "Attempting To Auto Login" } - viewModelScope.launch { + viewModelScope.launch(Dispatchers.Default) { val autoConnectUseCase: AutoConnectUseCase by inject() if (autoConnectUseCase()) { - // TODO + _uiState.update { it.copy(state = UiState.SUCCESS) } } } } diff --git a/shared/src/commonTest/kotlin/com/kevinschildhorn/fotopresenter/domain/LogoutUseCaseTest.kt b/shared/src/commonTest/kotlin/com/kevinschildhorn/fotopresenter/domain/LogoutUseCaseTest.kt new file mode 100644 index 00000000..6320ea3c --- /dev/null +++ b/shared/src/commonTest/kotlin/com/kevinschildhorn/fotopresenter/domain/LogoutUseCaseTest.kt @@ -0,0 +1,49 @@ +package com.kevinschildhorn.fotopresenter.domain + +import com.kevinschildhorn.fotopresenter.data.network.MockNetworkHandler +import com.kevinschildhorn.fotopresenter.testingModule +import kotlinx.coroutines.runBlocking +import org.junit.Test +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.test.KoinTest +import org.koin.test.inject +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.assertFalse + +/** +Testing [LogoutUseCase] + **/ +class LogoutUseCaseTest : KoinTest { + private val useCase: LogoutUseCase by inject() + + @BeforeTest + fun startTest() { + startKoin { + modules(testingModule()) + } + } + + @AfterTest + fun tearDown() { + stopKoin() + } + + @Test + fun `logout Success`() = + runBlocking { + useCase() + assertFalse(MockNetworkHandler.isConnected, "Failed to Disconnect") + } + + @Test + fun `logout Success Safe`() = + runBlocking { + MockNetworkHandler.disconnect() + assertFalse(MockNetworkHandler.isConnected, "Failed to Disconnect") + useCase() + assertFalse(MockNetworkHandler.isConnected, "Failed to Disconnect") + } + +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/com/kevinschildhorn/fotopresenter/ui/viewmodel/DirectoryViewModelTest.kt b/shared/src/commonTest/kotlin/com/kevinschildhorn/fotopresenter/ui/viewmodel/DirectoryViewModelTest.kt index f3b113a9..5999c779 100644 --- a/shared/src/commonTest/kotlin/com/kevinschildhorn/fotopresenter/ui/viewmodel/DirectoryViewModelTest.kt +++ b/shared/src/commonTest/kotlin/com/kevinschildhorn/fotopresenter/ui/viewmodel/DirectoryViewModelTest.kt @@ -30,7 +30,7 @@ class DirectoryViewModelTest : KoinTest { fun tearDown() { stopKoin() } - +/* @Test fun `UI State`() = runTest { @@ -52,5 +52,5 @@ class DirectoryViewModelTest : KoinTest { // assertEquals(2, directoryContents.allDirectories.count()) } MockNetworkHandler.disconnect() - } + }*/ } diff --git a/shared/src/desktopMain/kotlin/com/kevinschildhorn/fotopresenter/ui/shared/SharedImage.kt b/shared/src/desktopMain/kotlin/com/kevinschildhorn/fotopresenter/ui/shared/SharedImage.kt index 57d2d206..feda2259 100644 --- a/shared/src/desktopMain/kotlin/com/kevinschildhorn/fotopresenter/ui/shared/SharedImage.kt +++ b/shared/src/desktopMain/kotlin/com/kevinschildhorn/fotopresenter/ui/shared/SharedImage.kt @@ -6,3 +6,4 @@ import com.hierynomus.smbj.share.File actual fun getBitmapFromFile(file: File, size:Int): ImageBitmap? = file.inputStream.buffered().use(::loadImageBitmap) + diff --git a/shared/src/desktopMain/kotlin/com/kevinschildhorn/fotopresenter/ui/shared/SharedImageConverter.kt b/shared/src/desktopMain/kotlin/com/kevinschildhorn/fotopresenter/ui/shared/SharedImageConverter.kt new file mode 100644 index 00000000..eac3ecad --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/kevinschildhorn/fotopresenter/ui/shared/SharedImageConverter.kt @@ -0,0 +1,18 @@ +package com.kevinschildhorn.fotopresenter.ui.shared + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asSkiaBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import org.jetbrains.skia.Image +import kotlin.jvm.Throws + +actual object SharedImageConverter { + actual fun convertBytes(byteArray: ByteArray): ImageBitmap { + return Image.makeFromEncoded(byteArray).toComposeImageBitmap() + } + + @Throws(Exception::class) + actual fun convertImage(imageBitmap: ImageBitmap): ByteArray { + return Image.makeFromBitmap(imageBitmap.asSkiaBitmap()).encodeToData()?.bytes!! + } +} \ No newline at end of file