diff --git a/app/src/main/java/dev/dimension/flare/MainActivity.kt b/app/src/main/java/dev/dimension/flare/MainActivity.kt index 2b5d570e0..08825d1cb 100644 --- a/app/src/main/java/dev/dimension/flare/MainActivity.kt +++ b/app/src/main/java/dev/dimension/flare/MainActivity.kt @@ -14,7 +14,6 @@ class MainActivity : ComponentActivity() { installSplashScreen().setKeepOnScreenCondition { keepSplashOnScreen } enableEdgeToEdge() super.onCreate(savedInstanceState) - android.webkit.WebView.setWebContentsDebuggingEnabled(true) setContent { AppContainer( afterInit = { diff --git a/app/src/main/java/dev/dimension/flare/common/ComposeInAppNotification.kt b/app/src/main/java/dev/dimension/flare/common/ComposeInAppNotification.kt new file mode 100644 index 000000000..6c1a82ece --- /dev/null +++ b/app/src/main/java/dev/dimension/flare/common/ComposeInAppNotification.kt @@ -0,0 +1,54 @@ +package dev.dimension.flare.common + +import androidx.annotation.StringRes +import dev.dimension.flare.R +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +internal sealed interface Notification { + data class Progress( + val progress: Int, + val total: Int, + ) : Notification { + val percentage: Float + get() = progress.toFloat() / total + } + + data class StringNotification( + @StringRes val messageId: Int, + val success: Boolean, + ) : Notification +} + +internal class ComposeInAppNotification : InAppNotification { + private val _source = MutableStateFlow(Event(null, initialHandled = true)) + val source + get() = _source.asStateFlow() + + override fun onProgress( + message: Message, + progress: Int, + total: Int, + ) { + _source.value = Event(Notification.Progress(progress, total)) + } + + override fun onSuccess(message: Message) { + val messageId = + when (message) { + Message.Compose -> R.string.compose_notification_success_title + } + _source.value = Event(Notification.StringNotification(messageId, success = true)) + } + + override fun onError( + message: Message, + throwable: Throwable, + ) { + val messageId = + when (message) { + Message.Compose -> R.string.compose_notification_error_title + } + _source.value = Event(Notification.StringNotification(messageId, success = false)) + } +} diff --git a/app/src/main/java/dev/dimension/flare/common/Event.kt b/app/src/main/java/dev/dimension/flare/common/Event.kt new file mode 100644 index 000000000..72a31897e --- /dev/null +++ b/app/src/main/java/dev/dimension/flare/common/Event.kt @@ -0,0 +1,21 @@ +package dev.dimension.flare.common + +class Event( + private val content: T?, + initialHandled: Boolean = false, +) { + @Suppress("MemberVisibilityCanBePrivate") + var hasBeenHandled = initialHandled + private set // Allow external read but not write + + /** + * Returns the content and prevents its use again. + */ + fun getContentIfNotHandled(): T? = + if (hasBeenHandled) { + null + } else { + hasBeenHandled = true + content + } +} diff --git a/app/src/main/java/dev/dimension/flare/di/AndroidModule.kt b/app/src/main/java/dev/dimension/flare/di/AndroidModule.kt index 514857d85..22a27c689 100644 --- a/app/src/main/java/dev/dimension/flare/di/AndroidModule.kt +++ b/app/src/main/java/dev/dimension/flare/di/AndroidModule.kt @@ -1,11 +1,14 @@ package dev.dimension.flare.di import androidx.media3.common.util.UnstableApi +import dev.dimension.flare.common.ComposeInAppNotification +import dev.dimension.flare.common.InAppNotification import dev.dimension.flare.common.PlayerPoll import dev.dimension.flare.data.repository.ComposeNotifyUseCase import dev.dimension.flare.data.repository.SettingsRepository import dev.dimension.flare.ui.component.VideoPlayerPool import org.koin.core.module.dsl.singleOf +import org.koin.dsl.binds import org.koin.dsl.module @UnstableApi @@ -15,4 +18,5 @@ val androidModule = singleOf(::SettingsRepository) singleOf(::PlayerPoll) singleOf(::VideoPlayerPool) + singleOf(::ComposeInAppNotification) binds arrayOf(InAppNotification::class, ComposeInAppNotification::class) } diff --git a/app/src/main/java/dev/dimension/flare/ui/component/InAppNotificationComponent.kt b/app/src/main/java/dev/dimension/flare/ui/component/InAppNotificationComponent.kt new file mode 100644 index 000000000..a1e6d0f5d --- /dev/null +++ b/app/src/main/java/dev/dimension/flare/ui/component/InAppNotificationComponent.kt @@ -0,0 +1,116 @@ +package dev.dimension.flare.ui.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.res.stringResource +import androidx.compose.ui.unit.dp +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.CircleCheck +import compose.icons.fontawesomeicons.solid.CircleExclamation +import dev.dimension.flare.common.ComposeInAppNotification +import dev.dimension.flare.common.Notification +import kotlinx.coroutines.delay +import org.koin.compose.koinInject +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +@Composable +internal fun InAppNotificationComponent( + modifier: Modifier = Modifier, + notification: ComposeInAppNotification = koinInject(), +) { + val source by notification.source.collectAsState() + val content = remember(source) { source.getContentIfNotHandled() } + + content?.let { + when (it) { + is Notification.Progress -> { + LinearProgressIndicator( + progress = { it.percentage }, + modifier = + modifier + .fillMaxWidth(), + ) + } + is Notification.StringNotification -> { + var showNotification by remember { mutableStateOf(false) } + var showText by remember { mutableStateOf(false) } + LaunchedEffect(source) { + showNotification = true + delay(500.milliseconds) + showText = true + delay(3.seconds) + showNotification = false + } + AnimatedVisibility( + showNotification, + modifier = + modifier + .systemBarsPadding(), + enter = fadeIn() + slideInVertically(), + exit = fadeOut() + slideOutVertically(), + ) { + Surface( + shape = MaterialTheme.shapes.large, + shadowElevation = 8.dp, + tonalElevation = 8.dp, + ) { + Row( + modifier = + Modifier + .padding(12.dp) + .animateContentSize(), + verticalAlignment = Alignment.CenterVertically, + ) { + if (it.success) { + FAIcon( + FontAwesomeIcons.Solid.CircleCheck, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } else { + FAIcon( + FontAwesomeIcons.Solid.CircleExclamation, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + } + AnimatedVisibility(showText) { + Row { + Spacer(modifier = Modifier.width(12.dp)) + Text( + stringResource(it.messageId), + ) + } + } + } + } + } + } + } + } +} 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 8383ae8f8..b0af365fe 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 @@ -7,6 +7,7 @@ import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row @@ -104,6 +105,7 @@ 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.HtmlText +import dev.dimension.flare.ui.component.InAppNotificationComponent import dev.dimension.flare.ui.component.NavigationSuiteScaffold2 import dev.dimension.flare.ui.model.UiState import dev.dimension.flare.ui.model.collectAsUiState @@ -183,287 +185,294 @@ internal fun HomeScreen( ) val actualLayoutType = state.navigationState.type ?: layoutType FlareTheme { - NavigationSuiteScaffold2( - layoutType = actualLayoutType, + Box( 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 - } - launchSingleTop = true - restoreState = true - } - scope.launch { - drawerState.close() - } - } - } - }, - ) - }, - railHeader = { - accountTypeState.user.onSuccess { - IconButton( - onClick = { - scope.launch { - drawerState.open() - } - }, - ) { - 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, - ) + ) { + NavigationSuiteScaffold2( + modifier = Modifier.fillMaxSize(), + layoutType = actualLayoutType, + drawerHeader = { + DrawerHeader( + accountTypeState, + currentTab, + toAccoutSwitcher = { + state.setShowAccountSelection(true) }, - label = { - TabTitle( - title = tab.metaData.title, + showFab = actualLayoutType == NavigationSuiteType.NavigationDrawer, + toCompose = { + navController.toDestinationsNavigator().navigate( + direction = + ComposeRouteDestination( + it, + ), ) }, - badge = - if (badgeState.isSuccess) { - { - badgeState.onSuccess { - if (it > 0) { - Badge { - Text(text = it.toString()) - } + 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 } - } - } else { - null - }, - onLongClick = - if (tab is HomeTimelineTabItem || tab is ProfileTabItem) { - { - hapticFeedback.performHapticFeedback( - HapticFeedbackType.LongPress, - ) - state.setShowAccountSelection(true) - } - } 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 + scope.launch { + drawerState.close() } - 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, - ) - }, ) - } - }, - 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 + }, + railHeader = { + accountTypeState.user.onSuccess { + IconButton( + onClick = { + scope.launch { + drawerState.open() } - }, - icon = { + }, + ) { + AvatarComponent(it.avatar) + } + FloatingActionButton( + onClick = { + currentTab?.let { + navController.toDestinationsNavigator().navigate( + direction = + ComposeRouteDestination( + it.account, + ), + ) + } + }, + elevation = + FloatingActionButtonDefaults.elevation( + defaultElevation = 0.dp, + ), + ) { FAIcon( - imageVector = FontAwesomeIcons.Solid.Gear, - contentDescription = stringResource(id = R.string.settings_title), + imageVector = FontAwesomeIcons.Solid.Pen, + contentDescription = stringResource(id = R.string.compose_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) + } + } }, - exitTransition = { - materialSharedAxisYOut(true, slideDistance) + 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, + ) + }, + label = { + TabTitle( + title = tab.metaData.title, + ) + }, + badge = + if (badgeState.isSuccess) { + { + badgeState.onSuccess { + if (it > 0) { + Badge { + Text(text = it.toString()) + } + } + } + } + } else { + null + }, + onLongClick = + if (tab is HomeTimelineTabItem || tab is ProfileTabItem) { + { + hapticFeedback.performHapticFeedback( + HapticFeedbackType.LongPress, + ) + state.setShowAccountSelection(true) + } + } else { + null + }, + ) + } }, - popEnterTransition = { - materialSharedAxisYIn(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, + ) + }, + ) + } }, - popExitTransition = { - materialSharedAxisYOut(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)) + }, + ) + } }, + drawerGesturesEnabled = + state.navigationState.drawerEnabled && + accountTypeState.user.isSuccess, + drawerState = drawerState, ) { - tabs.all.forEach { (tab, tabState) -> - composable(tab.key) { - CompositionLocalProvider( - LocalTabState provides tabState, - ) { - Router( - modifier = Modifier.fillMaxSize(), - navGraph = NavGraphs.root, - direction = TabSplashScreenDestination, + 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, ) { - dependency(rootNavController) - dependency( - SplashScreenArgs( - getDirection( - tab, - tab.account, + Router( + modifier = Modifier.fillMaxSize(), + navGraph = NavGraphs.root, + direction = TabSplashScreenDestination, + ) { + 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, - ) - } - composable(ServiceSelectRouteDestination) { - Router( - modifier = Modifier.fillMaxSize(), - navGraph = NavGraphs.root, - direction = TabSplashScreenDestination, - ) { - dependency(rootNavController) - dependency( - SplashScreenArgs(ServiceSelectRouteDestination), + 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), + ) // dependency(tabState) - dependency(drawerState) - dependency(state.navigationState) + dependency(drawerState) + dependency(state.navigationState) + } } } } + BackHandler( + enabled = drawerState.isOpen, + onBack = { + scope.launch { + drawerState.close() + } + }, + ) + InAppNotificationComponent( + modifier = Modifier.align(Alignment.TopCenter), + ) } - BackHandler( - enabled = drawerState.isOpen, - onBack = { - scope.launch { - drawerState.close() - } - }, - ) if (state.showAccountSelection) { ModalBottomSheet( diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/common/InAppNotification.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/common/InAppNotification.kt new file mode 100644 index 000000000..b17c2621d --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/common/InAppNotification.kt @@ -0,0 +1,20 @@ +package dev.dimension.flare.common + +interface InAppNotification { + fun onProgress( + message: Message, + progress: Int, + total: Int, + ) + + fun onSuccess(message: Message) + + fun onError( + message: Message, + throwable: Throwable, + ) +} + +enum class Message { + Compose, +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposePresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposePresenter.kt index 660f77067..3717746ad 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposePresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposePresenter.kt @@ -194,9 +194,7 @@ class ComposePresenter( initialTextState = initialTextState, ) { override fun send(data: ComposeData) { - composeUseCase.invoke(data) { - // TODO: show notification - } + composeUseCase.invoke(data) } override fun selectAccount(account: UiAccount) { diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposeUseCase.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposeUseCase.kt index 531e5fdd6..817712853 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposeUseCase.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposeUseCase.kt @@ -1,5 +1,7 @@ package dev.dimension.flare.ui.presenter.compose +import dev.dimension.flare.common.InAppNotification +import dev.dimension.flare.common.Message import dev.dimension.flare.data.datasource.microblog.BlueskyComposeData import dev.dimension.flare.data.datasource.microblog.ComposeData import dev.dimension.flare.data.datasource.microblog.MastodonComposeData @@ -11,13 +13,28 @@ import kotlinx.coroutines.launch class ComposeUseCase( private val scope: CoroutineScope, + private val inAppNotification: InAppNotification, ) { + operator fun invoke(data: ComposeData) { + invoke(data) { + when (it) { + is ComposeProgressState.Error -> + inAppNotification.onError(Message.Compose, it.throwable) + is ComposeProgressState.Progress -> + inAppNotification.onProgress(Message.Compose, it.current, it.max) + ComposeProgressState.Success -> + inAppNotification.onSuccess(Message.Compose) + } + } + } + operator fun invoke( data: ComposeData, progress: (ComposeProgressState) -> Unit, ) { scope.launch { runCatching { + progress.invoke(ComposeProgressState.Progress(0, 1)) when (data) { is MastodonComposeData -> data.account.dataSource.compose(