diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 27afc44..1799e84 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -110,6 +110,7 @@ dependencies { implementation(libs.androidx.material3) implementation(libs.androidx.viewMaterial) implementation(libs.androidx.icons) + implementation(libs.androidx.constraintLayoutCompose) // Timber implementation(libs.timber) diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/data/api/ApiClient.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/data/api/ApiClient.kt index 2f78228..99b6498 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/data/api/ApiClient.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/data/api/ApiClient.kt @@ -19,6 +19,7 @@ import com.github.ai.split.api.response.PostGroupResponse import com.github.ai.split.api.response.PostMemberResponse import com.github.ai.split.api.response.PutExpenseResponse import com.github.ai.split.api.response.PutGroupResponse +import io.ktor.client.HttpClient class ApiClient( private val jsonSerializer: JsonSerializer, @@ -27,23 +28,28 @@ class ApiClient( private var baseUrl by atomicReference(settings.serverUrl) private var httpClient by atomicReference( - HttpClientFactory.createHttpClient( - jsonSerializer = jsonSerializer, - isSslVerificationEnabled = settings.isSslVerificationEnabled - ) + buildHttpClient(jsonSerializer, settings) ) fun updateHttpClient() { - httpClient = HttpClientFactory.createHttpClient( - jsonSerializer = jsonSerializer, - isSslVerificationEnabled = settings.isSslVerificationEnabled - ) + httpClient = buildHttpClient(jsonSerializer, settings) } fun updateServerUrl() { baseUrl = settings.serverUrl } + private fun buildHttpClient( + jsonSerializer: JsonSerializer, + settings: Settings + ): HttpClient { + return HttpClientFactory.createHttpClient( + jsonSerializer = jsonSerializer, + isSslVerificationEnabled = settings.isSslVerificationEnabled, + logLevel = settings.httpLogLevel + ) + } + suspend fun getGroups( uids: List, passwords: List diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/data/api/HttpClientExtensions.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/data/api/HttpClientExtensions.kt index fe2c27b..7c05958 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/data/api/HttpClientExtensions.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/data/api/HttpClientExtensions.kt @@ -57,6 +57,7 @@ suspend inline fun HttpClient.sendRequest( val status = response.status if (status != HttpStatusCode.OK) { + // TODO: Kotlin serializer cannot parse quoted json object from server val errorBody = Either .catch { response.body() } .getOrNull() diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/data/api/HttpClientFactory.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/data/api/HttpClientFactory.kt index 8fd4eea..708ee26 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/data/api/HttpClientFactory.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/data/api/HttpClientFactory.kt @@ -22,7 +22,8 @@ object HttpClientFactory { fun createHttpClient( jsonSerializer: JsonSerializer, - isSslVerificationEnabled: Boolean + isSslVerificationEnabled: Boolean, + logLevel: LogLevel ): HttpClient { return HttpClient(OkHttp) { install(ContentNegotiation) { @@ -34,7 +35,7 @@ object HttpClientFactory { Timber.d(message) } } - level = LogLevel.INFO + level = logLevel } if (BuildConfig.DEBUG && !isSslVerificationEnabled) { diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/data/database/AppDatabase.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/data/database/AppDatabase.kt index 65afb30..7e9a4e1 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/data/database/AppDatabase.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/data/database/AppDatabase.kt @@ -5,7 +5,7 @@ import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import com.github.ai.simplesplit.android.data.database.dao.GroupCredentialsDao -import com.github.ai.simplesplit.android.model.db.GroupCredentials +import com.github.ai.simplesplit.android.data.database.model.GroupCredentials @Database( entities = [GroupCredentials::class], diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/data/database/dao/GroupCredentialsDao.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/data/database/dao/GroupCredentialsDao.kt index f3678dc..d84ca5f 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/data/database/dao/GroupCredentialsDao.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/data/database/dao/GroupCredentialsDao.kt @@ -5,7 +5,7 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update -import com.github.ai.simplesplit.android.model.db.GroupCredentials +import com.github.ai.simplesplit.android.data.database.model.GroupCredentials import kotlinx.coroutines.flow.Flow @Dao diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/model/db/GroupCredentials.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/data/database/model/GroupCredentials.kt similarity index 82% rename from android/app/src/main/java/com/github/ai/simplesplit/android/model/db/GroupCredentials.kt rename to android/app/src/main/java/com/github/ai/simplesplit/android/data/database/model/GroupCredentials.kt index 8af6f70..d6757ea 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/model/db/GroupCredentials.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/data/database/model/GroupCredentials.kt @@ -1,4 +1,4 @@ -package com.github.ai.simplesplit.android.model.db +package com.github.ai.simplesplit.android.data.database.model import androidx.room.Entity import androidx.room.PrimaryKey diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/data/repository/GroupCredentialsRepository.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/data/repository/GroupCredentialsRepository.kt index 43a0cb7..51205f9 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/data/repository/GroupCredentialsRepository.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/data/repository/GroupCredentialsRepository.kt @@ -1,7 +1,7 @@ package com.github.ai.simplesplit.android.data.repository import com.github.ai.simplesplit.android.data.database.dao.GroupCredentialsDao -import com.github.ai.simplesplit.android.model.db.GroupCredentials +import com.github.ai.simplesplit.android.data.database.model.GroupCredentials import kotlinx.coroutines.flow.Flow class GroupCredentialsRepository( diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/data/settings/SettingKey.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/data/settings/SettingKey.kt index 823c1b2..2c09dda 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/data/settings/SettingKey.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/data/settings/SettingKey.kt @@ -3,10 +3,7 @@ package com.github.ai.simplesplit.android.data.settings enum class SettingKey( val key: String ) { - IS_SSL_VERIFICATION_ENABLE( - key = "isSslVerificationEnabled" - ), - SERVER_URL( - key = "serverUrl" - ) + IS_SSL_VERIFICATION_ENABLED(key = "isSslVerificationEnabled"), + SERVER_URL(key = "serverUrl"), + HTTP_LOG_LEVEL(key = "httpLogLevel") } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/data/settings/Settings.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/data/settings/Settings.kt index 2bdac8d..8bc6559 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/data/settings/Settings.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/data/settings/Settings.kt @@ -3,12 +3,16 @@ package com.github.ai.simplesplit.android.data.settings import android.content.Context import com.cioccarellia.ksprefs.KsPrefs import com.github.ai.simplesplit.android.data.api.ApiClient -import com.github.ai.simplesplit.android.data.settings.SettingKey.IS_SSL_VERIFICATION_ENABLE +import com.github.ai.simplesplit.android.data.settings.SettingKey.HTTP_LOG_LEVEL +import com.github.ai.simplesplit.android.data.settings.SettingKey.IS_SSL_VERIFICATION_ENABLED import com.github.ai.simplesplit.android.data.settings.SettingKey.SERVER_URL +import com.github.ai.simplesplit.android.utils.StringUtils +import io.ktor.client.plugins.logging.LogLevel interface Settings { var serverUrl: String var isSslVerificationEnabled: Boolean + var httpLogLevel: LogLevel } class SettingsImpl( @@ -18,9 +22,9 @@ class SettingsImpl( private val prefs = KsPrefs(context.applicationContext) override var isSslVerificationEnabled: Boolean - get() = prefs.pull(IS_SSL_VERIFICATION_ENABLE.key, false) + get() = prefs.pull(IS_SSL_VERIFICATION_ENABLED.key, true) set(value) { - prefs.push(IS_SSL_VERIFICATION_ENABLE.key, value) + prefs.push(IS_SSL_VERIFICATION_ENABLED.key, value) } override var serverUrl: String @@ -28,4 +32,15 @@ class SettingsImpl( set(value) { prefs.push(SERVER_URL.key, value) } + + override var httpLogLevel: LogLevel + get() { + return prefs.pull(HTTP_LOG_LEVEL.key, StringUtils.EMPTY).let { name -> + LogLevel.entries.find { level -> level.name == name } + ?: LogLevel.INFO + } + } + set(value) { + prefs.push(HTTP_LOG_LEVEL.key, value.name) + } } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/domain/usecase/CreateExportUrlUseCase.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/domain/usecase/CreateExportUrlUseCase.kt index 63f53c1..3edf0c9 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/domain/usecase/CreateExportUrlUseCase.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/domain/usecase/CreateExportUrlUseCase.kt @@ -1,7 +1,7 @@ package com.github.ai.simplesplit.android.domain.usecase +import com.github.ai.simplesplit.android.data.database.model.GroupCredentials import com.github.ai.simplesplit.android.data.settings.Settings -import com.github.ai.simplesplit.android.model.db.GroupCredentials class CreateExportUrlUseCase( private val settings: Settings diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/domain/usecase/CreateGroupUrlUseCase.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/domain/usecase/CreateGroupUrlUseCase.kt index 36fd1ef..d043ea7 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/domain/usecase/CreateGroupUrlUseCase.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/domain/usecase/CreateGroupUrlUseCase.kt @@ -1,7 +1,7 @@ package com.github.ai.simplesplit.android.domain.usecase +import com.github.ai.simplesplit.android.data.database.model.GroupCredentials import com.github.ai.simplesplit.android.data.settings.Settings -import com.github.ai.simplesplit.android.model.db.GroupCredentials class CreateGroupUrlUseCase( private val settings: Settings diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/domain/usecase/ParseGroupUrlUseCase.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/domain/usecase/ParseGroupUrlUseCase.kt index 272e7fe..c520e60 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/domain/usecase/ParseGroupUrlUseCase.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/domain/usecase/ParseGroupUrlUseCase.kt @@ -3,7 +3,7 @@ package com.github.ai.simplesplit.android.domain.usecase import androidx.core.net.toUri import arrow.core.Either import arrow.core.raise.either -import com.github.ai.simplesplit.android.model.db.GroupCredentials +import com.github.ai.simplesplit.android.data.database.model.GroupCredentials import com.github.ai.simplesplit.android.model.exception.ParsingException class ParseGroupUrlUseCase { diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/model/ErrorMessage.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/model/ErrorMessage.kt new file mode 100644 index 0000000..20373c0 --- /dev/null +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/model/ErrorMessage.kt @@ -0,0 +1,10 @@ +package com.github.ai.simplesplit.android.model + +import androidx.compose.runtime.Immutable + +@Immutable +data class ErrorMessage( + val message: String, + val actionText: String, + val actionId: Int? = null +) \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/model/exception/ApiException.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/model/exception/ApiException.kt index de30f69..c337c67 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/model/exception/ApiException.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/model/exception/ApiException.kt @@ -12,8 +12,8 @@ class NetworkException( ) : ApiException(cause = cause) class InvalidResponseException( - statusCode: Int, - errorMessage: ErrorMessageDto? = null + val statusCode: Int, + val errorMessage: ErrorMessageDto? = null ) : ApiException( message = "Invalid server response, HTTP status code: $statusCode" ) \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/ComposeComponents.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/Components.kt similarity index 100% rename from android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/ComposeComponents.kt rename to android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/Components.kt diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/EmptyState.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/EmptyState.kt new file mode 100644 index 0000000..758813a --- /dev/null +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/EmptyState.kt @@ -0,0 +1,26 @@ +package com.github.ai.simplesplit.android.presentation.core.compose + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.github.ai.simplesplit.android.presentation.core.compose.preview.ThemedScreenPreview +import com.github.ai.simplesplit.android.presentation.core.compose.theme.LightTheme + +@Composable +fun EmptyState(text: String) { + Text( + text = text, + style = TextSize.TITLE_LARGE.toTextStyle(), + color = TextColor.PRIMARY.toColor() + ) +} + +@Preview +@Composable +fun EmptyTextPreview() { + ThemedScreenPreview(theme = LightTheme) { + CenteredBox { + EmptyState("No data") + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/ErrorMessageCard.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/ErrorMessageCard.kt new file mode 100644 index 0000000..fda1ff4 --- /dev/null +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/ErrorMessageCard.kt @@ -0,0 +1,170 @@ +package com.github.ai.simplesplit.android.presentation.core.compose + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.constraintlayout.compose.ConstraintLayout +import androidx.constraintlayout.compose.Dimension +import com.github.ai.simplesplit.android.R +import com.github.ai.simplesplit.android.model.ErrorMessage +import com.github.ai.simplesplit.android.presentation.core.compose.preview.ElementSpace +import com.github.ai.simplesplit.android.presentation.core.compose.preview.ThemedScreenPreview +import com.github.ai.simplesplit.android.presentation.core.compose.theme.AppIcon +import com.github.ai.simplesplit.android.presentation.core.compose.theme.AppTheme +import com.github.ai.simplesplit.android.presentation.core.compose.theme.CardCornerSize +import com.github.ai.simplesplit.android.presentation.core.compose.theme.ElementMargin +import com.github.ai.simplesplit.android.presentation.core.compose.theme.GroupMargin +import com.github.ai.simplesplit.android.presentation.core.compose.theme.HalfMargin +import com.github.ai.simplesplit.android.presentation.core.compose.theme.LightTheme +import com.github.ai.simplesplit.android.presentation.core.compose.theme.QuarterMargin + +@Composable +fun ErrorMessageCard( + error: ErrorMessage, + onClose: () -> Unit, + onAction: ((actionId: Int) -> Unit)? = null +) { + Card( + shape = RoundedCornerShape(size = CardCornerSize), + colors = CardDefaults.cardColors( + containerColor = AppTheme.theme.colors.cardPrimaryBackground + ), + modifier = Modifier + .fillMaxWidth() + .padding( + start = GroupMargin, + end = GroupMargin, + bottom = ElementMargin + ) + ) { + ConstraintLayout( + modifier = Modifier + .fillMaxWidth() + .padding(start = GroupMargin, end = HalfMargin) + .defaultMinSize(minHeight = 96.dp) + ) { + val (message, closeIcon, actionButton) = createRefs() + + val isActionButtonVisible = error.actionText.isNotEmpty() + + Box( + modifier = Modifier + .constrainAs(closeIcon) { + top.linkTo(parent.top) + end.linkTo(parent.end) + } + .clickable( + onClick = onClose + ) + ) { + Icon( + imageVector = AppIcon.CLOSE.vector, + contentDescription = null, + tint = AppTheme.theme.colors.errorText, + modifier = Modifier + .padding(HalfMargin) + ) + } + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .defaultMinSize(minHeight = 64.dp) + .constrainAs(message) { + top.linkTo(closeIcon.bottom) + start.linkTo(parent.start) + end.linkTo(closeIcon.start) + if (!isActionButtonVisible) { + ( + bottom.linkTo(parent.bottom, margin = GroupMargin) + ) + } + width = Dimension.fillToConstraints + } + ) { + Text( + text = error.message, + style = TextSize.TITLE_MEDIUM.toTextStyle(), + color = TextColor.ERROR.toColor(), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + ) + } + + if (error.actionText.isNotEmpty()) { + val onActionClick = rememberOnClickedCallback { + if (error.actionId != null) { + onAction?.invoke(error.actionId) + } + } + + Button( + colors = ButtonDefaults.buttonColors( + containerColor = AppTheme.theme.colors.errorText + ), + onClick = onActionClick, + modifier = Modifier + .constrainAs(actionButton) { + end.linkTo(parent.end) + top.linkTo(message.bottom, margin = QuarterMargin) + bottom.linkTo(parent.bottom, margin = ElementMargin) + } + ) { + Text(error.actionText) + } + } + } + } +} + +@Preview +@Composable +fun ErrorMessagePreview() { + ThemedScreenPreview(theme = LightTheme) { + Column { + ErrorMessageCard( + error = newErrorMessage(), + onClose = {}, + onAction = {} + ) + + ElementSpace() + + ErrorMessageCard( + error = newErrorMessage(actionText = ""), + onClose = {}, + onAction = {} + ) + CenteredBox { + Text("Screen Content") + } + } + } +} + +@Composable +fun newErrorMessage( + message: String = stringResource(R.string.medium_dummy_text), + actionText: String = "Retry" +) = ErrorMessage( + message = message, + actionText = actionText +) \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/ErrorState.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/ErrorState.kt new file mode 100644 index 0000000..148e981 --- /dev/null +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/ErrorState.kt @@ -0,0 +1,81 @@ +package com.github.ai.simplesplit.android.presentation.core.compose + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.github.ai.simplesplit.android.model.ErrorMessage +import com.github.ai.simplesplit.android.presentation.core.compose.preview.ThemedScreenPreview +import com.github.ai.simplesplit.android.presentation.core.compose.theme.AppTheme +import com.github.ai.simplesplit.android.presentation.core.compose.theme.GroupMargin +import com.github.ai.simplesplit.android.presentation.core.compose.theme.LightTheme + +@Composable +fun ErrorState( + error: ErrorMessage, + onAction: ((actionId: Int) -> Unit)? = null +) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .padding(all = GroupMargin) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .defaultMinSize(minHeight = 64.dp) + ) { + Text( + text = error.message, + style = TextSize.TITLE_MEDIUM.toTextStyle(), + color = TextColor.ERROR.toColor(), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + ) + } + + if (error.actionText.isNotEmpty()) { + val onActionClick = rememberOnClickedCallback { + if (error.actionId != null) { + onAction?.invoke(error.actionId) + } + } + + Button( + colors = ButtonDefaults.buttonColors( + containerColor = AppTheme.theme.colors.errorText + ), + onClick = onActionClick, + modifier = Modifier + ) { + Text(error.actionText) + } + } + } +} + +@Preview +@Composable +fun ErrorStatePreview() { + ThemedScreenPreview(theme = LightTheme) { + ErrorState( + error = newErrorMessage(), + onAction = {} + ) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/theme/Dimens.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/theme/Dimens.kt index b8cd894..9eb5c04 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/theme/Dimens.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/theme/Dimens.kt @@ -16,6 +16,8 @@ val HugeMargin = 64.dp val EmptyMessageItemHeight = 200.dp val OneLineItemHeight = 48.dp val TwoLineItemHeight = 72.dp +val GroupTwoLineItemHeight = 52.dp +val GroupThreeLineItemHeight = 68.dp val CardCornerSize = 22.dp val DialogCardCornerSize = 16.dp \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/checkoutGroup/CheckoutGroupScreen.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/checkoutGroup/CheckoutGroupScreen.kt index 3d2e701..d4debc9 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/checkoutGroup/CheckoutGroupScreen.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/checkoutGroup/CheckoutGroupScreen.kt @@ -1,17 +1,14 @@ package com.github.ai.simplesplit.android.presentation.screens.checkoutGroup import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer 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.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -20,13 +17,13 @@ import androidx.compose.ui.res.stringResource import com.github.ai.simplesplit.android.R import com.github.ai.simplesplit.android.presentation.core.compose.AppTextField import com.github.ai.simplesplit.android.presentation.core.compose.CenteredBox +import com.github.ai.simplesplit.android.presentation.core.compose.ErrorMessageCard import com.github.ai.simplesplit.android.presentation.core.compose.TopBar import com.github.ai.simplesplit.android.presentation.core.compose.TopBarMenuItem import com.github.ai.simplesplit.android.presentation.core.compose.rememberCallback import com.github.ai.simplesplit.android.presentation.core.compose.rememberOnClickedCallback import com.github.ai.simplesplit.android.presentation.core.compose.theme.AppTheme import com.github.ai.simplesplit.android.presentation.core.compose.theme.ElementMargin -import com.github.ai.simplesplit.android.presentation.core.compose.theme.SmallMargin import com.github.ai.simplesplit.android.presentation.screens.checkoutGroup.model.CheckoutGroupIntent import com.github.ai.simplesplit.android.presentation.screens.checkoutGroup.model.CheckoutGroupState @@ -100,34 +97,29 @@ private fun RenderDataContent( modifier = Modifier .fillMaxSize() .verticalScroll(state = rememberScrollState()) - .padding(ElementMargin) ) { val onUrlChange = rememberCallback { newValue: String -> onIntent.invoke(CheckoutGroupIntent.OnUrlChanged(newValue)) } + val onCloseErrorClick = rememberOnClickedCallback { + onIntent.invoke(CheckoutGroupIntent.OnCloseErrorClick) + } + + if (state.error != null) { + ErrorMessageCard( + error = state.error, + onClose = onCloseErrorClick + ) + } AppTextField( value = state.url, error = state.urlError, label = stringResource(R.string.group_url), onValueChange = onUrlChange, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .padding(horizontal = ElementMargin) + .fillMaxWidth() ) - - Spacer(modifier = Modifier.height(SmallMargin)) - - // TODO: make error message more readable - - // Show error message if there's one - state.errorMessage?.let { errorMessage -> - Spacer(modifier = Modifier.height(SmallMargin)) - - Text( - text = errorMessage, - color = AppTheme.theme.colors.errorText, - style = AppTheme.theme.typography.bodySmall, - modifier = Modifier.fillMaxWidth() - ) - } } } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/checkoutGroup/CheckoutGroupViewModel.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/checkoutGroup/CheckoutGroupViewModel.kt index 8b2225e..ffad089 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/checkoutGroup/CheckoutGroupViewModel.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/checkoutGroup/CheckoutGroupViewModel.kt @@ -1,6 +1,5 @@ package com.github.ai.simplesplit.android.presentation.screens.checkoutGroup -import arrow.core.Either import com.github.ai.simplesplit.android.R import com.github.ai.simplesplit.android.presentation.core.ResourceProvider import com.github.ai.simplesplit.android.presentation.core.compose.navigation.Router @@ -9,9 +8,9 @@ import com.github.ai.simplesplit.android.presentation.core.mvi.nonStateAction import com.github.ai.simplesplit.android.presentation.screens.checkoutGroup.model.CheckoutGroupArgs import com.github.ai.simplesplit.android.presentation.screens.checkoutGroup.model.CheckoutGroupIntent import com.github.ai.simplesplit.android.presentation.screens.checkoutGroup.model.CheckoutGroupState -import com.github.ai.simplesplit.android.utils.getErrorMessage import com.github.ai.simplesplit.android.utils.mutableStateFlow import com.github.ai.simplesplit.android.utils.singleFlowOf +import com.github.ai.simplesplit.android.utils.toErrorMessage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow @@ -20,7 +19,7 @@ import kotlinx.coroutines.flow.flowOn class CheckoutGroupViewModel( private val interactor: CheckoutGroupInteractor, - private val resourceProvider: ResourceProvider, + private val resources: ResourceProvider, private val router: Router, private val args: CheckoutGroupArgs ) : MviViewModel( @@ -35,6 +34,7 @@ class CheckoutGroupViewModel( CheckoutGroupIntent.Initialize -> loadData() CheckoutGroupIntent.OnBackClick -> nonStateAction { navigateBack() } CheckoutGroupIntent.OnDoneClick -> onDoneClicked() + CheckoutGroupIntent.OnCloseErrorClick -> onCloseErrorClicked() is CheckoutGroupIntent.OnUrlChanged -> onUrlChanged(intent) } } @@ -54,28 +54,34 @@ class CheckoutGroupViewModel( private fun onDoneClicked(): Flow { if (dataState.url.isBlank()) { dataState = dataState.copy( - urlError = resourceProvider.getString(R.string.field_required_error) + urlError = resources.getString(R.string.field_required_error) ) return flowOf(dataState) } return flow { emit(CheckoutGroupState.Loading) - kotlinx.coroutines.delay(500) - when (val addGroupResult = interactor.addGroup(url = dataState.url.trim())) { - is Either.Left -> { - dataState = dataState.copy(errorMessage = addGroupResult.getErrorMessage()) + val addGroupResult = interactor.addGroup(url = dataState.url.trim()) + addGroupResult.fold( + ifLeft = { + dataState = dataState.copy( + error = it.toErrorMessage(resources) + ) emit(dataState) - } - - is Either.Right -> { + }, + ifRight = { navigateBack() } - } + ) }.flowOn(Dispatchers.IO) } + private fun onCloseErrorClicked(): Flow { + dataState = dataState.copy(error = null) + return flowOf(dataState) + } + private fun navigateBack() { router.exit() } diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/checkoutGroup/model/CheckoutGroupIntent.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/checkoutGroup/model/CheckoutGroupIntent.kt index 93efc3f..da09237 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/checkoutGroup/model/CheckoutGroupIntent.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/checkoutGroup/model/CheckoutGroupIntent.kt @@ -8,5 +8,6 @@ sealed class CheckoutGroupIntent( data object Initialize : CheckoutGroupIntent() data object OnBackClick : CheckoutGroupIntent() data object OnDoneClick : CheckoutGroupIntent() + data object OnCloseErrorClick : CheckoutGroupIntent() data class OnUrlChanged(val url: String) : CheckoutGroupIntent(isImmediate = true) } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/checkoutGroup/model/CheckoutGroupState.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/checkoutGroup/model/CheckoutGroupState.kt index c52541b..494a9ed 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/checkoutGroup/model/CheckoutGroupState.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/checkoutGroup/model/CheckoutGroupState.kt @@ -1,5 +1,6 @@ package com.github.ai.simplesplit.android.presentation.screens.checkoutGroup.model +import com.github.ai.simplesplit.android.model.ErrorMessage import com.github.ai.simplesplit.android.utils.StringUtils sealed interface CheckoutGroupState { @@ -9,6 +10,6 @@ sealed interface CheckoutGroupState { data class Data( val url: String = StringUtils.EMPTY, val urlError: String? = null, - val errorMessage: String? = null + val error: ErrorMessage? = null ) : CheckoutGroupState } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/ExpenseEditorInteractor.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/ExpenseEditorInteractor.kt index f3863ea..81f6e54 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/ExpenseEditorInteractor.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/ExpenseEditorInteractor.kt @@ -2,9 +2,9 @@ package com.github.ai.simplesplit.android.presentation.screens.expenseEditor import arrow.core.Either import arrow.core.raise.either +import com.github.ai.simplesplit.android.data.database.model.GroupCredentials import com.github.ai.simplesplit.android.data.repository.ExpenseRepository import com.github.ai.simplesplit.android.data.repository.GroupCredentialsRepository -import com.github.ai.simplesplit.android.model.db.GroupCredentials import com.github.ai.simplesplit.android.model.exception.AppException import com.github.ai.split.api.ExpenseDto import com.github.ai.split.api.UserUidDto diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/ExpenseEditorScreen.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/ExpenseEditorScreen.kt index 6f7dc26..6467aae 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/ExpenseEditorScreen.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/ExpenseEditorScreen.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -24,6 +23,8 @@ import com.github.ai.simplesplit.android.R import com.github.ai.simplesplit.android.presentation.core.compose.AppDropdownField import com.github.ai.simplesplit.android.presentation.core.compose.AppTextField import com.github.ai.simplesplit.android.presentation.core.compose.CenteredBox +import com.github.ai.simplesplit.android.presentation.core.compose.ErrorMessageCard +import com.github.ai.simplesplit.android.presentation.core.compose.ErrorState import com.github.ai.simplesplit.android.presentation.core.compose.TopBar import com.github.ai.simplesplit.android.presentation.core.compose.TopBarMenuItem import com.github.ai.simplesplit.android.presentation.core.compose.preview.ThemedScreenPreview @@ -87,9 +88,9 @@ private fun ExpenseEditorScreen( } is ExpenseEditorState.Error -> { - CenteredBox { - Text(text = state.message) - } + ErrorState( + error = state.error + ) } is ExpenseEditorState.Data -> { @@ -108,12 +109,21 @@ private fun RenderDataContent( state: ExpenseEditorState.Data, onIntent: (intent: ExpenseEditorIntent) -> Unit ) { + val onCloseErrorClick = rememberOnClickedCallback { + onIntent.invoke(ExpenseEditorIntent.OnCloseErrorClick) + } Column( modifier = Modifier .fillMaxSize() .verticalScroll(state = rememberScrollState()) - .padding(ElementMargin) ) { + if (state.error != null) { + ErrorMessageCard( + error = state.error, + onClose = onCloseErrorClick + ) + } + AppDropdownField( value = state.payer, label = stringResource(R.string.payer), @@ -121,7 +131,9 @@ private fun RenderDataContent( onValueChange = { newValue -> onIntent.invoke(ExpenseEditorIntent.OnPayerChanged(newValue)) }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .padding(horizontal = ElementMargin) + .fillMaxWidth() ) Spacer(modifier = Modifier.height(SmallMargin)) @@ -133,7 +145,9 @@ private fun RenderDataContent( onValueChange = { newValue -> onIntent.invoke(ExpenseEditorIntent.OnTitleChanged(newValue)) }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .padding(horizontal = ElementMargin) + .fillMaxWidth() ) Spacer(modifier = Modifier.height(SmallMargin)) @@ -146,7 +160,9 @@ private fun RenderDataContent( onIntent.invoke(ExpenseEditorIntent.OnAmountChanged(newValue)) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .padding(horizontal = ElementMargin) + .fillMaxWidth() ) } } diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/ExpenseEditorViewModel.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/ExpenseEditorViewModel.kt index 26a8aad..794457e 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/ExpenseEditorViewModel.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/ExpenseEditorViewModel.kt @@ -10,19 +10,18 @@ import com.github.ai.simplesplit.android.presentation.screens.expenseEditor.mode import com.github.ai.simplesplit.android.presentation.screens.expenseEditor.model.ExpenseEditorIntent import com.github.ai.simplesplit.android.presentation.screens.expenseEditor.model.ExpenseEditorMode import com.github.ai.simplesplit.android.presentation.screens.expenseEditor.model.ExpenseEditorState -import com.github.ai.simplesplit.android.utils.getErrorMessage import com.github.ai.simplesplit.android.utils.mutableStateFlow import com.github.ai.simplesplit.android.utils.singleFlowOf +import com.github.ai.simplesplit.android.utils.toErrorMessage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn -import timber.log.Timber class ExpenseEditorViewModel( private val interactor: ExpenseEditorInteractor, - private val resourceProvider: ResourceProvider, + private val resources: ResourceProvider, private val router: Router, private val args: ExpenseEditorArgs ) : MviViewModel( @@ -37,6 +36,7 @@ class ExpenseEditorViewModel( ExpenseEditorIntent.Initialize -> initialize() ExpenseEditorIntent.OnBackClick -> nonStateAction { navigateBack() } ExpenseEditorIntent.OnDoneClick -> onDoneClicked() + ExpenseEditorIntent.OnCloseErrorClick -> onCloseErrorClicked() is ExpenseEditorIntent.OnPayerChanged -> onPayerChanged(intent) is ExpenseEditorIntent.OnTitleChanged -> onTitleChanged(intent) is ExpenseEditorIntent.OnAmountChanged -> onAmountChanged(intent) @@ -115,17 +115,25 @@ class ExpenseEditorViewModel( router.exit() } + private fun onCloseErrorClicked(): Flow { + dataState = dataState.copy( + error = null + ) + + return flowOf(dataState) + } + private fun onDoneClicked(): Flow { if (dataState.title.isBlank()) { dataState = dataState.copy( - titleError = resourceProvider.getString(R.string.enter_expense_title) + titleError = resources.getString(R.string.enter_expense_title) ) return flowOf(dataState) } if (dataState.amount.isBlank()) { dataState = dataState.copy( - amountError = resourceProvider.getString(R.string.enter_amount) + amountError = resources.getString(R.string.enter_amount) ) return flowOf(dataState) } @@ -134,14 +142,14 @@ class ExpenseEditorViewModel( dataState.amount.toDouble() } catch (e: NumberFormatException) { dataState = dataState.copy( - amountError = resourceProvider.getString(R.string.invalid_amount) + amountError = resources.getString(R.string.invalid_amount) ) return flowOf(dataState) } if (amount <= 0) { dataState = dataState.copy( - amountError = resourceProvider.getString(R.string.amount_must_be_positive) + amountError = resources.getString(R.string.amount_must_be_positive) ) return flowOf(dataState) } @@ -172,18 +180,18 @@ class ExpenseEditorViewModel( } } - if (response.isLeft()) { - emit(ExpenseEditorState.Error(response.getErrorMessage())) - return@flow - } - - val expense = response.getOrNull() ?: return@flow - - // TODO: remove logging - Timber.d("Successfully: title=${expense.title}, uid=${expense.uid}, amount=$amount") - - router.setResult(Screen.ExpenseEditor::class, expense) - router.exit() + response.fold( + ifLeft = { error -> + dataState = dataState.copy( + error = error.toErrorMessage(resources) + ) + emit(dataState) + }, + ifRight = { expense -> + router.setResult(Screen.ExpenseEditor::class, expense) + router.exit() + } + ) }.flowOn(Dispatchers.IO) } } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/model/ExpenseEditorArgs.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/model/ExpenseEditorArgs.kt index 1643a89..83cca3c 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/model/ExpenseEditorArgs.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/model/ExpenseEditorArgs.kt @@ -1,6 +1,6 @@ package com.github.ai.simplesplit.android.presentation.screens.expenseEditor.model -import com.github.ai.simplesplit.android.model.db.GroupCredentials +import com.github.ai.simplesplit.android.data.database.model.GroupCredentials import com.github.ai.split.api.GroupDto import kotlinx.serialization.Serializable diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/model/ExpenseEditorIntent.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/model/ExpenseEditorIntent.kt index 1b0d5ca..b557eb1 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/model/ExpenseEditorIntent.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/model/ExpenseEditorIntent.kt @@ -9,6 +9,7 @@ sealed class ExpenseEditorIntent( data object Initialize : ExpenseEditorIntent() data object OnBackClick : ExpenseEditorIntent() data object OnDoneClick : ExpenseEditorIntent() + data object OnCloseErrorClick : ExpenseEditorIntent() data class OnPayerChanged(val payer: String) : ExpenseEditorIntent(isImmediate = true) data class OnTitleChanged(val title: String) : ExpenseEditorIntent(isImmediate = true) data class OnAmountChanged(val amount: String) : ExpenseEditorIntent(isImmediate = true) diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/model/ExpenseEditorState.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/model/ExpenseEditorState.kt index 8c870a8..da5f947 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/model/ExpenseEditorState.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/model/ExpenseEditorState.kt @@ -1,12 +1,13 @@ package com.github.ai.simplesplit.android.presentation.screens.expenseEditor.model +import com.github.ai.simplesplit.android.model.ErrorMessage import com.github.ai.simplesplit.android.utils.StringUtils sealed interface ExpenseEditorState { data object Loading : ExpenseEditorState data class Error( - val message: String + val error: ErrorMessage ) : ExpenseEditorState data class Data( @@ -15,6 +16,7 @@ sealed interface ExpenseEditorState { val amount: String = StringUtils.EMPTY, val availablePayers: List = emptyList(), val titleError: String? = null, - val amountError: String? = null + val amountError: String? = null, + val error: ErrorMessage? = null ) : ExpenseEditorState } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/GroupDetailsInteractor.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/GroupDetailsInteractor.kt index fe50ad1..140304f 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/GroupDetailsInteractor.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/GroupDetailsInteractor.kt @@ -2,12 +2,12 @@ package com.github.ai.simplesplit.android.presentation.screens.groupDetails import arrow.core.Either import arrow.core.raise.either +import com.github.ai.simplesplit.android.data.database.model.GroupCredentials import com.github.ai.simplesplit.android.data.repository.ExpenseRepository import com.github.ai.simplesplit.android.data.repository.GroupCredentialsRepository import com.github.ai.simplesplit.android.data.repository.GroupRepository import com.github.ai.simplesplit.android.domain.usecase.CreateExportUrlUseCase import com.github.ai.simplesplit.android.domain.usecase.CreateGroupUrlUseCase -import com.github.ai.simplesplit.android.model.db.GroupCredentials import com.github.ai.simplesplit.android.model.exception.AppException import com.github.ai.split.api.GroupDto diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/GroupDetailsScreen.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/GroupDetailsScreen.kt index b393401..0047235 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/GroupDetailsScreen.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/GroupDetailsScreen.kt @@ -1,5 +1,6 @@ package com.github.ai.simplesplit.android.presentation.screens.groupDetails +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn @@ -11,13 +12,14 @@ import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import com.github.ai.simplesplit.android.presentation.core.compose.CenteredBox +import com.github.ai.simplesplit.android.presentation.core.compose.ErrorMessageCard +import com.github.ai.simplesplit.android.presentation.core.compose.ErrorState import com.github.ai.simplesplit.android.presentation.core.compose.TopBar import com.github.ai.simplesplit.android.presentation.core.compose.TopBarMenuItem import com.github.ai.simplesplit.android.presentation.core.compose.cells.CellViewModel @@ -64,14 +66,15 @@ private fun GroupDetailsScreen( val onBackClick = rememberOnClickedCallback { onIntent.invoke(GroupDetailsIntent.OnBackClick) } - val onFabClick = rememberOnClickedCallback { onIntent.invoke(GroupDetailsIntent.OnFabClick) } - val onMenuItemClick = rememberCallback { _: TopBarMenuItem -> onIntent.invoke(GroupDetailsIntent.OnMenuClick) } + val onCloseErrorClick = rememberOnClickedCallback { + onIntent.invoke(GroupDetailsIntent.OnCloseErrorClick) + } Scaffold( topBar = { @@ -96,7 +99,6 @@ private fun GroupDetailsScreen( } } ) { padding -> - Surface( modifier = Modifier .fillMaxSize() @@ -112,19 +114,28 @@ private fun GroupDetailsScreen( } is GroupDetailsState.Data -> { - LazyColumn( - modifier = Modifier.fillMaxSize() - ) { - items(state.cellViewModels) { model -> - RenderCell(model) + Column { + if (state.error != null) { + ErrorMessageCard( + error = state.error, + onClose = onCloseErrorClick + ) + } + + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + items(state.cellViewModels) { model -> + RenderCell(model) + } } } } is GroupDetailsState.Error -> { - CenteredBox { - Text(text = state.message) - } + ErrorState( + error = state.message + ) } } } diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/GroupDetailsViewModel.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/GroupDetailsViewModel.kt index a19eec9..cef5f4f 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/GroupDetailsViewModel.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/GroupDetailsViewModel.kt @@ -2,7 +2,7 @@ package com.github.ai.simplesplit.android.presentation.screens.groupDetails import androidx.annotation.StringRes import com.github.ai.simplesplit.android.R -import com.github.ai.simplesplit.android.model.db.GroupCredentials +import com.github.ai.simplesplit.android.data.database.model.GroupCredentials import com.github.ai.simplesplit.android.presentation.core.ResourceProvider import com.github.ai.simplesplit.android.presentation.core.compose.cells.CellEvent import com.github.ai.simplesplit.android.presentation.core.compose.navigation.Router @@ -26,21 +26,22 @@ import com.github.ai.simplesplit.android.presentation.screens.groupDetails.model import com.github.ai.simplesplit.android.presentation.screens.groupEditor.model.GroupEditorArgs import com.github.ai.simplesplit.android.presentation.screens.groupEditor.model.GroupEditorMode import com.github.ai.simplesplit.android.presentation.screens.root.model.StartActivityEvent -import com.github.ai.simplesplit.android.utils.getErrorMessage import com.github.ai.simplesplit.android.utils.getStringOrNull import com.github.ai.simplesplit.android.utils.mutableStateFlow import com.github.ai.simplesplit.android.utils.parseCellId +import com.github.ai.simplesplit.android.utils.toErrorMessage import com.github.ai.split.api.GroupDto import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn class GroupDetailsViewModel( private val interactor: GroupDetailsInteractor, private val cellFactory: GroupDetailsCellFactory, - private val resourceProvider: ResourceProvider, + private val resources: ResourceProvider, private val router: Router, private val args: GroupDetailsArgs ) : CellsMviViewModel( @@ -85,6 +86,8 @@ class GroupDetailsViewModel( GroupDetailsIntent.OnRemoveGroupConfirmed -> removeGroup(args.group.uid) + GroupDetailsIntent.OnCloseErrorClick -> onCloseErrorClicked() + is GroupDetailsIntent.OnMenuClick -> nonStateAction { showGroupMenuDialog() } @@ -177,19 +180,18 @@ class GroupDetailsViewModel( emit(GroupDetailsState.Loading) } - val getGroupResult = interactor.getGroup( + interactor.getGroup( groupUid = groupUid, password = password + ).fold( + ifLeft = { error -> + emit(GroupDetailsState.Error(error.toErrorMessage(resources))) + }, + ifRight = { group -> + data = group + emit(createScreenState(group)) + } ) - if (getGroupResult.isLeft()) { - emit(GroupDetailsState.Error(getGroupResult.getErrorMessage())) - return@flow - } - - val groupDto = getGroupResult.getOrNull() ?: return@flow - data = groupDto - - emitAll(showData(groupDto)) }.flowOn(Dispatchers.IO) } @@ -197,18 +199,26 @@ class GroupDetailsViewModel( return flow { emit(GroupDetailsState.Loading) - val response = interactor.removeExpense( + interactor.removeExpense( password = args.password, expenseUid = expenseUid + ).fold( + ifLeft = { error -> + val newState = when (val state = state.value) { + is GroupDetailsState.Data -> state.copy( + error = error.toErrorMessage(resources) + ) + + else -> GroupDetailsState.Error(error.toErrorMessage(resources)) + } + + emit(newState) + }, + ifRight = { group -> + data = group + emit(createScreenState(group)) + } ) - if (response.isLeft()) { - emit(GroupDetailsState.Error(response.getErrorMessage())) - return@flow - } - - val group = response.getOrNull() ?: return@flow - - emitAll(showData(group)) }.flowOn(Dispatchers.IO) } @@ -251,7 +261,7 @@ class GroupDetailsViewModel( val items = GroupMenuAction.entries.map { entry -> MenuItem( icon = entry.icon, - text = resourceProvider.getString(entry.resourceId), + text = resources.getString(entry.resourceId), actionId = entry.ordinal ) } @@ -269,7 +279,7 @@ class GroupDetailsViewModel( val items = ExpenseMenuAction.entries.map { entry -> MenuItem( icon = entry.icon, - text = resourceProvider.getString(entry.resourceId), + text = resources.getString(entry.resourceId), actionId = entry.ordinal ) } @@ -293,10 +303,10 @@ class GroupDetailsViewModel( router.showDialog( Dialog.ConfirmationDialog( args = ConfirmationDialogArgs( - message = resourceProvider.getString( + message = resources.getString( R.string.remove_expense_confirmation_message ), - buttonTitle = resourceProvider.getString(R.string.remove) + buttonTitle = resources.getString(R.string.remove) ) ) ) @@ -311,10 +321,10 @@ class GroupDetailsViewModel( router.showDialog( Dialog.ConfirmationDialog( args = ConfirmationDialogArgs( - message = resourceProvider.getString( + message = resources.getString( R.string.remove_group_confirmation_message ), - buttonTitle = resourceProvider.getString(R.string.remove) + buttonTitle = resources.getString(R.string.remove) ) ) ) @@ -368,21 +378,28 @@ class GroupDetailsViewModel( } } - private fun showData(group: GroupDto): Flow { - return flow { - val viewModels = cellFactory.createCells( - group = group, - eventProvider = cellEventProvider - ) + private fun createScreenState(group: GroupDto): GroupDetailsState { + val viewModels = cellFactory.createCells( + group = group, + eventProvider = cellEventProvider + ) - emit(GroupDetailsState.Data(viewModels)) - }.flowOn(Dispatchers.IO) + return GroupDetailsState.Data(viewModels) + } + + private fun onCloseErrorClicked(): Flow { + val state = state.value.asDataOrNull() ?: return emptyFlow() + + return flowOf(state.copy(error = null)) } private fun getExpenseUidFromCellId(cellId: String): String? { return cellId.parseCellId()?.payload?.getStringOrNull() } + private fun GroupDetailsState.asDataOrNull(): GroupDetailsState.Data? = + this as? GroupDetailsState.Data + enum class GroupMenuAction( val icon: AppIcon, @StringRes val resourceId: Int diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/model/GroupDetailsIntent.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/model/GroupDetailsIntent.kt index f3668e6..27a3db2 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/model/GroupDetailsIntent.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/model/GroupDetailsIntent.kt @@ -14,6 +14,7 @@ sealed class GroupDetailsIntent( data object OnEditGroupClick : GroupDetailsIntent() data object OnRemoveGroupClick : GroupDetailsIntent() data object OnRemoveGroupConfirmed : GroupDetailsIntent() + data object OnCloseErrorClick : GroupDetailsIntent() data class OnExpenseClick(val expenseUid: String) : GroupDetailsIntent() data class OnExpenseLongClick(val expenseUid: String) : GroupDetailsIntent() data class OnEditExpenseClick(val expenseUid: String) : GroupDetailsIntent() diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/model/GroupDetailsState.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/model/GroupDetailsState.kt index a95111d..115fd3d 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/model/GroupDetailsState.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/model/GroupDetailsState.kt @@ -1,5 +1,6 @@ package com.github.ai.simplesplit.android.presentation.screens.groupDetails.model +import com.github.ai.simplesplit.android.model.ErrorMessage import com.github.ai.simplesplit.android.presentation.core.compose.cells.CellViewModel sealed interface GroupDetailsState { @@ -7,10 +8,11 @@ sealed interface GroupDetailsState { data object Loading : GroupDetailsState data class Error( - val message: String + val message: ErrorMessage ) : GroupDetailsState data class Data( - val cellViewModels: List + val cellViewModels: List = emptyList(), + val error: ErrorMessage? = null ) : GroupDetailsState } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorInteractor.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorInteractor.kt index cd9d11b..f44ae74 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorInteractor.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorInteractor.kt @@ -2,10 +2,10 @@ package com.github.ai.simplesplit.android.presentation.screens.groupEditor import arrow.core.Either import arrow.core.raise.either +import com.github.ai.simplesplit.android.data.database.model.GroupCredentials import com.github.ai.simplesplit.android.data.repository.GroupCredentialsRepository import com.github.ai.simplesplit.android.data.repository.GroupRepository import com.github.ai.simplesplit.android.data.repository.MemberRepository -import com.github.ai.simplesplit.android.model.db.GroupCredentials import com.github.ai.simplesplit.android.model.exception.AppException import com.github.ai.split.api.GroupDto import com.github.ai.split.api.UserNameDto diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorScreen.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorScreen.kt index 2a5de4f..61800e0 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorScreen.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorScreen.kt @@ -28,6 +28,8 @@ import androidx.compose.ui.res.stringResource import com.github.ai.simplesplit.android.R import com.github.ai.simplesplit.android.presentation.core.compose.AppTextField import com.github.ai.simplesplit.android.presentation.core.compose.CenteredBox +import com.github.ai.simplesplit.android.presentation.core.compose.ErrorMessageCard +import com.github.ai.simplesplit.android.presentation.core.compose.ErrorState import com.github.ai.simplesplit.android.presentation.core.compose.TopBar import com.github.ai.simplesplit.android.presentation.core.compose.TopBarMenuItem import com.github.ai.simplesplit.android.presentation.core.compose.rememberCallback @@ -58,11 +60,9 @@ private fun GroupEditorScreen( val onBackClick = rememberOnClickedCallback { onIntent.invoke(GroupEditorIntent.OnBackClick) } - val onMenuItemClick = rememberCallback { _: TopBarMenuItem -> onIntent.invoke(GroupEditorIntent.OnDoneClick) } - Scaffold( topBar = { TopBar( @@ -91,9 +91,9 @@ private fun GroupEditorScreen( } is GroupEditorState.Error -> { - CenteredBox { - Text(text = state.message) - } + ErrorState( + error = state.error + ) } is GroupEditorState.Data -> { @@ -112,12 +112,23 @@ private fun RenderDataContent( state: GroupEditorState.Data, onIntent: (intent: GroupEditorIntent) -> Unit ) { + val onCloseErrorClick = rememberOnClickedCallback { + onIntent.invoke(GroupEditorIntent.OnCloseErrorClick) + } + Column( modifier = Modifier .fillMaxSize() .verticalScroll(state = rememberScrollState()) .padding(ElementMargin) ) { + if (state.error != null) { + ErrorMessageCard( + error = state.error, + onClose = onCloseErrorClick + ) + } + AppTextField( value = state.title, error = state.titleError, diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorViewModel.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorViewModel.kt index 33b15b9..3760ed5 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorViewModel.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorViewModel.kt @@ -11,9 +11,9 @@ import com.github.ai.simplesplit.android.presentation.screens.groupEditor.model. import com.github.ai.simplesplit.android.presentation.screens.groupEditor.model.GroupEditorState import com.github.ai.simplesplit.android.presentation.screens.groupEditor.model.ValidationResult import com.github.ai.simplesplit.android.utils.StringUtils -import com.github.ai.simplesplit.android.utils.getErrorMessage import com.github.ai.simplesplit.android.utils.mutableStateFlow import com.github.ai.simplesplit.android.utils.singleFlowOf +import com.github.ai.simplesplit.android.utils.toErrorMessage import com.github.ai.split.api.GroupDto import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -21,11 +21,10 @@ import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn -import timber.log.Timber class GroupEditorViewModel( private val interactor: GroupEditorInteractor, - private val resourceProvider: ResourceProvider, + private val resources: ResourceProvider, private val router: Router, private val args: GroupEditorArgs ) : MviViewModel( @@ -42,6 +41,7 @@ class GroupEditorViewModel( GroupEditorIntent.OnBackClick -> nonStateAction { navigateBack() } GroupEditorIntent.OnAddMemberClick -> onAddMemberClicked() GroupEditorIntent.OnDoneClick -> onDoneClicked() + GroupEditorIntent.OnCloseErrorClick -> onCloseErrorClicked() is GroupEditorIntent.OnTitleChanged -> onTitleChanged(intent) is GroupEditorIntent.OnPasswordChanged -> onPasswordChanged(intent) is GroupEditorIntent.OnConfirmPasswordChanged -> onConfirmPasswordChanged(intent) @@ -188,17 +188,17 @@ class GroupEditorViewModel( } } - if (response.isLeft()) { - // TODO: show data state - emit(GroupEditorState.Error(response.getErrorMessage())) - return@flow - } - - val group = response.getOrNull() ?: return@flow - // TODO: remove logging - Timber.d("Successfully: uid=${group.uid}") - - router.exit() + response.fold( + ifLeft = { error -> + dataState = dataState.copy( + error = error.toErrorMessage(resources) + ) + emit(dataState) + }, + ifRight = { + router.exit() + } + ) } .flowOn(Dispatchers.IO) } @@ -209,24 +209,22 @@ class GroupEditorViewModel( is GroupEditorMode.EditGroup -> flow { emit(GroupEditorState.Loading) - val loadGroupResult = interactor.loadGroup( + interactor.loadGroup( uid = args.mode.credentials.groupUid, password = args.mode.credentials.password + ).fold( + ifLeft = { error -> + emit(GroupEditorState.Error(error.toErrorMessage(resources))) + }, + ifRight = { group -> + data = group + dataState = GroupEditorState.Data( + title = group.title, + members = group.members.map { member -> member.name } + ) + emit(dataState) + } ) - if (loadGroupResult.isLeft()) { - emit(GroupEditorState.Error(loadGroupResult.getErrorMessage())) - return@flow - } - - val group = loadGroupResult.getOrNull() ?: return@flow - - data = group - dataState = GroupEditorState.Data( - title = group.title, - members = group.members.map { member -> member.name } - ) - - emit(dataState) }.flowOn(Dispatchers.IO) } } @@ -254,6 +252,13 @@ class GroupEditorViewModel( } } + private fun onCloseErrorClicked(): Flow { + dataState = dataState.copy( + error = null + ) + return flowOf(dataState) + } + private fun validateEditGroupData(state: GroupEditorState.Data): ValidationResult { val title = state.title val password = state.password @@ -261,13 +266,13 @@ class GroupEditorViewModel( val members = state.members val emptyTitleError = if (title.isBlank()) { - resourceProvider.getString(R.string.enter_group_name) + resources.getString(R.string.enter_group_name) } else { null } val membersError = if (members.size < 2) { - resourceProvider.getString(R.string.invalid_member_count_message) + resources.getString(R.string.invalid_member_count_message) } else { null } @@ -276,19 +281,19 @@ class GroupEditorViewModel( confirmPassword.isNotBlank() ) { val emptyPasswordError = if (password.isBlank()) { - resourceProvider.getString(R.string.enter_password) + resources.getString(R.string.enter_password) } else { null } val emptyConfirmationError = if (confirmPassword.isBlank()) { - resourceProvider.getString(R.string.enter_password) + resources.getString(R.string.enter_password) } else { null } val passwordConfirmationError = if (password.trim() != confirmPassword.trim()) { - resourceProvider.getString(R.string.passwords_dont_match) + resources.getString(R.string.passwords_dont_match) } else { null } @@ -324,31 +329,31 @@ class GroupEditorViewModel( val members = state.members val emptyTitleError = if (title.isBlank()) { - resourceProvider.getString(R.string.enter_group_name) + resources.getString(R.string.enter_group_name) } else { null } val emptyPasswordError = if (title.isBlank()) { - resourceProvider.getString(R.string.enter_password) + resources.getString(R.string.enter_password) } else { null } val emptyConfirmationError = if (confirmPassword.isBlank()) { - resourceProvider.getString(R.string.enter_password) + resources.getString(R.string.enter_password) } else { null } val passwordConfirmationError = if (password.trim() != confirmPassword.trim()) { - resourceProvider.getString(R.string.passwords_dont_match) + resources.getString(R.string.passwords_dont_match) } else { null } val membersError = if (members.size < 2) { - resourceProvider.getString(R.string.invalid_member_count_message) + resources.getString(R.string.invalid_member_count_message) } else { null } diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorIntent.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorIntent.kt index 560ceaa..617c5e4 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorIntent.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorIntent.kt @@ -10,6 +10,7 @@ sealed class GroupEditorIntent( data object OnBackClick : GroupEditorIntent() data object OnAddMemberClick : GroupEditorIntent() data object OnDoneClick : GroupEditorIntent() + data object OnCloseErrorClick : GroupEditorIntent() data class OnTitleChanged(val title: String) : GroupEditorIntent(isImmediate = true) data class OnPasswordChanged(val password: String) : GroupEditorIntent(isImmediate = true) data class OnConfirmPasswordChanged( diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorMode.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorMode.kt index 813c4bc..9b12444 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorMode.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorMode.kt @@ -1,6 +1,6 @@ package com.github.ai.simplesplit.android.presentation.screens.groupEditor.model -import com.github.ai.simplesplit.android.model.db.GroupCredentials +import com.github.ai.simplesplit.android.data.database.model.GroupCredentials import kotlinx.serialization.Serializable @Serializable diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorState.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorState.kt index 7bd3203..585102b 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorState.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorState.kt @@ -1,12 +1,13 @@ package com.github.ai.simplesplit.android.presentation.screens.groupEditor.model +import com.github.ai.simplesplit.android.model.ErrorMessage import com.github.ai.simplesplit.android.utils.StringUtils sealed interface GroupEditorState { data object Loading : GroupEditorState data class Error( - val message: String + val error: ErrorMessage ) : GroupEditorState data class Data( @@ -19,8 +20,8 @@ sealed interface GroupEditorState { val passwordError: String? = null, val confirmPasswordError: String? = null, val memberError: String? = null, - val errorMessage: String? = null, val isPasswordVisible: Boolean = false, - val isConfirmPasswordVisible: Boolean = false + val isConfirmPasswordVisible: Boolean = false, + val error: ErrorMessage? = null ) : GroupEditorState } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/GroupsInteractor.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/GroupsInteractor.kt index 7984829..3e0ef73 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/GroupsInteractor.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/GroupsInteractor.kt @@ -2,11 +2,11 @@ package com.github.ai.simplesplit.android.presentation.screens.groups import arrow.core.Either import arrow.core.raise.either +import com.github.ai.simplesplit.android.data.database.model.GroupCredentials import com.github.ai.simplesplit.android.data.repository.GroupCredentialsRepository import com.github.ai.simplesplit.android.data.repository.GroupRepository import com.github.ai.simplesplit.android.domain.usecase.CreateExportUrlUseCase import com.github.ai.simplesplit.android.domain.usecase.CreateGroupUrlUseCase -import com.github.ai.simplesplit.android.model.db.GroupCredentials import com.github.ai.simplesplit.android.model.exception.AppException import com.github.ai.simplesplit.android.presentation.screens.groups.model.GroupsData import kotlinx.coroutines.flow.Flow @@ -39,7 +39,7 @@ class GroupsInteractor( GroupsData( groups = groups, - credentials = credentials + requestedCredentials = credentials ) } @@ -48,6 +48,13 @@ class GroupsInteractor( credentialsRepository.removeByGroupUid(groupUid) } + fun removeGroups(groupUids: List): Either = + either { + for (uid in groupUids) { + credentialsRepository.removeByGroupUid(uid) + } + } + fun getGroupCredentialsFlow(): Flow> = credentialsRepository.getAllFlow() fun createExportToCsvUrl(credentials: GroupCredentials): String = diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/GroupsScreen.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/GroupsScreen.kt index 2fb6642..dee1beb 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/GroupsScreen.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/GroupsScreen.kt @@ -1,5 +1,6 @@ package com.github.ai.simplesplit.android.presentation.screens.groups +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn @@ -9,7 +10,6 @@ import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -18,6 +18,9 @@ import androidx.compose.ui.res.stringResource import com.github.ai.simplesplit.android.BuildConfig import com.github.ai.simplesplit.android.R import com.github.ai.simplesplit.android.presentation.core.compose.CenteredBox +import com.github.ai.simplesplit.android.presentation.core.compose.EmptyState +import com.github.ai.simplesplit.android.presentation.core.compose.ErrorMessageCard +import com.github.ai.simplesplit.android.presentation.core.compose.ErrorState import com.github.ai.simplesplit.android.presentation.core.compose.TopBar import com.github.ai.simplesplit.android.presentation.core.compose.TopBarMenuItem import com.github.ai.simplesplit.android.presentation.core.compose.cells.CellViewModel @@ -53,6 +56,12 @@ private fun GroupsScreen( val onMenuClick = rememberCallback { item: TopBarMenuItem -> onIntent.invoke(GroupsIntent.OnSettingsClick) } + val onCloseErrorClick = rememberOnClickedCallback { + onIntent.invoke(GroupsIntent.OnCloseErrorClick) + } + val onErrorActionClick = rememberCallback { actionId: Int -> + onIntent.invoke(GroupsIntent.OnErrorActionClick(actionId)) + } Scaffold( topBar = { @@ -96,26 +105,52 @@ private fun GroupsScreen( } } - GroupsState.Empty -> { - CenteredBox { - Text(text = "No groups") // TODO: string + is GroupsState.Empty -> { + Column { + if (state.error != null) { + ErrorMessageCard( + error = state.error, + onClose = onCloseErrorClick, + onAction = onErrorActionClick + ) + } + + CenteredBox { + EmptyState( + text = stringResource(R.string.no_groups) + ) + } } } is GroupsState.Data -> { - LazyColumn( - modifier = Modifier.fillMaxSize() + Column( + modifier = Modifier ) { - items(state.cellViewModels) { model -> - RenderCell(model) + if (state.error != null) { + ErrorMessageCard( + error = state.error, + onClose = onCloseErrorClick, + onAction = onErrorActionClick + ) + } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + ) { + items(state.cellViewModels) { model -> + RenderCell(model) + } } } } is GroupsState.Error -> { - CenteredBox { - Text(text = state.message) - } + ErrorState( + error = state.message, + onAction = onErrorActionClick + ) } } } diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/GroupsViewModel.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/GroupsViewModel.kt index 9596d5f..6e4b6be 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/GroupsViewModel.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/GroupsViewModel.kt @@ -3,7 +3,8 @@ package com.github.ai.simplesplit.android.presentation.screens.groups import androidx.annotation.StringRes import androidx.lifecycle.viewModelScope import com.github.ai.simplesplit.android.R -import com.github.ai.simplesplit.android.model.db.GroupCredentials +import com.github.ai.simplesplit.android.data.database.model.GroupCredentials +import com.github.ai.simplesplit.android.model.ErrorMessage import com.github.ai.simplesplit.android.presentation.core.ResourceProvider import com.github.ai.simplesplit.android.presentation.core.compose.cells.CellEvent import com.github.ai.simplesplit.android.presentation.core.compose.navigation.Router @@ -26,20 +27,22 @@ import com.github.ai.simplesplit.android.presentation.screens.groups.model.Group import com.github.ai.simplesplit.android.presentation.screens.groups.model.GroupsState import com.github.ai.simplesplit.android.presentation.screens.root.model.StartActivityEvent import com.github.ai.simplesplit.android.utils.StringUtils -import com.github.ai.simplesplit.android.utils.getErrorMessage import com.github.ai.simplesplit.android.utils.getStringOrNull import com.github.ai.simplesplit.android.utils.mutableStateFlow import com.github.ai.simplesplit.android.utils.parseCellId +import com.github.ai.simplesplit.android.utils.toErrorMessage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch class GroupsViewModel( private val interactor: GroupsInteractor, - private val resourceProvider: ResourceProvider, + private val resources: ResourceProvider, private val router: Router ) : CellsMviViewModel( initialState = GroupsState.Loading, @@ -88,6 +91,10 @@ class GroupsViewModel( is GroupsIntent.OnRemoveGroupConfirmed -> removeGroup(intent.groupUid) + is GroupsIntent.OnCloseErrorClick -> onCloseErrorClicked() + + is GroupsIntent.OnErrorActionClick -> handleErrorAction(intent.actionId) + is GroupsIntent.OnCreateGroupClick -> nonStateAction { navigateToNewGroupScreen() } @@ -125,28 +132,45 @@ class GroupsViewModel( emit(GroupsState.Loading) } - val getDataResult = interactor.loadData() - if (getDataResult.isLeft()) { - val message = getDataResult.getErrorMessage() - emit(GroupsState.Error(message)) - return@flow - } - - val data = getDataResult.getOrNull() ?: GroupsData() - screenData = data - - if (data.groups.isNotEmpty()) { - val viewModels = cellFactory.createCells( - groups = data.groups, - eventProvider = cellEventProvider + interactor.loadData() + .fold( + ifLeft = { + emit(GroupsState.Error(it.toErrorMessage(resources))) + }, + ifRight = { data -> + emit(createScreenState(data)) + } ) - emit(GroupsState.Data(viewModels)) - } else { - emit(GroupsState.Empty) - } }.flowOn(Dispatchers.IO) } + private fun createScreenState(data: GroupsData): GroupsState { + screenData = data + + val missingGroupsError = if (data.groups.size != data.requestedCredentials.size) { + ErrorMessage( + message = resources.getString(R.string.missing_groups_error_message), + actionText = resources.getString(R.string.forget_missing), + actionId = ErrorAction.FORGET_MISSING_GROUPS.ordinal + ) + } else { + null + } + + return if (data.groups.isNotEmpty()) { + val viewModels = cellFactory.createCells( + data = data, + eventProvider = cellEventProvider + ) + GroupsState.Data( + cellViewModels = viewModels, + error = missingGroupsError + ) + } else { + GroupsState.Empty(error = missingGroupsError) + } + } + private fun removeGroup(groupUid: String): Flow { return flow { emit(GroupsState.Loading) @@ -159,7 +183,7 @@ class GroupsViewModel( private fun navigateToGroupDetails(groupUid: String) { val groups = screenData?.groups ?: emptyList() - val credentials = screenData?.credentials ?: emptyList() + val credentials = screenData?.requestedCredentials ?: emptyList() val groupAndCreds = groups.zip(credentials) .firstOrNull { (group, _) -> group.uid == groupUid } ?: return @@ -217,12 +241,12 @@ class GroupsViewModel( items = listOf( MenuItem( icon = AppIcon.ADD, - text = resourceProvider.getString(R.string.create_new_group), + text = resources.getString(R.string.create_new_group), actionId = MenuActions.CREATE_GROUP ), MenuItem( icon = AppIcon.LINK, - text = resourceProvider.getString(R.string.add_by_url), + text = resources.getString(R.string.add_by_url), actionId = MenuActions.ADD_GROUP_BY_URL ) ) @@ -240,7 +264,7 @@ class GroupsViewModel( val items = GroupMenuAction.entries.map { entry -> MenuItem( icon = entry.icon, - text = resourceProvider.getString(entry.resourceId), + text = resources.getString(entry.resourceId), actionId = entry.ordinal ) } @@ -264,10 +288,10 @@ class GroupsViewModel( router.showDialog( Dialog.ConfirmationDialog( args = ConfirmationDialogArgs( - message = resourceProvider.getString( + message = resources.getString( R.string.remove_group_confirmation_message ), - buttonTitle = resourceProvider.getString(R.string.remove) + buttonTitle = resources.getString(R.string.remove) ) ) ) @@ -309,12 +333,55 @@ class GroupsViewModel( } } + private fun onCloseErrorClicked(): Flow { + val state = this.state.value + + return flowOf( + when (state) { + is GroupsState.Empty -> state.copy(error = null) + is GroupsState.Data -> state.copy(error = null) + else -> state + } + ) + } + + private fun handleErrorAction(actionId: Int): Flow { + val action = ErrorAction.entries.getOrNull(actionId) ?: return emptyFlow() + + return when (action) { + ErrorAction.FORGET_MISSING_GROUPS -> onForgetMissingGroupsClicked() + } + } + + private fun onForgetMissingGroupsClicked(): Flow { + val data = screenData ?: return emptyFlow() + + return flow { + emit(GroupsState.Loading) + + val foundGroupsUids = data.groups + .map { group -> group.uid } + .toSet() + + val missingGroupsUids = data.requestedCredentials + .filter { credentials -> credentials.groupUid !in foundGroupsUids } + .map { credentials -> credentials.groupUid } + + val removeResult = interactor.removeGroups(missingGroupsUids) + if (removeResult.isLeft()) { + val message = removeResult.toErrorMessage(resources) + emit(GroupsState.Error(message)) + return@flow + } + }.flowOn(Dispatchers.IO) + } + private fun getGroupUidFromCellId(cellId: String): String? { return cellId.parseCellId()?.payload?.getStringOrNull() } private fun getGroupCredentials(groupUid: String): GroupCredentials? { - return screenData?.credentials + return screenData?.requestedCredentials ?.firstOrNull { creds -> creds.groupUid == groupUid } } @@ -328,6 +395,10 @@ class GroupsViewModel( SHARE_LINK(AppIcon.SHARE, R.string.share) } + enum class ErrorAction { + FORGET_MISSING_GROUPS + } + object MenuActions { const val CREATE_GROUP = 102 const val ADD_GROUP_BY_URL = 103 diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/cells/CellFactory.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/cells/CellFactory.kt index cd41825..0a7825f 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/cells/CellFactory.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/cells/CellFactory.kt @@ -2,58 +2,56 @@ package com.github.ai.simplesplit.android.presentation.screens.groups.cells import androidx.compose.ui.unit.dp import com.github.ai.simplesplit.android.presentation.core.compose.cells.CellEventProvider -import com.github.ai.simplesplit.android.presentation.core.compose.cells.CellModel import com.github.ai.simplesplit.android.presentation.core.compose.cells.CellViewModel import com.github.ai.simplesplit.android.presentation.core.compose.cells.model.SpaceCellModel import com.github.ai.simplesplit.android.presentation.core.compose.cells.viewModel.SpaceCellViewModel import com.github.ai.simplesplit.android.presentation.screens.groups.cells.model.GroupCellModel import com.github.ai.simplesplit.android.presentation.screens.groups.cells.viewModel.GroupCellViewModel +import com.github.ai.simplesplit.android.presentation.screens.groups.model.GroupsData import com.github.ai.simplesplit.android.utils.CellId import com.github.ai.simplesplit.android.utils.CellIdPayload.StringPayload import com.github.ai.simplesplit.android.utils.format -import com.github.ai.split.api.GroupDto class CellFactory { fun createCells( - groups: List, + data: GroupsData, eventProvider: CellEventProvider ): List { - val models = mutableListOf() + val cells = mutableListOf() - for ((index, group) in groups.withIndex()) { + for ((index, group) in data.groups.withIndex()) { val members = group.members .map { member -> member.name } .joinToString(", ") val sum = group.expenses.sumOf { expense -> expense.amount } - models.add( - GroupCellModel( - id = CellId("group", StringPayload(group.uid)).format(), - title = group.title, - description = group.description, - members = members, - amount = "%.2f".format(sum) + cells.add( + GroupCellViewModel( + GroupCellModel( + id = CellId("group", StringPayload(group.uid)).format(), + title = group.title, + description = group.description, + members = members, + amount = "%.2f".format(sum) + ), + eventProvider ) ) - if (index != groups.lastIndex) { - models.add( - SpaceCellModel( - id = "space_$index", - height = 12.dp + if (index != data.groups.lastIndex) { + cells.add( + SpaceCellViewModel( + SpaceCellModel( + id = "space_$index", + height = 12.dp + ) ) ) } } - return models.map { model -> - when (model) { - is SpaceCellModel -> SpaceCellViewModel(model) - is GroupCellModel -> GroupCellViewModel(model, eventProvider) - else -> throw IllegalArgumentException("Unknown model type: $model") - } - } + return cells } } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/cells/ui/GroupCell.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/cells/ui/GroupCell.kt index 1643c98..b2d1a45 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/cells/ui/GroupCell.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/cells/ui/GroupCell.kt @@ -3,27 +3,31 @@ package com.github.ai.simplesplit.android.presentation.screens.groups.cells.ui import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import androidx.constraintlayout.compose.ConstraintLayout +import androidx.constraintlayout.compose.Dimension +import com.github.ai.simplesplit.android.presentation.core.compose.TextSize +import com.github.ai.simplesplit.android.presentation.core.compose.preview.ElementSpace import com.github.ai.simplesplit.android.presentation.core.compose.preview.PreviewEventProvider import com.github.ai.simplesplit.android.presentation.core.compose.preview.ThemedPreview import com.github.ai.simplesplit.android.presentation.core.compose.rememberOnClickedCallback import com.github.ai.simplesplit.android.presentation.core.compose.theme.AppTheme import com.github.ai.simplesplit.android.presentation.core.compose.theme.CardCornerSize import com.github.ai.simplesplit.android.presentation.core.compose.theme.ElementMargin +import com.github.ai.simplesplit.android.presentation.core.compose.theme.GroupThreeLineItemHeight +import com.github.ai.simplesplit.android.presentation.core.compose.theme.GroupTwoLineItemHeight import com.github.ai.simplesplit.android.presentation.core.compose.theme.LightTheme import com.github.ai.simplesplit.android.presentation.core.compose.theme.QuarterMargin +import com.github.ai.simplesplit.android.presentation.core.compose.toTextStyle import com.github.ai.simplesplit.android.presentation.screens.groups.cells.model.GroupCellEvent import com.github.ai.simplesplit.android.presentation.screens.groups.cells.model.GroupCellModel import com.github.ai.simplesplit.android.presentation.screens.groups.cells.viewModel.GroupCellViewModel @@ -32,6 +36,7 @@ import com.github.ai.simplesplit.android.presentation.screens.groups.cells.viewM @Composable fun GroupCell(viewModel: GroupCellViewModel) { val model = viewModel.model + val isDescriptionVisible = model.description.isNotEmpty() val onClick = rememberOnClickedCallback { viewModel.sendEvent(GroupCellEvent.OnClick(model.id)) @@ -41,6 +46,12 @@ fun GroupCell(viewModel: GroupCellViewModel) { viewModel.sendEvent(GroupCellEvent.OnLongClick(model.id)) } + val minHeight = if (isDescriptionVisible) { + GroupThreeLineItemHeight + } else { + GroupTwoLineItemHeight + } + Card( shape = RoundedCornerShape(size = CardCornerSize), colors = CardDefaults.cardColors( @@ -52,56 +63,77 @@ fun GroupCell(viewModel: GroupCellViewModel) { horizontal = ElementMargin ) ) { - Column( + ConstraintLayout( modifier = Modifier .fillMaxWidth() .combinedClickable( onClick = onClick, onLongClick = onLongClick ) - .padding(ElementMargin) + .padding(horizontal = ElementMargin, vertical = ElementMargin) + .sizeIn(minHeight = minHeight) ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = model.title, - style = AppTheme.theme.typography.headlineSmall, - color = AppTheme.theme.colors.primaryText, - maxLines = 1, - modifier = Modifier.weight(1f) - ) + val (title, description, members, amount) = createRefs() - Text( - text = model.amount, - style = AppTheme.theme.typography.headlineSmall, - color = AppTheme.theme.colors.secondaryText, - maxLines = 1 - ) - } + Text( + text = model.title, + style = TextSize.TITLE_LARGE.toTextStyle(), + color = AppTheme.theme.colors.primaryText, + maxLines = 1, + modifier = Modifier + .constrainAs(title) { + top.linkTo(parent.top) + start.linkTo(parent.start) + end.linkTo(amount.start) + width = Dimension.fillToConstraints + } + ) - Spacer(modifier = Modifier.height(QuarterMargin)) + Text( + text = model.amount, + style = TextSize.TITLE_LARGE.toTextStyle(), + color = AppTheme.theme.colors.secondaryText, + maxLines = 1, + modifier = Modifier + .constrainAs(amount) { + top.linkTo(parent.top) + end.linkTo(parent.end) + } + ) - if (model.description.isNotEmpty()) { + if (isDescriptionVisible) { Text( text = model.description, - style = AppTheme.theme.typography.bodyMedium, + style = TextSize.BODY_MEDIUM.toTextStyle(), color = AppTheme.theme.colors.primaryText, maxLines = 1, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .constrainAs(description) { + top.linkTo(title.bottom) + start.linkTo(parent.start) + end.linkTo(parent.end) + width = Dimension.fillToConstraints + } ) } - if (model.members.isNotEmpty()) { - Text( - text = model.members, - style = AppTheme.theme.typography.bodyMedium, - color = AppTheme.theme.colors.secondaryText, - maxLines = 1, - modifier = Modifier.fillMaxWidth() - ) - } + Text( + text = model.members, + style = TextSize.BODY_MEDIUM.toTextStyle(), + color = AppTheme.theme.colors.secondaryText, + maxLines = 1, + modifier = Modifier + .constrainAs(members) { + if (isDescriptionVisible) { + top.linkTo(description.bottom) + } else { + top.linkTo(title.bottom, margin = QuarterMargin) + } + start.linkTo(parent.start) + end.linkTo(parent.end) + width = Dimension.fillToConstraints + } + ) } } } @@ -112,18 +144,23 @@ fun GroupCellPreview() { ThemedPreview(theme = LightTheme) { Column { GroupCell(newGroupCell()) + ElementSpace() + GroupCell(newGroupCell(description = "")) } } } -fun newGroupCell() = - GroupCellViewModel( - model = GroupCellModel( - id = "id", - title = "Title", - description = "Description", - members = "Mickey Mouse, Donald Duck", - amount = "100$" - ), - eventProvider = PreviewEventProvider - ) \ No newline at end of file +fun newGroupCell( + title: String = "Group", + description: String = "Description", + members: String = "Mickey Mouse, Donald Duck" +) = GroupCellViewModel( + model = GroupCellModel( + id = "id", + title = title, + description = description, + members = members, + amount = "100$" + ), + eventProvider = PreviewEventProvider +) \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/model/GroupsData.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/model/GroupsData.kt index 42f6ebb..d737f8d 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/model/GroupsData.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/model/GroupsData.kt @@ -1,9 +1,9 @@ package com.github.ai.simplesplit.android.presentation.screens.groups.model -import com.github.ai.simplesplit.android.model.db.GroupCredentials +import com.github.ai.simplesplit.android.data.database.model.GroupCredentials import com.github.ai.split.api.GroupDto data class GroupsData( val groups: List = emptyList(), - val credentials: List = emptyList() + val requestedCredentials: List = emptyList() ) \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/model/GroupsIntent.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/model/GroupsIntent.kt index 1e32022..02729d6 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/model/GroupsIntent.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/model/GroupsIntent.kt @@ -12,6 +12,8 @@ sealed class GroupsIntent( data object OnCreateGroupClick : GroupsIntent() data object OnAddGroupByUrlClick : GroupsIntent() data object OnSettingsClick : GroupsIntent() + data object OnCloseErrorClick : GroupsIntent() + data class OnErrorActionClick(val actionId: Int) : GroupsIntent() data class OnGroupClick(val groupUid: String) : GroupsIntent() data class OnGroupLongClick(val groupUid: String) : GroupsIntent() data class OnEditGroupClick(val groupUid: String) : GroupsIntent() diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/model/GroupsState.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/model/GroupsState.kt index c5db8ba..c512197 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/model/GroupsState.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/model/GroupsState.kt @@ -1,18 +1,22 @@ package com.github.ai.simplesplit.android.presentation.screens.groups.model +import com.github.ai.simplesplit.android.model.ErrorMessage import com.github.ai.simplesplit.android.presentation.core.compose.cells.CellViewModel sealed interface GroupsState { data object Loading : GroupsState - data object Empty : GroupsState + data class Empty( + val error: ErrorMessage? = null + ) : GroupsState data class Error( - val message: String + val message: ErrorMessage ) : GroupsState data class Data( - val cellViewModels: List + val cellViewModels: List = emptyList(), + val error: ErrorMessage? = null ) : GroupsState } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/settings/SettingsInteractor.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/settings/SettingsInteractor.kt index f7b4a12..4aa6f46 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/settings/SettingsInteractor.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/settings/SettingsInteractor.kt @@ -13,4 +13,8 @@ class SettingsInteractor( fun onServerUrlChanged() { api.updateServerUrl() } + + fun onLogLevelChanged() { + api.updateHttpClient() + } } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/settings/SettingsViewModel.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/settings/SettingsViewModel.kt index 07f2703..cb724c6 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/settings/SettingsViewModel.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/settings/SettingsViewModel.kt @@ -8,8 +8,10 @@ import com.github.ai.simplesplit.android.presentation.core.compose.navigation.Ro import com.github.ai.simplesplit.android.presentation.core.mvi.CellsMviViewModel import com.github.ai.simplesplit.android.presentation.core.mvi.nonStateAction import com.github.ai.simplesplit.android.presentation.screens.settings.cells.SettingsCellFactory +import com.github.ai.simplesplit.android.presentation.screens.settings.cells.SettingsCellFactory.SettingsCellId import com.github.ai.simplesplit.android.presentation.screens.settings.model.SettingsIntent import com.github.ai.simplesplit.android.presentation.screens.settings.model.SettingsState +import io.ktor.client.plugins.logging.LogLevel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf @@ -48,8 +50,21 @@ class SettingsViewModel( } is DropDownCellEvent.OnOptionSelect -> { - settings.serverUrl = event.selectedOption - interactor.onServerUrlChanged() + when (event.cellId) { + SettingsCellId.HTTP_LOG_LEVEL.name -> { + val level = LogLevel.entries + .find { level -> level.name == event.selectedOption } + ?: LogLevel.INFO + + settings.httpLogLevel = level + interactor.onLogLevelChanged() + } + + SettingsCellId.SERVER_URL.name -> { + settings.serverUrl = event.selectedOption + interactor.onServerUrlChanged() + } + } } } } diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/settings/cells/SettingsCellFactory.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/settings/cells/SettingsCellFactory.kt index c7bc0ad..966da00 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/settings/cells/SettingsCellFactory.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/settings/cells/SettingsCellFactory.kt @@ -10,6 +10,7 @@ import com.github.ai.simplesplit.android.presentation.core.compose.cells.model.D import com.github.ai.simplesplit.android.presentation.core.compose.cells.model.SwitchCellModel import com.github.ai.simplesplit.android.presentation.core.compose.cells.viewModel.DropDownCellViewModel import com.github.ai.simplesplit.android.presentation.core.compose.cells.viewModel.SwitchCellViewModel +import io.ktor.client.plugins.logging.LogLevel class SettingsCellFactory( private val resources: ResourceProvider @@ -33,6 +34,20 @@ class SettingsCellFactory( ) ) + val logOptions = LogLevel.entries.map { level -> level.name } + + cells.add( + DropDownCellViewModel( + DropDownCellModel( + id = SettingsCellId.HTTP_LOG_LEVEL.name, + title = resources.getString(R.string.http_log_level), + options = logOptions, + selectedOption = settings.httpLogLevel.name + ), + eventProvider + ) + ) + cells.add( SwitchCellViewModel( SwitchCellModel( @@ -53,6 +68,7 @@ class SettingsCellFactory( enum class SettingsCellId { SERVER_URL, - SSL_CERTIFICATE_SWITCH + SSL_CERTIFICATE_SWITCH, + HTTP_LOG_LEVEL } } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/utils/ArrowEtensions.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/utils/ArrowEtensions.kt index c90b938..fe5e099 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/utils/ArrowEtensions.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/utils/ArrowEtensions.kt @@ -1,9 +1,23 @@ package com.github.ai.simplesplit.android.utils import arrow.core.Either +import com.github.ai.simplesplit.android.R +import com.github.ai.simplesplit.android.model.ErrorMessage import com.github.ai.simplesplit.android.model.exception.AppException +import com.github.ai.simplesplit.android.presentation.core.ResourceProvider -fun Either.getErrorMessage(): String { +fun Either.getMessage(): String { // TODO: get root cause return leftOrNull()?.message ?: "Error has been occurred" +} + +fun Either.toErrorMessage(resources: ResourceProvider): ErrorMessage { + val message = leftOrNull()?.formatReadableMessage(resources) + ?: resources.getString(R.string.unknown_error_message) + + return ErrorMessage( + message = message, + actionText = StringUtils.EMPTY, + actionId = null + ) } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/utils/ExceptionExtensions.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/utils/ExceptionExtensions.kt new file mode 100644 index 0000000..73f71db --- /dev/null +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/utils/ExceptionExtensions.kt @@ -0,0 +1,61 @@ +package com.github.ai.simplesplit.android.utils + +import com.github.ai.simplesplit.android.R +import com.github.ai.simplesplit.android.model.ErrorMessage +import com.github.ai.simplesplit.android.model.exception.AppException +import com.github.ai.simplesplit.android.model.exception.InvalidResponseException +import com.github.ai.simplesplit.android.model.exception.NetworkException +import com.github.ai.simplesplit.android.presentation.core.ResourceProvider + +fun Throwable.getRootCause(): Throwable? { + var result: Throwable? = this.cause + + while (result?.cause != null) { + val cause = result.cause + requireNotNull(cause) + result = cause + } + + return result +} + +fun Throwable?.hasMessage(): Boolean = this?.message?.isNotBlank() == true + +fun AppException.toErrorMessage(resources: ResourceProvider): ErrorMessage { + return ErrorMessage( + message = this.formatReadableMessage(resources), + actionText = StringUtils.EMPTY, + actionId = null + ) +} + +fun AppException.formatReadableMessage(resources: ResourceProvider): String { + val error = this + val cause = getRootCause() + + return when { + error is NetworkException -> buildString { + append(resources.getString(R.string.network_error_message)) + + if (cause.hasMessage()) { + append(": ") + append(cause?.message) + } + } + + error is InvalidResponseException -> buildString { + append(resources.getString(R.string.invalid_server_response)) + + append(". ") + if (error.errorMessage?.message?.isNotBlank() == true) { + append(error.errorMessage.message) + } else { + append(resources.getString(R.string.http_status_code_with_str, error.statusCode)) + } + } + + cause.hasMessage() -> cause?.message ?: StringUtils.EMPTY + error.hasMessage() -> error.message ?: StringUtils.EMPTY + else -> resources.getString(R.string.unknown_error_message) + } +} \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 09666f4..f0af920 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -7,6 +7,10 @@ Share This field is required An error occurred. Please try again. + Unknown error has been occurred + Network error has been occurred + Invalid server response + HTTP status code: %s Do you really want to remove this group? @@ -16,12 +20,16 @@ Groups Create new group Add by url + No groups + Some groups are missing in the server response. Potentially their password could be changed or they were removed + Forget them Settings Server URL Validate SSL certificates Disables SSL certificate validation during HTTPS requests + HTTP log level Create New Group diff --git a/android/app/src/main/res/values/untraslatable_strings.xml b/android/app/src/main/res/values/untraslatable_strings.xml index ae9a2f4..5c5c4de 100644 --- a/android/app/src/main/res/values/untraslatable_strings.xml +++ b/android/app/src/main/res/values/untraslatable_strings.xml @@ -1,5 +1,6 @@ Lorem Ipsum + Lorem Ipsum is simply dummy text of the printing and typesetting industry Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry\'s standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. \ No newline at end of file diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index be849b5..cf03fb2 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -40,6 +40,7 @@ mordant = "3.0.1" truth = "1.4.4" jgit = "6.2.0.202206071550-r" ksprefs = "2.4.1" +constraintLayoutCompose = "1.0.1" [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } @@ -83,6 +84,7 @@ androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-viewMaterial = { module = "com.google.android.material:material", version.ref = "viewMaterial" } androidx-icons = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIcons" } +androidx-constraintLayoutCompose = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "constraintLayoutCompose" } ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" } ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" } ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" }