From 13f722ffb4238e34d0ef06061c3ef8aeb5d5c191 Mon Sep 17 00:00:00 2001 From: Nicole Georgieva <93789076+nicolegeorgieva@users.noreply.github.com> Date: Sun, 1 Dec 2024 00:59:01 +0200 Subject: [PATCH] Ivy switch (#54) * Implement IvySwitch component and use it in SettingsContent * Improve switch UI in Settings * Handle terms of use and privacy policy click events * Improve Settings screen's UI * Improve Settings screen's UI & SettingsButton * Implement DeleteAccountConfirmationDialog composable * Improve UI/UX * Fix event handling * Fix setting button titles * Remove not needed imports * Support button loading state --- .../kotlin/component/button/IvyButton.kt | 29 +++- .../kotlin/component/button/IvySwitch.kt | 49 +++++++ .../ui/screen/home/composable/HomeContent.kt | 26 +++- .../ui/screen/settings/SettingsViewModel.kt | 31 ++-- .../ui/screen/settings/SettingsViewState.kt | 8 +- .../DeleteAccountConfirmationDialog.kt | 63 ++++++++ .../settings/composable/SettingsContent.kt | 137 +++++++++++++----- 7 files changed, 280 insertions(+), 63 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/component/button/IvySwitch.kt create mode 100644 composeApp/src/commonMain/kotlin/ui/screen/settings/composable/DeleteAccountConfirmationDialog.kt diff --git a/composeApp/src/commonMain/kotlin/component/button/IvyButton.kt b/composeApp/src/commonMain/kotlin/component/button/IvyButton.kt index e5dd34f..4dd362a 100644 --- a/composeApp/src/commonMain/kotlin/component/button/IvyButton.kt +++ b/composeApp/src/commonMain/kotlin/component/button/IvyButton.kt @@ -1,10 +1,7 @@ package component.button import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -32,19 +29,37 @@ sealed interface ButtonStyle { fun IvyButton( appearance: ButtonAppearance, modifier: Modifier = Modifier, + loading: Boolean = false, enabled: Boolean = true, text: @Composable (RowScope.() -> Unit)? = null, icon: @Composable (RowScope.() -> Unit)? = null, iconRight: @Composable (RowScope.() -> Unit)? = null, onClick: () -> Unit, ) { + val contentPadding = if (text == null) { + PaddingValues(all = 8.dp) + } else { + PaddingValues( + horizontal = 16.dp, + vertical = 8.dp + ) + } + ButtonWrapper( - appearance = appearance, modifier = modifier, - enabled = enabled, + appearance = appearance, + contentPadding = contentPadding, + enabled = enabled && !loading, onClick = onClick ) { when { + loading -> { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = LocalContentColor.current + ) + } + icon != null && text != null -> { icon() Spacer(Modifier.width(8.dp)) @@ -71,12 +86,12 @@ fun IvyButton( @Composable private fun ButtonWrapper( appearance: ButtonAppearance, + contentPadding: PaddingValues, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit, content: @Composable (RowScope.() -> Unit) ) { - val contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) val colors = appearance.buttonColors() when (appearance) { diff --git a/composeApp/src/commonMain/kotlin/component/button/IvySwitch.kt b/composeApp/src/commonMain/kotlin/component/button/IvySwitch.kt new file mode 100644 index 0000000..bc0faba --- /dev/null +++ b/composeApp/src/commonMain/kotlin/component/button/IvySwitch.kt @@ -0,0 +1,49 @@ +package component.button + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Switch +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import ui.theme.colorsExt + +@Composable +fun IvySwitch( + checked: Boolean, + modifier: Modifier = Modifier, + onCheckedChange: (Boolean) -> Unit, + text: @Composable () -> Unit, +) { + Row( + modifier = modifier + .clip(MaterialTheme.shapes.medium) + .clickable( + onClick = { + onCheckedChange(!checked) + } + ) + .background( + color = MaterialTheme.colorsExt.backgroundVariant, + shape = MaterialTheme.shapes.medium + ) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + text() + Spacer(Modifier.weight(1f)) + Switch( + checked = checked, + onCheckedChange = onCheckedChange + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ui/screen/home/composable/HomeContent.kt b/composeApp/src/commonMain/kotlin/ui/screen/home/composable/HomeContent.kt index beb4414..69ff1ba 100644 --- a/composeApp/src/commonMain/kotlin/ui/screen/home/composable/HomeContent.kt +++ b/composeApp/src/commonMain/kotlin/ui/screen/home/composable/HomeContent.kt @@ -7,13 +7,16 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material.Icon import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import component.* -import component.button.SecondaryButton +import component.button.ButtonAppearance +import component.button.ButtonStyle +import component.button.IvyButton import ivy.model.CourseId import ui.screen.home.HomeItemViewState import ui.screen.home.HomeViewEvent @@ -32,11 +35,8 @@ fun HomeContent( ), title = "Learn", actions = { - // TODO - update button - SecondaryButton( - text = "", - icon = Icons.Filled.Settings, - onClick = { + SettingsButton( + onSettingsClick = { onEvent(HomeViewEvent.OnSettingsClick) } ) @@ -101,4 +101,18 @@ fun HomeContent( } } } +} + +@Composable +private fun SettingsButton(onSettingsClick: () -> Unit) { + IvyButton( + appearance = ButtonAppearance.Filled(style = ButtonStyle.Secondary), + icon = { + Icon( + imageVector = Icons.Filled.Settings, + contentDescription = null + ) + }, + onClick = onSettingsClick + ) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ui/screen/settings/SettingsViewModel.kt b/composeApp/src/commonMain/kotlin/ui/screen/settings/SettingsViewModel.kt index 2607e97..58c1aae 100644 --- a/composeApp/src/commonMain/kotlin/ui/screen/settings/SettingsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/ui/screen/settings/SettingsViewModel.kt @@ -1,14 +1,17 @@ package ui.screen.settings import androidx.compose.runtime.* +import androidx.compose.ui.platform.UriHandler +import ivy.IvyUrls import navigation.Navigation import ui.ComposeViewModel class SettingsViewModel( private val navigation: Navigation, + private val uriHandler: UriHandler ) : ComposeViewModel { private var soundEnabled by mutableStateOf(true) - private var deleteDialogVisible by mutableStateOf(false) + private var deleteDialog by mutableStateOf(null) @Composable override fun viewState(): SettingsViewState { @@ -17,7 +20,7 @@ class SettingsViewModel( } return SettingsViewState( soundEnabled = getSoundEnabled(), - deleteDialogVisible = getDeleteDialogVisible() + deleteDialog = getDeleteDialogState() ) } @@ -27,8 +30,8 @@ class SettingsViewModel( } @Composable - private fun getDeleteDialogVisible(): Boolean { - return deleteDialogVisible + private fun getDeleteDialogState(): DeleteDialogViewState? { + return deleteDialog } override fun onEvent(event: SettingsViewEvent) { @@ -37,11 +40,12 @@ class SettingsViewModel( SettingsViewEvent.OnPremiumClick -> handlePremiumClick() is SettingsViewEvent.OnSoundEnabledChange -> handleSoundEnabledChange(event) SettingsViewEvent.OnPrivacyClick -> handlePrivacyClick() + SettingsViewEvent.OnLogOutClick -> handleLogOutClick() SettingsViewEvent.OnDeleteAccountClick -> handleDeleteAccountClick() SettingsViewEvent.OnTermsOfUseClick -> handleTermsOfUseClick() SettingsViewEvent.OnPrivacyPolicyClick -> handlePrivacyPolicyClick() - SettingsViewEvent.OnCancelDeleteAccountClick -> handleConfirmDeleteAccountClick() - SettingsViewEvent.OnConfirmDeleteAccountClick -> handleCancelDeleteAccountClick() + SettingsViewEvent.OnCancelDeleteAccountClick -> handleCancelDeleteAccountClick() + SettingsViewEvent.OnConfirmDeleteAccountClick -> handleConfirmDeleteAccountClick() } } @@ -61,23 +65,28 @@ class SettingsViewModel( // TODO - handle event } - private fun handleDeleteAccountClick() { - deleteDialogVisible = true + private fun handleLogOutClick() { + // TODO - handle event } private fun handleTermsOfUseClick() { - // TODO - handle event + uriHandler.openUri(IvyUrls.tos) } private fun handlePrivacyPolicyClick() { - // TODO - handle event + uriHandler.openUri(IvyUrls.privacy) + } + + private fun handleDeleteAccountClick() { + deleteDialog = DeleteDialogViewState(ctaLoading = false) } private fun handleConfirmDeleteAccountClick() { + deleteDialog = DeleteDialogViewState(ctaLoading = true) // TODO - handle event } private fun handleCancelDeleteAccountClick() { - // TODO - handle event + deleteDialog = null } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ui/screen/settings/SettingsViewState.kt b/composeApp/src/commonMain/kotlin/ui/screen/settings/SettingsViewState.kt index e3aabc2..f082112 100644 --- a/composeApp/src/commonMain/kotlin/ui/screen/settings/SettingsViewState.kt +++ b/composeApp/src/commonMain/kotlin/ui/screen/settings/SettingsViewState.kt @@ -5,7 +5,12 @@ import androidx.compose.runtime.Immutable @Immutable data class SettingsViewState( val soundEnabled: Boolean, - val deleteDialogVisible: Boolean, + val deleteDialog: DeleteDialogViewState? +) + +@Immutable +data class DeleteDialogViewState( + val ctaLoading: Boolean ) sealed interface SettingsViewEvent { @@ -13,6 +18,7 @@ sealed interface SettingsViewEvent { data object OnPremiumClick : SettingsViewEvent data class OnSoundEnabledChange(val enabled: Boolean) : SettingsViewEvent data object OnPrivacyClick : SettingsViewEvent + data object OnLogOutClick : SettingsViewEvent data object OnDeleteAccountClick : SettingsViewEvent data object OnTermsOfUseClick : SettingsViewEvent data object OnPrivacyPolicyClick : SettingsViewEvent diff --git a/composeApp/src/commonMain/kotlin/ui/screen/settings/composable/DeleteAccountConfirmationDialog.kt b/composeApp/src/commonMain/kotlin/ui/screen/settings/composable/DeleteAccountConfirmationDialog.kt new file mode 100644 index 0000000..71e5886 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ui/screen/settings/composable/DeleteAccountConfirmationDialog.kt @@ -0,0 +1,63 @@ +package ui.screen.settings.composable + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.AlertDialog +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import component.button.ButtonAppearance +import component.button.ButtonStyle +import component.button.IvyButton +import ui.screen.settings.DeleteDialogViewState +import ui.theme.colorsExt + +@Composable +fun DeleteAccountConfirmationDialog( + viewState: DeleteDialogViewState, + modifier: Modifier = Modifier, + onConfirmDeleteAccountClick: () -> Unit, + onCancelDeleteAccountClick: () -> Unit +) { + AlertDialog( + modifier = modifier, + title = { + Text( + text = "Confirm Account Deletion", + style = MaterialTheme.typography.subtitle1 + ) + }, + text = { + Text( + text = "By proceeding, you confirm your request to permanently delete your account and all " + + "associated data. This action is irreversible and cannot be undone. Please review our " + + "Privacy Policy and Terms of Service for further details.", + style = MaterialTheme.typography.subtitle2 + ) + }, + onDismissRequest = onCancelDeleteAccountClick, + confirmButton = { + IvyButton( + appearance = ButtonAppearance.Filled(ButtonStyle.Destructive), + loading = viewState.ctaLoading, + onClick = onConfirmDeleteAccountClick, + text = { + Text(text = "DELETE ACCOUNT") + } + ) + }, + dismissButton = { + IvyButton( + appearance = ButtonAppearance.Filled(ButtonStyle.Neutral), + onClick = onCancelDeleteAccountClick, + text = { + Text(text = "Cancel") + } + ) + }, + backgroundColor = MaterialTheme.colorsExt.backgroundVariant, + contentColor = MaterialTheme.colorsExt.onBackgroundVariant, + shape = RoundedCornerShape(8.dp) + ) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ui/screen/settings/composable/SettingsContent.kt b/composeApp/src/commonMain/kotlin/ui/screen/settings/composable/SettingsContent.kt index fd96621..0d7ae2b 100644 --- a/composeApp/src/commonMain/kotlin/ui/screen/settings/composable/SettingsContent.kt +++ b/composeApp/src/commonMain/kotlin/ui/screen/settings/composable/SettingsContent.kt @@ -3,8 +3,11 @@ package ui.screen.settings.composable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.material.Switch +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ExitToApp import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -15,10 +18,12 @@ import component.LearnScaffold import component.button.ButtonAppearance import component.button.ButtonStyle import component.button.IvyButton +import component.button.IvySwitch import component.platformHorizontalPadding -import component.text.Title import ui.screen.settings.SettingsViewEvent import ui.screen.settings.SettingsViewState +import ui.theme.Gray +import ui.theme.colorsExt @Composable fun SettingsContent( @@ -40,30 +45,49 @@ fun SettingsContent( val horizontalPadding = platformHorizontalPadding() LazyColumn( modifier = Modifier.widthIn(max = 500.dp), - horizontalAlignment = Alignment.CenterHorizontally, contentPadding = PaddingValues(horizontal = horizontalPadding) ) { + sectionDivider(text = "Premium") premiumButton( onPremiumClick = { onEvent(SettingsViewEvent.OnPremiumClick) } ) + sectionDivider("App") appSettingsSection( soundEnabled = viewState.soundEnabled, onSoundEnabledChange = { onEvent(SettingsViewEvent.OnSoundEnabledChange(it)) } ) + sectionDivider("Account") privacyButton( onPrivacyClick = { onEvent(SettingsViewEvent.OnPrivacyClick) } ) + spacerItem( + key = "spacer 1", + height = 8.dp + ) + logOutButton( + onLogOutClick = { + onEvent(SettingsViewEvent.OnLogOutClick) + } + ) + spacerItem( + key = "spacer 2", + height = 8.dp + ) deleteAccountButton( onDeleteAccountClick = { onEvent(SettingsViewEvent.OnDeleteAccountClick) } ) + spacerItem( + key = "spacer 3", + height = 8.dp + ) legalFooter( onTermsOfUseClick = { onEvent(SettingsViewEvent.OnTermsOfUseClick) @@ -75,23 +99,47 @@ fun SettingsContent( } } } + + if (viewState.deleteDialog != null) { + DeleteAccountConfirmationDialog( + viewState = viewState.deleteDialog, + onConfirmDeleteAccountClick = { + onEvent(SettingsViewEvent.OnConfirmDeleteAccountClick) + }, + onCancelDeleteAccountClick = { + onEvent(SettingsViewEvent.OnCancelDeleteAccountClick) + } + ) + } +} + +private fun LazyListScope.sectionDivider(text: String) { + item(key = text) { + Text( + modifier = Modifier.padding( + start = 24.dp, + top = 24.dp, + bottom = 8.dp + ), + text = text, + style = MaterialTheme.typography.subtitle1, + color = Gray + ) + } } private fun LazyListScope.premiumButton( onPremiumClick: () -> Unit ) { item(key = "premium") { - Column { - Title("App") - IvyButton( - modifier = Modifier.fillMaxWidth(), - appearance = ButtonAppearance.Filled(ButtonStyle.Primary), - text = { - Text("Premium") - }, - onClick = onPremiumClick - ) - } + IvyButton( + modifier = Modifier.fillMaxWidth(), + appearance = ButtonAppearance.Filled(ButtonStyle.Primary), + text = { + Text("Upgrade to Premium") + }, + onClick = onPremiumClick + ) } } @@ -100,14 +148,10 @@ private fun LazyListScope.appSettingsSection( onSoundEnabledChange: (Boolean) -> Unit, ) { item(key = "app") { - Column { - Title("App") - Spacer(Modifier.height(12.dp)) - SoundSwitch( - soundEnabled = soundEnabled, - onSoundEnabledChange = onSoundEnabledChange - ) - } + SoundSwitch( + soundEnabled = soundEnabled, + onSoundEnabledChange = onSoundEnabledChange + ) } } @@ -117,23 +161,19 @@ private fun SoundSwitch( modifier: Modifier = Modifier, onSoundEnabledChange: (Boolean) -> Unit, ) { - IvyButton( + IvySwitch( modifier = modifier, - appearance = ButtonAppearance.Filled(ButtonStyle.Neutral), - text = { - Text("Sounds") - Spacer(Modifier.weight(1f)) - Switch( - modifier = Modifier.defaultMinSize(minHeight = 0.dp), - checked = soundEnabled, - onCheckedChange = { - onSoundEnabledChange(it) - }, - ) - }, - onClick = { + checked = soundEnabled, + onCheckedChange = { onSoundEnabledChange(!soundEnabled) }, + text = { + Text( + text = "Sounds", + style = MaterialTheme.typography.button, + color = MaterialTheme.colorsExt.onBackgroundVariant + ) + } ) } @@ -152,6 +192,27 @@ private fun LazyListScope.privacyButton( } } +private fun LazyListScope.logOutButton( + onLogOutClick: () -> Unit +) { + item(key = "log-out") { + IvyButton( + modifier = Modifier.fillMaxWidth(), + appearance = ButtonAppearance.Outlined(ButtonStyle.Neutral), + icon = { + Icon( + imageVector = Icons.AutoMirrored.Filled.ExitToApp, + contentDescription = null + ) + }, + text = { + Text("Log out") + }, + onClick = onLogOutClick + ) + } +} + private fun LazyListScope.deleteAccountButton( onDeleteAccountClick: () -> Unit ) { @@ -180,14 +241,14 @@ private fun LazyListScope.legalFooter( IvyButton( appearance = ButtonAppearance.Text(style = ButtonStyle.Neutral), text = { - Text("Terms of use") + Text("Terms of Service") }, onClick = onTermsOfUseClick, ) IvyButton( appearance = ButtonAppearance.Text(style = ButtonStyle.Neutral), text = { - Text("Privacy policy") + Text("Privacy Policy") }, onClick = onPrivacyPolicyClick, )