diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 5b929a3..c6cfd09 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -14,11 +14,11 @@ # Uncomment this to preserve the line number information for # debugging stack traces. -#-keepattributes SourceFile,LineNumberTable +-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile +-renamesourcefileattribute SourceFile -dontobfuscate diff --git a/app/src/main/java/me/ash/reader/ui/component/BottomDrawer.kt b/app/src/main/java/me/ash/reader/ui/component/BottomDrawer.kt index 4043ef9..ff09f00 100644 --- a/app/src/main/java/me/ash/reader/ui/component/BottomDrawer.kt +++ b/app/src/main/java/me/ash/reader/ui/component/BottomDrawer.kt @@ -2,6 +2,7 @@ package me.ash.reader.ui.component import androidx.compose.foundation.background import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ModalBottomSheetDefaults @@ -12,6 +13,7 @@ import androidx.compose.material3.Surface 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 androidx.compose.ui.zIndex @@ -55,7 +57,8 @@ fun BottomDrawer( ) { Row( modifier = modifier - .size(38.dp, 4.dp) + .size(30.dp, 4.dp) + .clip(CircleShape) .background(MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)) .zIndex(1f) ) {} diff --git a/app/src/main/java/me/ash/reader/ui/component/ClipboardTextField.kt b/app/src/main/java/me/ash/reader/ui/component/ClipboardTextField.kt new file mode 100644 index 0000000..90dcd00 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/component/ClipboardTextField.kt @@ -0,0 +1,83 @@ +package me.ash.reader.ui.component + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActionScope +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp + +@Composable +fun ClipboardTextField( + modifier: Modifier = Modifier, + readOnly: Boolean = false, + value: String = "", + onValueChange: (String) -> Unit = {}, + placeholder: String = "", + errorText: String = "", + imeAction: ImeAction = ImeAction.Done, + focusManager: FocusManager? = null, + onConfirm: (String) -> Unit = {}, +) { + Column(modifier = modifier) { + Spacer(modifier = Modifier.height(10.dp)) + TextField( + readOnly = readOnly, + value = value, + onValueChange = onValueChange, + placeholder = placeholder, + errorMessage = errorText, + keyboardActions = KeyboardActions( + onDone = if (imeAction == ImeAction.Done) + action(focusManager, onConfirm, value) else null, + onGo = if (imeAction == ImeAction.Go) + action(focusManager, onConfirm, value) else null, + onNext = if (imeAction == ImeAction.Next) + action(focusManager, onConfirm, value) else null, + onPrevious = if (imeAction == ImeAction.Previous) + action(focusManager, onConfirm, value) else null, + onSearch = if (imeAction == ImeAction.Search) + action(focusManager, onConfirm, value) else null, + onSend = if (imeAction == ImeAction.Send) + action(focusManager, onConfirm, value) else null, + ), + keyboardOptions = KeyboardOptions( + imeAction = imeAction + ), + ) + if (errorText.isNotEmpty()) { + SelectionContainer { + Text( + modifier = Modifier + .padding(start = 16.dp) + .horizontalScroll(rememberScrollState()), + text = errorText, + color = MaterialTheme.colorScheme.error, + maxLines = 1, + softWrap = false, + ) + } + } + Spacer(modifier = Modifier.height(10.dp)) + } +} + +private fun action( + focusManager: FocusManager?, + onConfirm: (String) -> Unit, + value: String +): KeyboardActionScope.() -> Unit = { + focusManager?.clearFocus() + onConfirm(value) +} \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/ui/component/SelectionChip.kt b/app/src/main/java/me/ash/reader/ui/component/SelectionChip.kt index e77775b..5243a74 100644 --- a/app/src/main/java/me/ash/reader/ui/component/SelectionChip.kt +++ b/app/src/main/java/me/ash/reader/ui/component/SelectionChip.kt @@ -44,7 +44,7 @@ fun SelectionChip( val focusManager = LocalFocusManager.current FilterChip( - modifier = modifier, + modifier = modifier.defaultMinSize(minHeight = 36.dp), colors = ChipDefaults.filterChipColors( backgroundColor = MaterialTheme.colorScheme.surfaceVariant, contentColor = MaterialTheme.colorScheme.onSurfaceVariant, diff --git a/app/src/main/java/me/ash/reader/ui/component/TextField.kt b/app/src/main/java/me/ash/reader/ui/component/TextField.kt new file mode 100644 index 0000000..a86c978 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/component/TextField.kt @@ -0,0 +1,90 @@ +package me.ash.reader.ui.component + +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.TextFieldDefaults +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.ContentPaste +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.stringResource +import kotlinx.coroutines.delay +import me.ash.reader.R + +@Composable +fun TextField( + readOnly: Boolean, + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + errorMessage: String, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions(), +) { + val clipboardManager = LocalClipboardManager.current + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + delay(100) // ??? + focusRequester.requestFocus() + } + + androidx.compose.material.TextField( + modifier = Modifier.focusRequester(focusRequester), + colors = TextFieldDefaults.textFieldColors( + backgroundColor = Color.Transparent, + cursorColor = MaterialTheme.colorScheme.onSurface, + textColor = MaterialTheme.colorScheme.onSurface, + focusedIndicatorColor = MaterialTheme.colorScheme.primary, + ), + enabled = !readOnly, + value = value, + onValueChange = { + if (!readOnly) onValueChange(it) + }, + placeholder = { + Text( + text = placeholder, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f) + ) + }, + isError = errorMessage.isNotEmpty(), + singleLine = true, + trailingIcon = { + if (value.isNotEmpty()) { + IconButton(onClick = { + if (!readOnly) onValueChange("") + }) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(R.string.clear), + tint = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), + ) + } + } else { + IconButton(onClick = { + onValueChange(clipboardManager.getText()?.text ?: "") + }) { + Icon( + imageVector = Icons.Rounded.ContentPaste, + contentDescription = stringResource(R.string.paste), + tint = MaterialTheme.colorScheme.primary + ) + } + } + }, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + ) +} \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/ui/component/TextFieldDialog.kt b/app/src/main/java/me/ash/reader/ui/component/TextFieldDialog.kt new file mode 100644 index 0000000..4609764 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/component/TextFieldDialog.kt @@ -0,0 +1,92 @@ +package me.ash.reader.ui.component + +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.window.DialogProperties +import com.google.accompanist.pager.ExperimentalPagerApi +import me.ash.reader.R + +@OptIn(ExperimentalPagerApi::class) +@Composable +fun TextFieldDialog( + modifier: Modifier = Modifier, + properties: DialogProperties = DialogProperties(), + visible: Boolean = false, + readOnly: Boolean = false, + title: String = "", + icon: ImageVector? = null, + value: String = "", + placeholder: String = "", + errorText: String = "", + dismissText: String = stringResource(R.string.cancel), + confirmText: String = stringResource(R.string.confirm), + onValueChange: (String) -> Unit = {}, + onDismissRequest: () -> Unit = {}, + onConfirm: (String) -> Unit = {}, + imeAction: ImeAction = ImeAction.Done, +) { + val focusManager = LocalFocusManager.current + + Dialog( + modifier = modifier, + visible = visible, + onDismissRequest = onDismissRequest, + icon = { + icon?.let { + Icon( + imageVector = icon, + contentDescription = title, + ) + } + }, + title = { + Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis) + }, + text = { + ClipboardTextField( + modifier = modifier, + readOnly = readOnly, + value = value, + onValueChange = onValueChange, + placeholder = placeholder, + errorText = errorText, + imeAction = imeAction, + focusManager = focusManager, + onConfirm = onConfirm, + ) + }, + confirmButton = { + TextButton( + enabled = value.isNotBlank(), + onClick = { + focusManager.clearFocus() + onConfirm(value) + } + ) { + Text( + text = confirmText, + color = if (value.isNotBlank()) { + Color.Unspecified + } else { + MaterialTheme.colorScheme.outline.copy(alpha = 0.7f) + } + ) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = dismissText) + } + }, + ) +} \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/ui/page/home/HomePage.kt b/app/src/main/java/me/ash/reader/ui/page/home/HomePage.kt index aa460e5..84eb4a8 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/HomePage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/HomePage.kt @@ -67,6 +67,10 @@ fun HomePage( BackHandler(true) { val currentPage = viewState.pagerState.currentPage + if (currentPage == 0) { + context.findActivity()?.moveTaskToBack(false) + return@BackHandler + } homeViewModel.dispatch( HomeViewAction.ScrollToPage( scope = scope, diff --git a/app/src/main/java/me/ash/reader/ui/page/home/drawer/feed/DeleteFeedDialog.kt b/app/src/main/java/me/ash/reader/ui/page/home/drawer/feed/DeleteFeedDialog.kt index c0c2803..d9d3d99 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/drawer/feed/DeleteFeedDialog.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/drawer/feed/DeleteFeedDialog.kt @@ -2,7 +2,7 @@ package me.ash.reader.ui.page.home.drawer.feed import android.widget.Toast import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.DeleteOutline +import androidx.compose.material.icons.outlined.DeleteForever import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -36,7 +36,7 @@ fun DeleteFeedDialog( }, icon = { Icon( - imageVector = Icons.Rounded.DeleteOutline, + imageVector = Icons.Outlined.DeleteForever, contentDescription = stringResource(R.string.subscribe), ) }, diff --git a/app/src/main/java/me/ash/reader/ui/page/home/drawer/feed/FeedOptionDrawer.kt b/app/src/main/java/me/ash/reader/ui/page/home/drawer/feed/FeedOptionDrawer.kt index 0344e6a..f3c605d 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/drawer/feed/FeedOptionDrawer.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/drawer/feed/FeedOptionDrawer.kt @@ -1,16 +1,19 @@ package me.ash.reader.ui.page.home.drawer.feed -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.DeleteOutline +import androidx.compose.material.icons.outlined.CreateNewFolder import androidx.compose.material.icons.rounded.RssFeed -import androidx.compose.material3.* +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -18,7 +21,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import me.ash.reader.R import me.ash.reader.ui.component.BottomDrawer -import me.ash.reader.ui.component.Subtitle +import me.ash.reader.ui.component.TextFieldDialog import me.ash.reader.ui.ext.collectAsStateValue import me.ash.reader.ui.ext.roundClick import me.ash.reader.ui.page.home.feeds.subscribe.ResultView @@ -27,10 +30,10 @@ import me.ash.reader.ui.page.home.feeds.subscribe.ResultView @Composable fun FeedOptionDrawer( modifier: Modifier = Modifier, - viewModel: FeedOptionViewModel = hiltViewModel(), + feedOptionViewModel: FeedOptionViewModel = hiltViewModel(), content: @Composable () -> Unit = {}, ) { - val viewState = viewModel.viewState.collectAsStateValue() + val viewState = feedOptionViewModel.viewState.collectAsStateValue() val feed = viewState.feed BottomDrawer( @@ -65,53 +68,24 @@ fun FeedOptionDrawer( groups = viewState.groups, selectedAllowNotificationPreset = viewState.feed?.isNotification ?: false, selectedParseFullContentPreset = viewState.feed?.isFullContent ?: false, + showUnsubscribe = true, selectedGroupId = viewState.feed?.groupId ?: "", - newGroupContent = viewState.newGroupContent, - onNewGroupValueChange = { - viewModel.dispatch(FeedOptionViewAction.InputNewGroup(it)) - }, - newGroupSelected = viewState.newGroupSelected, - changeNewGroupSelected = { - viewModel.dispatch(FeedOptionViewAction.SelectedNewGroup(it)) - }, allowNotificationPresetOnClick = { - viewModel.dispatch(FeedOptionViewAction.ChangeAllowNotificationPreset) + feedOptionViewModel.dispatch(FeedOptionViewAction.ChangeAllowNotificationPreset) }, parseFullContentPresetOnClick = { - viewModel.dispatch(FeedOptionViewAction.ChangeParseFullContentPreset) + feedOptionViewModel.dispatch(FeedOptionViewAction.ChangeParseFullContentPreset) + }, + unsubscribeOnClick = { + feedOptionViewModel.dispatch(FeedOptionViewAction.ShowDeleteDialog) }, onGroupClick = { - viewModel.dispatch(FeedOptionViewAction.SelectedGroup(it)) + feedOptionViewModel.dispatch(FeedOptionViewAction.SelectedGroup(it)) }, - onKeyboardAction = { }, - ) - Spacer(modifier = Modifier.height(20.dp)) - Subtitle(text = stringResource(R.string.options)) - Spacer(modifier = Modifier.height(10.dp)) - Button( - colors = ButtonDefaults.buttonColors( - containerColor = Color.Transparent, - contentColor = MaterialTheme.colorScheme.error, - ), - border = BorderStroke( - width = 1.dp, - color = MaterialTheme.colorScheme.error, - ), - onClick = { - viewModel.dispatch(FeedOptionViewAction.ShowDeleteDialog) + onAddNewGroup = { + feedOptionViewModel.dispatch(FeedOptionViewAction.ShowNewGroupDialog) } - ) { - Icon( - modifier = Modifier.size(ButtonDefaults.IconSize), - imageVector = Icons.Rounded.DeleteOutline, - contentDescription = stringResource(R.string.delete), - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text( - text = stringResource(R.string.unsubscribe), - style = MaterialTheme.typography.titleSmall, - ) - } + ) } } ) { @@ -119,4 +93,21 @@ fun FeedOptionDrawer( } DeleteFeedDialog(feedName = feed?.name ?: "") + + TextFieldDialog( + visible = viewState.newGroupDialogVisible, + title = stringResource(R.string.create_new_group), + icon = Icons.Outlined.CreateNewFolder, + value = viewState.newGroupContent, + placeholder = stringResource(R.string.name), + onValueChange = { + feedOptionViewModel.dispatch(FeedOptionViewAction.InputNewGroup(it)) + }, + onDismissRequest = { + feedOptionViewModel.dispatch(FeedOptionViewAction.HideNewGroupDialog) + }, + onConfirm = { + feedOptionViewModel.dispatch(FeedOptionViewAction.AddNewGroup) + } + ) } \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/ui/page/home/drawer/feed/FeedOptionViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/home/drawer/feed/FeedOptionViewModel.kt index 40ea545..d8410dd 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/drawer/feed/FeedOptionViewModel.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/drawer/feed/FeedOptionViewModel.kt @@ -49,12 +49,14 @@ class FeedOptionViewModel @Inject constructor( is FeedOptionViewAction.Hide -> hide(action.scope) is FeedOptionViewAction.SelectedGroup -> selectedGroup(action.groupId) is FeedOptionViewAction.InputNewGroup -> inputNewGroup(action.content) - is FeedOptionViewAction.SelectedNewGroup -> selectedNewGroup(action.selected) is FeedOptionViewAction.ChangeAllowNotificationPreset -> changeAllowNotificationPreset() is FeedOptionViewAction.ChangeParseFullContentPreset -> changeParseFullContentPreset() is FeedOptionViewAction.ShowDeleteDialog -> showDeleteDialog() is FeedOptionViewAction.HideDeleteDialog -> hideDeleteDialog() is FeedOptionViewAction.Delete -> delete(action.callback) + is FeedOptionViewAction.AddNewGroup -> addNewGroup() + is FeedOptionViewAction.ShowNewGroupDialog -> changeNewGroupDialogVisible(true) + is FeedOptionViewAction.HideNewGroupDialog -> changeNewGroupDialogVisible(false) } } @@ -81,6 +83,15 @@ class FeedOptionViewModel @Inject constructor( } } + private fun changeNewGroupDialogVisible(visible: Boolean) { + _viewState.update { + it.copy( + newGroupDialogVisible = visible, + newGroupContent = "", + ) + } + } + private fun inputNewGroup(content: String) { _viewState.update { it.copy( @@ -89,6 +100,15 @@ class FeedOptionViewModel @Inject constructor( } } + private fun addNewGroup() { + if (_viewState.value.newGroupContent.isNotBlank()) { + viewModelScope.launch { + selectedGroup(rssRepository.get().addGroup(_viewState.value.newGroupContent)) + changeNewGroupDialogVisible(false) + } + } + } + private fun selectedGroup(groupId: String) { viewModelScope.launch(Dispatchers.IO) { _viewState.value.feed?.let { @@ -102,14 +122,6 @@ class FeedOptionViewModel @Inject constructor( } } - private fun selectedNewGroup(selected: Boolean) { - _viewState.update { - it.copy( - newGroupSelected = selected, - ) - } - } - private fun changeParseFullContentPreset() { viewModelScope.launch(Dispatchers.IO) { _viewState.value.feed?.let { @@ -170,7 +182,7 @@ data class FeedOptionViewState( val feed: Feed? = null, val selectedGroupId: String = "", val newGroupContent: String = "", - val newGroupSelected: Boolean = false, + val newGroupDialogVisible: Boolean = false, val groups: List = emptyList(), val deleteDialogVisible: Boolean = false, ) @@ -196,14 +208,14 @@ sealed class FeedOptionViewAction { val content: String ) : FeedOptionViewAction() - data class SelectedNewGroup( - val selected: Boolean - ) : FeedOptionViewAction() - data class Delete( val callback: () -> Unit = {} ) : FeedOptionViewAction() object ShowDeleteDialog : FeedOptionViewAction() object HideDeleteDialog : FeedOptionViewAction() + + object ShowNewGroupDialog : FeedOptionViewAction() + object HideNewGroupDialog : FeedOptionViewAction() + object AddNewGroup : FeedOptionViewAction() } diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsPage.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsPage.kt index cd9591b..5b2e2ed 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsPage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsPage.kt @@ -10,9 +10,9 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.KeyboardArrowRight +import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Refresh -import androidx.compose.material.icons.rounded.Tune import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier @@ -106,7 +106,8 @@ fun FeedsPage( navigationIcon = { FeedbackIconButton( isHaptic = false, - imageVector = Icons.Rounded.Tune, + modifier = Modifier.size(20.dp), + imageVector = Icons.Outlined.Settings, contentDescription = stringResource(R.string.settings), tint = MaterialTheme.colorScheme.onSurface, ) { diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/ResultView.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/ResultView.kt index 2d03324..7dfccbe 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/ResultView.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/ResultView.kt @@ -3,18 +3,27 @@ package me.ash.reader.ui.page.home.feeds.subscribe import android.content.Intent import android.net.Uri import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Article import androidx.compose.material.icons.outlined.Notifications import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -24,7 +33,6 @@ import com.google.accompanist.flowlayout.MainAxisAlignment import me.ash.reader.R import me.ash.reader.data.entity.Group import me.ash.reader.ui.component.SelectionChip -import me.ash.reader.ui.component.SelectionEditorChip import me.ash.reader.ui.component.Subtitle import me.ash.reader.ui.ext.roundClick @@ -35,41 +43,39 @@ fun ResultView( groups: List = emptyList(), selectedAllowNotificationPreset: Boolean = false, selectedParseFullContentPreset: Boolean = false, + showUnsubscribe: Boolean = false, selectedGroupId: String = "", - newGroupContent: String = "", - newGroupSelected: Boolean, - onNewGroupValueChange: (String) -> Unit = {}, - changeNewGroupSelected: (Boolean) -> Unit = {}, allowNotificationPresetOnClick: () -> Unit = {}, parseFullContentPresetOnClick: () -> Unit = {}, + unsubscribeOnClick: () -> Unit = {}, onGroupClick: (groupId: String) -> Unit = {}, - onKeyboardAction: () -> Unit = {}, + onAddNewGroup: () -> Unit = {}, ) { + LaunchedEffect(Unit) { + if (groups.isNotEmpty()) onGroupClick(groups.first().id) + } + Column( modifier = modifier.verticalScroll(rememberScrollState()) ) { - Link( - text = link - ) + Link(text = link) Spacer(modifier = Modifier.height(26.dp)) Preset( selectedAllowNotificationPreset = selectedAllowNotificationPreset, selectedParseFullContentPreset = selectedParseFullContentPreset, + showUnsubscribe = showUnsubscribe, allowNotificationPresetOnClick = allowNotificationPresetOnClick, parseFullContentPresetOnClick = parseFullContentPresetOnClick, + unsubscribeOnClick = unsubscribeOnClick, ) Spacer(modifier = Modifier.height(26.dp)) AddToGroup( groups = groups, selectedGroupId = selectedGroupId, - newGroupContent = newGroupContent, - newGroupSelected = newGroupSelected, - onNewGroupValueChange = onNewGroupValueChange, - changeNewGroupSelected = changeNewGroupSelected, onGroupClick = onGroupClick, - onKeyboardAction = onKeyboardAction, + onAddNewGroup = onAddNewGroup, ) Spacer(modifier = Modifier.height(6.dp)) } @@ -105,8 +111,10 @@ private fun Link( private fun Preset( selectedAllowNotificationPreset: Boolean = false, selectedParseFullContentPreset: Boolean = false, + showUnsubscribe: Boolean = false, allowNotificationPresetOnClick: () -> Unit = {}, parseFullContentPresetOnClick: () -> Unit = {}, + unsubscribeOnClick: () -> Unit = {}, ) { Subtitle(text = stringResource(R.string.preset)) Spacer(modifier = Modifier.height(10.dp)) @@ -147,6 +155,15 @@ private fun Preset( ) { parseFullContentPresetOnClick() } + if (showUnsubscribe) { + SelectionChip( + modifier = Modifier.animateContentSize(), + content = stringResource(R.string.unsubscribe), + selected = false, + ) { + unsubscribeOnClick() + } + } } } @@ -154,39 +171,61 @@ private fun Preset( private fun AddToGroup( groups: List, selectedGroupId: String, - newGroupContent: String, - newGroupSelected: Boolean, - onNewGroupValueChange: (String) -> Unit = {}, - changeNewGroupSelected: (Boolean) -> Unit = {}, onGroupClick: (groupId: String) -> Unit = {}, - onKeyboardAction: () -> Unit = {}, + onAddNewGroup: () -> Unit = {}, ) { Subtitle(text = stringResource(R.string.add_to_group)) Spacer(modifier = Modifier.height(10.dp)) - FlowRow( - mainAxisAlignment = MainAxisAlignment.Start, - crossAxisSpacing = 10.dp, - mainAxisSpacing = 10.dp, - ) { - groups.forEach { - SelectionChip( - modifier = Modifier.animateContentSize(), - content = it.name, - selected = !newGroupSelected && it.id == selectedGroupId, - ) { - changeNewGroupSelected(false) - onGroupClick(it.id) - } - } - SelectionEditorChip( - modifier = Modifier.animateContentSize(), - content = newGroupContent, - onValueChange = onNewGroupValueChange, - selected = newGroupSelected, - onKeyboardAction = onKeyboardAction, + if (groups.size > 6) { + LazyRow { + items(groups) { + SelectionChip( + modifier = Modifier.animateContentSize(), + content = it.name, + selected = it.id == selectedGroupId, + ) { + onGroupClick(it.id) + } + Spacer(modifier = Modifier.width(10.dp)) + } + item { NewGroupButton(onAddNewGroup) } + } + } else { + FlowRow( + mainAxisAlignment = MainAxisAlignment.Start, + crossAxisSpacing = 10.dp, + mainAxisSpacing = 10.dp, ) { - changeNewGroupSelected(true) + groups.forEach { + SelectionChip( + modifier = Modifier.animateContentSize(), + content = it.name, + selected = it.id == selectedGroupId, + ) { + onGroupClick(it.id) + } + } + NewGroupButton(onAddNewGroup) } } +} + +@Composable +private fun NewGroupButton(onAddNewGroup: () -> Unit) { + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable { onAddNewGroup() }, + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = Icons.Outlined.Add, + contentDescription = stringResource(R.string.create_new_group), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } } \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SearchView.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SearchView.kt deleted file mode 100644 index d629329..0000000 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SearchView.kt +++ /dev/null @@ -1,125 +0,0 @@ -package me.ash.reader.ui.page.home.feeds.subscribe - -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material.TextField -import androidx.compose.material.TextFieldDefaults -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Close -import androidx.compose.material.icons.rounded.ContentPaste -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.delay -import me.ash.reader.R - -@Composable -fun SearchView( - modifier: Modifier = Modifier, - readOnly: Boolean = false, - inputLink: String = "", - errorMessage: String = "", - onLinkValueChange: (String) -> Unit = {}, - onKeyboardAction: () -> Unit = {}, -) { - val focusManager = LocalFocusManager.current - val clipboardManager = LocalClipboardManager.current - val focusRequester = remember { FocusRequester() } - - LaunchedEffect(Unit) { - delay(100) // ??? - focusRequester.requestFocus() - } - - Column(modifier = modifier) { - Spacer(modifier = Modifier.height(10.dp)) - TextField( - modifier = Modifier.focusRequester(focusRequester), - colors = TextFieldDefaults.textFieldColors( - backgroundColor = Color.Transparent, - cursorColor = MaterialTheme.colorScheme.onSurface, - textColor = MaterialTheme.colorScheme.onSurface, - focusedIndicatorColor = MaterialTheme.colorScheme.primary, - ), - enabled = !readOnly, - value = inputLink, - onValueChange = { - if (!readOnly) onLinkValueChange(it) - }, - placeholder = { - Text( - text = stringResource(R.string.feed_or_site_url), - color = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f) - ) - }, - isError = errorMessage.isNotEmpty(), - singleLine = true, - trailingIcon = { - if (inputLink.isNotEmpty()) { - IconButton(onClick = { - if (!readOnly) onLinkValueChange("") - }) { - Icon( - imageVector = Icons.Rounded.Close, - contentDescription = stringResource(R.string.clear), - tint = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), - ) - } - } else { - IconButton(onClick = { - onLinkValueChange(clipboardManager.getText()?.text ?: "") - }) { - Icon( - imageVector = Icons.Rounded.ContentPaste, - contentDescription = stringResource(R.string.paste), - tint = MaterialTheme.colorScheme.primary - ) - } - } - }, - keyboardActions = KeyboardActions( - onSearch = { - focusManager.clearFocus() - onKeyboardAction() - } - ), - keyboardOptions = KeyboardOptions( - imeAction = ImeAction.Search - ), - ) - if (errorMessage.isNotEmpty()) { - SelectionContainer { - Text( - modifier = Modifier - .padding(start = 16.dp) - .horizontalScroll(rememberScrollState()), - text = errorMessage, - color = MaterialTheme.colorScheme.error, - maxLines = 1, - softWrap = false, - ) - } - } - Spacer(modifier = Modifier.height(10.dp)) - } -} \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeDialog.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeDialog.kt index fa57b5b..522341b 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeDialog.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeDialog.kt @@ -5,6 +5,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.* import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CreateNewFolder import androidx.compose.material.icons.rounded.RssFeed import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -18,12 +19,16 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.hilt.navigation.compose.hiltViewModel import me.ash.reader.R +import me.ash.reader.ui.component.ClipboardTextField import me.ash.reader.ui.component.Dialog -import me.ash.reader.ui.ext.* +import me.ash.reader.ui.component.TextFieldDialog +import me.ash.reader.ui.ext.collectAsStateValue @OptIn( androidx.compose.ui.ExperimentalComposeUiApi::class, @@ -45,15 +50,9 @@ fun SubscribeDialog( } } } - val readYouString = stringResource(R.string.read_you) - val defaultString = stringResource(R.string.defaults) LaunchedEffect(viewState.visible) { if (viewState.visible) { - val defaultGroupId = context.dataStore - .get(DataStoreKeys.CurrentAccountId)!! - .spacerDollar(readYouString + defaultString) - subscribeViewModel.dispatch(SubscribeViewAction.SelectedGroup(defaultGroupId)) subscribeViewModel.dispatch(SubscribeViewAction.Init) } else { subscribeViewModel.dispatch(SubscribeViewAction.Reset) @@ -77,11 +76,13 @@ fun SubscribeDialog( }, title = { Text( - if (viewState.isSearchPage) { + text = if (viewState.isSearchPage) { viewState.title } else { viewState.feed?.name ?: stringResource(R.string.unknown) - } + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) }, text = { @@ -93,14 +94,17 @@ fun SubscribeDialog( } ) { targetExpanded -> if (targetExpanded) { - SearchView( + ClipboardTextField( readOnly = viewState.lockLinkInput, - inputLink = viewState.linkContent, - errorMessage = viewState.errorMessage, - onLinkValueChange = { + value = viewState.linkContent, + onValueChange = { subscribeViewModel.dispatch(SubscribeViewAction.InputLink(it)) }, - onKeyboardAction = { + placeholder = stringResource(R.string.feed_or_site_url), + errorText = viewState.errorMessage, + imeAction = ImeAction.Search, + focusManager = focusManager, + onConfirm = { subscribeViewModel.dispatch(SubscribeViewAction.Search) }, ) @@ -111,14 +115,6 @@ fun SubscribeDialog( selectedAllowNotificationPreset = viewState.allowNotificationPreset, selectedParseFullContentPreset = viewState.parseFullContentPreset, selectedGroupId = viewState.selectedGroupId, - newGroupContent = viewState.newGroupContent, - onNewGroupValueChange = { - subscribeViewModel.dispatch(SubscribeViewAction.InputNewGroup(it)) - }, - newGroupSelected = viewState.newGroupSelected, - changeNewGroupSelected = { - subscribeViewModel.dispatch(SubscribeViewAction.SelectedNewGroup(it)) - }, allowNotificationPresetOnClick = { subscribeViewModel.dispatch(SubscribeViewAction.ChangeAllowNotificationPreset) }, @@ -128,8 +124,8 @@ fun SubscribeDialog( onGroupClick = { subscribeViewModel.dispatch(SubscribeViewAction.SelectedGroup(it)) }, - onKeyboardAction = { - subscribeViewModel.dispatch(SubscribeViewAction.Subscribe) + onAddNewGroup = { + subscribeViewModel.dispatch(SubscribeViewAction.ShowNewGroupDialog) }, ) } @@ -138,7 +134,7 @@ fun SubscribeDialog( confirmButton = { if (viewState.isSearchPage) { TextButton( - enabled = viewState.linkContent.isNotEmpty() + enabled = viewState.linkContent.isNotBlank() && viewState.title != stringResource(R.string.searching), onClick = { focusManager.clearFocus() @@ -147,7 +143,7 @@ fun SubscribeDialog( ) { Text( text = stringResource(R.string.search), - color = if (viewState.linkContent.isNotEmpty()) { + color = if (viewState.linkContent.isNotBlank()) { Color.Unspecified } else { MaterialTheme.colorScheme.outline.copy(alpha = 0.7f) @@ -188,4 +184,21 @@ fun SubscribeDialog( } }, ) + + TextFieldDialog( + visible = viewState.newGroupDialogVisible, + title = stringResource(R.string.create_new_group), + icon = Icons.Outlined.CreateNewFolder, + value = viewState.newGroupContent, + placeholder = stringResource(R.string.name), + onValueChange = { + subscribeViewModel.dispatch(SubscribeViewAction.InputNewGroup(it)) + }, + onDismissRequest = { + subscribeViewModel.dispatch(SubscribeViewAction.HideNewGroupDialog) + }, + onConfirm = { + subscribeViewModel.dispatch(SubscribeViewAction.AddNewGroup) + } + ) } \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeViewModel.kt index 6a6389b..2b4f807 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeViewModel.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeViewModel.kt @@ -40,6 +40,8 @@ class SubscribeViewModel @Inject constructor( is SubscribeViewAction.Reset -> reset() is SubscribeViewAction.Show -> changeVisible(true) is SubscribeViewAction.Hide -> changeVisible(false) + is SubscribeViewAction.ShowNewGroupDialog -> changeNewGroupDialogVisible(true) + is SubscribeViewAction.HideNewGroupDialog -> changeNewGroupDialogVisible(false) is SubscribeViewAction.SwitchPage -> switchPage(action.isSearchPage) is SubscribeViewAction.ImportFromInputStream -> importFromInputStream(action.inputStream) is SubscribeViewAction.InputLink -> inputLink(action.content) @@ -50,7 +52,7 @@ class SubscribeViewModel @Inject constructor( changeParseFullContentPreset() is SubscribeViewAction.SelectedGroup -> selectedGroup(action.groupId) is SubscribeViewAction.InputNewGroup -> inputNewGroup(action.content) - is SubscribeViewAction.SelectedNewGroup -> selectedNewGroup(action.selected) + is SubscribeViewAction.AddNewGroup -> addNewGroup() is SubscribeViewAction.Subscribe -> subscribe() } } @@ -90,14 +92,7 @@ class SubscribeViewModel @Inject constructor( val articles = _viewState.value.articles viewModelScope.launch(Dispatchers.IO) { val groupId = async { - if ( - _viewState.value.newGroupSelected && - _viewState.value.newGroupContent.isNotBlank() - ) { - rssRepository.get().addGroup(_viewState.value.newGroupContent) - } else { - _viewState.value.selectedGroupId - } + _viewState.value.selectedGroupId } rssRepository.get().subscribe( feed.copy( @@ -118,11 +113,17 @@ class SubscribeViewModel @Inject constructor( } } - private fun selectedNewGroup(selected: Boolean) { - _viewState.update { - it.copy( - newGroupSelected = selected, - ) + private fun addNewGroup() { + if (_viewState.value.newGroupContent.isNotBlank()) { + viewModelScope.launch { + selectedGroup(rssRepository.get().addGroup(_viewState.value.newGroupContent)) + changeNewGroupDialogVisible(false) + _viewState.update { + it.copy( + newGroupContent = "", + ) + } + } } } @@ -224,6 +225,14 @@ class SubscribeViewModel @Inject constructor( } } + private fun changeNewGroupDialogVisible(visible: Boolean) { + _viewState.update { + it.copy( + newGroupDialogVisible = visible, + ) + } + } + private fun switchPage(isSearchPage: Boolean) { _viewState.update { it.copy( @@ -245,8 +254,8 @@ data class SubscribeViewState( val allowNotificationPreset: Boolean = false, val parseFullContentPreset: Boolean = false, val selectedGroupId: String = "", + val newGroupDialogVisible: Boolean = false, val newGroupContent: String = "", - val newGroupSelected: Boolean = false, val groups: Flow> = emptyFlow(), val isSearchPage: Boolean = true, ) @@ -258,6 +267,10 @@ sealed class SubscribeViewAction { object Show : SubscribeViewAction() object Hide : SubscribeViewAction() + object ShowNewGroupDialog : SubscribeViewAction() + object HideNewGroupDialog : SubscribeViewAction() + object AddNewGroup : SubscribeViewAction() + data class SwitchPage( val isSearchPage: Boolean ) : SubscribeViewAction() @@ -270,7 +283,7 @@ sealed class SubscribeViewAction { val content: String ) : SubscribeViewAction() - object Search: SubscribeViewAction() + object Search : SubscribeViewAction() object ChangeAllowNotificationPreset : SubscribeViewAction() object ChangeParseFullContentPreset : SubscribeViewAction() @@ -283,9 +296,5 @@ sealed class SubscribeViewAction { val content: String ) : SubscribeViewAction() - data class SelectedNewGroup( - val selected: Boolean - ) : SubscribeViewAction() - object Subscribe : SubscribeViewAction() } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 5090934..ab3ae8d 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -31,13 +31,14 @@ 允许通知 全文解析 添加到组 - 新建分组 + 新建分组 + 名称 打开 %1$s 选项 删除 - “%1$s” 已被删除 + \"%1$s\" 已被删除 取消订阅 - 不再订阅 “%1$s”,同时删除其所有已归档的文章。 + 不再订阅 \"%1$s\",同时删除其所有已归档的文章。 今天 昨天 %1$s %2$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5f8d0d1..736a7c7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -31,13 +31,14 @@ Allow Notification Parse Full Content Add to Group - New Group + Create New Group + Name Open %1$s Options Delete - "%1$s" has been deleted + \"%1$s\" has been deleted Unsubscribe - Unsubscribe "%1$s" and delete all its archived articles. + Unsubscribe \"%1$s\" and delete all its archived articles. Today Yesterday %1$s At %2$s