diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8853e921..f347c051 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -72,6 +72,7 @@ dependencies { implementation("androidx.activity:activity-compose:1.9.0") implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") implementation("androidx.compose.material3.adaptive:adaptive:1.0.0-alpha11") implementation("androidx.compose.material3.adaptive:adaptive-layout:1.0.0-alpha11") implementation("androidx.compose.material3.adaptive:adaptive-navigation:1.0.0-alpha11") diff --git a/app/src/main/java/com/jocmp/capyreader/ui/articles/ArticleLayout.kt b/app/src/main/java/com/jocmp/capyreader/ui/articles/ArticleLayout.kt index 23f52e50..6ee13cf1 100644 --- a/app/src/main/java/com/jocmp/capyreader/ui/articles/ArticleLayout.kt +++ b/app/src/main/java/com/jocmp/capyreader/ui/articles/ArticleLayout.kt @@ -81,9 +81,14 @@ fun ArticleLayout( onMarkAllRead: (range: MarkRead) -> Unit, onRemoveFeed: (feedID: String, onSuccess: () -> Unit, onFailure: () -> Unit) -> Unit, drawerValue: DrawerValue = DrawerValue.Closed, + showUnauthorizedMessage: Boolean, + onUnauthorizedDismissRequest: () -> Unit ) { val templateColors = articleTemplateColors() val (isInitialized, setInitialized) = rememberSaveable { mutableStateOf(false) } + val (isUpdatePasswordDialogOpen, setUpdatePasswordDialogOpen) = rememberSaveable { + mutableStateOf(false) + } val drawerState = rememberDrawerState(drawerValue) val coroutineScope = rememberCoroutineScope() val scaffoldNavigator = rememberListDetailPaneScaffoldNavigator() @@ -100,6 +105,11 @@ fun ArticleLayout( val unsubscribeErrorMessage = stringResource(R.string.unsubscribe_error) val currentFeed = findCurrentFeed(filter, allFeeds) + val openUpdatePasswordDialog = { + onUnauthorizedDismissRequest() + setUpdatePasswordDialogOpen(true) + } + val navigateToDetail = { scaffoldNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail) } @@ -276,6 +286,25 @@ fun ArticleLayout( } ) + if (showUnauthorizedMessage) { + UnauthorizedAlertDialog( + onConfirm = openUpdatePasswordDialog, + onDismissRequest = onUnauthorizedDismissRequest, + ) + } + + if (isUpdatePasswordDialogOpen) { + UpdateAuthDialog( + onSuccess = { message -> + setUpdatePasswordDialogOpen(false) + showSnackbar(message) + }, + onDismissRequest = { + setUpdatePasswordDialogOpen(false) + } + ) + } + LaunchedEffect(Unit) { if (!isInitialized) { state.startRefresh() @@ -329,6 +358,8 @@ fun ArticleLayoutPreview() { onToggleArticleStar = {}, onMarkAllRead = {}, drawerValue = DrawerValue.Open, + showUnauthorizedMessage = false, + onUnauthorizedDismissRequest = {} ) } } diff --git a/app/src/main/java/com/jocmp/capyreader/ui/articles/ArticleScreen.kt b/app/src/main/java/com/jocmp/capyreader/ui/articles/ArticleScreen.kt index c6e225b8..ebde73f1 100644 --- a/app/src/main/java/com/jocmp/capyreader/ui/articles/ArticleScreen.kt +++ b/app/src/main/java/com/jocmp/capyreader/ui/articles/ArticleScreen.kt @@ -38,5 +38,7 @@ fun ArticleScreen( onToggleArticleRead = viewModel::toggleArticleRead, onToggleArticleStar = viewModel::toggleArticleStar, onMarkAllRead = viewModel::markAllRead, + showUnauthorizedMessage = viewModel.showUnauthorizedMessage, + onUnauthorizedDismissRequest = viewModel::dismissUnauthorizedMessage ) } diff --git a/app/src/main/java/com/jocmp/capyreader/ui/articles/ArticleScreenViewModel.kt b/app/src/main/java/com/jocmp/capyreader/ui/articles/ArticleScreenViewModel.kt index e2fe4439..0de876de 100644 --- a/app/src/main/java/com/jocmp/capyreader/ui/articles/ArticleScreenViewModel.kt +++ b/app/src/main/java/com/jocmp/capyreader/ui/articles/ArticleScreenViewModel.kt @@ -2,7 +2,10 @@ package com.jocmp.capyreader.ui.articles import android.app.Application import android.content.Context +import android.util.Log +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData @@ -16,6 +19,7 @@ import com.jocmp.capy.Feed import com.jocmp.capy.Folder import com.jocmp.capy.MarkRead import com.jocmp.capy.buildPager +import com.jocmp.capy.common.UnauthorizedError import com.jocmp.capy.countAll import com.jocmp.capyreader.common.AppPreferences import com.jocmp.capyreader.sync.addStarAsync @@ -42,7 +46,9 @@ class ArticleScreenViewModel( val filter = MutableStateFlow(appPreferences.filter.get()) - private val articleState = mutableStateOf(account.findArticle(appPreferences.articleID.get())) + private var _article by mutableStateOf(account.findArticle(appPreferences.articleID.get())) + + private var _showUnauthorizedMessage by mutableStateOf(UnauthorizedMessageState.HIDE) private val _counts = filter.flatMapLatest { latestFilter -> account.countAll(latestFilter.status) @@ -68,8 +74,11 @@ class ArticleScreenViewModel( it.values.sum() } + val showUnauthorizedMessage: Boolean + get() = _showUnauthorizedMessage == UnauthorizedMessageState.SHOW + val article: Article? - get() = articleState.value + get() = _article private val filterStatus: ArticleStatus get() = filter.value.status @@ -130,14 +139,17 @@ class ArticleScreenViewModel( } ) } - } fun refreshFeed(onComplete: () -> Unit) { refreshJob?.cancel() refreshJob = viewModelScope.launch(Dispatchers.IO) { - account.refresh() + account.refresh().onFailure { throwable -> + if (throwable is UnauthorizedError && _showUnauthorizedMessage == UnauthorizedMessageState.HIDE) { + _showUnauthorizedMessage = UnauthorizedMessageState.SHOW + } + } onComplete() } @@ -145,15 +157,15 @@ class ArticleScreenViewModel( fun selectArticle(articleID: String, completion: (article: Article) -> Unit) { viewModelScope.launch { - articleState.value = account.findArticle(articleID = articleID)?.copy(read = true) - articleState.value?.let(completion) + _article = account.findArticle(articleID = articleID)?.copy(read = true) + _article?.let(completion) appPreferences.articleID.set(articleID) markRead(articleID) } } fun toggleArticleRead() { - articleState.value?.let { article -> + _article?.let { article -> viewModelScope.launch { if (article.read) { markUnread(article.id) @@ -162,12 +174,12 @@ class ArticleScreenViewModel( } } - articleState.value = article.copy(read = !article.read) + _article = article.copy(read = !article.read) } } fun toggleArticleStar() { - articleState.value?.let { article -> + _article?.let { article -> viewModelScope.launch { if (article.starred) { removeStar(article.id) @@ -175,13 +187,17 @@ class ArticleScreenViewModel( addStar(article.id) } - articleState.value = article.copy(starred = !article.starred) + _article = article.copy(starred = !article.starred) } } } + fun dismissUnauthorizedMessage() { + _showUnauthorizedMessage = UnauthorizedMessageState.LATER + } + fun clearArticle() { - articleState.value = null + _article = null viewModelScope.launch { appPreferences.articleID.delete() @@ -246,6 +262,12 @@ class ArticleScreenViewModel( private val context: Context get() = application.applicationContext + + enum class UnauthorizedMessageState { + HIDE, + SHOW, + LATER, + } } private fun List.withPositiveCount(status: ArticleStatus): List { diff --git a/app/src/main/java/com/jocmp/capyreader/ui/articles/ArticlesModule.kt b/app/src/main/java/com/jocmp/capyreader/ui/articles/ArticlesModule.kt index db2dbf46..e1dd9489 100644 --- a/app/src/main/java/com/jocmp/capyreader/ui/articles/ArticlesModule.kt +++ b/app/src/main/java/com/jocmp/capyreader/ui/articles/ArticlesModule.kt @@ -32,4 +32,7 @@ internal val articlesModule = module { appPreferences = get() ) } + viewModel { + UpdateAuthViewModel(get()) + } } diff --git a/app/src/main/java/com/jocmp/capyreader/ui/articles/EditFeedDialog.kt b/app/src/main/java/com/jocmp/capyreader/ui/articles/EditFeedDialog.kt index a7b96ec5..7dd96550 100644 --- a/app/src/main/java/com/jocmp/capyreader/ui/articles/EditFeedDialog.kt +++ b/app/src/main/java/com/jocmp/capyreader/ui/articles/EditFeedDialog.kt @@ -25,9 +25,7 @@ fun EditFeedDialog( } Dialog(onDismissRequest = onCancel) { - Card( - shape = RoundedCornerShape(16.dp) - ) { + Card { EditFeedView( feed = feed, folders = folders, diff --git a/app/src/main/java/com/jocmp/capyreader/ui/articles/EditFeedView.kt b/app/src/main/java/com/jocmp/capyreader/ui/articles/EditFeedView.kt index 8c96438d..c66316b6 100644 --- a/app/src/main/java/com/jocmp/capyreader/ui/articles/EditFeedView.kt +++ b/app/src/main/java/com/jocmp/capyreader/ui/articles/EditFeedView.kt @@ -4,14 +4,16 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -26,7 +28,6 @@ import com.jocmp.capy.EditFeedFormEntry import com.jocmp.capy.Feed import com.jocmp.capy.Folder import com.jocmp.capyreader.R -import com.jocmp.capyreader.ui.components.TextField import com.jocmp.capyreader.ui.fixtures.FeedPreviewFixture @Composable @@ -65,10 +66,12 @@ fun EditFeedView( } Column( - Modifier - .padding(16.dp) + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier .verticalScroll(scrollState) + .padding(16.dp) ) { + TextField( value = name, onValueChange = setName, @@ -79,7 +82,8 @@ fun EditFeedView( keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.Words, autoCorrect = false - ) + ), + modifier = Modifier.fillMaxWidth() ) TextField( value = addedFolder, @@ -90,9 +94,13 @@ fun EditFeedView( keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.Words, autoCorrect = false - ) + ), + modifier = Modifier.fillMaxWidth() ) - Column(modifier = Modifier.padding(horizontal = 16.dp)) { + Column( + modifier = Modifier + .padding(horizontal = 8.dp) + ) { displaySwitchFolders.forEach { (folderTitle, checked) -> Row( horizontalArrangement = Arrangement.SpaceBetween, @@ -108,13 +116,13 @@ fun EditFeedView( } } Row( - horizontalArrangement = Arrangement.End, + horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.End), modifier = Modifier.fillMaxWidth() ) { TextButton(onClick = onCancel) { Text(stringResource(R.string.feed_form_cancel)) } - Button(onClick = { submitFeed() }) { + TextButton(onClick = { submitFeed() }) { Text(stringResource(R.string.edit_feed_submit)) } } diff --git a/app/src/main/java/com/jocmp/capyreader/ui/articles/FilterAppBarTitle.kt b/app/src/main/java/com/jocmp/capyreader/ui/articles/FilterAppBarTitle.kt index b867d386..8719df5f 100644 --- a/app/src/main/java/com/jocmp/capyreader/ui/articles/FilterAppBarTitle.kt +++ b/app/src/main/java/com/jocmp/capyreader/ui/articles/FilterAppBarTitle.kt @@ -18,22 +18,15 @@ fun FilterAppBarTitle( allFeeds: List, folders: List, ) { - val articleStatusTitle = (filter as? ArticleFilter.Articles)?.run { - stringResource(articleStatus.navigationTitle) - } - - val text = remember(filter) { - when (filter) { - is ArticleFilter.Articles -> articleStatusTitle - is ArticleFilter.Feeds -> { - allFeeds.find { it.id == filter.feedID }?.title - } - - is ArticleFilter.Folders -> { - folders.find { it.title == filter.folderTitle }?.title - } - }.orEmpty() - } + val text = when (filter) { + is ArticleFilter.Articles -> stringResource(filter.articleStatus.navigationTitle) + is ArticleFilter.Feeds -> { + allFeeds.find { it.id == filter.feedID }?.title + } + is ArticleFilter.Folders -> { + folders.find { it.title == filter.folderTitle }?.title + } + }.orEmpty() Text( text, diff --git a/app/src/main/java/com/jocmp/capyreader/ui/articles/UnauthorizedAlertDialog.kt b/app/src/main/java/com/jocmp/capyreader/ui/articles/UnauthorizedAlertDialog.kt new file mode 100644 index 00000000..51216883 --- /dev/null +++ b/app/src/main/java/com/jocmp/capyreader/ui/articles/UnauthorizedAlertDialog.kt @@ -0,0 +1,34 @@ +package com.jocmp.capyreader.ui.articles + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.jocmp.capyreader.R + +@Composable +fun UnauthorizedAlertDialog( + onConfirm: () -> Unit, + onDismissRequest: () -> Unit, +) { + AlertDialog( + title = { + Text(stringResource(R.string.unauthorized_dialog_title)) + }, + text = { + Text(stringResource(R.string.unauthorized_dialog_description)) + }, + onDismissRequest = onDismissRequest, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(R.string.unauthorized_dialog_dismiss_text)) + } + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(R.string.unauthorized_dialog_confirm_text)) + } + } + ) +} diff --git a/app/src/main/java/com/jocmp/capyreader/ui/articles/UpdateAuthDialog.kt b/app/src/main/java/com/jocmp/capyreader/ui/articles/UpdateAuthDialog.kt new file mode 100644 index 00000000..69fa4244 --- /dev/null +++ b/app/src/main/java/com/jocmp/capyreader/ui/articles/UpdateAuthDialog.kt @@ -0,0 +1,39 @@ +package com.jocmp.capyreader.ui.articles + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.jocmp.capyreader.R +import com.jocmp.capyreader.ui.components.DialogCard +import org.koin.compose.koinInject + +@Composable +fun UpdateAuthDialog( + onDismissRequest: () -> Unit, + onSuccess: (message: String) -> Unit, + viewModel: UpdateAuthViewModel = koinInject() +) { + val successMessage = stringResource(R.string.update_auth_success_message) + + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + DialogCard { + UpdateAuthView( + onPasswordChange = viewModel::setPassword, + onNavigateBack = onDismissRequest, + onSubmit = { + viewModel.submit { + onSuccess(successMessage) + } + }, + username = viewModel.username, + password = viewModel.password, + loading = viewModel.loading, + showError = viewModel.showError + ) + } + } +} diff --git a/app/src/main/java/com/jocmp/capyreader/ui/articles/UpdateAuthView.kt b/app/src/main/java/com/jocmp/capyreader/ui/articles/UpdateAuthView.kt new file mode 100644 index 00000000..7c7e0640 --- /dev/null +++ b/app/src/main/java/com/jocmp/capyreader/ui/articles/UpdateAuthView.kt @@ -0,0 +1,87 @@ +package com.jocmp.capyreader.ui.articles + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MediumTopAppBar +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.jocmp.capyreader.R +import com.jocmp.capyreader.ui.components.DialogCard +import com.jocmp.capyreader.ui.login.AuthFields +import com.jocmp.capyreader.ui.theme.CapyTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UpdateAuthView( + onPasswordChange: (password: String) -> Unit = {}, + onSubmit: () -> Unit = {}, + onNavigateBack: () -> Unit = {}, + username: String, + password: String, + loading: Boolean = false, + showError: Boolean = false, +) { + val errorMessage = if (showError) { + stringResource(R.string.update_auth_error_message) + } else { + null + } + + DialogCard { + MediumTopAppBar( + colors = TopAppBarDefaults.mediumTopAppBarColors().copy( + containerColor = colorScheme.surfaceVariant + ), + title = { + Text(text = stringResource(R.string.update_auth_title)) + }, + navigationIcon = { + IconButton( + onClick = onNavigateBack, + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = null + ) + } + }, + ) + Column( + Modifier + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + ) { + AuthFields( + onPasswordChange = onPasswordChange, + onSubmit = onSubmit, + username = username, + password = password, + readOnlyUsername = true, + loading = loading, + errorMessage = errorMessage + ) + } + } +} + +@Preview +@Composable +private fun UpdateAuthViewPreview() { + CapyTheme { + UpdateAuthView( + username = "test@example.com", + password = "secrets" + ) + } +} diff --git a/app/src/main/java/com/jocmp/capyreader/ui/articles/UpdateAuthViewModel.kt b/app/src/main/java/com/jocmp/capyreader/ui/articles/UpdateAuthViewModel.kt new file mode 100644 index 00000000..b015392c --- /dev/null +++ b/app/src/main/java/com/jocmp/capyreader/ui/articles/UpdateAuthViewModel.kt @@ -0,0 +1,55 @@ +package com.jocmp.capyreader.ui.articles + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.jocmp.capy.Account +import com.jocmp.capy.accounts.verifyCredentials +import com.jocmp.capyreader.common.Async +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class UpdateAuthViewModel( + private val account: Account +) : ViewModel() { + private var _password by mutableStateOf("") + private var _result by mutableStateOf>(Async.Uninitialized) + + val username = account.preferences.username.get() + + val password: String + get() = _password + + val loading: Boolean + get() = _result is Async.Loading + + val showError: Boolean + get() = _result is Async.Failure + + fun setPassword(password: String) { + _password = password + } + + fun submit(onSuccess: () -> Unit) { + if (password.isBlank()) { + _result = Async.Failure(loginError()) + } + + viewModelScope.launch(Dispatchers.IO) { + _result = Async.Loading + + val isSuccessful = verifyCredentials(username = username, password = password) + + if (isSuccessful) { + account.preferences.password.set(password) + onSuccess() + } else { + _result = Async.Failure(loginError()) + } + } + } + + private fun loginError() = Error("Error logging in") +} diff --git a/app/src/main/java/com/jocmp/capyreader/ui/components/DialogCard.kt b/app/src/main/java/com/jocmp/capyreader/ui/components/DialogCard.kt new file mode 100644 index 00000000..7d6e29d7 --- /dev/null +++ b/app/src/main/java/com/jocmp/capyreader/ui/components/DialogCard.kt @@ -0,0 +1,16 @@ +package com.jocmp.capyreader.ui.components + +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.material3.Card +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun DialogCard(content: @Composable () -> Unit) { + Card( + Modifier.sizeIn(maxHeight = 600.dp, maxWidth = 400.dp) + ) { + content() + } +} diff --git a/app/src/main/java/com/jocmp/capyreader/ui/components/TextField.kt b/app/src/main/java/com/jocmp/capyreader/ui/components/TextField.kt deleted file mode 100644 index 434d6567..00000000 --- a/app/src/main/java/com/jocmp/capyreader/ui/components/TextField.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.jocmp.capyreader.ui.components - -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.material3.TextField as MaterialTextField -@Composable -fun TextField( - value: String, - modifier: Modifier = Modifier, - onValueChange: (String) -> Unit, - readOnly: Boolean = false, - label: @Composable (() -> Unit)? = null, - placeholder: @Composable (() -> Unit)? = null, - supportingText: @Composable (() -> Unit)? = null, - keyboardOptions: KeyboardOptions = KeyboardOptions(), -) { - MaterialTextField( - value = value, - onValueChange = onValueChange, - modifier = modifier.then( - Modifier.fillMaxWidth() - .padding(bottom = 8.dp) - ), - readOnly = readOnly, - label = label, - placeholder = placeholder, - supportingText = supportingText, - keyboardOptions = keyboardOptions - ) -} - -@Preview -@Composable -fun TextFieldPreview() { - TextField( - value = "Hello Moto", - onValueChange = {} - ) -} diff --git a/app/src/main/java/com/jocmp/capyreader/ui/login/AccountNavigation.kt b/app/src/main/java/com/jocmp/capyreader/ui/login/AccountNavigation.kt index 99e98fd4..9bcf078b 100644 --- a/app/src/main/java/com/jocmp/capyreader/ui/login/AccountNavigation.kt +++ b/app/src/main/java/com/jocmp/capyreader/ui/login/AccountNavigation.kt @@ -1,16 +1,12 @@ package com.jocmp.capyreader.ui.login -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.sizeIn -import androidx.compose.material3.Card import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import androidx.navigation.compose.dialog import com.jocmp.capyreader.ui.Route +import com.jocmp.capyreader.ui.components.DialogCard import com.jocmp.capyreader.ui.settings.SettingsScreen fun NavGraphBuilder.accountsGraph( @@ -24,7 +20,7 @@ fun NavGraphBuilder.accountsGraph( onSuccess = onLoginSuccess, ) } - settingsLayout(isCompactWindow) { + dynamicLayout(isCompactWindow) { SettingsScreen( onLogout = onLogout, onNavigateBack = onNavigateBackFromSettings @@ -32,7 +28,7 @@ fun NavGraphBuilder.accountsGraph( } } -fun NavGraphBuilder.settingsLayout(isCompactWindow: Boolean, content: @Composable () -> Unit) { +fun NavGraphBuilder.dynamicLayout(isCompactWindow: Boolean, content: @Composable () -> Unit) { val route = Route.Settings.path if (isCompactWindow) { @@ -44,14 +40,7 @@ fun NavGraphBuilder.settingsLayout(isCompactWindow: Boolean, content: @Composabl route, dialogProperties = DialogProperties(usePlatformDefaultWidth = false) ) { - - Card( - Modifier - .padding(32.dp) - .sizeIn(maxHeight = 600.dp, maxWidth = 400.dp) - ) { - content() - } + DialogCard(content = content) } } } diff --git a/app/src/main/java/com/jocmp/capyreader/ui/login/AuthFields.kt b/app/src/main/java/com/jocmp/capyreader/ui/login/AuthFields.kt new file mode 100644 index 00000000..687f4756 --- /dev/null +++ b/app/src/main/java/com/jocmp/capyreader/ui/login/AuthFields.kt @@ -0,0 +1,171 @@ +package com.jocmp.capyreader.ui.login + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Badge +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.AutofillType.EmailAddress +import androidx.compose.ui.autofill.AutofillType.Password +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.jocmp.capyreader.R +import com.jocmp.capyreader.ui.autofill +import com.jocmp.capyreader.ui.theme.CapyTheme + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun AuthFields( + onUsernameChange: (username: String) -> Unit = {}, + onPasswordChange: (password: String) -> Unit, + onSubmit: () -> Unit, + username: String, + readOnlyUsername: Boolean = false, + password: String, + loading: Boolean = false, + errorMessage: String? = null, +) { + val (showPassword, setPasswordVisibility) = rememberSaveable { + mutableStateOf(false) + } + + val passwordTransformation = if (showPassword) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + } + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + errorMessage?.let { message -> + ErrorAlert(message = message) + } + TextField( + value = username, + onValueChange = onUsernameChange, + singleLine = true, + enabled = !readOnlyUsername, + readOnly = readOnlyUsername, + label = { + Text(stringResource(R.string.auth_fields_username)) + }, + modifier = Modifier + .fillMaxWidth() + .autofill( + listOf(EmailAddress), + onFill = onUsernameChange + ) + ) + TextField( + value = password, + onValueChange = onPasswordChange, + label = { + Text(stringResource(R.string.auth_fields_password)) + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + visualTransformation = passwordTransformation, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .autofill( + listOf(Password), + onFill = onPasswordChange + ), + trailingIcon = { + val image = if (showPassword) { + Icons.Filled.Visibility + } else { + Icons.Filled.VisibilityOff + } + + val contentDescription = if (showPassword) { + R.string.auth_fields_hide_password + } else { + R.string.auth_fields_show_password + } + + IconButton( + onClick = { + setPasswordVisibility(!showPassword) + } + ) { + Icon(imageVector = image, stringResource(contentDescription)) + } + } + ) + Column( + Modifier.padding(top = 16.dp) + ) { + Button( + onClick = onSubmit, + enabled = !loading, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.auth_fields_log_in_button)) + } + } + } +} + +@Composable +private fun ErrorAlert(message: String) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(colorScheme.error) + .fillMaxWidth() + ) { + Text( + text = message, + color = colorScheme.onError, + modifier = Modifier.padding( + horizontal = 8.dp, + vertical = 4.dp, + ), + ) + } +} + +@Preview +@Composable +private fun AuthFieldsPreview() { + CapyTheme { + AuthFields( + onUsernameChange = {}, + onPasswordChange = {}, + onSubmit = {}, + username = "test@example.com", + password = "its a secret to everyone", + loading = true + ) + } +} diff --git a/app/src/main/java/com/jocmp/capyreader/ui/login/LoginModule.kt b/app/src/main/java/com/jocmp/capyreader/ui/login/LoginModule.kt index 862aef6d..775ecafd 100644 --- a/app/src/main/java/com/jocmp/capyreader/ui/login/LoginModule.kt +++ b/app/src/main/java/com/jocmp/capyreader/ui/login/LoginModule.kt @@ -5,7 +5,7 @@ import org.koin.dsl.module val loginModule = module { viewModel { - AccountIndexViewModel( + LoginViewModel( accountManager = get(), appPreferences = get() ) diff --git a/app/src/main/java/com/jocmp/capyreader/ui/login/LoginScreen.kt b/app/src/main/java/com/jocmp/capyreader/ui/login/LoginScreen.kt index 63ad3a59..c4359f77 100644 --- a/app/src/main/java/com/jocmp/capyreader/ui/login/LoginScreen.kt +++ b/app/src/main/java/com/jocmp/capyreader/ui/login/LoginScreen.kt @@ -1,27 +1,21 @@ package com.jocmp.capyreader.ui.login import androidx.compose.foundation.layout.Column -import androidx.compose.material3.Button -import androidx.compose.material3.Text -import androidx.compose.material3.TextField +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.autofill.AutofillType.EmailAddress -import androidx.compose.ui.autofill.AutofillType.Password import androidx.compose.ui.tooling.preview.Preview import com.google.firebase.crashlytics.ktx.crashlytics import com.google.firebase.ktx.Firebase -import com.jocmp.capyreader.ui.autofill import org.koin.compose.koinInject -@OptIn(ExperimentalComposeUiApi::class) @Composable fun LoginScreen( - viewModel: AccountIndexViewModel = koinInject(), + viewModel: LoginViewModel = koinInject(), onSuccess: () -> Unit, ) { val (username, setUsername) = rememberSaveable { @@ -33,37 +27,23 @@ fun LoginScreen( } val login = { - viewModel.login(username, password, - onSuccess = onSuccess, - onFailure = { /* Show toast */ } - ) + viewModel.login(username, password) { result -> + result.fold( + onSuccess = { onSuccess() }, + onFailure = {} + ) + } } - Column { - TextField( - value = username, - onValueChange = setUsername, - label = { - Text("Username") - }, - modifier = Modifier.autofill( - listOf(EmailAddress), - onFill = setUsername + Scaffold { padding -> + Column(Modifier.padding(padding)) { + AuthFields( + username = username, + password = password, + onUsernameChange = setUsername, + onPasswordChange = setPassword, + onSubmit = login, ) - ) - TextField( - value = password, - onValueChange = setPassword, - label = { - Text("Password") - }, - modifier = Modifier.autofill( - listOf(Password), - onFill = setPassword - ) - ) - Button(onClick = { login() }) { - Text("Save") } } @@ -72,6 +52,7 @@ fun LoginScreen( } } + @Preview @Composable private fun LoginScreenPreview() { diff --git a/app/src/main/java/com/jocmp/capyreader/ui/login/AccountIndexViewModel.kt b/app/src/main/java/com/jocmp/capyreader/ui/login/LoginViewModel.kt similarity index 86% rename from app/src/main/java/com/jocmp/capyreader/ui/login/AccountIndexViewModel.kt rename to app/src/main/java/com/jocmp/capyreader/ui/login/LoginViewModel.kt index a6f18bbc..e8541c7d 100644 --- a/app/src/main/java/com/jocmp/capyreader/ui/login/AccountIndexViewModel.kt +++ b/app/src/main/java/com/jocmp/capyreader/ui/login/LoginViewModel.kt @@ -8,15 +8,14 @@ import com.jocmp.capyreader.common.AppPreferences import com.jocmp.capyreader.loadAccountModules import kotlinx.coroutines.launch -class AccountIndexViewModel( +class LoginViewModel( private val accountManager: AccountManager, private val appPreferences: AppPreferences, ) : ViewModel() { fun login( username: String, password: String, - onSuccess: () -> Unit, - onFailure: () -> Unit, + onComplete: (result: Result) -> Unit ) { viewModelScope.launch { val result = verifyCredentials(username = username, password = password) @@ -31,9 +30,9 @@ class AccountIndexViewModel( loadAccountModules() - onSuccess() + Result.success(Unit) } else { - onFailure() + Result.failure(Exception("Couldn't log in")) } } } diff --git a/app/src/main/java/com/jocmp/capyreader/ui/settings/SettingsView.kt b/app/src/main/java/com/jocmp/capyreader/ui/settings/SettingsView.kt index b127879f..46f6716c 100644 --- a/app/src/main/java/com/jocmp/capyreader/ui/settings/SettingsView.kt +++ b/app/src/main/java/com/jocmp/capyreader/ui/settings/SettingsView.kt @@ -18,8 +18,6 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -46,8 +44,6 @@ fun SettingsView( onRequestLogout: () -> Unit, accountName: String ) { - val snackbarHostState = remember { SnackbarHostState() } - val (isRemoveDialogOpen, setRemoveDialogOpen) = remember { mutableStateOf(false) } val onRemoveCancel = { @@ -63,7 +59,7 @@ fun SettingsView( topBar = { MediumTopAppBar( title = { - Text(text = "Settings") + Text(text = stringResource(R.string.settings_top_bar_title)) }, navigationIcon = { IconButton( @@ -77,9 +73,6 @@ fun SettingsView( }, ) }, - snackbarHost = { - SnackbarHost(hostState = snackbarHostState) - }, ) { contentPadding -> Column( verticalArrangement = Arrangement.SpaceBetween, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a4adcf23..4f832343 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -58,4 +58,17 @@ Open article actions Mark below as read Mark above as read + You\'re logged out + There was an issue connecting to your account. Please log in again. + Later + Log In + Password + Email + Hide password + Show password + Log In + Settings + Log in to Feedbin + Invalid email or password + Successfully logged in diff --git a/capy/src/main/java/com/jocmp/capy/Account.kt b/capy/src/main/java/com/jocmp/capy/Account.kt index 02832932..23eff643 100644 --- a/capy/src/main/java/com/jocmp/capy/Account.kt +++ b/capy/src/main/java/com/jocmp/capy/Account.kt @@ -68,9 +68,7 @@ data class Account( return delegate.removeFeed(feedID = feedID) } - suspend fun refresh() { - delegate.refresh() - } + suspend fun refresh(): Result = delegate.refresh() suspend fun findFeed(feedID: String): Feed? { return feedRecords.findBy(feedID) diff --git a/capy/src/main/java/com/jocmp/capy/accounts/FeedbinAccountDelegate.kt b/capy/src/main/java/com/jocmp/capy/accounts/FeedbinAccountDelegate.kt index c40e81a1..68bb5486 100644 --- a/capy/src/main/java/com/jocmp/capy/accounts/FeedbinAccountDelegate.kt +++ b/capy/src/main/java/com/jocmp/capy/accounts/FeedbinAccountDelegate.kt @@ -4,6 +4,7 @@ import com.jocmp.capy.AccountDelegate import com.jocmp.capy.Article import com.jocmp.capy.Feed import com.jocmp.capy.accounts.AddFeedResult.AddFeedError +import com.jocmp.capy.common.UnauthorizedError import com.jocmp.capy.common.host import com.jocmp.capy.common.nowUTC import com.jocmp.capy.common.toDateTime @@ -196,6 +197,8 @@ internal class FeedbinAccountDelegate( Result.success(Unit) } catch (exception: UnknownHostException) { Result.failure(exception) + } catch (e: UnauthorizedError) { + return Result.failure(e) } } @@ -349,5 +352,7 @@ private suspend fun withErrorHandling(func: suspend () -> T?): Result { } } catch (e: UnknownHostException) { return Result.failure(e) + } catch (e: UnauthorizedError) { + return Result.failure(e) } } diff --git a/capy/src/main/java/com/jocmp/capy/common/UnauthorizedError.kt b/capy/src/main/java/com/jocmp/capy/common/UnauthorizedError.kt new file mode 100644 index 00000000..7deb4c17 --- /dev/null +++ b/capy/src/main/java/com/jocmp/capy/common/UnauthorizedError.kt @@ -0,0 +1,6 @@ +package com.jocmp.capy.common + +/** + * Throw when a source returns a 401/Unauthorized response + */ +class UnauthorizedError: Error() diff --git a/capy/src/main/java/com/jocmp/capy/common/WithResult.kt b/capy/src/main/java/com/jocmp/capy/common/WithResult.kt index e9e690a0..8df5790e 100644 --- a/capy/src/main/java/com/jocmp/capy/common/WithResult.kt +++ b/capy/src/main/java/com/jocmp/capy/common/WithResult.kt @@ -5,6 +5,10 @@ import retrofit2.Response internal fun withResult(response: Response, handler: (result: T) -> Unit) { val result = response.body() + if (response.code() == 401) { + throw UnauthorizedError() + } + if (!response.isSuccessful || result == null) { return }