From ad5c5b9d5432e4ffb6b55de1351b66f69551f77e Mon Sep 17 00:00:00 2001 From: Tlaster Date: Tue, 24 Sep 2024 19:52:18 +0900 Subject: [PATCH 1/2] [WIP] add home tab support --- .../flare/ui/component/FlareTopAppBar.kt | 40 +++++ .../ui/component/status/LazyStatusItems.kt | 1 + .../flare/ui/screen/home/TimelineScreen.kt | 111 +++++++++--- .../flare/ui/screen/profile/ProfileScreen.kt | 54 +++--- .../ui/screen/settings/TabCustomizeScreen.kt | 160 +++++------------- .../datasource/mastodon/MastodonDataSource.kt | 29 +--- .../dev/dimension/flare/ui/model/UiList.kt | 6 + .../flare/ui/model/mapper/Bluesky.kt | 6 + .../dimension/flare/ui/model/mapper/VVO.kt | 9 + .../presenter/list/PinnableListPresenter.kt | 72 ++++++++ 10 files changed, 306 insertions(+), 182 deletions(-) create mode 100644 app/src/main/java/dev/dimension/flare/ui/component/FlareTopAppBar.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/list/PinnableListPresenter.kt diff --git a/app/src/main/java/dev/dimension/flare/ui/component/FlareTopAppBar.kt b/app/src/main/java/dev/dimension/flare/ui/component/FlareTopAppBar.kt new file mode 100644 index 000000000..04d9ab025 --- /dev/null +++ b/app/src/main/java/dev/dimension/flare/ui/component/FlareTopAppBar.kt @@ -0,0 +1,40 @@ +package dev.dimension.flare.ui.component + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp + +@ExperimentalMaterial3Api +@Composable +fun FlareTopAppBar( + title: @Composable () -> Unit, + modifier: Modifier = Modifier, + navigationIcon: @Composable () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, + expandedHeight: Dp = TopAppBarDefaults.TopAppBarExpandedHeight, + windowInsets: WindowInsets = TopAppBarDefaults.windowInsets, + colors: TopAppBarColors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + scrolledContainerColor = MaterialTheme.colorScheme.surface, + ), + scrollBehavior: TopAppBarScrollBehavior? = null, +) { + androidx.compose.material3.TopAppBar( + title = title, + modifier = modifier, + navigationIcon = navigationIcon, + actions = actions, + expandedHeight = expandedHeight, + windowInsets = windowInsets, + colors = colors, + scrollBehavior = scrollBehavior, + ) +} diff --git a/app/src/main/java/dev/dimension/flare/ui/component/status/LazyStatusItems.kt b/app/src/main/java/dev/dimension/flare/ui/component/status/LazyStatusItems.kt index 041dcb838..0f4beae11 100644 --- a/app/src/main/java/dev/dimension/flare/ui/component/status/LazyStatusItems.kt +++ b/app/src/main/java/dev/dimension/flare/ui/component/status/LazyStatusItems.kt @@ -275,6 +275,7 @@ internal fun StatusPlaceholder(modifier: Modifier = Modifier) { Column( modifier = modifier, ) { + Spacer(modifier = Modifier.height(4.dp)) UserPlaceholder() Spacer(modifier = Modifier.height(8.dp)) Text( diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/home/TimelineScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/TimelineScreen.kt index ac245a049..5a273c63a 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/home/TimelineScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/home/TimelineScreen.kt @@ -5,8 +5,12 @@ import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -15,10 +19,13 @@ import androidx.compose.material3.DrawerState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SecondaryScrollableTabRow +import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -43,14 +50,17 @@ import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.AnglesUp import compose.icons.fontawesomeicons.solid.Pen +import compose.icons.fontawesomeicons.solid.Sliders import dev.dimension.flare.R import dev.dimension.flare.common.isRefreshing import dev.dimension.flare.common.onSuccess +import dev.dimension.flare.data.model.HomeTimelineTabItem import dev.dimension.flare.data.model.TimelineTabItem import dev.dimension.flare.molecule.producePresenter import dev.dimension.flare.ui.component.AvatarComponent import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.FlareScaffold +import dev.dimension.flare.ui.component.FlareTopAppBar import dev.dimension.flare.ui.component.LocalBottomBarHeight import dev.dimension.flare.ui.component.RefreshContainer import dev.dimension.flare.ui.component.ThemeWrapper @@ -63,6 +73,7 @@ import dev.dimension.flare.ui.presenter.home.UserPresenter import dev.dimension.flare.ui.presenter.home.UserState import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.screen.settings.TabTitle +import dev.dimension.flare.ui.theme.screenHorizontalPadding import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.mapNotNull @@ -122,30 +133,88 @@ private fun TimelineScreen( val topAppBarScrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() FlareScaffold( topBar = { - TopAppBar( - title = { - TabTitle(title = tabItem.metaData.title) - }, - scrollBehavior = topAppBarScrollBehavior, - navigationIcon = { - if (LocalBottomBarHeight.current != 0.dp) { - state.user.onSuccess { - IconButton( - onClick = toQuickMenu, - ) { - AvatarComponent(it.avatar, size = 24.dp) + Column { + FlareTopAppBar( + title = { + TabTitle(title = tabItem.metaData.title) + }, + scrollBehavior = topAppBarScrollBehavior, + navigationIcon = { + if (LocalBottomBarHeight.current != 0.dp) { + state.user.onSuccess { + IconButton( + onClick = toQuickMenu, + ) { + AvatarComponent(it.avatar, size = 24.dp) + } } } - } - }, - actions = { - state.user.onError { - TextButton(onClick = toLogin) { - Text(text = stringResource(id = R.string.login_button)) + }, + actions = { + state.user + .onError { + TextButton(onClick = toLogin) { + Text(text = stringResource(id = R.string.login_button)) + } + }.onSuccess { + if (tabItem is HomeTimelineTabItem) { + IconButton( + onClick = { + }, + ) { + FAIcon( + FontAwesomeIcons.Solid.Sliders, + contentDescription = null, + ) + } + } + } + }, + ) + Box( + modifier = + Modifier + .background(MaterialTheme.colorScheme.surface) + .fillMaxWidth(), + ) { + if (tabItem is HomeTimelineTabItem) { + SecondaryScrollableTabRow( + selectedTabIndex = 0, + edgePadding = screenHorizontalPadding, + divider = {}, + ) { + Tab( + selected = true, + onClick = {}, + ) { + Text( + "Home", + modifier = + Modifier + .padding(8.dp), + ) + } + Tab( + selected = false, + onClick = {}, + ) { + Text( + "Home", + modifier = + Modifier + .padding(8.dp), + ) + } } + HorizontalDivider( + modifier = + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth(), + ) } - }, - ) + } + } }, floatingActionButton = { state.user.onSuccess { diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/profile/ProfileScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/profile/ProfileScreen.kt index 04adbba5a..1a6060871 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/profile/ProfileScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/profile/ProfileScreen.kt @@ -1,6 +1,5 @@ package dev.dimension.flare.ui.screen.profile -import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -665,33 +664,40 @@ private fun ProfileScreen( }, content = { Column { - AnimatedVisibility(state.profileTabs.size > 1) { - SecondaryScrollableTabRow( - selectedTabIndex = pagerState.currentPage, - modifier = Modifier.fillMaxWidth(), - edgePadding = screenHorizontalPadding, - divider = {}, - ) { - state.profileTabs.forEachIndexed { index, profileTab -> - Tab( - selected = pagerState.currentPage == index, - onClick = { - scope.launch { - pagerState.animateScrollToPage(index) - } - }, - ) { - Text( - profileTab.title, - modifier = - Modifier - .padding(8.dp), - ) + Box { + if (state.profileTabs.size > 1) { + SecondaryScrollableTabRow( + selectedTabIndex = pagerState.currentPage, + modifier = Modifier.fillMaxWidth(), + edgePadding = screenHorizontalPadding, + divider = {}, + ) { + state.profileTabs.forEachIndexed { index, profileTab -> + Tab( + selected = pagerState.currentPage == index, + onClick = { + scope.launch { + pagerState.animateScrollToPage(index) + } + }, + ) { + Text( + profileTab.title, + modifier = + Modifier + .padding(8.dp), + ) + } } } } + HorizontalDivider( + modifier = + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth(), + ) } - HorizontalDivider() HorizontalPager( state = pagerState, ) { index -> diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/settings/TabCustomizeScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/settings/TabCustomizeScreen.kt index 88476a726..75237ef53 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/settings/TabCustomizeScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/settings/TabCustomizeScreen.kt @@ -58,7 +58,6 @@ import compose.icons.fontawesomeicons.solid.Pen import compose.icons.fontawesomeicons.solid.Plus import compose.icons.fontawesomeicons.solid.Trash import dev.dimension.flare.R -import dev.dimension.flare.common.PagingState import dev.dimension.flare.data.model.Bluesky import dev.dimension.flare.data.model.IconType import dev.dimension.flare.data.model.ListTimelineTabItem @@ -68,7 +67,6 @@ import dev.dimension.flare.data.model.TimelineTabItem import dev.dimension.flare.data.model.TitleType import dev.dimension.flare.data.repository.SettingsRepository import dev.dimension.flare.model.AccountType -import dev.dimension.flare.model.PlatformType import dev.dimension.flare.molecule.producePresenter import dev.dimension.flare.ui.component.AvatarComponent import dev.dimension.flare.ui.component.AvatarComponentDefaults @@ -76,6 +74,7 @@ import dev.dimension.flare.ui.component.BackButton import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.FlareScaffold import dev.dimension.flare.ui.component.ThemeWrapper +import dev.dimension.flare.ui.model.UiList import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiState import dev.dimension.flare.ui.model.collectAsUiState @@ -85,9 +84,8 @@ import dev.dimension.flare.ui.model.onError import dev.dimension.flare.ui.model.onLoading import dev.dimension.flare.ui.model.onSuccess import dev.dimension.flare.ui.presenter.home.UserPresenter -import dev.dimension.flare.ui.presenter.home.bluesky.BlueskyFeedsPresenter import dev.dimension.flare.ui.presenter.invoke -import dev.dimension.flare.ui.presenter.list.AllListPresenter +import dev.dimension.flare.ui.presenter.list.PinnableListPresenter import dev.dimension.flare.ui.presenter.settings.AccountsPresenter import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -677,123 +675,59 @@ private fun allTabsPresenter(except: ImmutableList) = @Composable private fun dynamicTabPresenter(profile: UiProfile) = run { - val items: UiState>? = - when (profile.platformType) { - PlatformType.Mastodon -> - remember { - AllListPresenter(accountType = AccountType.Specific(profile.key)) - }.invoke().items.let { state -> - when (state) { - is PagingState.Empty -> null - is PagingState.Error -> null - is PagingState.Loading -> UiState.Loading() - is PagingState.Success -> { - (0 until state.itemCount) - .mapNotNull { index -> - state[index] - }.map { - ListTimelineTabItem( - account = AccountType.Specific(profile.key), - listId = it.id, - metaData = - TabMetaData( - title = TitleType.Text(it.title), - icon = - IconType.Mixed( - icon = IconType.Material.MaterialIcon.List, - userKey = profile.key, - ), + val state = + remember(profile) { + PinnableListPresenter(accountType = AccountType.Specific(profile.key)) + }.invoke() + val items = + remember(state.items) { + state.items + .map { + it.map { + // TODO: get feed out of list + if (it.type == UiList.Type.Feed) { + Bluesky.FeedTabItem( + account = AccountType.Specific(profile.key), + uri = it.id, + metaData = + TabMetaData( + title = TitleType.Text(it.title), + icon = + IconType.Mixed( + icon = IconType.Material.MaterialIcon.List, + userKey = profile.key, ), - ) - }.let { - UiState.Success(it) - } + ), + ) + } else { + ListTimelineTabItem( + account = AccountType.Specific(profile.key), + listId = it.id, + metaData = + TabMetaData( + title = TitleType.Text(it.title), + icon = + IconType.Mixed( + icon = IconType.Material.MaterialIcon.List, + userKey = profile.key, + ), + ), + ) } } + }.flatMap( + onError = { + UiState.Success(persistentListOf()) + }, + ) { + UiState.Success(it) } - PlatformType.Misskey -> null - PlatformType.Bluesky -> { - val feeds = - remember { - BlueskyFeedsPresenter(accountType = AccountType.Specific(profile.key)) - }.invoke().myFeeds.let { state -> - when (state) { - is PagingState.Empty -> null - is PagingState.Error -> null - is PagingState.Loading -> UiState.Loading() - is PagingState.Success -> { - (0 until state.itemCount) - .mapNotNull { index -> - state[index] - }.map { - Bluesky.FeedTabItem( - account = AccountType.Specific(profile.key), - uri = it.id, - metaData = - TabMetaData( - title = TitleType.Text(it.title), - icon = - IconType.Mixed( - icon = IconType.Material.MaterialIcon.Feeds, - userKey = profile.key, - ), - ), - ) - }.let { - UiState.Success(it) - } - } - } - } ?: UiState.Success(persistentListOf()) - val list = - remember { - AllListPresenter(accountType = AccountType.Specific(profile.key)) - }.invoke().items.let { state -> - when (state) { - is PagingState.Empty -> null - is PagingState.Error -> null - is PagingState.Loading -> UiState.Loading() - is PagingState.Success -> { - (0 until state.itemCount) - .mapNotNull { index -> - state[index] - }.map { - ListTimelineTabItem( - account = AccountType.Specific(profile.key), - listId = it.id, - metaData = - TabMetaData( - title = TitleType.Text(it.title), - icon = - IconType.Mixed( - icon = IconType.Material.MaterialIcon.List, - userKey = profile.key, - ), - ), - ) - }.let { - UiState.Success(it) - } - } - } - } ?: UiState.Success(persistentListOf()) - if (feeds is UiState.Success || list is UiState.Success) { - UiState.Success( - (feeds as? UiState.Success)?.data.orEmpty() + - (list as? UiState.Success)?.data.orEmpty(), - ) - } else { - null - } - } - PlatformType.xQt -> null - PlatformType.VVo -> null } object { val items = - items?.map { + items.map { it.toImmutableList() - } ?: UiState.Success(persistentListOf()) + } } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonDataSource.kt index 298b093c6..63b1cc304 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonDataSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonDataSource.kt @@ -929,11 +929,7 @@ class MastodonDataSource( .lists() .mapNotNull { it.id?.let { it1 -> - UiList( - id = it1, - title = it.title.orEmpty(), - platformType = PlatformType.Mastodon, - ) + it.render() } }.toImmutableList() }, @@ -995,13 +991,7 @@ class MastodonDataSource( MemCacheable( key = "listInfo_$listId", fetchSource = { - service.getList(listId).let { - UiList( - id = it.id ?: "", - title = it.title.orEmpty(), - platformType = PlatformType.Mastodon, - ) - } + service.getList(listId).render() }, ) @@ -1093,12 +1083,7 @@ class MastodonDataSource( MemCacheable.updateWith>( key = userListsKey(userKey), ) { - it + - UiList( - id = list.id, - title = list.title.orEmpty(), - platformType = PlatformType.Mastodon, - ) + it + list.render() } } } @@ -1142,12 +1127,8 @@ class MastodonDataSource( .accountLists(userKey.id) .body() ?.mapNotNull { - it.id?.let { it1 -> - UiList( - id = it1, - title = it.title.orEmpty(), - platformType = PlatformType.Mastodon, - ) + it.id?.let { _ -> + it.render() } }.orEmpty() .toImmutableList() diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiList.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiList.kt index 321121dc3..8062b6f99 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiList.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiList.kt @@ -14,8 +14,14 @@ data class UiList( val likedCount: Long = 0, val liked: Boolean = false, val platformType: PlatformType, + val type: Type = Type.List, ) { val likedCountHumanized by lazy { likedCount.humanize() } + + enum class Type { + Feed, + List, + } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Bluesky.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Bluesky.kt index 86e61ed7a..5fdb4b676 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Bluesky.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Bluesky.kt @@ -68,6 +68,11 @@ private fun parseBluesky( for (facet in facets) { val start = facet.index.byteStart.toInt() val end = facet.index.byteEnd.toInt() + // some facets may have same start + // for example: https://bsky.app/profile/technews4869.bsky.social/post/3l4vfqetv7t25 + if (codePointIndex > start) { + continue + } val beforeFacetText = codePoints.drop(codePointIndex).take(start - codePointIndex).stringify() element.appendTextWithBr(beforeFacetText) val facetText = codePoints.drop(start).take(end - start).stringify() @@ -686,6 +691,7 @@ internal fun GeneratorView.render(accountKey: MicroBlogKey) = likedCount = likeCount ?: 0, liked = viewer?.like?.atUri != null, platformType = PlatformType.Bluesky, + type = UiList.Type.Feed, ) internal fun ListView.render(accountKey: MicroBlogKey) = diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/VVO.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/VVO.kt index d8174a0d5..b2d29c032 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/VVO.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/VVO.kt @@ -6,6 +6,7 @@ import com.fleeksoft.ksoup.nodes.TextNode import dev.dimension.flare.common.AppDeepLink import dev.dimension.flare.data.datasource.microblog.StatusAction import dev.dimension.flare.data.datasource.microblog.StatusEvent +import dev.dimension.flare.data.network.mastodon.api.model.MastodonList import dev.dimension.flare.data.network.vvo.model.Attitude import dev.dimension.flare.data.network.vvo.model.Comment import dev.dimension.flare.data.network.vvo.model.Status @@ -13,6 +14,7 @@ import dev.dimension.flare.data.network.vvo.model.User import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType import dev.dimension.flare.model.vvoHost +import dev.dimension.flare.ui.model.UiList import dev.dimension.flare.ui.model.UiMedia import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiTimeline @@ -510,3 +512,10 @@ private fun replaceMentionAndHashtag( } } } + +internal fun MastodonList.render(): UiList = + UiList( + id = id.orEmpty(), + title = title.orEmpty(), + platformType = PlatformType.Mastodon, + ) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/list/PinnableListPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/list/PinnableListPresenter.kt new file mode 100644 index 000000000..f9700f343 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/list/PinnableListPresenter.kt @@ -0,0 +1,72 @@ +package dev.dimension.flare.ui.presenter.list + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import dev.dimension.flare.common.collectAsState +import dev.dimension.flare.data.datasource.bluesky.BlueskyDataSource +import dev.dimension.flare.data.datasource.microblog.ListDataSource +import dev.dimension.flare.data.repository.accountServiceProvider +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.ui.model.UiList +import dev.dimension.flare.ui.model.UiState +import dev.dimension.flare.ui.model.flatMap +import dev.dimension.flare.ui.model.map +import dev.dimension.flare.ui.model.mapNotNull +import dev.dimension.flare.ui.model.toUi +import dev.dimension.flare.ui.presenter.PresenterBase +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +class PinnableListPresenter( + private val accountType: AccountType, +) : PresenterBase() { + @Composable + override fun body(): PinnableListState { + val serviceState = accountServiceProvider(accountType = accountType) + val items = + serviceState + .mapNotNull { service -> + remember(service) { + service + .takeIf { + it is ListDataSource + }?.let { + it as ListDataSource + }?.myList + }?.collectAsState()?.toUi() + }.flatMap { it } + + val bsky = + serviceState + .mapNotNull { service -> + remember(service) { + service + .takeIf { + it is BlueskyDataSource + }?.let { + it as BlueskyDataSource + }?.myFeeds + }?.collectAsState()?.toUi() + }.flatMap { it } + + val result = + if (bsky is UiState.Success) { + items.map { + it + bsky.data + } + } else { + items + } + + return object : PinnableListState { + override val items = + result.map { + it.toImmutableList() + } + } + } +} + +interface PinnableListState { + val items: UiState> +} From 1454afa79954c34e2635b73f1a9b87b2f3c27254 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Wed, 25 Sep 2024 17:46:45 +0900 Subject: [PATCH 2/2] add home tab edit --- .../dimension/flare/data/model/TabSettings.kt | 10 + .../flare/ui/screen/home/HomeScreen.kt | 8 +- .../ui/screen/home/HomeTimelineScreen.kt | 371 ++++++++++++++++++ .../flare/ui/screen/home/TabSettingScreen.kt | 370 +++++++++++++++++ .../flare/ui/screen/home/TimelineScreen.kt | 233 ++--------- .../ui/screen/settings/TabCustomizeScreen.kt | 109 ++--- app/src/main/res/values/strings.xml | 2 + 7 files changed, 847 insertions(+), 256 deletions(-) create mode 100644 app/src/main/java/dev/dimension/flare/ui/screen/home/TabSettingScreen.kt diff --git a/app/src/main/java/dev/dimension/flare/data/model/TabSettings.kt b/app/src/main/java/dev/dimension/flare/data/model/TabSettings.kt index 3281c2e1c..9e9933a21 100644 --- a/app/src/main/java/dev/dimension/flare/data/model/TabSettings.kt +++ b/app/src/main/java/dev/dimension/flare/data/model/TabSettings.kt @@ -49,6 +49,7 @@ import java.io.OutputStream data class TabSettings( val items: List = TimelineTabItem.default, val secondaryItems: List? = null, + val homeTabs: Map> = mapOf(), ) @Serializable @@ -562,6 +563,15 @@ data class HomeTimelineTabItem( override fun createPresenter(): TimelinePresenter = HomeTimelinePresenter(account) override fun update(metaData: TabMetaData): TabItem = copy(metaData = metaData) + constructor(accountType: AccountType) : + this( + account = accountType, + metaData = + TabMetaData( + title = TitleType.Localized(TitleType.Localized.LocalizedKey.Home), + icon = IconType.Material(IconType.Material.MaterialIcon.Home), + ), + ) } @Serializable diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt index 9ece06730..c456eecbd 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt @@ -70,6 +70,7 @@ import com.ramcosta.composedestinations.generated.NavGraphs import com.ramcosta.composedestinations.generated.destinations.BlueskyFeedsRouteDestination import com.ramcosta.composedestinations.generated.destinations.ComposeRouteDestination import com.ramcosta.composedestinations.generated.destinations.DiscoverRouteDestination +import com.ramcosta.composedestinations.generated.destinations.HomeTimelineRouteDestination import com.ramcosta.composedestinations.generated.destinations.ListScreenRouteDestination import com.ramcosta.composedestinations.generated.destinations.MeRouteDestination import com.ramcosta.composedestinations.generated.destinations.NotificationRouteDestination @@ -93,6 +94,7 @@ import dev.dimension.flare.R import dev.dimension.flare.data.model.AllListTabItem import dev.dimension.flare.data.model.Bluesky import dev.dimension.flare.data.model.DiscoverTabItem +import dev.dimension.flare.data.model.HomeTimelineTabItem import dev.dimension.flare.data.model.NotificationTabItem import dev.dimension.flare.data.model.ProfileTabItem import dev.dimension.flare.data.model.SettingsTabItem @@ -746,6 +748,10 @@ private fun getDirection( MeRouteDestination(accountType) } + is HomeTimelineTabItem -> { + HomeTimelineRouteDestination(accountType) + } + is TimelineTabItem -> { TimelineRouteDestination(tab) } @@ -1019,7 +1025,7 @@ internal fun RegisterTabCallback(lazyListState: LazyStaggeredGridState) { } } } - DisposableEffect(tabState, callback) { + DisposableEffect(tabState, callback, lazyListState) { tabState.registerCallback(callback) onDispose { tabState.unregisterCallback(callback) diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt index ba88ec2c8..68d8e528f 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt @@ -1,16 +1,91 @@ package dev.dimension.flare.ui.screen.home +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.DrawerState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.IconButton +import androidx.compose.material3.SecondaryScrollableTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.generated.destinations.ComposeRouteDestination import com.ramcosta.composedestinations.generated.destinations.ServiceSelectRouteDestination +import com.ramcosta.composedestinations.generated.destinations.TabSettingRouteDestination import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.AnglesUp +import compose.icons.fontawesomeicons.solid.Pen +import compose.icons.fontawesomeicons.solid.Sliders +import dev.dimension.flare.R +import dev.dimension.flare.common.PagingState +import dev.dimension.flare.common.isRefreshing +import dev.dimension.flare.common.onSuccess +import dev.dimension.flare.data.model.HomeTimelineTabItem +import dev.dimension.flare.data.model.TimelineTabItem +import dev.dimension.flare.data.repository.SettingsRepository import dev.dimension.flare.model.AccountType +import dev.dimension.flare.molecule.producePresenter +import dev.dimension.flare.ui.component.AvatarComponent +import dev.dimension.flare.ui.component.FAIcon +import dev.dimension.flare.ui.component.FlareScaffold +import dev.dimension.flare.ui.component.FlareTopAppBar +import dev.dimension.flare.ui.component.LocalBottomBarHeight +import dev.dimension.flare.ui.component.RefreshContainer import dev.dimension.flare.ui.component.ThemeWrapper +import dev.dimension.flare.ui.component.status.LazyStatusVerticalStaggeredGrid +import dev.dimension.flare.ui.component.status.status +import dev.dimension.flare.ui.model.UiTimeline +import dev.dimension.flare.ui.model.collectAsUiState +import dev.dimension.flare.ui.model.flatMap +import dev.dimension.flare.ui.model.map +import dev.dimension.flare.ui.model.onError +import dev.dimension.flare.ui.model.onSuccess +import dev.dimension.flare.ui.presenter.home.UserPresenter +import dev.dimension.flare.ui.presenter.home.UserState +import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.screen.settings.TabTitle +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch +import org.koin.compose.koinInject @Destination( wrappers = [ThemeWrapper::class], @@ -23,6 +98,7 @@ internal fun HomeTimelineRoute( ) { val scope = rememberCoroutineScope() HomeTimelineScreen( + accountType = accountType, toCompose = { navigator.navigate(ComposeRouteDestination(accountType = accountType)) }, @@ -34,13 +110,308 @@ internal fun HomeTimelineRoute( toLogin = { navigator.navigate(ServiceSelectRouteDestination) }, + toTabSettings = { + navigator.navigate(TabSettingRouteDestination(accountType)) + }, ) } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun HomeTimelineScreen( + accountType: AccountType, toCompose: () -> Unit, toQuickMenu: () -> Unit, toLogin: () -> Unit, + toTabSettings: () -> Unit, +) { + val state by producePresenter(key = "home_timeline_$accountType") { + timelinePresenter(accountType) + } + val scope = rememberCoroutineScope() + state.pagerState.onSuccess { pagerState -> + state.tabState.onSuccess { tabState -> + val lazyListState = tabState[pagerState.currentPage].lazyListState + RegisterTabCallback(lazyListState = lazyListState) + } + } + + val topAppBarScrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() + FlareScaffold( + topBar = { + FlareTopAppBar( + title = { + state.pagerState.onSuccess { pagerState -> + state.tabState.onSuccess { tabs -> + if (tabs.size > 1) { + SecondaryScrollableTabRow( + selectedTabIndex = pagerState.currentPage, + edgePadding = 0.dp, + divider = {}, + modifier = Modifier.fillMaxWidth(), + ) { + state.tabState.onSuccess { tabs -> + tabs.forEachIndexed { index, tab -> + Tab( + selected = index == pagerState.currentPage, + onClick = { + scope.launch { + pagerState.scrollToPage(index) + } + }, + ) { + TabTitle( + tab.timelineTabItem.metaData.title, + modifier = + Modifier + .padding(8.dp), + ) + } + } + } + } + } else { + TabTitle(title = tabs[0].timelineTabItem.metaData.title) + } + } + } + }, + scrollBehavior = topAppBarScrollBehavior, + navigationIcon = { + if (LocalBottomBarHeight.current != 0.dp) { + state.user.onSuccess { + IconButton( + onClick = toQuickMenu, + ) { + AvatarComponent(it.avatar, size = 24.dp) + } + } + } + }, + actions = { + state.user + .onError { + TextButton(onClick = toLogin) { + Text(text = stringResource(id = R.string.login_button)) + } + }.onSuccess { + IconButton( + onClick = toTabSettings, + ) { + FAIcon( + FontAwesomeIcons.Solid.Sliders, + contentDescription = null, + ) + } + } + }, + ) + }, + floatingActionButton = { + state.user.onSuccess { + AnimatedVisibility( + visible = topAppBarScrollBehavior.state.heightOffset == 0f && LocalBottomBarHeight.current != 0.dp, + enter = scaleIn(), + exit = scaleOut(), + ) { + FloatingActionButton( + onClick = toCompose, + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Pen, + contentDescription = stringResource(id = R.string.compose_title), + ) + } + } + } + }, + modifier = Modifier.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection), + ) { contentPadding -> + state.pagerState.onSuccess { pagerState -> + state.tabState.onSuccess { tabs -> + if (tabs.size == 1) { + // workaround for a bug in HorizontalPager with Drawer + // https://issuetracker.google.com/issues/167408603 + TimelineItemContent( + state = tabs[0], + contentPadding = contentPadding, + modifier = Modifier.fillMaxWidth(), + ) + } else { + HorizontalPager( + state = pagerState, + ) { index -> + val tab = tabs[index] + TimelineItemContent( + state = tab, + contentPadding = contentPadding, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } + } +} + +@Composable +internal fun TimelineItemContent( + state: TimelineItemState, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), ) { + val scope = rememberCoroutineScope() + RefreshContainer( + modifier = modifier, + onRefresh = state::refreshSync, + isRefreshing = state.isRefreshing, + indicatorPadding = contentPadding, + content = { + LazyStatusVerticalStaggeredGrid( + state = state.lazyListState, + contentPadding = contentPadding, + modifier = Modifier.fillMaxSize(), + ) { + status(state.listState) + } + state.listState.onSuccess { + AnimatedVisibility( + state.showNewToots, + enter = slideInVertically { -it }, + exit = slideOutVertically { -it }, + modifier = + Modifier + .padding(contentPadding) + .align(Alignment.TopCenter), + ) { + FilledTonalButton( + onClick = { + state.onNewTootsShown() + scope.launch { + state.lazyListState.scrollToItem(0) + } + }, + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.AnglesUp, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(text = stringResource(id = R.string.home_timeline_new_toots)) + } + } + } + }, + ) +} + +@Composable +private fun timelinePresenter( + accountType: AccountType, + settingsRepository: SettingsRepository = koinInject(), +) = run { + val accountState = + remember(accountType) { + UserPresenter( + accountType = accountType, + userKey = null, + ) + }.invoke() + val tabSettings by settingsRepository.tabSettings.collectAsUiState() + val tabs = + remember(accountType, accountState, tabSettings) { + accountState.user.flatMap { user -> + tabSettings.map { + it.homeTabs.getOrDefault( + user.key, + listOf(HomeTimelineTabItem(accountType = AccountType.Specific(user.key))), + ) + } + } + } + val pagerState = + tabs.map { + rememberPagerState { it.size } + } + val tabState = + tabs.map { + it + .map { + timelineItemPresenter(it) + }.toImmutableList() + } + + object : UserState by accountState { + val pagerState = pagerState + val tabState = tabState + } +} + +@Composable +internal fun timelineItemPresenter(timelineTabItem: TimelineTabItem): TimelineItemState { + val timelinePresenter = + remember(timelineTabItem) { + timelineTabItem.createPresenter() + } + val scope = rememberCoroutineScope() + val state = timelinePresenter() + var showNewToots by remember { mutableStateOf(false) } + state.listState.onSuccess { + LaunchedEffect(Unit) { + snapshotFlow { + if (itemCount > 0) { + peek(0)?.itemKey + } else { + null + } + }.mapNotNull { it } + .distinctUntilChanged() + .drop(1) + .collect { + showNewToots = true + } + } + } + val lazyListState = rememberLazyStaggeredGridState() + val isAtTheTop by remember { + derivedStateOf { + lazyListState.firstVisibleItemIndex == 0 + } + } + LaunchedEffect(isAtTheTop, showNewToots) { + if (isAtTheTop) { + showNewToots = false + } + } + return object : TimelineItemState { + override val listState = state.listState + override val showNewToots = showNewToots + override val isRefreshing = state.listState.isRefreshing + override val lazyListState = lazyListState + override val timelineTabItem = timelineTabItem + + override fun onNewTootsShown() { + showNewToots = false + } + + override fun refreshSync() { + scope.launch { + state.refresh() + } + } + } +} + +@Immutable +internal interface TimelineItemState { + val listState: PagingState + val showNewToots: Boolean + val isRefreshing: Boolean + val lazyListState: LazyStaggeredGridState + val timelineTabItem: TimelineTabItem + + fun onNewTootsShown() + + fun refreshSync() } diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/home/TabSettingScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/TabSettingScreen.kt new file mode 100644 index 000000000..adcaa61d8 --- /dev/null +++ b/app/src/main/java/dev/dimension/flare/ui/screen/home/TabSettingScreen.kt @@ -0,0 +1,370 @@ +package dev.dimension.flare.ui.screen.home + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.AnimationConstants +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.Bars +import compose.icons.fontawesomeicons.solid.Plus +import compose.icons.fontawesomeicons.solid.Trash +import dev.dimension.flare.R +import dev.dimension.flare.data.model.HomeTimelineTabItem +import dev.dimension.flare.data.model.TimelineTabItem +import dev.dimension.flare.data.repository.SettingsRepository +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.molecule.producePresenter +import dev.dimension.flare.ui.component.BackButton +import dev.dimension.flare.ui.component.FAIcon +import dev.dimension.flare.ui.component.FlareScaffold +import dev.dimension.flare.ui.component.ThemeWrapper +import dev.dimension.flare.ui.model.collectAsUiState +import dev.dimension.flare.ui.model.flatMap +import dev.dimension.flare.ui.model.map +import dev.dimension.flare.ui.model.onSuccess +import dev.dimension.flare.ui.presenter.home.UserPresenter +import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.screen.settings.TabTitle +import dev.dimension.flare.ui.screen.settings.dynamicTabPresenter +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState + +@Destination( + wrappers = [ThemeWrapper::class], +) +@Composable +internal fun TabSettingRoute( + navigator: DestinationsNavigator, + accountType: AccountType, +) { + TabSettingScreen( + accountType = accountType, + onBack = navigator::navigateUp, + ) +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +private fun TabSettingScreen( + accountType: AccountType, + onBack: () -> Unit, +) { + val haptics = LocalHapticFeedback.current + val state by producePresenter { + presenter(accountType = accountType) + } + DisposableEffect(Unit) { + onDispose { + state.commit() + } + } + FlareScaffold( + topBar = { + TopAppBar( + title = { + Text(text = stringResource(R.string.tab_settings_title)) + }, + navigationIcon = { + BackButton(onBack = onBack) + }, + actions = { + IconButton( + onClick = { + state.setAddTab(true) + }, + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Plus, + contentDescription = stringResource(id = R.string.tab_settings_add), + ) + } + }, + ) + }, + ) { + val lazyListState = rememberLazyListState() + val reorderableLazyColumnState = + rememberReorderableLazyListState(lazyListState) { from, to -> + state.moveTab(from.key, to.key) + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + } + LazyColumn( + state = lazyListState, + contentPadding = it, + ) { + items(state.currentTabs, key = { it.key }) { item -> + var shouldDismiss by remember { mutableStateOf(false) } + val swipeState = + rememberSwipeToDismissBoxState( + confirmValueChange = { + if (it != SwipeToDismissBoxValue.Settled) { + shouldDismiss = true + } + it != SwipeToDismissBoxValue.Settled + }, + ) + LaunchedEffect(shouldDismiss) { + if (shouldDismiss) { + delay(AnimationConstants.DefaultDurationMillis.toLong()) + state.deleteTab(item) + shouldDismiss = false + } + } + ReorderableItem(reorderableLazyColumnState, key = item.key) { isDragging -> + AnimatedVisibility( + visible = !shouldDismiss, + exit = + shrinkVertically( + animationSpec = tween(), + shrinkTowards = Alignment.Top, + ) + fadeOut(), + ) { + val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp) + Surface( + shadowElevation = elevation, + ) { + SwipeToDismissBox( + state = swipeState, + enableDismissFromEndToStart = state.canSwipeToDelete, + enableDismissFromStartToEnd = state.canSwipeToDelete, + backgroundContent = { + val alignment = + when (swipeState.dismissDirection) { + SwipeToDismissBoxValue.StartToEnd -> Alignment.CenterStart + SwipeToDismissBoxValue.EndToStart -> Alignment.CenterEnd + SwipeToDismissBoxValue.Settled -> Alignment.Center + } + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.error) + .padding(16.dp), + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Trash, + contentDescription = stringResource(id = R.string.tab_settings_remove), + modifier = + Modifier +// .size(24.dp) + .align(alignment), + tint = MaterialTheme.colorScheme.onError, + ) + } + }, + ) { + ListItem( + headlineContent = { + TabTitle(item.metaData.title) + }, + trailingContent = { + IconButton( + modifier = + Modifier.draggableHandle( + onDragStarted = { + haptics.performHapticFeedback( + HapticFeedbackType.LongPress, + ) + }, + onDragStopped = { + haptics.performHapticFeedback( + HapticFeedbackType.LongPress, + ) + }, + ), + onClick = {}, + ) { + FAIcon( + FontAwesomeIcons.Solid.Bars, + contentDescription = stringResource(id = R.string.tab_settings_drag), + ) + } + }, + ) + } + } + } + } + } + } + } + + state.remainTabs.onSuccess { remainTabs -> + if (state.showAddTab) { + ModalBottomSheet( + onDismissRequest = { + state.setAddTab(false) + }, + ) { + LazyColumn { + items(remainTabs) { + ListItem( + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + headlineContent = { + TabTitle(it.metaData.title) + }, + modifier = + Modifier + .clickable { + state.addTab(it) + state.setAddTab(false) + }, + trailingContent = { + FAIcon( + FontAwesomeIcons.Solid.Plus, + contentDescription = stringResource(id = R.string.tab_settings_add), + ) + }, + ) + } + } + } + } + } +} + +@Composable +private fun presenter( + accountType: AccountType, + settingsRepository: SettingsRepository = koinInject(), + appScope: CoroutineScope = koinInject(), +) = run { + val accountState = + remember(accountType) { + UserPresenter( + accountType = accountType, + userKey = null, + ) + }.invoke() + val tabSettings by settingsRepository.tabSettings.collectAsUiState() + val cacheTabs = + remember { + mutableStateListOf() + } + val currentTabs = + accountState.user.flatMap { user -> + tabSettings.map { + it.homeTabs + .getOrDefault(user.key, listOf(HomeTimelineTabItem(accountType = AccountType.Specific(user.key)))) + .toImmutableList() + } + } + currentTabs + .onSuccess { + LaunchedEffect(it.size) { + cacheTabs.clear() + cacheTabs.addAll(it) + } + } + val remainTabs = + accountState + .user + .map { user -> + TimelineTabItem.defaultPrimary(user) + + TimelineTabItem.defaultSecondary( + user, + ) + }.flatMap { items -> + accountState.user.flatMap { user -> + dynamicTabPresenter(accountKey = user.key) + .map { dynTabs -> + items + dynTabs + }.map { + it.toImmutableList() + } + } + }.map { + it + .filterIsInstance() + .filter { tab -> + tab !in cacheTabs + }.toImmutableList() + } + var showAddTab by remember { mutableStateOf(false) } + object { + val currentTabs = cacheTabs.toImmutableList() + val remainTabs = remainTabs + val canSwipeToDelete = cacheTabs.size > 1 + val showAddTab = showAddTab + + fun moveTab( + from: Any, + to: Any, + ) { + val fromIndex = cacheTabs.indexOfFirst { it.key == from } + val toIndex = cacheTabs.indexOfFirst { it.key == to } + cacheTabs.add(toIndex, cacheTabs.removeAt(fromIndex)) + } + + fun commit() { + accountState.user.onSuccess { user -> + appScope.launch { + settingsRepository.updateTabSettings { + copy( + homeTabs = homeTabs + (user.key to cacheTabs), + ) + } + } + } + } + + fun deleteTab(tab: TimelineTabItem) { + cacheTabs.removeIf { it.key == tab.key } + } + + fun addTab(tab: TimelineTabItem) { + cacheTabs.add(tab) + } + + fun setAddTab(value: Boolean) { + showAddTab = value + } + } +} diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/home/TimelineScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/TimelineScreen.kt index 5a273c63a..f3a2d194c 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/home/TimelineScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/home/TimelineScreen.kt @@ -3,40 +3,19 @@ package dev.dimension.flare.ui.screen.home import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState import androidx.compose.material3.DrawerState import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SecondaryScrollableTabRow -import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource @@ -48,35 +27,21 @@ import com.ramcosta.composedestinations.generated.destinations.ServiceSelectRout import com.ramcosta.composedestinations.navigation.DestinationsNavigator import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid -import compose.icons.fontawesomeicons.solid.AnglesUp import compose.icons.fontawesomeicons.solid.Pen -import compose.icons.fontawesomeicons.solid.Sliders import dev.dimension.flare.R -import dev.dimension.flare.common.isRefreshing -import dev.dimension.flare.common.onSuccess -import dev.dimension.flare.data.model.HomeTimelineTabItem import dev.dimension.flare.data.model.TimelineTabItem import dev.dimension.flare.molecule.producePresenter import dev.dimension.flare.ui.component.AvatarComponent import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.FlareScaffold -import dev.dimension.flare.ui.component.FlareTopAppBar import dev.dimension.flare.ui.component.LocalBottomBarHeight -import dev.dimension.flare.ui.component.RefreshContainer import dev.dimension.flare.ui.component.ThemeWrapper -import dev.dimension.flare.ui.component.status.LazyStatusVerticalStaggeredGrid -import dev.dimension.flare.ui.component.status.status import dev.dimension.flare.ui.model.onError import dev.dimension.flare.ui.model.onSuccess -import dev.dimension.flare.ui.presenter.home.TimelineState import dev.dimension.flare.ui.presenter.home.UserPresenter import dev.dimension.flare.ui.presenter.home.UserState import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.screen.settings.TabTitle -import dev.dimension.flare.ui.theme.screenHorizontalPadding -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch // @RootNavGraph(start = true) // sets this as the start destination of the default nav graph @@ -114,107 +79,38 @@ private fun TimelineScreen( toQuickMenu: () -> Unit, toLogin: () -> Unit, ) { - val state by producePresenter(key = "home_timeline_${tabItem.key}") { + val state by producePresenter(key = "timeline_${tabItem.key}") { timelinePresenter(tabItem) } - val scope = rememberCoroutineScope() - val lazyListState = rememberLazyStaggeredGridState() - RegisterTabCallback(lazyListState = lazyListState) - val isAtTheTop by remember { - derivedStateOf { - lazyListState.firstVisibleItemIndex == 0 - } - } - LaunchedEffect(isAtTheTop, state.showNewToots) { - if (isAtTheTop) { - state.onNewTootsShown() - } - } + RegisterTabCallback(lazyListState = state.lazyListState) val topAppBarScrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() FlareScaffold( topBar = { - Column { - FlareTopAppBar( - title = { - TabTitle(title = tabItem.metaData.title) - }, - scrollBehavior = topAppBarScrollBehavior, - navigationIcon = { - if (LocalBottomBarHeight.current != 0.dp) { - state.user.onSuccess { - IconButton( - onClick = toQuickMenu, - ) { - AvatarComponent(it.avatar, size = 24.dp) - } - } - } - }, - actions = { - state.user - .onError { - TextButton(onClick = toLogin) { - Text(text = stringResource(id = R.string.login_button)) - } - }.onSuccess { - if (tabItem is HomeTimelineTabItem) { - IconButton( - onClick = { - }, - ) { - FAIcon( - FontAwesomeIcons.Solid.Sliders, - contentDescription = null, - ) - } - } - } - }, - ) - Box( - modifier = - Modifier - .background(MaterialTheme.colorScheme.surface) - .fillMaxWidth(), - ) { - if (tabItem is HomeTimelineTabItem) { - SecondaryScrollableTabRow( - selectedTabIndex = 0, - edgePadding = screenHorizontalPadding, - divider = {}, - ) { - Tab( - selected = true, - onClick = {}, - ) { - Text( - "Home", - modifier = - Modifier - .padding(8.dp), - ) - } - Tab( - selected = false, - onClick = {}, + TopAppBar( + title = { + TabTitle(title = tabItem.metaData.title) + }, + scrollBehavior = topAppBarScrollBehavior, + navigationIcon = { + if (LocalBottomBarHeight.current != 0.dp) { + state.user.onSuccess { + IconButton( + onClick = toQuickMenu, ) { - Text( - "Home", - modifier = - Modifier - .padding(8.dp), - ) + AvatarComponent(it.avatar, size = 24.dp) } } - HorizontalDivider( - modifier = - Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth(), - ) } - } - } + }, + actions = { + state.user + .onError { + TextButton(onClick = toLogin) { + Text(text = stringResource(id = R.string.login_button)) + } + } + }, + ) }, floatingActionButton = { state.user.onSuccess { @@ -236,49 +132,10 @@ private fun TimelineScreen( }, modifier = Modifier.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection), ) { contentPadding -> - RefreshContainer( - modifier = - Modifier - .fillMaxSize(), - onRefresh = state::refreshSync, - isRefreshing = state.isRefreshing, - indicatorPadding = contentPadding, - content = { - LazyStatusVerticalStaggeredGrid( - state = lazyListState, - contentPadding = contentPadding, - ) { - status(state.listState) - } - state.listState.onSuccess { - AnimatedVisibility( - state.showNewToots, - enter = slideInVertically { -it }, - exit = slideOutVertically { -it }, - modifier = - Modifier - .padding(contentPadding) - .align(Alignment.TopCenter), - ) { - FilledTonalButton( - onClick = { - state.onNewTootsShown() - scope.launch { - lazyListState.scrollToItem(0) - } - }, - ) { - FAIcon( - imageVector = FontAwesomeIcons.Solid.AnglesUp, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text(text = stringResource(id = R.string.home_timeline_new_toots)) - } - } - } - }, + TimelineItemContent( + state = state, + contentPadding = contentPadding, + modifier = Modifier.fillMaxSize(), ) } } @@ -286,8 +143,7 @@ private fun TimelineScreen( @Composable private fun timelinePresenter(tabItem: TimelineTabItem) = run { - val scope = rememberCoroutineScope() - val state = remember(tabItem.account) { tabItem.createPresenter() }.invoke() + val state = timelineItemPresenter(tabItem) val accountState = remember(tabItem.account) { UserPresenter( @@ -295,35 +151,6 @@ private fun timelinePresenter(tabItem: TimelineTabItem) = userKey = null, ) }.invoke() - var showNewToots by remember { mutableStateOf(false) } - state.listState.onSuccess { - LaunchedEffect(Unit) { - snapshotFlow { - if (itemCount > 0) { - peek(0)?.itemKey - } else { - null - } - }.mapNotNull { it } - .distinctUntilChanged() - .drop(1) - .collect { - showNewToots = true - } - } - } - object : UserState by accountState, TimelineState by state { - val showNewToots = showNewToots - val isRefreshing = state.listState.isRefreshing - - fun onNewTootsShown() { - showNewToots = false - } - - fun refreshSync() { - scope.launch { - state.refresh() - } - } + object : UserState by accountState, TimelineItemState by state { } } diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/settings/TabCustomizeScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/settings/TabCustomizeScreen.kt index 75237ef53..01abcfc8b 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/settings/TabCustomizeScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/settings/TabCustomizeScreen.kt @@ -67,6 +67,7 @@ import dev.dimension.flare.data.model.TimelineTabItem import dev.dimension.flare.data.model.TitleType import dev.dimension.flare.data.repository.SettingsRepository import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.molecule.producePresenter import dev.dimension.flare.ui.component.AvatarComponent import dev.dimension.flare.ui.component.AvatarComponentDefaults @@ -75,7 +76,6 @@ import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.FlareScaffold import dev.dimension.flare.ui.component.ThemeWrapper import dev.dimension.flare.ui.model.UiList -import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiState import dev.dimension.flare.ui.model.collectAsUiState import dev.dimension.flare.ui.model.flatMap @@ -230,6 +230,12 @@ private fun TabCustomizeScreen(onBack: () -> Unit) { state.addTab(it) state.setAddTab(false) }, + trailingContent = { + FAIcon( + FontAwesomeIcons.Solid.Plus, + contentDescription = stringResource(id = R.string.tab_settings_add), + ) + }, ) } @@ -263,6 +269,12 @@ private fun TabCustomizeScreen(onBack: () -> Unit) { state.addTab(tab) state.setAddTab(false) }, + trailingContent = { + FAIcon( + FontAwesomeIcons.Solid.Plus, + contentDescription = stringResource(id = R.string.tab_settings_add), + ) + }, ) } } @@ -654,8 +666,7 @@ private fun allTabsPresenter(except: ImmutableList) = }.toImmutableList() }.flatMap { items -> userState.flatMap { user -> - dynamicTabPresenter(profile = user) - .items + dynamicTabPresenter(accountKey = user.key) .map { dynTabs -> items + dynTabs }.map { @@ -673,60 +684,54 @@ private fun allTabsPresenter(except: ImmutableList) = } @Composable -private fun dynamicTabPresenter(profile: UiProfile) = +internal fun dynamicTabPresenter(accountKey: MicroBlogKey) = run { val state = - remember(profile) { - PinnableListPresenter(accountType = AccountType.Specific(profile.key)) + remember(accountKey) { + PinnableListPresenter(accountType = AccountType.Specific(accountKey)) }.invoke() - val items = - remember(state.items) { - state.items - .map { - it.map { - // TODO: get feed out of list - if (it.type == UiList.Type.Feed) { - Bluesky.FeedTabItem( - account = AccountType.Specific(profile.key), - uri = it.id, - metaData = - TabMetaData( - title = TitleType.Text(it.title), - icon = - IconType.Mixed( - icon = IconType.Material.MaterialIcon.List, - userKey = profile.key, - ), - ), - ) - } else { - ListTimelineTabItem( - account = AccountType.Specific(profile.key), - listId = it.id, - metaData = - TabMetaData( - title = TitleType.Text(it.title), - icon = - IconType.Mixed( - icon = IconType.Material.MaterialIcon.List, - userKey = profile.key, - ), - ), - ) - } + remember(state.items) { + state.items + .map { + it.map { + // TODO: get feed out of list + if (it.type == UiList.Type.Feed) { + Bluesky.FeedTabItem( + account = AccountType.Specific(accountKey), + uri = it.id, + metaData = + TabMetaData( + title = TitleType.Text(it.title), + icon = + IconType.Mixed( + icon = IconType.Material.MaterialIcon.List, + userKey = accountKey, + ), + ), + ) + } else { + ListTimelineTabItem( + account = AccountType.Specific(accountKey), + listId = it.id, + metaData = + TabMetaData( + title = TitleType.Text(it.title), + icon = + IconType.Mixed( + icon = IconType.Material.MaterialIcon.List, + userKey = accountKey, + ), + ), + ) } - }.flatMap( - onError = { - UiState.Success(persistentListOf()) - }, - ) { - UiState.Success(it) } - } - - object { - val items = - items.map { + }.flatMap( + onError = { + UiState.Success(persistentListOf()) + }, + ) { + UiState.Success(it) + }.map { it.toImmutableList() } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b7fe22252..b8b7d0331 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -411,4 +411,6 @@ Timeline Media + + Tab settings \ No newline at end of file