From 37d716f92ea51257cefb4b4433bafec83bb5d05e Mon Sep 17 00:00:00 2001 From: Tlaster Date: Thu, 26 Sep 2024 17:09:23 +0900 Subject: [PATCH] add notification badge support --- .../ui/component/NavigationSuiteScaffold2.kt | 381 ++++++---- .../flare/ui/screen/home/HomeScreen.kt | 716 ++++++++---------- .../ui/screen/home/HomeTimelineScreen.kt | 6 + .../flare/ui/screen/home/TabSettingScreen.kt | 2 +- .../datasource/mastodon/MastodonDataSource.kt | 17 + .../mastodon/NotificationRemoteMediator.kt | 7 + .../microblog/MicroblogDataSource.kt | 4 + .../network/mastodon/api/MastodonResources.kt | 14 + .../data/network/mastodon/api/model/Emoji.kt | 26 + .../home/NotificationBadgePresenter.kt | 55 ++ 10 files changed, 653 insertions(+), 575 deletions(-) create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/NotificationBadgePresenter.kt diff --git a/app/src/main/java/dev/dimension/flare/ui/component/NavigationSuiteScaffold2.kt b/app/src/main/java/dev/dimension/flare/ui/component/NavigationSuiteScaffold2.kt index 62f761bcd..6495be887 100644 --- a/app/src/main/java/dev/dimension/flare/ui/component/NavigationSuiteScaffold2.kt +++ b/app/src/main/java/dev/dimension/flare/ui/component/NavigationSuiteScaffold2.kt @@ -24,9 +24,13 @@ import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.BadgedBox +import androidx.compose.material3.DrawerState +import androidx.compose.material3.DrawerValue import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.NavigationDrawerItem @@ -47,6 +51,7 @@ import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffo import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScope import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType import androidx.compose.material3.contentColorFor +import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.State @@ -56,6 +61,7 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -70,6 +76,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import dev.dimension.flare.ui.component.status.FlareDividerDefaults +import kotlinx.coroutines.launch import kotlin.math.roundToInt private val bottomBarHeight = 56.dp @@ -82,6 +89,8 @@ fun NavigationSuiteScaffold2( navigationSuiteItems: NavigationSuiteScope2.() -> Unit, secondaryItems: NavigationSuiteScope2.() -> Unit, modifier: Modifier = Modifier, + drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed), + drawerGesturesEnabled: Boolean = true, layoutType: NavigationSuiteType = NavigationSuiteScaffoldDefaults.calculateFromAdaptiveInfo(currentWindowAdaptiveInfo()), navigationSuiteColors: NavigationSuiteColors = NavigationSuiteDefaults.colors(), @@ -92,6 +101,7 @@ fun NavigationSuiteScaffold2( footerItems: NavigationSuiteScope2.() -> Unit = {}, content: @Composable () -> Unit = {}, ) { + val coroutineScope = rememberCoroutineScope() val bottomBarHeightPx = with(LocalDensity.current) { val navigationBar = WindowInsets.navigationBars @@ -123,177 +133,159 @@ fun NavigationSuiteScaffold2( ) { val scope by rememberStateOfItems(navigationSuiteItems) val footerScope by rememberStateOfItems(footerItems) - Row { - AnimatedVisibility(layoutType == NavigationSuiteType.NavigationRail) { - NavigationRail( - header = railHeader, - containerColor = navigationSuiteColors.navigationRailContainerColor, - contentColor = navigationSuiteColors.navigationRailContentColor, - ) { - scope.itemList.forEach { - NavigationRailItem( - modifier = it.modifier, - selected = it.selected, - onClick = it.onClick, - icon = { NavigationItemIcon(icon = it.icon, badge = it.badge) }, - enabled = it.enabled, - label = it.label, - alwaysShowLabel = it.alwaysShowLabel, - colors = - it.colors?.navigationRailItemColors - ?: NavigationRailItemDefaults.colors(), - interactionSource = it.interactionSource, - ) - } - Spacer(modifier = Modifier.weight(1f)) - footerScope.itemList.forEach { - NavigationRailItem( - modifier = it.modifier, - selected = it.selected, - onClick = it.onClick, - icon = { NavigationItemIcon(icon = it.icon, badge = it.badge) }, - enabled = it.enabled, - label = it.label, - alwaysShowLabel = it.alwaysShowLabel, - colors = - it.colors?.navigationRailItemColors - ?: NavigationRailItemDefaults.colors(), - interactionSource = it.interactionSource, - ) - } - } - } - AnimatedVisibility(layoutType == NavigationSuiteType.NavigationDrawer) { - val secondaryScope by rememberStateOfItems(secondaryItems) - PermanentDrawerSheet( - modifier = - Modifier - .width(240.dp) - .padding(horizontal = 12.dp), + val secondaryScope by rememberStateOfItems(secondaryItems) + ModalNavigationDrawer( + drawerContent = { + ModalDrawerSheet( drawerContainerColor = navigationSuiteColors.navigationDrawerContainerColor, drawerContentColor = navigationSuiteColors.navigationDrawerContentColor, ) { - drawerHeader?.invoke(this) - drawerHeader?.let { - Spacer(modifier = Modifier.height(16.dp)) - } - scope.itemList.forEach { - NavigationDrawerItem( - modifier = it.modifier, - selected = it.selected, - onClick = it.onClick, - icon = it.icon, - badge = it.badge, - label = { it.label?.invoke() ?: Text("") }, - colors = - it.colors?.navigationDrawerItemColors - ?: NavigationDrawerItemDefaults.colors(), - interactionSource = it.interactionSource, - ) - } - if (secondaryScope.itemList.isNotEmpty()) { - HorizontalDivider() - } - secondaryScope.itemList.forEach { - NavigationDrawerItem( - modifier = it.modifier, - selected = it.selected, - onClick = it.onClick, - icon = it.icon, - badge = it.badge, - label = { it.label?.invoke() ?: Text("") }, - colors = - it.colors?.navigationDrawerItemColors - ?: NavigationDrawerItemDefaults.colors(), - interactionSource = it.interactionSource, - ) + DrawerContent( + drawerHeader = drawerHeader, + showPrimaryItems = layoutType != NavigationSuiteType.NavigationBar, + scope = scope, + secondaryScope = secondaryScope, + footerScope = footerScope, + onItemClicked = { + coroutineScope.launch { + drawerState.close() + } + }, + ) + } + }, + gesturesEnabled = layoutType != NavigationSuiteType.NavigationDrawer && drawerGesturesEnabled, + drawerState = drawerState, + ) { + Row { + AnimatedVisibility(layoutType == NavigationSuiteType.NavigationRail) { + NavigationRail( + header = railHeader, + containerColor = navigationSuiteColors.navigationRailContainerColor, + contentColor = navigationSuiteColors.navigationRailContentColor, + ) { + scope.itemList.forEach { + NavigationRailItem( + modifier = it.modifier, + selected = it.selected, + onClick = it.onClick, + icon = { NavigationItemIcon(icon = it.icon, badge = it.badge) }, + enabled = it.enabled, + label = it.label, + alwaysShowLabel = it.alwaysShowLabel, + colors = + it.colors?.navigationRailItemColors + ?: NavigationRailItemDefaults.colors(), + interactionSource = it.interactionSource, + ) + } + Spacer(modifier = Modifier.weight(1f)) + footerScope.itemList.forEach { + NavigationRailItem( + modifier = it.modifier, + selected = it.selected, + onClick = it.onClick, + icon = { NavigationItemIcon(icon = it.icon, badge = it.badge) }, + enabled = it.enabled, + label = it.label, + alwaysShowLabel = it.alwaysShowLabel, + colors = + it.colors?.navigationRailItemColors + ?: NavigationRailItemDefaults.colors(), + interactionSource = it.interactionSource, + ) + } } - Spacer(modifier = Modifier.weight(1f)) - footerScope.itemList.forEach { - NavigationDrawerItem( - modifier = it.modifier, - selected = it.selected, - onClick = it.onClick, - icon = it.icon, - badge = it.badge, - label = { it.label?.invoke() ?: Text("") }, - colors = - it.colors?.navigationDrawerItemColors - ?: NavigationDrawerItemDefaults.colors(), - interactionSource = it.interactionSource, + } + AnimatedVisibility(layoutType == NavigationSuiteType.NavigationDrawer) { + PermanentDrawerSheet( + modifier = + Modifier + .width(240.dp) + .padding(horizontal = 12.dp), + drawerContainerColor = navigationSuiteColors.navigationDrawerContainerColor, + drawerContentColor = navigationSuiteColors.navigationDrawerContentColor, + ) { + DrawerContent( + drawerHeader = drawerHeader, + showPrimaryItems = true, + scope = scope, + secondaryScope = secondaryScope, + footerScope = footerScope, + onItemClicked = {}, ) } } - } - Box { - CompositionLocalProvider( - LocalBottomBarHeight provides - if (layoutType == NavigationSuiteType.NavigationBar) { - bottomBarHeight - } else { - 0.dp - }, - ) { - content.invoke() - } - androidx.compose.animation.AnimatedVisibility( - layoutType == NavigationSuiteType.NavigationBar, - enter = slideInVertically { it }, - exit = slideOutVertically { it }, - modifier = - Modifier - .align(Alignment.BottomCenter) - .offset { IntOffset(x = 0, y = -bottomBarOffsetHeightPx.roundToInt()) }, - ) { - Surface( -// color = navigationSuiteColors.navigationBarContainerColor, - contentColor = navigationSuiteColors.navigationBarContentColor, + Box { + CompositionLocalProvider( + LocalBottomBarHeight provides + if (layoutType == NavigationSuiteType.NavigationBar) { + bottomBarHeight + } else { + 0.dp + }, ) { - Box { - HorizontalDivider( - modifier = - Modifier - .align(Alignment.TopCenter) - .fillMaxWidth(), - color = FlareDividerDefaults.color, - thickness = FlareDividerDefaults.thickness, - ) - Row( - modifier = - Modifier - .fillMaxWidth() - .windowInsetsPadding( - WindowInsets.systemBars.only( - WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom, + content.invoke() + } + androidx.compose.animation.AnimatedVisibility( + layoutType == NavigationSuiteType.NavigationBar, + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + modifier = + Modifier + .align(Alignment.BottomCenter) + .offset { IntOffset(x = 0, y = -bottomBarOffsetHeightPx.roundToInt()) }, + ) { + Surface( +// color = navigationSuiteColors.navigationBarContainerColor, + contentColor = navigationSuiteColors.navigationBarContentColor, + ) { + Box { + HorizontalDivider( + modifier = + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth(), + color = FlareDividerDefaults.color, + thickness = FlareDividerDefaults.thickness, + ) + Row( + modifier = + Modifier + .fillMaxWidth() + .windowInsetsPadding( + WindowInsets.systemBars.only( + WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom, + ), ), - ), - verticalAlignment = Alignment.CenterVertically, - ) { - scope.itemList.forEach { - Box( - modifier = - Modifier - .weight(1f) - .clickable { - it.onClick() - }.height(bottomBarHeight), - contentAlignment = Alignment.Center, - ) { - val colors = it.colors?.navigationBarItemColors ?: NavigationBarItemDefaults.colors() - val color = - with(colors) { - when { - !it.enabled -> disabledIconColor - it.selected -> MaterialTheme.colorScheme.primary - else -> unselectedIconColor + verticalAlignment = Alignment.CenterVertically, + ) { + scope.itemList.forEach { + Box( + modifier = + Modifier + .weight(1f) + .clickable { + it.onClick() + }.height(bottomBarHeight), + contentAlignment = Alignment.Center, + ) { + val colors = it.colors?.navigationBarItemColors ?: NavigationBarItemDefaults.colors() + val color = + with(colors) { + when { + !it.enabled -> disabledIconColor + it.selected -> MaterialTheme.colorScheme.primary + else -> unselectedIconColor + } } + val iconColor by animateColorAsState( + targetValue = color, + animationSpec = tween(100), + ) + CompositionLocalProvider(LocalContentColor provides iconColor) { + NavigationItemIcon(icon = it.icon, badge = it.badge) } - val iconColor by animateColorAsState( - targetValue = color, - animationSpec = tween(100), - ) - CompositionLocalProvider(LocalContentColor provides iconColor) { - NavigationItemIcon(icon = it.icon, badge = it.badge) } } } @@ -306,6 +298,75 @@ fun NavigationSuiteScaffold2( } } +@Composable +private fun ColumnScope.DrawerContent( + drawerHeader: @Composable (ColumnScope.() -> Unit)?, + onItemClicked: () -> Unit, + showPrimaryItems: Boolean, + scope: NavigationSuiteItemProvider, + secondaryScope: NavigationSuiteItemProvider, + footerScope: NavigationSuiteItemProvider, +) { + drawerHeader?.invoke(this) + if (showPrimaryItems) { + scope.itemList.forEach { + NavigationDrawerItem( + modifier = it.modifier, + selected = it.selected, + onClick = { + it.onClick() + onItemClicked() + }, + icon = it.icon, + badge = it.badge, + label = { it.label?.invoke() ?: Text("") }, + colors = + it.colors?.navigationDrawerItemColors + ?: NavigationDrawerItemDefaults.colors(), + interactionSource = it.interactionSource, + ) + } + } + if (secondaryScope.itemList.isNotEmpty()) { + HorizontalDivider() + } + secondaryScope.itemList.forEach { + NavigationDrawerItem( + modifier = it.modifier, + selected = it.selected, + onClick = { + it.onClick() + onItemClicked() + }, + icon = it.icon, + badge = it.badge, + label = { it.label?.invoke() ?: Text("") }, + colors = + it.colors?.navigationDrawerItemColors + ?: NavigationDrawerItemDefaults.colors(), + interactionSource = it.interactionSource, + ) + } + Spacer(modifier = Modifier.weight(1f)) + footerScope.itemList.forEach { + NavigationDrawerItem( + modifier = it.modifier, + selected = it.selected, + onClick = { + it.onClick() + onItemClicked() + }, + icon = it.icon, + badge = it.badge, + label = { it.label?.invoke() ?: Text("") }, + colors = + it.colors?.navigationDrawerItemColors + ?: NavigationDrawerItemDefaults.colors(), + interactionSource = it.interactionSource, + ) + } +} + private interface NavigationSuiteItemProvider { val itemsCount: Int val itemList: MutableVector 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 c456eecbd..f34ed4621 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 @@ -16,21 +16,16 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Badge import androidx.compose.material3.Button import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButtonDefaults -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.ModalDrawerSheet -import androidx.compose.material3.ModalNavigationDrawer -import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo @@ -112,9 +107,11 @@ import dev.dimension.flare.ui.model.UiState import dev.dimension.flare.ui.model.collectAsUiState import dev.dimension.flare.ui.model.flatMap import dev.dimension.flare.ui.model.isSuccess +import dev.dimension.flare.ui.model.map import dev.dimension.flare.ui.model.onLoading import dev.dimension.flare.ui.model.onSuccess import dev.dimension.flare.ui.presenter.home.ActiveAccountPresenter +import dev.dimension.flare.ui.presenter.home.NotificationBadgePresenter import dev.dimension.flare.ui.presenter.home.UserPresenter import dev.dimension.flare.ui.presenter.home.UserState import dev.dimension.flare.ui.presenter.invoke @@ -183,398 +180,264 @@ internal fun HomeScreen( ) val actualLayoutType = state.navigationState.type ?: layoutType FlareTheme { - ModalNavigationDrawer( - drawerState = drawerState, - drawerContent = { - ModalDrawerSheet( - content = { - Column( - modifier = - Modifier -// .padding(horizontal = 12.dp) - .weight(1f) - .verticalScroll(rememberScrollState()), - ) { - DrawerHeader( - accountTypeState = accountTypeState, - currentTab = currentTab, -// navController = navController, - showFab = layoutType != NavigationSuiteType.NavigationBar, - toAccoutSwitcher = { - state.setShowAccountSelection(true) - }, - toCompose = { - navController.toDestinationsNavigator().navigate( - direction = - ComposeRouteDestination( - it, - ), - ) - }, - toProfile = { - state.tabs.onSuccess { - val key = it.extraProfileRoute?.tabItem?.key - if (key != null) { - navController.navigate(key) { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - } - launchSingleTop = true - restoreState = true - } - scope.launch { - drawerState.close() - } - } + NavigationSuiteScaffold2( + layoutType = actualLayoutType, + modifier = modifier, + drawerHeader = { + DrawerHeader( + accountTypeState, + currentTab, + toAccoutSwitcher = { + state.setShowAccountSelection(true) + }, + showFab = actualLayoutType == NavigationSuiteType.NavigationDrawer, + toCompose = { + navController.toDestinationsNavigator().navigate( + direction = + ComposeRouteDestination( + it, + ), + ) + }, + toProfile = { + state.tabs.onSuccess { + val key = it.extraProfileRoute?.tabItem?.key + if (key != null) { + navController.navigate(key) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true } - }, - ) - if (layoutType != NavigationSuiteType.NavigationBar) { - tabs.primary.forEach { (tab, tabState) -> - NavigationDrawerItem( - selected = currentRoute == tab.key, - onClick = { - if (currentRoute == tab.key) { - tabState.onClick() - } else { - navController.navigate(tab.key) { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - } - launchSingleTop = true - restoreState = true - } - } - scope.launch { - drawerState.close() - } - }, - icon = { - TabIcon( - accountType = tab.account, - icon = tab.metaData.icon, - title = tab.metaData.title, - ) - }, - label = { - TabTitle( - title = tab.metaData.title, - ) - }, - ) + launchSingleTop = true + restoreState = true } - } - if (tabs.secondary.isNotEmpty()) { - HorizontalDivider() - } - tabs.secondary.forEach { (tab, tabState) -> - NavigationDrawerItem( - selected = currentRoute == tab.key, - onClick = { - if (currentRoute == tab.key) { - tabState.onClick() - } else { - navController.navigate(tab.key) { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - } - launchSingleTop = true - restoreState = true - } - } - scope.launch { - drawerState.close() - } - }, - icon = { - TabIcon( - accountType = tab.account, - icon = tab.metaData.icon, - title = tab.metaData.title, - iconOnly = tabs.secondaryIconOnly, - ) - }, - label = { - TabTitle( - title = tab.metaData.title, - ) - }, - ) - } - } - NavigationDrawerItem( - label = { - Text(stringResource(R.string.settings_title)) - }, - selected = currentRoute == SettingsRouteDestination.route, - onClick = { - navController - .navigate(SettingsRouteDestination.route) { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - } - launchSingleTop = true - restoreState = true - } scope.launch { drawerState.close() } - }, - icon = { - FAIcon( - FontAwesomeIcons.Solid.Gear, - contentDescription = stringResource(R.string.settings_title), - ) - }, - ) + } + } }, ) }, - gesturesEnabled = - state.navigationState.drawerEnabled && - actualLayoutType != NavigationSuiteType.NavigationDrawer && - accountTypeState.user.isSuccess, - ) { - NavigationSuiteScaffold2( - layoutType = actualLayoutType, - modifier = modifier, - drawerHeader = { - DrawerHeader( - accountTypeState, - currentTab, - toAccoutSwitcher = { - state.setShowAccountSelection(true) + railHeader = { + accountTypeState.user.onSuccess { + IconButton( + onClick = { + scope.launch { + drawerState.open() + } }, - toCompose = { - navController.toDestinationsNavigator().navigate( - direction = - ComposeRouteDestination( - it, - ), + ) { + AvatarComponent(it.avatar) + } + FloatingActionButton( + onClick = { + currentTab?.let { + navController.toDestinationsNavigator().navigate( + direction = + ComposeRouteDestination( + it.account, + ), + ) + } + }, + elevation = + FloatingActionButtonDefaults.elevation( + defaultElevation = 0.dp, + ), + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Pen, + contentDescription = stringResource(id = R.string.compose_title), + ) + } + } + }, + navigationSuiteItems = { + tabs.primary.forEach { (tab, tabState, badgeState) -> + item( + selected = currentRoute == tab.key, + onClick = { + if (currentRoute == tab.key) { + tabState.onClick() + } else { + navController.navigate(tab.key) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + }, + icon = { + TabIcon( + accountType = tab.account, + icon = tab.metaData.icon, + title = tab.metaData.title, ) }, - toProfile = { - state.tabs.onSuccess { - val key = it.extraProfileRoute?.tabItem?.key - if (key != null) { - navController.navigate(key) { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true + label = { + TabTitle( + title = tab.metaData.title, + ) + }, + badge = + if (badgeState.isSuccess) { + { + badgeState.onSuccess { + if (it > 0) { + Badge { + Text(text = it.toString()) + } } - launchSingleTop = true - restoreState = true } - scope.launch { - drawerState.close() + } + } else { + null + }, + ) + } + }, + secondaryItems = { + tabs.secondary.forEach { (tab, tabState) -> + item( + selected = currentRoute == tab.key, + onClick = { + if (currentRoute == tab.key) { + tabState.onClick() + } else { + navController.navigate(tab.key) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true } + launchSingleTop = true + restoreState = true } } }, + icon = { + TabIcon( + accountType = tab.account, + icon = tab.metaData.icon, + title = tab.metaData.title, + iconOnly = tabs.secondaryIconOnly, + ) + }, + label = { + TabTitle( + title = tab.metaData.title, + ) + }, ) - }, - railHeader = { - accountTypeState.user.onSuccess { - IconButton( - onClick = { - scope.launch { - drawerState.open() - } - }, - ) { - AvatarComponent(it.avatar) - } - FloatingActionButton( - onClick = { - currentTab?.let { - navController.toDestinationsNavigator().navigate( - direction = - ComposeRouteDestination( - it.account, - ), - ) + } + }, + footerItems = { + accountTypeState.user.onSuccess { + item( + selected = currentRoute == SettingsRouteDestination.route, + onClick = { + navController + .navigate(SettingsRouteDestination.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true } - }, - elevation = - FloatingActionButtonDefaults.elevation( - defaultElevation = 0.dp, - ), - ) { + }, + icon = { FAIcon( - imageVector = FontAwesomeIcons.Solid.Pen, - contentDescription = stringResource(id = R.string.compose_title), + imageVector = FontAwesomeIcons.Solid.Gear, + contentDescription = stringResource(id = R.string.settings_title), ) - } - } + }, + label = { + Text(text = stringResource(id = R.string.settings_title)) + }, + ) + } + }, + drawerGesturesEnabled = + state.navigationState.drawerEnabled && + accountTypeState.user.isSuccess, + drawerState = drawerState, + ) { + val slideDistance = rememberSlideDistance() + NavHost( + navController = navController, + startDestination = + tabs.primary + .first() + .tabItem.key, + enterTransition = { + materialSharedAxisYIn(true, slideDistance) }, - navigationSuiteItems = { - tabs.primary.forEach { (tab, tabState) -> - item( - selected = currentRoute == tab.key, - onClick = { - if (currentRoute == tab.key) { - tabState.onClick() - } else { - navController.navigate(tab.key) { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - } - launchSingleTop = true - restoreState = true - } - } - }, - icon = { - TabIcon( - accountType = tab.account, - icon = tab.metaData.icon, - title = tab.metaData.title, - ) - }, - label = { - TabTitle( - title = tab.metaData.title, - ) - }, - ) - } + exitTransition = { + materialSharedAxisYOut(true, slideDistance) }, - secondaryItems = { - tabs.secondary.forEach { (tab, tabState) -> - item( - selected = currentRoute == tab.key, - onClick = { - if (currentRoute == tab.key) { - tabState.onClick() - } else { - navController.navigate(tab.key) { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - } - launchSingleTop = true - restoreState = true - } - } - }, - icon = { - TabIcon( - accountType = tab.account, - icon = tab.metaData.icon, - title = tab.metaData.title, - iconOnly = tabs.secondaryIconOnly, - ) - }, - label = { - TabTitle( - title = tab.metaData.title, - ) - }, - ) - } + popEnterTransition = { + materialSharedAxisYIn(true, slideDistance) }, - footerItems = { - accountTypeState.user.onSuccess { - item( - selected = currentRoute == SettingsRouteDestination.route, - onClick = { - navController - .navigate(SettingsRouteDestination.route) { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - } - launchSingleTop = true - restoreState = true - } - }, - icon = { - FAIcon( - imageVector = FontAwesomeIcons.Solid.Gear, - contentDescription = stringResource(id = R.string.settings_title), - ) - }, - label = { - Text(text = stringResource(id = R.string.settings_title)) - }, - ) - } + popExitTransition = { + materialSharedAxisYOut(true, slideDistance) }, ) { - val slideDistance = rememberSlideDistance() - NavHost( - navController = navController, - startDestination = - tabs.primary - .first() - .tabItem.key, - enterTransition = { - materialSharedAxisYIn(true, slideDistance) - }, - exitTransition = { - materialSharedAxisYOut(true, slideDistance) - }, - popEnterTransition = { - materialSharedAxisYIn(true, slideDistance) - }, - popExitTransition = { - materialSharedAxisYOut(true, slideDistance) - }, - ) { - tabs.all.forEach { (tab, tabState) -> - composable(tab.key) { - CompositionLocalProvider( - LocalTabState provides tabState, + tabs.all.forEach { (tab, tabState) -> + composable(tab.key) { + CompositionLocalProvider( + LocalTabState provides tabState, + ) { + Router( + modifier = Modifier.fillMaxSize(), + navGraph = NavGraphs.root, + direction = TabSplashScreenDestination, ) { - Router( - modifier = Modifier.fillMaxSize(), - navGraph = NavGraphs.root, - direction = TabSplashScreenDestination, - ) { - dependency(rootNavController) - dependency( - SplashScreenArgs( - getDirection( - tab, - tab.account, - ), + dependency(rootNavController) + dependency( + SplashScreenArgs( + getDirection( + tab, + tab.account, ), - ) + ), + ) // dependency(tabState) - dependency(drawerState) - dependency(state.navigationState) - } + dependency(drawerState) + dependency(state.navigationState) } } } - composable(SettingsRouteDestination) { - Router( - modifier = Modifier.fillMaxSize(), - navGraph = NavGraphs.root, - direction = SettingsRouteDestination, - ) { - dependency(rootNavController) - dependency(drawerState) - dependency(state.navigationState) - } + } + composable(SettingsRouteDestination) { + Router( + modifier = Modifier.fillMaxSize(), + navGraph = NavGraphs.root, + direction = SettingsRouteDestination, + ) { + dependency(rootNavController) + dependency(drawerState) + dependency(state.navigationState) } - dialogComposable(ComposeRouteDestination) { - ComposeRoute( - navigator = destinationsNavigator(navController), - accountType = navArgs.accountType, + } + dialogComposable(ComposeRouteDestination) { + ComposeRoute( + navigator = destinationsNavigator(navController), + accountType = navArgs.accountType, + ) + } + composable(ServiceSelectRouteDestination) { + Router( + modifier = Modifier.fillMaxSize(), + navGraph = NavGraphs.root, + direction = TabSplashScreenDestination, + ) { + dependency(rootNavController) + dependency( + SplashScreenArgs(ServiceSelectRouteDestination), ) - } - composable(ServiceSelectRouteDestination) { - Router( - modifier = Modifier.fillMaxSize(), - navGraph = NavGraphs.root, - direction = TabSplashScreenDestination, - ) { - dependency(rootNavController) - dependency( - SplashScreenArgs(ServiceSelectRouteDestination), - ) // dependency(tabState) - dependency(drawerState) - dependency(state.navigationState) - } + dependency(drawerState) + dependency(state.navigationState) } } } @@ -843,37 +706,67 @@ private fun presenter(settingsRepository: SettingsRepository = koinInject()) = NavigationState() } val tabs = - account.user.flatMap( - onError = { - UiState.Success( - HomeTabState( - primary = - TimelineTabItem.guest - .map { - HomeTabItem(it) - }.toImmutableList(), - secondary = persistentListOf(), - extraProfileRoute = null, - secondaryIconOnly = true, - ), - ) - }, - ) { user -> - settingsRepository.tabSettings.collectAsUiState().value.flatMap( + account.user + .flatMap( onError = { UiState.Success( HomeTabState( primary = - TimelineTabItem.default + TimelineTabItem.guest .map { HomeTabItem(it) }.toImmutableList(), - secondary = - TimelineTabItem - .defaultSecondary(user) + secondary = persistentListOf(), + extraProfileRoute = null, + secondaryIconOnly = true, + ), + ) + }, + ) { user -> + settingsRepository.tabSettings.collectAsUiState().value.flatMap( + onError = { + UiState.Success( + HomeTabState( + primary = + TimelineTabItem.default + .map { + HomeTabItem(it) + }.toImmutableList(), + secondary = + TimelineTabItem + .defaultSecondary(user) + .map { + HomeTabItem(it) + }.toImmutableList(), + extraProfileRoute = + HomeTabItem( + tabItem = + ProfileTabItem( + accountKey = user.key, + userKey = user.key, + ), + ), + secondaryIconOnly = true, + ), + ) + }, + ) { tabSettings -> + val secondary = + tabSettings.secondaryItems ?: TimelineTabItem.defaultSecondary(user) + UiState.Success( + HomeTabState( + primary = + tabSettings.items .map { HomeTabItem(it) }.toImmutableList(), + secondary = + secondary + .filter { + tabSettings.items.none { item -> item.key == it.key } + }.map { + HomeTabItem(it) + }.toImmutableList(), extraProfileRoute = HomeTabItem( tabItem = @@ -882,40 +775,25 @@ private fun presenter(settingsRepository: SettingsRepository = koinInject()) = userKey = user.key, ), ), - secondaryIconOnly = true, + secondaryIconOnly = tabSettings.secondaryItems == null, ), ) - }, - ) { tabSettings -> - val secondary = - tabSettings.secondaryItems ?: TimelineTabItem.defaultSecondary(user) - UiState.Success( - HomeTabState( - primary = - tabSettings.items - .map { - HomeTabItem(it) - }.toImmutableList(), - secondary = - secondary - .filter { - tabSettings.items.none { item -> item.key == it.key } - }.map { - HomeTabItem(it) - }.toImmutableList(), - extraProfileRoute = - HomeTabItem( - tabItem = - ProfileTabItem( - accountKey = user.key, - userKey = user.key, - ), - ), - secondaryIconOnly = tabSettings.secondaryItems == null, - ), + } + }.map { + it.copy( + primary = + it.primary + .map { + if (it.tabItem is NotificationTabItem) { + it.copy( + badgeCountState = notificationBadgePresenter(it.tabItem.account), + ) + } else { + it + } + }.toImmutableList(), ) } - } var showAccountSelection by remember { mutableStateOf(false) } @@ -935,6 +813,15 @@ private fun presenter(settingsRepository: SettingsRepository = koinInject()) = } } +@Composable +private fun notificationBadgePresenter(accountType: AccountType): UiState { + val presenter = + remember(accountType) { + NotificationBadgePresenter(accountType) + }.invoke() + return presenter.count +} + @Composable private fun accountTypePresenter(accountType: AccountType) = run { @@ -945,6 +832,7 @@ private fun accountTypePresenter(accountType: AccountType) = private data class HomeTabItem( val tabItem: TabItem, val tabState: TabState = TabState(), + val badgeCountState: UiState = UiState.Success(0), ) @Immutable 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 68d8e528f..5f503e1b7 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 @@ -76,6 +76,7 @@ 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.NotificationBadgePresenter import dev.dimension.flare.ui.presenter.home.UserPresenter import dev.dimension.flare.ui.presenter.home.UserState import dev.dimension.flare.ui.presenter.invoke @@ -354,6 +355,10 @@ internal fun timelineItemPresenter(timelineTabItem: TimelineTabItem): TimelineIt remember(timelineTabItem) { timelineTabItem.createPresenter() } + val badge = + remember(timelineTabItem) { + NotificationBadgePresenter(timelineTabItem.account) + }.invoke() val scope = rememberCoroutineScope() val state = timelinePresenter() var showNewToots by remember { mutableStateOf(false) } @@ -399,6 +404,7 @@ internal fun timelineItemPresenter(timelineTabItem: TimelineTabItem): TimelineIt scope.launch { state.refresh() } + badge.refresh() } } } 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 index adcaa61d8..19276a292 100644 --- 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 @@ -324,7 +324,7 @@ private fun presenter( it .filterIsInstance() .filter { tab -> - tab !in cacheTabs + cacheTabs.none { it.key == tab.key } }.toImmutableList() } var showAddTab by remember { mutableStateOf(false) } 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 13940cea3..f3ff3dccc 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 @@ -231,6 +231,9 @@ class MastodonDataSource( database, account.accountKey, pagingKey, + onClearMarker = { + MemCacheable.update(notificationMarkerKey, 0) + }, ) NotificationFilter.Mention -> @@ -1147,4 +1150,18 @@ class MastodonDataSource( ) { updateList(listId, metaData.title) } + + private val notificationMarkerKey: String + get() = "notificationBadgeCount_${account.accountKey}" + + override fun notificationBadgeCount(): CacheData { + return MemCacheable( + key = notificationMarkerKey, + fetchSource = { + val marker = service.notificationMarkers().notifications?.lastReadID ?: return@MemCacheable 0 + val timeline = service.notification(min_id = marker) + timeline.size + }, + ) + } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/NotificationRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/NotificationRemoteMediator.kt index 0a7871024..0a7039bee 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/NotificationRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/NotificationRemoteMediator.kt @@ -8,6 +8,8 @@ import dev.dimension.flare.data.database.cache.CacheDatabase import dev.dimension.flare.data.database.cache.mapper.Mastodon import dev.dimension.flare.data.database.cache.model.DbPagingTimelineView import dev.dimension.flare.data.network.mastodon.MastodonService +import dev.dimension.flare.data.network.mastodon.api.model.MarkerUpdate +import dev.dimension.flare.data.network.mastodon.api.model.UpdateContent import dev.dimension.flare.model.MicroBlogKey @OptIn(ExperimentalPagingApi::class) @@ -16,6 +18,7 @@ internal class NotificationRemoteMediator( private val database: CacheDatabase, private val accountKey: MicroBlogKey, private val pagingKey: String, + private val onClearMarker: () -> Unit, ) : RemoteMediator() { override suspend fun load( loadType: LoadType, @@ -30,6 +33,10 @@ internal class NotificationRemoteMediator( limit = state.config.pageSize, ).also { database.pagingTimelineDao().delete(pagingKey = pagingKey, accountKey = accountKey) + it.firstOrNull()?.id?.let { it1 -> + service.updateMarker(MarkerUpdate(notifications = UpdateContent(it1))) + onClearMarker.invoke() + } } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/MicroblogDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/MicroblogDataSource.kt index be06c2d4f..d7361a3c5 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/MicroblogDataSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/MicroblogDataSource.kt @@ -2,6 +2,7 @@ package dev.dimension.flare.data.datasource.microblog import androidx.paging.PagingData import dev.dimension.flare.common.CacheData +import dev.dimension.flare.common.Cacheable import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiAccount import dev.dimension.flare.ui.model.UiHashtag @@ -12,6 +13,7 @@ import dev.dimension.flare.ui.model.UiTimeline import dev.dimension.flare.ui.model.UiUserV2 import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf interface MicroblogDataSource { val account: UiAccount @@ -92,6 +94,8 @@ interface MicroblogDataSource { userKey: MicroBlogKey, relation: UiRelation, ) + + fun notificationBadgeCount(): CacheData = Cacheable({ }, { flowOf(0) }) } data class ComposeProgress( diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/mastodon/api/MastodonResources.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/mastodon/api/MastodonResources.kt index ae8a24792..475890fe4 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/mastodon/api/MastodonResources.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/mastodon/api/MastodonResources.kt @@ -1,9 +1,23 @@ package dev.dimension.flare.data.network.mastodon.api +import de.jensklingenberg.ktorfit.http.Body import de.jensklingenberg.ktorfit.http.GET +import de.jensklingenberg.ktorfit.http.Header +import de.jensklingenberg.ktorfit.http.POST import dev.dimension.flare.data.network.mastodon.api.model.Emoji +import dev.dimension.flare.data.network.mastodon.api.model.Marker +import dev.dimension.flare.data.network.mastodon.api.model.MarkerUpdate internal interface MastodonResources { @GET("api/v1/custom_emojis") suspend fun emojis(): List + + @GET("api/v1/markers?timeline[]=notifications") + suspend fun notificationMarkers(): Marker + + @POST("api/v1/markers") + suspend fun updateMarker( + @Body data: MarkerUpdate, + @Header("Content-Type") contentType: String = "application/json", + ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/mastodon/api/model/Emoji.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/mastodon/api/model/Emoji.kt index 95fe5a97f..793bd740c 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/mastodon/api/model/Emoji.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/mastodon/api/model/Emoji.kt @@ -13,3 +13,29 @@ internal data class Emoji( val visibleInPicker: Boolean? = null, val category: String? = null, ) + +@Serializable +data class Marker( + val notifications: Notifications? = null, +) + +@Serializable +data class Notifications( + @SerialName("last_read_id") + val lastReadID: String? = null, + val version: Long? = null, + @SerialName("updated_at") + val updatedAt: String? = null, +) + +@Serializable +data class MarkerUpdate( + val home: UpdateContent? = null, + val notifications: UpdateContent? = null, +) + +@Serializable +data class UpdateContent( + @SerialName("last_read_id") + val lastReadID: String? = null, +) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/NotificationBadgePresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/NotificationBadgePresenter.kt new file mode 100644 index 000000000..f54291cdf --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/NotificationBadgePresenter.kt @@ -0,0 +1,55 @@ +package dev.dimension.flare.ui.presenter.home + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import dev.dimension.flare.common.collectAsState +import dev.dimension.flare.data.repository.accountServiceProvider +import dev.dimension.flare.model.AccountType +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.onSuccess +import dev.dimension.flare.ui.model.toUi +import dev.dimension.flare.ui.presenter.PresenterBase +import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.minutes + +class NotificationBadgePresenter( + private val accountType: AccountType, + private val autoRefresh: Boolean = true, +) : PresenterBase() { + @Composable + override fun body(): NotificationBadgeState { + val serviceState = accountServiceProvider(accountType = accountType) + val countState = + serviceState.map { service -> + remember(service) { + service.notificationBadgeCount() + }.collectAsState() + } + if (autoRefresh) { + countState.onSuccess { + LaunchedEffect(Unit) { + delay(1.minutes) + it.refresh() + } + } + } + return object : NotificationBadgeState { + override val count: UiState = countState.flatMap { it.toUi() } + + override fun refresh() { + countState.onSuccess { + it.refresh() + } + } + } + } +} + +interface NotificationBadgeState { + val count: UiState + + fun refresh() +}