From 9fd4710815de0be943d441492931bd2de8724f07 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Tue, 19 Aug 2025 00:07:26 +0900 Subject: [PATCH 01/33] make desktop works again --- .../dev/dimension/flare/ui/AppContainer.kt | 15 - .../ui/screen/settings/TabCustomizeScreen.kt | 10 +- desktopApp/build.gradle.kts | 3 +- .../main/composeResources/values/strings.xml | 3 + .../main/kotlin/dev/dimension/flare/App.kt | 161 +++++--- .../main/kotlin/dev/dimension/flare/Main.kt | 21 +- .../dimension/flare/data/model/TabSettings.kt | 4 + .../flare/ui/component/EmojiPicker.kt | 6 +- .../flare/ui/component/MasterDetailView.kt | 2 +- .../dimension/flare/ui/component/TabIcon.kt | 18 +- .../dimension/flare/ui/route/CustomNavType.kt | 48 --- .../dimension/flare/ui/route/HomeTabRoute.kt | 14 - .../dev/dimension/flare/ui/route/Route.kt | 202 ++++++++++ .../dev/dimension/flare/ui/route/Router.kt | 365 +++--------------- .../dimension/flare/ui/route/StackManager.kt | 228 +++++++++++ .../flare/ui/screen/feeds/FeedListScreen.kt | 8 +- .../flare/ui/screen/home/DiscoverScreen.kt | 6 +- .../ui/screen/home/NotificationScreen.kt | 10 +- .../flare/ui/screen/home/ProfileScreen.kt | 205 +++++----- .../flare/ui/screen/home/TimelineScreen.kt | 6 +- .../flare/ui/screen/list/AllListScreen.kt | 4 +- .../ui/screen/media/StatusMediaScreen.kt | 19 +- .../serviceselect/ServiceSelectScreen.kt | 14 +- .../flare/ui/screen/status/StatusScreen.kt | 2 +- .../ui/screen/status/VVOCommentScreen.kt | 2 +- .../flare/ui/screen/status/VVOStatusScreen.kt | 8 +- .../screen/status/action/AddReactionSheet.kt | 2 +- .../action/BlueskyReportStatusDialog.kt | 10 +- .../action/DeleteStatusConfirmDialog.kt | 6 +- .../status/action/MastodonReportDialog.kt | 6 +- .../status/action/MisskeyReportDialog.kt | 8 +- .../dimension/flare/ui/theme/FlareTheme.kt | 36 +- gradle/libs.versions.toml | 9 +- .../ui/component/platform/PlaceHolder.jvm.kt | 2 +- .../component/platform/PlatformButton.jvm.kt | 6 +- .../ui/component/platform/PlatformCard.jvm.kt | 6 +- .../platform/PlatformCheckbox.jvm.kt | 4 +- .../platform/PlatformDropdown.jvm.kt | 6 +- .../ui/component/platform/PlatformIcon.jvm.kt | 2 +- .../platform/PlatformIndication.jvm.kt | 2 +- .../platform/PlatformProgressIndicator.jvm.kt | 6 +- .../component/platform/PlatformSlider.jvm.kt | 2 +- .../ui/component/platform/PlatformText.jvm.kt | 4 +- .../flare/ui/theme/PlatformColorScheme.jvm.kt | 2 +- .../flare/ui/theme/PlatformShapes.jvm.kt | 29 +- .../flare/ui/theme/PlatformTypography.jvm.kt | 2 +- .../dev/dimension/flare/ui/theme/Theme.jvm.kt | 4 +- 47 files changed, 863 insertions(+), 675 deletions(-) delete mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/CustomNavType.kt delete mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/HomeTabRoute.kt create mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/StackManager.kt diff --git a/app/src/main/java/dev/dimension/flare/ui/AppContainer.kt b/app/src/main/java/dev/dimension/flare/ui/AppContainer.kt index d851e1102..0070c5d4a 100644 --- a/app/src/main/java/dev/dimension/flare/ui/AppContainer.kt +++ b/app/src/main/java/dev/dimension/flare/ui/AppContainer.kt @@ -30,21 +30,6 @@ fun AppContainer(afterInit: () -> Unit) { @Composable fun FlareApp(content: @Composable () -> Unit) { -// setSingletonImageLoaderFactory { context -> -// ImageLoader.Builder(context) -// .components { -// if (Build.VERSION.SDK_INT >= 28) { -// add(AnimatedImageDecoder.Factory()) -// } else { -// add(GifDecoder.Factory()) -// } -// add(AnimatedPngDecoder.Factory()) -// add(SvgDecoder.Factory()) -// add(AnimatedWebPDecoder.Factory()) -// } -// .crossfade(true) -// .build() -// } val settingsRepository = koinInject() val appearanceSettings by settingsRepository.appearanceSettings.collectAsState( AppearanceSettings(), 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 ec4df9dc1..2ef225afc 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 @@ -549,7 +549,15 @@ fun TabIcon( } is IconType.Url -> { - NetworkImage(icon.url, contentDescription = null, modifier = modifier.size(24.dp)) + NetworkImage( + icon.url, + contentDescription = + when (title) { + is TitleType.Localized -> stringResource(id = title.resId) + is TitleType.Text -> title.content + }, + modifier = modifier.size(24.dp), + ) } } } diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 82e6948dc..e68419774 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -24,10 +24,9 @@ dependencies { implementation(compose.desktop.currentOs) implementation(libs.kotlinx.coroutines.swing) implementation(libs.fluent.ui) - implementation(libs.window.styler) implementation(libs.jSystemThemeDetector) implementation(libs.composeIcons.fontAwesome) - implementation(libs.jetbrains.navigation.compose) + implementation(libs.lifecycle.viewmodel.compose) implementation(libs.bundles.coil3) implementation(libs.bundles.kotlinx) implementation(libs.ksoup) diff --git a/desktopApp/src/main/composeResources/values/strings.xml b/desktopApp/src/main/composeResources/values/strings.xml index 5e0c208e4..73aa8a850 100644 --- a/desktopApp/src/main/composeResources/values/strings.xml +++ b/desktopApp/src/main/composeResources/values/strings.xml @@ -116,4 +116,7 @@ Subscribe Social + + Antenna + Mixed \ No newline at end of file diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt index 3fea5a9d5..147751e6c 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt @@ -13,7 +13,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect 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 @@ -25,25 +24,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import androidx.navigation.NavDestination.Companion.hasRoute -import androidx.navigation.NavDestination.Companion.hierarchy -import androidx.navigation.NavHostController -import androidx.navigation.compose.currentBackStackEntryAsState -import androidx.navigation.compose.rememberNavController -import com.konyaco.fluent.FluentTheme -import com.konyaco.fluent.component.Badge -import com.konyaco.fluent.component.BadgeStatus -import com.konyaco.fluent.component.Button -import com.konyaco.fluent.component.Icon -import com.konyaco.fluent.component.MenuItemSeparator -import com.konyaco.fluent.component.NavigationDefaults -import com.konyaco.fluent.component.NavigationDisplayMode -import com.konyaco.fluent.component.NavigationView -import com.konyaco.fluent.component.SubtleButton -import com.konyaco.fluent.component.Text -import com.konyaco.fluent.component.menuItem -import com.konyaco.fluent.component.rememberNavigationState import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.Gear @@ -53,6 +33,7 @@ import dev.dimension.flare.data.model.AllListTabItem import dev.dimension.flare.data.model.Bluesky import dev.dimension.flare.data.model.DirectMessageTabItem import dev.dimension.flare.data.model.DiscoverTabItem +import dev.dimension.flare.data.model.Misskey import dev.dimension.flare.data.model.NotificationTabItem import dev.dimension.flare.data.model.ProfileTabItem import dev.dimension.flare.data.model.RssTabItem @@ -75,10 +56,31 @@ import dev.dimension.flare.ui.presenter.home.ActiveAccountPresenter import dev.dimension.flare.ui.presenter.home.UserState import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.route.Route +import dev.dimension.flare.ui.route.Route.AllLists +import dev.dimension.flare.ui.route.Route.BlueskyFeeds +import dev.dimension.flare.ui.route.Route.DirectMessage +import dev.dimension.flare.ui.route.Route.Discover +import dev.dimension.flare.ui.route.Route.MeRoute +import dev.dimension.flare.ui.route.Route.Notification +import dev.dimension.flare.ui.route.Route.Timeline import dev.dimension.flare.ui.route.Router +import dev.dimension.flare.ui.route.StackManager +import dev.dimension.flare.ui.route.rememberStackManager +import io.github.composefluent.FluentTheme +import io.github.composefluent.component.Badge +import io.github.composefluent.component.BadgeStatus +import io.github.composefluent.component.Button +import io.github.composefluent.component.Icon +import io.github.composefluent.component.MenuItemSeparator +import io.github.composefluent.component.NavigationDefaults +import io.github.composefluent.component.NavigationDisplayMode +import io.github.composefluent.component.NavigationView +import io.github.composefluent.component.SubtleButton +import io.github.composefluent.component.Text +import io.github.composefluent.component.menuItem +import io.github.composefluent.component.rememberNavigationState import io.ktor.http.Url import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import moe.tlaster.precompose.molecule.producePresenter import org.jetbrains.compose.resources.stringResource @@ -87,7 +89,6 @@ import org.jetbrains.compose.resources.stringResource internal fun FlareApp( onRawImage: (String) -> Unit, onStatusMedia: (AccountType, MicroBlogKey, Int) -> Unit, - navController: NavHostController = rememberNavController(), ) { val state by producePresenter { presenter() } val bigScreen = isBigScreen() @@ -98,25 +99,24 @@ internal fun FlareApp( NavigationDisplayMode.LeftCompact } var selectedIndex by remember { mutableStateOf(0) } - val currentEntry by navController.currentBackStackEntryAsState() - val currentDestination = currentEntry?.destination val uriHandler = LocalUriHandler.current - fun navigate(route: Route) { - navController.navigate(route) { -// popUpTo(navController.graph.findStartDestination().id) { -// saveState = true -// } - launchSingleTop = true -// restoreState = true + state.tabs.onSuccess { tabs -> + val stackManager = + rememberStackManager(startRoute = getRoute(tabs.primary.first().tabItem), key = tabs) + val currentRoute = + remember(stackManager.stack) { + stackManager.current + } + + fun navigate(route: Route) { + stackManager.push(route) } - } - val canNavigateUp by remember(navController) { - navController.currentBackStack.map { - it.size > 1 + + fun goBack() { + stackManager.pop() } - }.collectAsState(false) - state.tabs.onSuccess { tabs -> + LaunchedEffect(selectedIndex) { val tab = tabs.all[selectedIndex] navigate(getRoute(tab.tabItem)) @@ -131,9 +131,9 @@ internal fun FlareApp( backButton = { NavigationDefaults.BackButton( onClick = { - navController.navigateUp() + goBack() }, - disabled = !canNavigateUp, + disabled = !stackManager.canGoBack, ) }, menuItems = { @@ -161,7 +161,11 @@ internal fun FlareApp( if (navigationState.expanded) { Column { RichText(user.name, maxLines = 1) - Text(user.handle, style = FluentTheme.typography.caption, maxLines = 1) + Text( + user.handle, + style = FluentTheme.typography.caption, + maxLines = 1, + ) } } } @@ -216,10 +220,10 @@ internal fun FlareApp( index: Int, ) { menuItem( - selected = currentDestination?.hierarchy?.any { it.hasRoute(getRoute(tab.tabItem)::class) } == true, + selected = currentRoute == getRoute(tab.tabItem), onClick = { if (selectedIndex == index) { - tab.tabState.onClick() + state.scrollToTopRegistry.scrollToTop() } else { selectedIndex = index } @@ -270,7 +274,7 @@ internal fun FlareApp( contentPadding = PaddingValues(top = 8.dp), footerItems = { menuItem( - selected = currentDestination?.hierarchy?.any { it.hasRoute(Route.Settings::class) } == true, + selected = currentRoute == Route.Settings, onClick = { navigate(Route.Settings) }, @@ -294,17 +298,16 @@ internal fun FlareApp( LocalUriHandler provides remember { ProxyUriHandler( - navController = navController, + stackManager = stackManager, actualUriHandler = uriHandler, onRawImage = onRawImage, onStatusMedia = onStatusMedia, ) }, - LocalTabState provides currentTab?.tabState, + LocalScrollToTopRegistry provides state.scrollToTopRegistry, ) { Router( - startDestination = getRoute(tabs.primary.first().tabItem), - navController = navController, + manager = stackManager, ) } } @@ -313,15 +316,16 @@ internal fun FlareApp( private fun getRoute(tab: TabItem): Route = when (tab) { - is DiscoverTabItem -> Route.Discover(tab.account) - is ProfileTabItem -> Route.MeRoute(tab.account) - is TimelineTabItem -> Route.Timeline(tab) - is NotificationTabItem -> Route.Notification(tab.account) + is DiscoverTabItem -> Discover(tab.account) + is ProfileTabItem -> MeRoute(tab.account) + is TimelineTabItem -> Timeline(tab) + is NotificationTabItem -> Notification(tab.account) SettingsTabItem -> Route.Settings - is AllListTabItem -> Route.AllLists(tab.account) - is Bluesky.FeedsTabItem -> Route.BlueskyFeeds(tab.account) - is DirectMessageTabItem -> Route.DirectMessage(tab.account) + is AllListTabItem -> AllLists(tab.account) + is Bluesky.FeedsTabItem -> BlueskyFeeds(tab.account) + is DirectMessageTabItem -> DirectMessage(tab.account) is RssTabItem -> Route.Rss + is Misskey.AntennasListTabItem -> Route.Rss } @Composable @@ -329,12 +333,17 @@ private fun presenter() = run { val accountState = remember { ActiveAccountPresenter() }.invoke() val tabState = remember { HomeTabsPresenter(flowOf(TabSettings())) }.invoke() + val scrollToTopRegistry = + remember { + ScrollToTopRegistry() + } object : UserState by accountState, HomeTabsPresenter.State by tabState { + val scrollToTopRegistry = scrollToTopRegistry } } private class ProxyUriHandler( - private val navController: NavController, + private val stackManager: StackManager, private val actualUriHandler: UriHandler, private val onRawImage: (String) -> Unit, private val onStatusMedia: (AccountType, MicroBlogKey, Int) -> Unit, @@ -349,6 +358,7 @@ private class ProxyUriHandler( onRawImage(rawImage) } } + "StatusMedia" -> { val accountKey = data.parameters["accountKey"]?.let(MicroBlogKey::valueOf) val statusKey = data.segments.getOrNull(0)?.let(MicroBlogKey::valueOf) @@ -358,7 +368,16 @@ private class ProxyUriHandler( onStatusMedia(accountType, statusKey, index) } } - else -> navController.navigate(uri) + + else -> { + Route.parse(uri)?.let { + stackManager.push(it) + } ?: run { + // If the URI does not match any known route, we can handle it as a custom URI scheme + // For example, you might want to log it or show an error + println("Unhandled URI: $uri") + } + } } } else { actualUriHandler.openUri(uri) @@ -366,8 +385,24 @@ private class ProxyUriHandler( } } -private val LocalTabState = - androidx.compose.runtime.staticCompositionLocalOf { +private class ScrollToTopRegistry { + private val callbacks = mutableSetOf<() -> Unit>() + + fun registerCallback(callback: () -> Unit) { + callbacks.add(callback) + } + + fun unregisterCallback(callback: () -> Unit) { + callbacks.remove(callback) + } + + fun scrollToTop() { + callbacks.forEach { it.invoke() } + } +} + +private val LocalScrollToTopRegistry = + androidx.compose.runtime.staticCompositionLocalOf { null } @@ -376,18 +411,20 @@ internal fun RegisterTabCallback( lazyListState: LazyStaggeredGridState, onRefresh: () -> Unit, ) { - val tabState = LocalTabState.current val onRefreshState by rememberUpdatedState(onRefresh) + val tabState = LocalScrollToTopRegistry.current if (tabState != null) { val scope = rememberCoroutineScope() val callback: () -> Unit = remember(lazyListState, scope) { { - if (lazyListState.firstVisibleItemIndex == 0) { - onRefreshState() + if (lazyListState.firstVisibleItemIndex == 0 && + lazyListState.firstVisibleItemScrollOffset == 0 + ) { + onRefreshState.invoke() } else { scope.launch { - if (lazyListState.firstVisibleItemIndex > 40) { + if (lazyListState.firstVisibleItemIndex > 20) { lazyListState.scrollToItem(0) } else { lazyListState.animateScrollToItem(0) diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt index b24d05de4..7bde6d361 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt @@ -12,7 +12,6 @@ import androidx.compose.ui.window.Window import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState -import androidx.navigation.compose.rememberNavController import coil3.ImageLoader import coil3.compose.setSingletonImageLoaderFactory import coil3.request.crossfade @@ -22,11 +21,9 @@ import dev.dimension.flare.ui.route.FloatingWindowState import dev.dimension.flare.ui.route.WindowRoute import dev.dimension.flare.ui.route.WindowRouter import dev.dimension.flare.ui.theme.FlareTheme -import org.apache.commons.lang3.SystemUtils import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.koin.core.context.startKoin -import java.awt.Desktop fun main(args: Array) { startKoin { @@ -54,14 +51,14 @@ fun main(args: Array) { ) } } - val navController = rememberNavController() - LaunchedEffect(Unit) { - if (SystemUtils.IS_OS_MAC_OSX) { - Desktop.getDesktop().setOpenURIHandler { - navController.navigate(it.uri.toString()) - } - } - } +// val navController = rememberNavController() +// LaunchedEffect(Unit) { +// if (SystemUtils.IS_OS_MAC_OSX) { +// Desktop.getDesktop().setOpenURIHandler { +// navController.navigate(it.uri.toString()) +// } +// } +// } Window( onCloseRequest = ::exitApplication, title = stringResource(Res.string.app_name), @@ -74,7 +71,7 @@ fun main(args: Array) { ) { FlareTheme { FlareApp( - navController = navController, +// navController = navController, onRawImage = { url -> openWindow( url, diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/data/model/TabSettings.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/data/model/TabSettings.kt index 276f5545f..c154d7e46 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/data/model/TabSettings.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/data/model/TabSettings.kt @@ -21,6 +21,7 @@ import compose.icons.fontawesomeicons.solid.SquareRss import compose.icons.fontawesomeicons.solid.Star import compose.icons.fontawesomeicons.solid.Users import dev.dimension.flare.Res +import dev.dimension.flare.antenna_title import dev.dimension.flare.dm_list_title import dev.dimension.flare.home_tab_bookmarks_title import dev.dimension.flare.home_tab_discover_title @@ -33,6 +34,7 @@ import dev.dimension.flare.home_tab_me_title import dev.dimension.flare.home_tab_notifications_title import dev.dimension.flare.mastodon_tab_local_title import dev.dimension.flare.mastodon_tab_public_title +import dev.dimension.flare.mixed_timeline_title import dev.dimension.flare.rss_title import dev.dimension.flare.settings_title import dev.dimension.flare.social_title @@ -57,6 +59,8 @@ internal val TitleType.Localized.res: StringResource TitleType.Localized.LocalizedKey.DirectMessage -> Res.string.dm_list_title TitleType.Localized.LocalizedKey.Rss -> Res.string.rss_title TitleType.Localized.LocalizedKey.Social -> Res.string.social_title + TitleType.Localized.LocalizedKey.Antenna -> Res.string.antenna_title + TitleType.Localized.LocalizedKey.MixedTimeline -> Res.string.mixed_timeline_title } internal fun IconType.Material.MaterialIcon.toIcon(): ImageVector = diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/EmojiPicker.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/EmojiPicker.kt index 073e3ece7..998b0f4ad 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/EmojiPicker.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/EmojiPicker.kt @@ -16,9 +16,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.konyaco.fluent.component.ListHeader -import com.konyaco.fluent.component.Text -import com.konyaco.fluent.component.TextField import dev.dimension.flare.Res import dev.dimension.flare.emoji_picker_recent import dev.dimension.flare.emoji_picker_search @@ -27,6 +24,9 @@ import dev.dimension.flare.ui.model.UiEmoji import dev.dimension.flare.ui.model.onSuccess import dev.dimension.flare.ui.presenter.compose.EmojiHistoryPresenter import dev.dimension.flare.ui.presenter.invoke +import io.github.composefluent.component.ListHeader +import io.github.composefluent.component.Text +import io.github.composefluent.component.TextField import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.toImmutableList diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/MasterDetailView.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/MasterDetailView.kt index ad457b0ab..399456a9d 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/MasterDetailView.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/MasterDetailView.kt @@ -9,8 +9,8 @@ import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.konyaco.fluent.FluentTheme import dev.dimension.flare.ui.component.platform.isBigScreen +import io.github.composefluent.FluentTheme @Composable internal fun MasterDetailView( diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/TabIcon.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/TabIcon.kt index 09a82790e..a1405cad2 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/TabIcon.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/TabIcon.kt @@ -11,8 +11,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.konyaco.fluent.FluentTheme -import com.konyaco.fluent.component.Text import dev.dimension.flare.data.model.IconType import dev.dimension.flare.data.model.TabItem import dev.dimension.flare.data.model.TitleType @@ -23,6 +21,8 @@ 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.invoke +import io.github.composefluent.FluentTheme +import io.github.composefluent.component.Text import moe.tlaster.precompose.molecule.producePresenter import org.jetbrains.compose.resources.stringResource @@ -134,5 +134,19 @@ fun TabIcon( } } } + + is IconType.Url -> { + NetworkImage( + icon.url, + contentDescription = + when (title) { + is TitleType.Localized -> stringResource(title.res) + is TitleType.Text -> title.content + }, + modifier = + modifier + .size(24.dp), + ) + } } } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/CustomNavType.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/CustomNavType.kt deleted file mode 100644 index 84c3e1491..000000000 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/CustomNavType.kt +++ /dev/null @@ -1,48 +0,0 @@ -package dev.dimension.flare.ui.route - -import androidx.core.bundle.Bundle -import androidx.navigation.NavType -import dev.dimension.flare.common.decodeJson -import dev.dimension.flare.common.encodeJson -import dev.dimension.flare.model.MicroBlogKey -import io.ktor.http.encodeURLPathPart -import kotlinx.serialization.KSerializer - -internal val MicroblogKeyNavType = - object : NavType(isNullableAllowed = true) { - override fun get( - bundle: Bundle, - key: String, - ): MicroBlogKey? = bundle.getString(key)?.let(MicroBlogKey::valueOf) - - override fun parseValue(value: String): MicroBlogKey = MicroBlogKey.valueOf(value) - - override fun put( - bundle: Bundle, - key: String, - value: MicroBlogKey, - ) { - bundle.putString(key, value.toString()) - } - } - -internal data class JsonSerializableNavType( - private val serializer: KSerializer, -) : NavType(isNullableAllowed = false) { - override fun put( - bundle: Bundle, - key: String, - value: T, - ) { - bundle.putString(key, value.encodeJson(serializer)) - } - - override fun get( - bundle: Bundle, - key: String, - ): T = parseValue(bundle.getString(key)!!) - - override fun serializeAsValue(value: T): String = value.encodeJson(serializer).encodeURLPathPart() - - override fun parseValue(value: String): T = value.decodeJson(serializer) -} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/HomeTabRoute.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/HomeTabRoute.kt deleted file mode 100644 index cff70f1a0..000000000 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/HomeTabRoute.kt +++ /dev/null @@ -1,14 +0,0 @@ -package dev.dimension.flare.ui.route - -import androidx.compose.runtime.Immutable -import androidx.compose.ui.graphics.vector.ImageVector -import org.jetbrains.compose.resources.StringResource -import kotlin.reflect.KClass - -@Immutable -internal data class HomeTabRoute( - val route: Route, - val routeClass: KClass, - val title: StringResource, - val icon: ImageVector, -) diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt index cdd1feca8..aab4f6181 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt @@ -3,6 +3,7 @@ package dev.dimension.flare.ui.route import dev.dimension.flare.data.model.TimelineTabItem import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey +import io.ktor.http.Url import kotlinx.serialization.Serializable @Serializable @@ -56,4 +57,205 @@ internal sealed interface Route { data class DirectMessage( val accountType: AccountType, ) : Route + + companion object { + public fun parse(url: String): Route? { + val data = Url(url) + return when (data.host) { + "Callback" -> + when (data.segments.getOrNull(0)) { + "SignIn" -> + when (data.segments.getOrNull(1)) { +// "Mastodon" -> Route.Callback.Mastodon +// "Misskey" -> Route.Callback.Misskey + else -> null + } + + else -> null + } + + "Search" -> { + val accountKey = data.parameters["accountKey"]?.let { MicroBlogKey.valueOf(it) } + val keyword = data.segments.getOrNull(0) ?: return null + val accountType = accountKey?.let { AccountType.Specific(it) } ?: AccountType.Guest +// Route.Search(accountType, keyword) + null + } + + "Profile" -> { + val accountKey = data.parameters["accountKey"]?.let { MicroBlogKey.valueOf(it) } + val userKey = MicroBlogKey.valueOf(data.segments.getOrNull(0) ?: return null) + val accountType = accountKey?.let { AccountType.Specific(it) } ?: AccountType.Guest + Route.Profile(accountType, userKey) + } + + "ProfileWithNameAndHost" -> { + val accountKey = data.parameters["accountKey"]?.let { MicroBlogKey.valueOf(it) } + val userName = data.segments.getOrNull(0) ?: return null + val host = data.segments.getOrNull(1) ?: return null + val accountType = accountKey?.let { AccountType.Specific(it) } ?: AccountType.Guest +// Route.Profile.UserNameWithHost(accountType, userName, host) + null + } + + "StatusDetail" -> { + val accountKey = data.parameters["accountKey"]?.let { MicroBlogKey.valueOf(it) } + val statusKey = MicroBlogKey.valueOf(data.segments.getOrNull(0) ?: return null) + val accountType = accountKey?.let { AccountType.Specific(it) } ?: AccountType.Guest +// Route.Status.Detail(statusKey, accountType) + null + } + + "Compose" -> + when (data.segments.getOrNull(0)) { + "Reply" -> { + val accountKey = MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) + val statusKey = MicroBlogKey.valueOf(data.segments.getOrNull(2) ?: return null) +// Route.Compose.Reply(accountKey, statusKey) + null + } + + "Quote" -> { + val accountKey = MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) + val statusKey = MicroBlogKey.valueOf(data.segments.getOrNull(2) ?: return null) +// Route.Compose.Quote(accountKey, statusKey) + null + } + + "New" -> { + val accountKey = MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) +// Route.Compose.New(AccountType.Specific(accountKey)) + null + } + + else -> null + } + + "RawImage" -> { + val rawImage = data.segments.getOrNull(0) ?: return null +// Route.Media.Image(rawImage, previewUrl = null) + null + } + + "VVO" -> + when (data.segments.getOrNull(0)) { + "StatusDetail" -> { + val accountKey = MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) + val statusKey = MicroBlogKey.valueOf(data.segments.getOrNull(2) ?: return null) +// Route.Status.VVOStatus(statusKey, AccountType.Specific(accountKey)) + null + } + + "CommentDetail" -> { + val accountKey = MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) + val statusKey = MicroBlogKey.valueOf(data.segments.getOrNull(2) ?: return null) +// Route.Status.VVOComment(statusKey, AccountType.Specific(accountKey)) + null + } + + "ReplyToComment" -> { + val accountKey = MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) + val replyTo = MicroBlogKey.valueOf(data.segments.getOrNull(2) ?: return null) + val rootId = data.segments.getOrNull(3) ?: return null +// Route.Compose.VVOReplyComment(accountKey, replyTo, rootId) + null + } + + else -> null + } + + "DeleteStatus" -> { + val accountKey = MicroBlogKey.valueOf(data.segments.getOrNull(0) ?: return null) + val statusKey = MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) +// Route.Status.DeleteConfirm(statusKey, AccountType.Specific(accountKey)) + null + } + + "AddReaction" -> { + val accountKey = MicroBlogKey.valueOf(data.segments.getOrNull(0) ?: return null) + val statusKey = MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) +// Route.Status.AddReaction(statusKey, AccountType.Specific(accountKey)) + null + } + + "Bluesky" -> + when (data.segments.getOrNull(0)) { + "ReportStatus" -> { + val accountKey = MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) + val statusKey = MicroBlogKey.valueOf(data.segments.getOrNull(2) ?: return null) +// Route.Status.BlueskyReport(statusKey, AccountType.Specific(accountKey)) + null + } + + else -> null + } + + "Mastodon" -> + when (data.segments.getOrNull(0)) { + "ReportStatus" -> { + val accountKey = MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) + val statusKey = MicroBlogKey.valueOf(data.segments.getOrNull(2) ?: return null) + val userKey = MicroBlogKey.valueOf(data.segments.getOrNull(3) ?: return null) +// Route.Status.MastodonReport( +// statusKey = statusKey, +// userKey = userKey, +// accountType = AccountType.Specific(accountKey), +// ) + null + } + + else -> null + } + + "Misskey" -> + when (data.segments.getOrNull(0)) { + "ReportStatus" -> { + val accountKey = MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) + val statusKey = MicroBlogKey.valueOf(data.segments.getOrNull(2) ?: return null) + val userKey = MicroBlogKey.valueOf(data.segments.getOrNull(3) ?: return null) +// Route.Status.MisskeyReport( +// accountType = AccountType.Specific(accountKey), +// statusKey = statusKey, +// userKey = userKey, +// ) + null + } + + else -> null + } + + "StatusMedia" -> { + val accountKey = data.parameters["accountKey"]?.let { MicroBlogKey.valueOf(it) } + val statusKey = MicroBlogKey.valueOf(data.segments.getOrNull(0) ?: return null) + val index = data.segments.getOrNull(1)?.toIntOrNull() ?: return null + val accountType = accountKey?.let { AccountType.Specific(it) } ?: AccountType.Guest + val preview = data.parameters["preview"] +// Route.Media.StatusMedia(accountType = accountType, statusKey = statusKey, index = index, preview = preview) + null + } + + "Podcast" -> { + val accountKey = MicroBlogKey.valueOf(data.segments.getOrNull(0) ?: return null) + val id = data.segments.getOrNull(1) ?: return null + val accountType = accountKey.let { AccountType.Specific(it) } +// Route.Media.Podcast(accountType = accountType, id = id) + null + } + + "AltText" -> { + val text = data.segments.getOrNull(0) ?: return null +// Route.Status.AltText(text) + null + } + + "RSS" -> { + val feedUrl = data.segments.getOrNull(0) ?: return null +// Route.Rss.Detail(feedUrl) + null + } + + else -> null + } + } + } } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt index c2018d722..ef0b042f2 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt @@ -1,328 +1,73 @@ package dev.dimension.flare.ui.route -import androidx.compose.animation.AnimatedContentScope -import androidx.compose.animation.AnimatedContentTransitionScope -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.SizeTransform +import androidx.compose.animation.AnimatedContent import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.navigation.NavBackStackEntry -import androidx.navigation.NavDeepLink -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavHostController -import androidx.navigation.NavType -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.dialog -import androidx.navigation.compose.rememberNavController -import androidx.navigation.toRoute -import com.konyaco.fluent.component.Text -import dev.dimension.flare.common.AppDeepLink -import dev.dimension.flare.data.model.TimelineTabItem -import dev.dimension.flare.model.AccountType -import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.screen.feeds.FeedScreen -import dev.dimension.flare.ui.screen.home.DiscoverScreen -import dev.dimension.flare.ui.screen.home.NotificationScreen import dev.dimension.flare.ui.screen.home.ProfileScreen import dev.dimension.flare.ui.screen.home.TimelineScreen -import dev.dimension.flare.ui.screen.list.ListScreen -import dev.dimension.flare.ui.screen.serviceselect.ServiceSelectScreen -import dev.dimension.flare.ui.screen.status.StatusScreen -import dev.dimension.flare.ui.screen.status.VVOCommentScreen -import dev.dimension.flare.ui.screen.status.VVOStatusScreen -import dev.dimension.flare.ui.screen.status.action.AddReactionSheet -import dev.dimension.flare.ui.screen.status.action.BlueskyReportStatusDialog -import dev.dimension.flare.ui.screen.status.action.DeleteStatusConfirmDialog -import dev.dimension.flare.ui.screen.status.action.MastodonReportDialog -import dev.dimension.flare.ui.screen.status.action.MisskeyReportDialog -import kotlinx.collections.immutable.persistentMapOf -import kotlin.reflect.KType -import kotlin.reflect.typeOf - -private val typeMaps = - persistentMapOf( - typeOf() to MicroblogKeyNavType, - typeOf() to JsonSerializableNavType(AccountType.serializer()), - typeOf() to JsonSerializableNavType(TimelineTabItem.serializer()), - ) +import io.github.composefluent.component.Text @OptIn(ExperimentalComposeUiApi::class) @Composable internal fun Router( - startDestination: Route, - navController: NavHostController = rememberNavController(), + manager: StackManager, modifier: Modifier = Modifier, ) { - NavHost( - navController = navController, - modifier = modifier, - startDestination = startDestination, - typeMap = typeMaps, - ) { - screen { (_, args) -> - TimelineScreen(args.tabItem) - } - screen { (_, args) -> - DiscoverScreen(args.accountType) - } - screen { - Text("Settings") - } - screen { - Text("Rss") - } - screen { (_, args) -> - ProfileScreen( - accountType = args.accountType, - userKey = args.userKey, - ) - } - composable( - AppDeepLink.Profile.ROUTE, - ) { - val userKey = it.arguments?.getString("userKey")?.let(MicroBlogKey::valueOf) - val accountKey = - it.arguments - ?.getString("accountKey") - ?.let(MicroBlogKey::valueOf) - ?.let(AccountType::Specific) ?: AccountType.Guest - if (userKey != null) { - ProfileScreen( - accountType = accountKey, - userKey = userKey, - ) - } - } - screen { (_, args) -> - ProfileScreen( - accountType = args.accountType, - userKey = null, - ) - } - screen { - ServiceSelectScreen( - onBack = navController::navigateUp, - onVVO = { - }, - onXQT = { - }, - ) - } - screen { (_, args) -> - ListScreen(args.accountType) - } - screen { (_, args) -> - FeedScreen(args.accountType) - } - screen { - } - screen { (_, args) -> - NotificationScreen(args.accountType) - } - dialog(AppDeepLink.Bluesky.ReportStatus.ROUTE) { - val accountType = - it.arguments - ?.getString("accountKey") - ?.let(MicroBlogKey::valueOf) - ?.let(AccountType::Specific) - val statusKey = - it.arguments - ?.getString("statusKey") - ?.let(MicroBlogKey::valueOf) - if (accountType != null && statusKey != null) { - BlueskyReportStatusDialog( - accountType = accountType, - statusKey = statusKey, - onBack = navController::navigateUp, - ) - } - } - dialog(AppDeepLink.DeleteStatus.ROUTE) { - val accountType = - it.arguments - ?.getString("accountKey") - ?.let(MicroBlogKey::valueOf) - ?.let(AccountType::Specific) - val statusKey = - it.arguments - ?.getString("statusKey") - ?.let(MicroBlogKey::valueOf) - if (accountType != null && statusKey != null) { - DeleteStatusConfirmDialog( - accountType = accountType, - statusKey = statusKey, - onBack = navController::navigateUp, - ) - } - } - dialog(AppDeepLink.Mastodon.ReportStatus.ROUTE) { - val accountType = - it.arguments - ?.getString("accountKey") - ?.let(MicroBlogKey::valueOf) - ?.let(AccountType::Specific) - val statusKey = - it.arguments - ?.getString("statusKey") - ?.let(MicroBlogKey::valueOf) - val userKey = - it.arguments - ?.getString("userKey") - ?.let(MicroBlogKey::valueOf) - if (accountType != null && statusKey != null && userKey != null) { - MastodonReportDialog( - accountType = accountType, - statusKey = statusKey, - userKey = userKey, - onBack = navController::navigateUp, - ) - } - } - dialog(AppDeepLink.Misskey.ReportStatus.ROUTE) { - val accountType = - it.arguments - ?.getString("accountKey") - ?.let(MicroBlogKey::valueOf) - ?.let(AccountType::Specific) - val statusKey = - it.arguments - ?.getString("statusKey") - ?.let(MicroBlogKey::valueOf) - val userKey = - it.arguments - ?.getString("userKey") - ?.let(MicroBlogKey::valueOf) - if (accountType != null && statusKey != null && userKey != null) { - MisskeyReportDialog( - accountType = accountType, - statusKey = statusKey, - userKey = userKey, - onBack = navController::navigateUp, - ) - } - } - composable(AppDeepLink.StatusDetail.ROUTE) { - val statusKey = - it.arguments - ?.getString("statusKey") - ?.let(MicroBlogKey::valueOf) - val accountKey = - it.arguments - ?.getString("accountKey") - ?.let(MicroBlogKey::valueOf) - ?.let(AccountType::Specific) - if (statusKey != null && accountKey != null) { - StatusScreen( - statusKey = statusKey, - onBack = navController::navigateUp, - accountType = accountKey, - ) - } - } - composable(AppDeepLink.VVO.CommentDetail.ROUTE) { - val statusKey = - it.arguments - ?.getString("statusKey") - ?.let(MicroBlogKey::valueOf) - val accountKey = - it.arguments - ?.getString("accountKey") - ?.let(MicroBlogKey::valueOf) - ?.let(AccountType::Specific) - if (statusKey != null && accountKey != null) { - VVOCommentScreen( - commentKey = statusKey, - onBack = navController::navigateUp, - accountType = accountKey, - ) - } - } - composable(AppDeepLink.VVO.StatusDetail.ROUTE) { - val statusKey = - it.arguments - ?.getString("statusKey") - ?.let(MicroBlogKey::valueOf) - val accountKey = - it.arguments - ?.getString("accountKey") - ?.let(MicroBlogKey::valueOf) - ?.let(AccountType::Specific) - if (statusKey != null && accountKey != null) { - VVOStatusScreen( - statusKey = statusKey, - onBack = navController::navigateUp, - accountType = accountKey, - ) - } - } - dialog(AppDeepLink.AddReaction.ROUTE) { - val statusKey = - it.arguments - ?.getString("statusKey") - ?.let(MicroBlogKey::valueOf) - val accountKey = - it.arguments - ?.getString("accountKey") - ?.let(MicroBlogKey::valueOf) - ?.let(AccountType::Specific) - if (statusKey != null && accountKey != null) { - AddReactionSheet( - statusKey = statusKey, - accountType = accountKey, - onBack = navController::navigateUp, - ) - } - } + fun navigate(route: Route) { + manager.push(route) } -} -private inline fun NavGraphBuilder.screen( - typeMap: Map> = typeMaps, - deepLinks: List = emptyList(), - noinline enterTransition: ( - AnimatedContentTransitionScope.() -> @JvmSuppressWildcards - EnterTransition? - )? = - null, - noinline exitTransition: ( - AnimatedContentTransitionScope.() -> @JvmSuppressWildcards - ExitTransition? - )? = - null, - noinline popEnterTransition: ( - AnimatedContentTransitionScope.() -> @JvmSuppressWildcards - EnterTransition? - )? = - enterTransition, - noinline popExitTransition: ( - AnimatedContentTransitionScope.() -> @JvmSuppressWildcards - ExitTransition? - )? = - exitTransition, - noinline sizeTransform: ( - AnimatedContentTransitionScope.() -> @JvmSuppressWildcards - SizeTransform? - )? = - null, - noinline content: @Composable AnimatedContentScope.(Pair) -> Unit, -) { - composable( - typeMap = typeMap, - deepLinks = deepLinks, - enterTransition = enterTransition, - exitTransition = exitTransition, - popEnterTransition = popEnterTransition, - popExitTransition = popExitTransition, - sizeTransform = sizeTransform, - ) { - val args = - remember(it) { - it.toRoute() + fun onBack() { + manager.pop() + } + AnimatedContent( + manager.current, + ) { entry -> + entry.Content { route -> + when (route) { + is Route.AllLists -> { + Text("route") + } + is Route.BlueskyFeeds -> { + Text("route") + } + is Route.DirectMessage -> { + Text("route") + } + is Route.Discover -> { + Text("route") + } + is Route.MeRoute -> { + ProfileScreen( + accountType = route.accountType, + userKey = null, + ) + } + is Route.Notification -> { + Text("route") + } + is Route.Profile -> { + ProfileScreen( + accountType = route.accountType, + userKey = route.userKey, + ) + } + Route.Rss -> { + Text("rss") + } + Route.ServiceSelect -> { + Text("route") + } + Route.Settings -> { + Text("route") + } + is Route.Timeline -> { + TimelineScreen( + route.tabItem, + ) + } } - content(it to args) + } } } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/StackManager.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/StackManager.kt new file mode 100644 index 000000000..874a1c5da --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/StackManager.kt @@ -0,0 +1,228 @@ +package dev.dimension.flare.ui.route + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.SaveableStateHolder +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.get +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlin.reflect.KClass + +@Composable +internal fun rememberStackManager( + startRoute: Route, + key: Any? = null, +): StackManager { + val navControllerViewModel = viewModel() + val savableStateHolder = rememberSaveableStateHolder() + + return remember(key) { + StackManager( + startRoute = startRoute, + navControllerViewModel = navControllerViewModel, + savableStateHolder = savableStateHolder, + ) + } +} + +internal class StackManager( + private val startRoute: Route, + private val navControllerViewModel: NavControllerViewModel, + private val savableStateHolder: SaveableStateHolder, +) { + val stack: MutableList = mutableStateListOf() + + private var _current = + mutableStateOf( + stack.lastOrNull() ?: Entry( + id = "0", + route = startRoute, + viewModelStoreProvider = navControllerViewModel, + savableStateHolder = savableStateHolder, + ), + ) + + val current: Entry + get() = _current.value + + private var _canGoBack = mutableStateOf(false) + + val canGoBack: Boolean + get() = _canGoBack.value + + init { + // Initialize the stack with the start route + push(startRoute) + } + + fun push(route: Route) { + if (stack.isNotEmpty() && stack.last().route == route) { + return + } + val entry = + Entry( + id = (stack.size).toString(), + route = route, + viewModelStoreProvider = navControllerViewModel, + savableStateHolder = savableStateHolder, + ) + stack.add(entry) + updateEntry() + } + + fun pop() { + if (stack.size > 1) { + val item = stack.last() + item.setWillBeCleared() + stack.removeLast() + } else { + null + } + updateEntry() + } + + private fun updateEntry() { + if (stack.isNotEmpty()) { + _current.value = stack.last() + } else { + _current.value = + Entry( + id = "0", + route = startRoute, + viewModelStoreProvider = navControllerViewModel, + savableStateHolder = savableStateHolder, + ) + } + _canGoBack.value = stack.size > 1 + } + + fun clear() { + for (item in stack) { + item.setWillBeCleared() + } + stack.clear() + } + + data class Entry( + private val id: String, + val route: Route, + private val viewModelStoreProvider: ViewModelStoreProvider, + private val savableStateHolder: SaveableStateHolder, + ) : LifecycleOwner, + ViewModelStoreOwner { + private var willBeCleared = false + private val lifecycleRegistry = + androidx.lifecycle.LifecycleRegistry(this).apply { + handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + } + + override val lifecycle: Lifecycle + get() = lifecycleRegistry + + fun active() { + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + } + + fun inactive() { + if (willBeCleared) { + savableStateHolder.removeState(id) + viewModelStoreProvider.clear(id) + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) + println("Lifecycle for $id is destroyed and cleared.") + } else { + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP) + } + } + + fun setWillBeCleared() { + willBeCleared = true + } + + override val viewModelStore by lazy { + viewModelStoreProvider.getViewModelStore(id) + } + + @Composable + fun Content(content: @Composable (route: Route) -> Unit) { + CompositionLocalProvider( + LocalLifecycleOwner provides this, + LocalViewModelStoreOwner provides this, + ) { + savableStateHolder.SaveableStateProvider(id) { + content.invoke(route) + } + } + DisposableEffect(Unit) { + // Activate the lifecycle when the composable is composed + active() + onDispose { + // Inactive the lifecycle when the composable is disposed + inactive() + } + } + } + } +} + +interface ViewModelStoreProvider { + fun getViewModelStore(backStackEntryId: String): ViewModelStore + + fun clear(backStackEntryId: String) +} + +internal class NavControllerViewModel : + ViewModel(), + ViewModelStoreProvider { + private val viewModelStores = mutableMapOf() + + override fun clear(backStackEntryId: String) { + // Clear and remove the NavGraph's ViewModelStore + val viewModelStore = viewModelStores.remove(backStackEntryId) + viewModelStore?.clear() + } + + override fun onCleared() { + for (store in viewModelStores.values) { + store.clear() + } + viewModelStores.clear() + } + + override fun getViewModelStore(backStackEntryId: String): ViewModelStore { + var viewModelStore = viewModelStores[backStackEntryId] + if (viewModelStore == null) { + viewModelStore = ViewModelStore() + viewModelStores[backStackEntryId] = viewModelStore + } + return viewModelStore + } + + companion object { + private val FACTORY: ViewModelProvider.Factory = + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create( + modelClass: KClass, + extras: CreationExtras, + ): T = NavControllerViewModel() as T + } + + fun getInstance(viewModelStore: ViewModelStore): NavControllerViewModel { + val viewModelProvider = ViewModelProvider.create(viewModelStore, FACTORY) + return viewModelProvider.get() + } + } +} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedListScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedListScreen.kt index 7506c3fb6..b7839bf70 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedListScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedListScreen.kt @@ -12,10 +12,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.konyaco.fluent.component.ListItem -import com.konyaco.fluent.component.ListItemDefaults -import com.konyaco.fluent.component.SubtleButton -import com.konyaco.fluent.component.Text import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.Plus @@ -33,6 +29,10 @@ import dev.dimension.flare.ui.model.UiList import dev.dimension.flare.ui.presenter.home.bluesky.BlueskyFeedsPresenter import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.theme.screenHorizontalPadding +import io.github.composefluent.component.ListItem +import io.github.composefluent.component.ListItemDefaults +import io.github.composefluent.component.SubtleButton +import io.github.composefluent.component.Text import moe.tlaster.precompose.molecule.producePresenter import org.jetbrains.compose.resources.stringResource diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt index 34fe7604c..39ee89de8 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt @@ -19,9 +19,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.konyaco.fluent.component.ListItem -import com.konyaco.fluent.component.Text -import com.konyaco.fluent.surface.Card import dev.dimension.flare.RegisterTabCallback import dev.dimension.flare.common.onLoading import dev.dimension.flare.common.onSuccess @@ -35,6 +32,9 @@ import dev.dimension.flare.ui.presenter.home.DiscoverPresenter import dev.dimension.flare.ui.presenter.home.DiscoverState import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.theme.screenHorizontalPadding +import io.github.composefluent.component.ListItem +import io.github.composefluent.component.Text +import io.github.composefluent.surface.Card import moe.tlaster.precompose.molecule.producePresenter @OptIn(ExperimentalLayoutApi::class) diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/NotificationScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/NotificationScreen.kt index 486f2cec8..7b013715f 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/NotificationScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/NotificationScreen.kt @@ -13,11 +13,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.konyaco.fluent.component.ProgressBar -import com.konyaco.fluent.component.SegmentedButton -import com.konyaco.fluent.component.SegmentedControl -import com.konyaco.fluent.component.SegmentedItemPosition -import com.konyaco.fluent.component.Text import dev.dimension.flare.RegisterTabCallback import dev.dimension.flare.common.isRefreshing import dev.dimension.flare.model.AccountType @@ -28,6 +23,11 @@ import dev.dimension.flare.ui.presenter.home.NotificationPresenter import dev.dimension.flare.ui.presenter.home.UserPresenter import dev.dimension.flare.ui.presenter.home.UserState import dev.dimension.flare.ui.presenter.invoke +import io.github.composefluent.component.ProgressBar +import io.github.composefluent.component.SegmentedButton +import io.github.composefluent.component.SegmentedControl +import io.github.composefluent.component.SegmentedItemPosition +import io.github.composefluent.component.Text import kotlinx.coroutines.launch import moe.tlaster.precompose.molecule.producePresenter diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/ProfileScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/ProfileScreen.kt index 767a553c5..5184896ef 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/ProfileScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/ProfileScreen.kt @@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -24,12 +23,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.unit.dp -import com.konyaco.fluent.FluentTheme -import com.konyaco.fluent.component.SegmentedButton -import com.konyaco.fluent.component.SegmentedControl -import com.konyaco.fluent.component.SegmentedItemPosition -import com.konyaco.fluent.component.Text -import com.konyaco.fluent.surface.Card import dev.dimension.flare.RegisterTabCallback import dev.dimension.flare.Res import dev.dimension.flare.data.datasource.microblog.ProfileTab @@ -56,6 +49,12 @@ import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.presenter.profile.ProfilePresenter import dev.dimension.flare.ui.presenter.profile.ProfileState import dev.dimension.flare.ui.presenter.settings.AccountsPresenter +import io.github.composefluent.FluentTheme +import io.github.composefluent.component.SegmentedButton +import io.github.composefluent.component.SegmentedControl +import io.github.composefluent.component.SegmentedItemPosition +import io.github.composefluent.component.Text +import io.github.composefluent.surface.Card import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch import moe.tlaster.precompose.molecule.producePresenter @@ -119,107 +118,104 @@ internal fun ProfileScreen( } } } - Column( - modifier = Modifier.fillMaxSize(), + LazyStatusVerticalStaggeredGrid( + contentPadding = + PaddingValues( + vertical = + if (isBigScreen) { + 16.dp + } else { + 0.dp + }, + ), + state = listState, ) { - LazyStatusVerticalStaggeredGrid( - contentPadding = - PaddingValues( - vertical = - if (isBigScreen) { - 16.dp - } else { - 0.dp - }, - ), - state = listState, - ) { - if (!isBigScreen) { + if (!isBigScreen) { + item( + span = StaggeredGridItemSpan.FullLine, + ) { + ProfileHeader( + state = state.state, + menu = { + ProfileMenu( + profileState = state.state, + setShowMoreMenus = state::setShowMoreMenus, + showMoreMenus = state.showMoreMenus, + toEditAccountList = toEditAccountList, + accountsState = state.allAccountsState, + toSearchUserUsingAccount = toSearchUserUsingAccount, + toStartMessage = toStartMessage, + ) + }, + onAvatarClick = { + }, + onBannerClick = { + }, + isBigScreen = false, + onFollowListClick = onFollowListClick, + onFansListClick = onFansListClick, + ) + } + } + state.state.tabs.onSuccess { tabs -> + if (tabs.size > 1) { item( span = StaggeredGridItemSpan.FullLine, ) { - ProfileHeader( - state = state.state, - menu = { - ProfileMenu( - profileState = state.state, - setShowMoreMenus = state::setShowMoreMenus, - showMoreMenus = state.showMoreMenus, - toEditAccountList = toEditAccountList, - accountsState = state.allAccountsState, - toSearchUserUsingAccount = toSearchUserUsingAccount, - toStartMessage = toStartMessage, - ) - }, - onAvatarClick = { - }, - onBannerClick = { - }, - isBigScreen = false, - onFollowListClick = onFollowListClick, - onFansListClick = onFansListClick, - ) - } - } - state.state.tabs.onSuccess { tabs -> - if (tabs.size > 1) { - item( - span = StaggeredGridItemSpan.FullLine, - ) { - SegmentedControl { - repeat(tabs.size) { index -> - val tab = tabs.get(index) - SegmentedButton( - checked = state.selectedTab == index, - onCheckedChanged = { - state.setSelectedTab(index) + SegmentedControl { + repeat(tabs.size) { index -> + val tab = tabs.get(index) + SegmentedButton( + checked = state.selectedTab == index, + onCheckedChanged = { + state.setSelectedTab(index) + }, + position = + when (index) { + 0 -> SegmentedItemPosition.Start + tabs.size - 1 -> SegmentedItemPosition.End + else -> SegmentedItemPosition.Center }, - position = - when (index) { - 0 -> SegmentedItemPosition.Start - tabs.size - 1 -> SegmentedItemPosition.End - else -> SegmentedItemPosition.Center - }, - ) { - Text( - stringResource(tab.title), - ) - } + ) { + Text( + stringResource(tab.title), + ) } } } } - when (val tab = tabs.get(state.selectedTab)) { - is ProfileState.Tab.Media -> { - items( - tab.data, - loadingContent = { - Card( - modifier = Modifier, - ) { - Box(modifier = Modifier.size(120.dp).placeholder(true)) - } - }, - ) { item -> - CompositionLocalProvider( - LocalComponentAppearance provides - LocalComponentAppearance.current.copy( - videoAutoplay = ComponentAppearance.VideoAutoplay.NEVER, - ), + } + when (val tab = tabs.get(state.selectedTab)) { + is ProfileState.Tab.Media -> { + items( + tab.data, + loadingContent = { + Card( + modifier = Modifier, ) { - val media = item.media - MediaItem( - media = media, - showCountdown = false, - modifier = - Modifier - .clip(FluentTheme.shapes.control) - .padding( - vertical = 4.dp, - ).clipToBounds() - .clickable { - val content = item.status.content - if (content is UiTimeline.ItemContent.Status) { + Box(modifier = Modifier.size(120.dp).placeholder(true)) + } + }, + ) { item -> + CompositionLocalProvider( + LocalComponentAppearance provides + LocalComponentAppearance.current.copy( + videoAutoplay = ComponentAppearance.VideoAutoplay.NEVER, + ), + ) { + val media = item.media + MediaItem( + media = media, + showCountdown = false, + modifier = + Modifier + .clip(FluentTheme.shapes.control) + .padding( + vertical = 4.dp, + ).clipToBounds() + .clickable { + val content = item.status.content + if (content is UiTimeline.ItemContent.Status) { // onItemClicked( // content.statusKey, // item.index, @@ -230,15 +226,14 @@ internal fun ProfileScreen( // else -> null // }, // ) - } - }, - ) - } + } + }, + ) } } - is ProfileState.Tab.Timeline -> { - status(tab.data) - } + } + is ProfileState.Tab.Timeline -> { + status(tab.data) } } } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt index 7cfeb640e..56f9eebf2 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt @@ -25,9 +25,6 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.konyaco.fluent.component.AccentButton -import com.konyaco.fluent.component.ProgressBar -import com.konyaco.fluent.component.Text import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.AnglesUp @@ -46,6 +43,9 @@ 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 +import io.github.composefluent.component.AccentButton +import io.github.composefluent.component.ProgressBar +import io.github.composefluent.component.Text import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.mapNotNull diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/list/AllListScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/list/AllListScreen.kt index f36a3902c..bbb485d87 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/list/AllListScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/list/AllListScreen.kt @@ -13,8 +13,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.konyaco.fluent.FluentTheme -import com.konyaco.fluent.component.SubtleButton import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.EllipsisVertical @@ -28,6 +26,8 @@ import dev.dimension.flare.ui.component.uiListItemComponent import dev.dimension.flare.ui.model.UiList import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.presenter.list.AllListPresenter +import io.github.composefluent.FluentTheme +import io.github.composefluent.component.SubtleButton import moe.tlaster.precompose.molecule.producePresenter import org.jetbrains.compose.resources.stringResource diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt index 5bcca564b..5b7749404 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt @@ -33,10 +33,6 @@ import coil3.compose.SubcomposeAsyncImage import coil3.request.ImageRequest import coil3.request.crossfade import coil3.size.Size -import com.konyaco.fluent.FluentTheme -import com.konyaco.fluent.component.GridViewItem -import com.konyaco.fluent.component.HorizontalFlipView -import com.konyaco.fluent.component.SubtleButton import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.Chalkboard @@ -59,15 +55,21 @@ import dev.dimension.flare.ui.model.onSuccess import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.presenter.status.StatusPresenter import dev.dimension.flare.ui.presenter.status.StatusState +import io.github.composefluent.FluentTheme +import io.github.composefluent.component.GridViewItem +import io.github.composefluent.component.HorizontalFlipView +import io.github.composefluent.component.SubtleButton import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch +import me.saket.telephoto.ExperimentalTelephotoApi +import me.saket.telephoto.zoomable.Viewport import me.saket.telephoto.zoomable.ZoomSpec import me.saket.telephoto.zoomable.rememberZoomableState +import me.saket.telephoto.zoomable.spatial.CoordinateSpace import me.saket.telephoto.zoomable.zoomable import moe.tlaster.precompose.molecule.producePresenter import org.jetbrains.compose.resources.stringResource import javax.swing.JFileChooser -import kotlin.collections.orEmpty @Composable internal fun StatusMediaScreen( @@ -230,6 +232,7 @@ internal fun StatusMediaScreen( } } +@OptIn(ExperimentalTelephotoApi::class) @Composable private fun ImageItem( url: String, @@ -255,8 +258,10 @@ private fun ImageItem( } ?: setLockPager(false) } val aspectRatio = - remember(zoomableState.transformedContentBounds) { - zoomableState.transformedContentBounds.let { + remember(zoomableState.coordinateSystem.unscaledContentBounds) { + with(zoomableState.coordinateSystem) { + unscaledContentBounds.rectIn(CoordinateSpace.Viewport) + }.let { it.height / it.width } } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt index 0a6e96582..59f92a271 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt @@ -27,13 +27,6 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.konyaco.fluent.FluentTheme -import com.konyaco.fluent.component.AccentButton -import com.konyaco.fluent.component.ProgressBar -import com.konyaco.fluent.component.ProgressRing -import com.konyaco.fluent.component.Text -import com.konyaco.fluent.component.TextField -import com.konyaco.fluent.surface.Card import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.CircleQuestion @@ -61,6 +54,13 @@ import dev.dimension.flare.ui.model.takeSuccess import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.presenter.login.ServiceSelectPresenter import dev.dimension.flare.ui.presenter.login.ServiceSelectState +import io.github.composefluent.FluentTheme +import io.github.composefluent.component.AccentButton +import io.github.composefluent.component.ProgressBar +import io.github.composefluent.component.ProgressRing +import io.github.composefluent.component.Text +import io.github.composefluent.component.TextField +import io.github.composefluent.surface.Card import kotlinx.coroutines.flow.distinctUntilChanged import moe.tlaster.precompose.molecule.producePresenter import org.apache.commons.lang3.SystemUtils diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/StatusScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/StatusScreen.kt index e0ec9a9b2..614c9889b 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/StatusScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/StatusScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.konyaco.fluent.component.ProgressBar import dev.dimension.flare.RegisterTabCallback import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey @@ -24,6 +23,7 @@ import dev.dimension.flare.ui.component.status.LazyStatusVerticalStaggeredGrid import dev.dimension.flare.ui.component.status.status import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.presenter.status.StatusContextPresenter +import io.github.composefluent.component.ProgressBar import kotlinx.coroutines.launch import moe.tlaster.precompose.molecule.producePresenter diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOCommentScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOCommentScreen.kt index eb50302a2..3a61008d3 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOCommentScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOCommentScreen.kt @@ -15,7 +15,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.konyaco.fluent.component.ProgressBar import dev.dimension.flare.RegisterTabCallback import dev.dimension.flare.common.onSuccess import dev.dimension.flare.model.AccountType @@ -27,6 +26,7 @@ import dev.dimension.flare.ui.model.onLoading import dev.dimension.flare.ui.model.onSuccess import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.presenter.status.VVOCommentPresenter +import io.github.composefluent.component.ProgressBar import kotlinx.coroutines.launch import moe.tlaster.precompose.molecule.producePresenter diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOStatusScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOStatusScreen.kt index fc7027209..14a58d24e 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOStatusScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOStatusScreen.kt @@ -21,10 +21,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.konyaco.fluent.component.SegmentedButton -import com.konyaco.fluent.component.SegmentedControl -import com.konyaco.fluent.component.SegmentedItemPosition -import com.konyaco.fluent.component.Text import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.FileCircleExclamation @@ -49,6 +45,10 @@ import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.presenter.status.VVOStatusDetailPresenter import dev.dimension.flare.ui.presenter.status.VVOStatusDetailState import dev.dimension.flare.ui.theme.screenHorizontalPadding +import io.github.composefluent.component.SegmentedButton +import io.github.composefluent.component.SegmentedControl +import io.github.composefluent.component.SegmentedItemPosition +import io.github.composefluent.component.Text import moe.tlaster.precompose.molecule.producePresenter import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/action/AddReactionSheet.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/action/AddReactionSheet.kt index f23036c48..09279b975 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/action/AddReactionSheet.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/action/AddReactionSheet.kt @@ -6,7 +6,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.konyaco.fluent.component.Flyout import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.component.EmojiPicker @@ -14,6 +13,7 @@ import dev.dimension.flare.ui.model.onSuccess import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.presenter.status.action.AddReactionPresenter import dev.dimension.flare.ui.theme.screenHorizontalPadding +import io.github.composefluent.component.Flyout import moe.tlaster.precompose.molecule.producePresenter @Composable diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/action/BlueskyReportStatusDialog.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/action/BlueskyReportStatusDialog.kt index ffeef8f05..901f5fcf0 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/action/BlueskyReportStatusDialog.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/action/BlueskyReportStatusDialog.kt @@ -5,11 +5,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import com.konyaco.fluent.component.ContentDialog -import com.konyaco.fluent.component.ContentDialogButton -import com.konyaco.fluent.component.ListItem -import com.konyaco.fluent.component.RadioButton -import com.konyaco.fluent.component.Text import dev.dimension.flare.Res import dev.dimension.flare.cancel import dev.dimension.flare.model.AccountType @@ -33,6 +28,11 @@ import dev.dimension.flare.ui.model.onSuccess import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.presenter.status.action.BlueskyReportStatusPresenter import dev.dimension.flare.ui.presenter.status.action.BlueskyReportStatusState +import io.github.composefluent.component.ContentDialog +import io.github.composefluent.component.ContentDialogButton +import io.github.composefluent.component.ListItem +import io.github.composefluent.component.RadioButton +import io.github.composefluent.component.Text import moe.tlaster.precompose.molecule.producePresenter import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/action/DeleteStatusConfirmDialog.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/action/DeleteStatusConfirmDialog.kt index 44305636a..655d30211 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/action/DeleteStatusConfirmDialog.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/action/DeleteStatusConfirmDialog.kt @@ -3,9 +3,6 @@ package dev.dimension.flare.ui.screen.status.action import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import com.konyaco.fluent.component.ContentDialog -import com.konyaco.fluent.component.ContentDialogButton -import com.konyaco.fluent.component.Text import dev.dimension.flare.Res import dev.dimension.flare.cancel import dev.dimension.flare.delete @@ -16,6 +13,9 @@ import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.presenter.status.action.DeleteStatusPresenter import dev.dimension.flare.ui.presenter.status.action.DeleteStatusState +import io.github.composefluent.component.ContentDialog +import io.github.composefluent.component.ContentDialogButton +import io.github.composefluent.component.Text import moe.tlaster.precompose.molecule.producePresenter import org.jetbrains.compose.resources.stringResource diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/action/MastodonReportDialog.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/action/MastodonReportDialog.kt index edf4b690c..e9aca8299 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/action/MastodonReportDialog.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/action/MastodonReportDialog.kt @@ -3,9 +3,6 @@ package dev.dimension.flare.ui.screen.status.action import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import com.konyaco.fluent.component.ContentDialog -import com.konyaco.fluent.component.ContentDialogButton -import com.konyaco.fluent.component.Text import dev.dimension.flare.Res import dev.dimension.flare.cancel import dev.dimension.flare.mastodon_report_description @@ -15,6 +12,9 @@ import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.report import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.presenter.status.action.MastodonReportPresenter +import io.github.composefluent.component.ContentDialog +import io.github.composefluent.component.ContentDialogButton +import io.github.composefluent.component.Text import moe.tlaster.precompose.molecule.producePresenter import org.jetbrains.compose.resources.stringResource diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/action/MisskeyReportDialog.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/action/MisskeyReportDialog.kt index c2bc61336..6096d8228 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/action/MisskeyReportDialog.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/action/MisskeyReportDialog.kt @@ -10,10 +10,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.konyaco.fluent.component.ContentDialog -import com.konyaco.fluent.component.ContentDialogButton -import com.konyaco.fluent.component.Text -import com.konyaco.fluent.component.TextField import dev.dimension.flare.Res import dev.dimension.flare.cancel import dev.dimension.flare.model.AccountType @@ -23,6 +19,10 @@ import dev.dimension.flare.report_description import dev.dimension.flare.report_title import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.presenter.status.action.MisskeyReportPresenter +import io.github.composefluent.component.ContentDialog +import io.github.composefluent.component.ContentDialogButton +import io.github.composefluent.component.Text +import io.github.composefluent.component.TextField import moe.tlaster.precompose.molecule.producePresenter import org.jetbrains.compose.resources.stringResource diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt index 13215ab51..22c2d5f3d 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt @@ -21,12 +21,12 @@ import androidx.compose.ui.node.DrawModifierNode import androidx.compose.ui.node.invalidateDraw import androidx.compose.ui.unit.dp import androidx.compose.ui.window.FrameWindowScope -import com.konyaco.fluent.ExperimentalFluentApi -import com.konyaco.fluent.FluentTheme -import com.konyaco.fluent.darkColors -import com.konyaco.fluent.lightColors -import com.mayakapps.compose.windowstyler.WindowFrameStyle -import com.mayakapps.compose.windowstyler.WindowStyleManager +import dev.dimension.flare.ui.component.ComponentAppearance +import dev.dimension.flare.ui.component.LocalComponentAppearance +import io.github.composefluent.ExperimentalFluentApi +import io.github.composefluent.FluentTheme +import io.github.composefluent.darkColors +import io.github.composefluent.lightColors import kotlinx.coroutines.launch import org.apache.commons.lang3.SystemUtils @@ -45,17 +45,17 @@ internal fun FrameWindowScope.FlareTheme( lightColors() }, ) { - val micaBase = FluentTheme.colors.background.mica.base - LaunchedEffect(window, isDarkTheme) { - WindowStyleManager( - window = window, - isDarkTheme = isDarkTheme, - frameStyle = - WindowFrameStyle( - titleBarColor = micaBase, - ), - ) - } +// val micaBase = FluentTheme.colors.background.mica.base +// LaunchedEffect(window, isDarkTheme) { +// WindowStyleManager( +// window = window, +// isDarkTheme = isDarkTheme, +// frameStyle = +// WindowFrameStyle( +// titleBarColor = micaBase, +// ), +// ) +// } if (SystemUtils.IS_OS_MAC) { LaunchedEffect(window) { window.rootPane.apply { @@ -65,12 +65,14 @@ internal fun FrameWindowScope.FlareTheme( } } } + CompositionLocalProvider( LocalIndication provides FluentIndication( hover = Color.Transparent, pressed = Color.Transparent, ), + LocalComponentAppearance provides ComponentAppearance(), ) { Box( modifier = diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 62f01243b..780206a31 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,7 @@ clikt = "5.0.3" collection = "1.5.0" compileSdk = "36" haze = "1.6.10" -lifecycleViewmodelCompose = "2.9.1" +lifecycleViewmodelComposeVersion = "2.9.2" minSdk = "23" java = "21" agp = "8.12.0" @@ -70,6 +70,7 @@ lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtim lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle-runtime-ktx" } activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } +lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelComposeVersion" } openai-client = { module = "com.aallam.openai:openai-client", version.ref = "openaiClient" } reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } stately-iso-collections = { module = "co.touchlab:stately-iso-collections", version.ref = "stately" } @@ -190,15 +191,13 @@ compose-cupertino = { module = "io.github.alexzhirkevich:cupertino-core", versio xmlUtil = { module = "io.github.pdvrieze.xmlutil:serialization", version = "0.91.2" } -fluent-ui = { module = "com.konyaco:fluent", version = "0.1.0-SNAPSHOT" } - -window-styler = { module = "com.mayakapps.compose:window-styler", version = "0.3.3-SNAPSHOT" } +fluent-ui = { module = "io.github.compose-fluent:fluent", version = "v0.1.0" } commons-lang3 = { module = "org.apache.commons:commons-lang3", version = "3.18.0" } jSystemThemeDetector = { module = "com.github.Dansoftowner:jSystemThemeDetector", version = "3.9.1" } -jetbrains-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version = "2.9.0-beta04" } +jetbrains-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version = "2.9.0-beta05" } krypto = { module = "com.soywiz:korlibs-crypto", version = "6.0.1" } androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" } diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlaceHolder.jvm.kt b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlaceHolder.jvm.kt index 94ae7b405..e64b434bc 100644 --- a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlaceHolder.jvm.kt +++ b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlaceHolder.jvm.kt @@ -9,7 +9,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.graphics.isSpecified -import com.konyaco.fluent.FluentTheme +import io.github.composefluent.FluentTheme import io.github.fornewid.placeholder.foundation.PlaceholderHighlight import io.github.fornewid.placeholder.foundation.placeholder diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.jvm.kt b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.jvm.kt index 6442079d0..bdd5e960f 100644 --- a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.jvm.kt +++ b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.jvm.kt @@ -3,9 +3,9 @@ package dev.dimension.flare.ui.component.platform import androidx.compose.foundation.layout.RowScope import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.konyaco.fluent.component.AccentButton -import com.konyaco.fluent.component.Button -import com.konyaco.fluent.component.SubtleButton +import io.github.composefluent.component.AccentButton +import io.github.composefluent.component.Button +import io.github.composefluent.component.SubtleButton @Composable internal actual fun PlatformButton( diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCard.jvm.kt b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCard.jvm.kt index b3cd62187..cbf3b22a1 100644 --- a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCard.jvm.kt +++ b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCard.jvm.kt @@ -4,9 +4,9 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape -import com.konyaco.fluent.FluentTheme -import com.konyaco.fluent.surface.Card -import com.konyaco.fluent.surface.CardDefaults +import io.github.composefluent.FluentTheme +import io.github.composefluent.surface.Card +import io.github.composefluent.surface.CardDefaults @Composable internal actual fun PlatformCard( diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCheckbox.jvm.kt b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCheckbox.jvm.kt index 75f894c05..52852674e 100644 --- a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCheckbox.jvm.kt +++ b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCheckbox.jvm.kt @@ -4,8 +4,8 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import com.konyaco.fluent.component.CheckBox -import com.konyaco.fluent.component.RadioButton +import io.github.composefluent.component.CheckBox +import io.github.composefluent.component.RadioButton @Composable internal actual fun PlatformCheckbox( diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformDropdown.jvm.kt b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformDropdown.jvm.kt index 87b46770f..d599e189c 100644 --- a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformDropdown.jvm.kt +++ b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformDropdown.jvm.kt @@ -2,9 +2,9 @@ package dev.dimension.flare.ui.component.platform import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.konyaco.fluent.component.MenuFlyout -import com.konyaco.fluent.component.MenuFlyoutItem -import com.konyaco.fluent.component.MenuFlyoutScope +import io.github.composefluent.component.MenuFlyout +import io.github.composefluent.component.MenuFlyoutItem +import io.github.composefluent.component.MenuFlyoutScope @Composable internal actual fun PlatformDropdownMenu( diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIcon.jvm.kt b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIcon.jvm.kt index c4db5d5c5..4bd539a86 100644 --- a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIcon.jvm.kt +++ b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIcon.jvm.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import com.konyaco.fluent.component.Icon +import io.github.composefluent.component.Icon @Composable internal actual fun PlatformIcon( diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIndication.jvm.kt b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIndication.jvm.kt index b9d32f652..07603469e 100644 --- a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIndication.jvm.kt +++ b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIndication.jvm.kt @@ -16,7 +16,7 @@ import androidx.compose.ui.node.DrawModifierNode import androidx.compose.ui.node.invalidateDraw import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.isUnspecified -import com.konyaco.fluent.FluentTheme +import io.github.composefluent.FluentTheme import kotlinx.coroutines.launch @Composable diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformProgressIndicator.jvm.kt b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformProgressIndicator.jvm.kt index b54da6109..aeb2b99ef 100644 --- a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformProgressIndicator.jvm.kt +++ b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformProgressIndicator.jvm.kt @@ -3,9 +3,9 @@ package dev.dimension.flare.ui.component.platform import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import com.konyaco.fluent.FluentTheme -import com.konyaco.fluent.component.ProgressBar -import com.konyaco.fluent.component.ProgressRing +import io.github.composefluent.FluentTheme +import io.github.composefluent.component.ProgressBar +import io.github.composefluent.component.ProgressRing @Composable internal actual fun PlatformLinearProgressIndicator( diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformSlider.jvm.kt b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformSlider.jvm.kt index ded55c44d..5d055661d 100644 --- a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformSlider.jvm.kt +++ b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformSlider.jvm.kt @@ -2,7 +2,7 @@ package dev.dimension.flare.ui.component.platform import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.konyaco.fluent.component.Slider +import io.github.composefluent.component.Slider @Composable internal actual fun PlatformSlider( diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformText.jvm.kt b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformText.jvm.kt index e7c0fe498..7b754b08b 100644 --- a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformText.jvm.kt +++ b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformText.jvm.kt @@ -15,8 +15,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.TextUnit -import com.konyaco.fluent.LocalTextStyle -import com.konyaco.fluent.component.Text +import io.github.composefluent.LocalTextStyle +import io.github.composefluent.component.Text @Composable internal actual fun PlatformText( diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformColorScheme.jvm.kt b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformColorScheme.jvm.kt index 8afa6f38c..124fbe782 100644 --- a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformColorScheme.jvm.kt +++ b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformColorScheme.jvm.kt @@ -2,7 +2,7 @@ package dev.dimension.flare.ui.theme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color -import com.konyaco.fluent.FluentTheme +import io.github.composefluent.FluentTheme internal actual object PlatformColorScheme { actual val primary: Color diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.jvm.kt b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.jvm.kt index ea865ad96..9b20aab29 100644 --- a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.jvm.kt +++ b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.jvm.kt @@ -1,8 +1,11 @@ package dev.dimension.flare.ui.theme +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Shape -import com.konyaco.fluent.FluentTheme +import androidx.compose.ui.unit.dp +import io.github.composefluent.FluentTheme internal actual object PlatformShapes { actual val extraSmall: Shape @@ -17,4 +20,28 @@ internal actual object PlatformShapes { actual val large: Shape @Composable get() = FluentTheme.shapes.overlay + actual val topCardShape: Shape + @Composable + get() = + RoundedCornerShape( + topStart = 8.dp, + topEnd = 8.dp, + bottomStart = 4.dp, + bottomEnd = 4.dp, + ) + actual val bottomCardShape: Shape + @Composable + get() = + RoundedCornerShape( + topStart = 4.dp, + topEnd = 4.dp, + bottomStart = 8.dp, + bottomEnd = 8.dp, + ) + actual val listCardContainerShape: CornerBasedShape + @Composable + get() = RoundedCornerShape(4.dp) + actual val listCardItemShape: CornerBasedShape + @Composable + get() = RoundedCornerShape(4.dp) } diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformTypography.jvm.kt b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformTypography.jvm.kt index 5f305407c..2991a5c46 100644 --- a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformTypography.jvm.kt +++ b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformTypography.jvm.kt @@ -2,7 +2,7 @@ package dev.dimension.flare.ui.theme import androidx.compose.runtime.Composable import androidx.compose.ui.text.TextStyle -import com.konyaco.fluent.FluentTheme +import io.github.composefluent.FluentTheme internal actual object PlatformTypography { actual val caption: TextStyle diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/Theme.jvm.kt b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/Theme.jvm.kt index 9a6dcc2a7..5da9ec84e 100644 --- a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/Theme.jvm.kt +++ b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/Theme.jvm.kt @@ -3,8 +3,8 @@ package dev.dimension.flare.ui.theme import androidx.compose.runtime.Composable import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.ui.graphics.Color -import com.konyaco.fluent.FluentTheme -import com.konyaco.fluent.LocalContentColor +import io.github.composefluent.FluentTheme +import io.github.composefluent.LocalContentColor @Composable internal actual fun isLightTheme(): Boolean = !FluentTheme.colors.darkMode From 46bc0545bc467ce756718867f4808f9ba5e81475 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Tue, 19 Aug 2025 00:11:11 +0900 Subject: [PATCH 02/33] add desktop ci --- .github/workflows/desktop.yml | 42 +++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/desktop.yml diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml new file mode 100644 index 000000000..bbb5ed0cc --- /dev/null +++ b/.github/workflows/desktop.yml @@ -0,0 +1,42 @@ +name: Android CI + +on: + push: + branches: + - master + - release + - develop + tags: + - '**' + paths-ignore: + - '**.md' + - '**.yml' + pull_request: + branches: + - master + - release + - develop + +jobs: + build: + runs-on: [ubuntu-latest] + timeout-minutes: 30 + + steps: + - uses: yumis-coconudge/clean-workspace-action@v1 + + - uses: actions/checkout@v3 + with: + submodules: true + - name: Set up JDK + uses: actions/setup-java@v3 + with: + distribution: 'zulu' + java-version: 21 + + # Build with Gradle + - name: Build with Gradle + env: + BUILD_NUMBER: ${{github.run_number}} + BUILD_VERSION: ${{github.ref_name}} + run: ./gradlew :desktop:build :desktop:check --stacktrace From 5016c8fd3a1c2e727c821853d805007fd90ee4b2 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Tue, 19 Aug 2025 00:11:29 +0900 Subject: [PATCH 03/33] update desktop ci name --- .github/workflows/desktop.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index bbb5ed0cc..a644f851a 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -1,4 +1,4 @@ -name: Android CI +name: Desktop CI on: push: From 2a867cad3cd4d39ed14c41974aab0c785d336b15 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Tue, 19 Aug 2025 00:39:41 +0900 Subject: [PATCH 04/33] update route stack manager --- .../main/kotlin/dev/dimension/flare/App.kt | 6 +- .../dev/dimension/flare/ui/route/Router.kt | 9 +- .../dimension/flare/ui/route/StackManager.kt | 146 +++++++++++------- 3 files changed, 101 insertions(+), 60 deletions(-) diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt index 147751e6c..3de4e5c69 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt @@ -103,7 +103,11 @@ internal fun FlareApp( state.tabs.onSuccess { tabs -> val stackManager = - rememberStackManager(startRoute = getRoute(tabs.primary.first().tabItem), key = tabs) + rememberStackManager( + startRoute = getRoute(tabs.primary.first().tabItem), + key = tabs, + topLevelRoutes = tabs.primary.map { getRoute(it.tabItem) }, + ) val currentRoute = remember(stackManager.stack) { stackManager.current diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt index ef0b042f2..ef0174782 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import dev.dimension.flare.ui.screen.home.ProfileScreen import dev.dimension.flare.ui.screen.home.TimelineScreen +import dev.dimension.flare.ui.screen.serviceselect.ServiceSelectScreen import io.github.composefluent.component.Text @OptIn(ExperimentalComposeUiApi::class) @@ -57,7 +58,13 @@ internal fun Router( Text("rss") } Route.ServiceSelect -> { - Text("route") + ServiceSelectScreen( + onBack = ::onBack, + onVVO = { + }, + onXQT = { + }, + ) } Route.Settings -> { Text("route") diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/StackManager.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/StackManager.kt index 874a1c5da..b1e3a6ed6 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/StackManager.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/StackManager.kt @@ -20,11 +20,14 @@ import androidx.lifecycle.viewmodel.CreationExtras import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import androidx.lifecycle.viewmodel.compose.viewModel import kotlin.reflect.KClass +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid @Composable internal fun rememberStackManager( startRoute: Route, key: Any? = null, + topLevelRoutes: List = emptyList(), ): StackManager { val navControllerViewModel = viewModel() val savableStateHolder = rememberSaveableStateHolder() @@ -34,12 +37,14 @@ internal fun rememberStackManager( startRoute = startRoute, navControllerViewModel = navControllerViewModel, savableStateHolder = savableStateHolder, + topLevelRoutes = topLevelRoutes, ) } } internal class StackManager( private val startRoute: Route, + private val topLevelRoutes: List, private val navControllerViewModel: NavControllerViewModel, private val savableStateHolder: SaveableStateHolder, ) { @@ -48,7 +53,6 @@ internal class StackManager( private var _current = mutableStateOf( stack.lastOrNull() ?: Entry( - id = "0", route = startRoute, viewModelStoreProvider = navControllerViewModel, savableStateHolder = savableStateHolder, @@ -72,14 +76,36 @@ internal class StackManager( if (stack.isNotEmpty() && stack.last().route == route) { return } - val entry = - Entry( - id = (stack.size).toString(), - route = route, - viewModelStoreProvider = navControllerViewModel, - savableStateHolder = savableStateHolder, - ) - stack.add(entry) + if (route in topLevelRoutes) { + val entry = stack.find { it.route == route } + if (entry != null) { + // remove rest of the stack and set the entry as current + for (item in stack) { + if (item != entry && item.route !in topLevelRoutes) { + item.setWillBeCleared() + } + } + stack.removeAll { it.route !in topLevelRoutes || it == entry } + stack.add(entry) + } else { + val entry = + Entry( + route = route, + viewModelStoreProvider = navControllerViewModel, + savableStateHolder = savableStateHolder, + ) + stack.add(entry) + } + } else { + val entry = + Entry( + route = route, + viewModelStoreProvider = navControllerViewModel, + savableStateHolder = savableStateHolder, + ) + stack.add(entry) + } + updateEntry() } @@ -100,7 +126,6 @@ internal class StackManager( } else { _current.value = Entry( - id = "0", route = startRoute, viewModelStoreProvider = navControllerViewModel, savableStateHolder = savableStateHolder, @@ -116,65 +141,70 @@ internal class StackManager( stack.clear() } - data class Entry( - private val id: String, - val route: Route, - private val viewModelStoreProvider: ViewModelStoreProvider, - private val savableStateHolder: SaveableStateHolder, - ) : LifecycleOwner, - ViewModelStoreOwner { - private var willBeCleared = false - private val lifecycleRegistry = - androidx.lifecycle.LifecycleRegistry(this).apply { - handleLifecycleEvent(Lifecycle.Event.ON_CREATE) - } + data class Entry + @OptIn(ExperimentalUuidApi::class) + constructor( + private val id: String = Uuid.random().toString(), + val route: Route, + private val viewModelStoreProvider: ViewModelStoreProvider, + private val savableStateHolder: SaveableStateHolder, + ) : LifecycleOwner, + ViewModelStoreOwner { + private var willBeCleared = false + private val lifecycleRegistry = + androidx.lifecycle.LifecycleRegistry(this).apply { + handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + } - override val lifecycle: Lifecycle - get() = lifecycleRegistry + override val lifecycle: Lifecycle + get() = lifecycleRegistry - fun active() { - lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) - } + fun active() { + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + } - fun inactive() { - if (willBeCleared) { - savableStateHolder.removeState(id) - viewModelStoreProvider.clear(id) - lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) - println("Lifecycle for $id is destroyed and cleared.") - } else { - lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP) + fun inactive() { + if (willBeCleared) { + savableStateHolder.removeState(id) + viewModelStoreProvider.clear(id) + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) + println("Entry $id is cleared and destroyed") + } else { + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP) + } } - } - fun setWillBeCleared() { - willBeCleared = true - } + fun setWillBeCleared() { + willBeCleared = true + if (lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) { + inactive() + } + } - override val viewModelStore by lazy { - viewModelStoreProvider.getViewModelStore(id) - } + override val viewModelStore by lazy { + viewModelStoreProvider.getViewModelStore(id) + } - @Composable - fun Content(content: @Composable (route: Route) -> Unit) { - CompositionLocalProvider( - LocalLifecycleOwner provides this, - LocalViewModelStoreOwner provides this, - ) { - savableStateHolder.SaveableStateProvider(id) { - content.invoke(route) + @Composable + fun Content(content: @Composable (route: Route) -> Unit) { + CompositionLocalProvider( + LocalLifecycleOwner provides this, + LocalViewModelStoreOwner provides this, + ) { + savableStateHolder.SaveableStateProvider(id) { + content.invoke(route) + } } - } - DisposableEffect(Unit) { - // Activate the lifecycle when the composable is composed - active() - onDispose { - // Inactive the lifecycle when the composable is disposed - inactive() + DisposableEffect(Unit) { + // Activate the lifecycle when the composable is composed + active() + onDispose { + // Inactive the lifecycle when the composable is disposed + inactive() + } } } } - } } interface ViewModelStoreProvider { From a2e6fa8d9923b79696bc26fc8346f144be7de2c4 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Tue, 19 Aug 2025 07:01:12 +0900 Subject: [PATCH 05/33] update desktop ui --- .../main/composeResources/values/strings.xml | 5 + .../main/kotlin/dev/dimension/flare/App.kt | 324 ++++++++++-------- .../main/kotlin/dev/dimension/flare/Main.kt | 2 +- .../dimension/flare/ui/component/TabIcon.kt | 15 + .../dev/dimension/flare/ui/route/Router.kt | 72 +++- .../dimension/flare/ui/route/StackManager.kt | 41 ++- .../flare/ui/screen/feeds/FeedListScreen.kt | 24 +- .../flare/ui/screen/home/ProfileScreen.kt | 20 ++ .../flare/ui/screen/list/AllListScreen.kt | 64 ++-- .../serviceselect/ServiceSelectScreen.kt | 123 ++++--- .../dimension/flare/ui/theme/FlareTheme.kt | 18 +- .../platform/PlatformListItem.jvm.kt | 6 +- 12 files changed, 447 insertions(+), 267 deletions(-) diff --git a/desktopApp/src/main/composeResources/values/strings.xml b/desktopApp/src/main/composeResources/values/strings.xml index 73aa8a850..fdca647ae 100644 --- a/desktopApp/src/main/composeResources/values/strings.xml +++ b/desktopApp/src/main/composeResources/values/strings.xml @@ -119,4 +119,9 @@ Antenna Mixed + + Username + Password + Two-factor authentication code + \ No newline at end of file diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt index 3de4e5c69..186c2b240 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt @@ -1,24 +1,31 @@ package dev.dimension.flare +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height 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.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.movableContentOf 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 import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler @@ -44,10 +51,8 @@ import dev.dimension.flare.data.model.TimelineTabItem import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.component.AvatarComponent -import dev.dimension.flare.ui.component.RichText import dev.dimension.flare.ui.component.TabIcon import dev.dimension.flare.ui.component.TabTitle -import dev.dimension.flare.ui.component.platform.isBigScreen import dev.dimension.flare.ui.model.isSuccess import dev.dimension.flare.ui.model.onError import dev.dimension.flare.ui.model.onSuccess @@ -67,18 +72,14 @@ import dev.dimension.flare.ui.route.Router import dev.dimension.flare.ui.route.StackManager import dev.dimension.flare.ui.route.rememberStackManager import io.github.composefluent.FluentTheme +import io.github.composefluent.background.Layer import io.github.composefluent.component.Badge import io.github.composefluent.component.BadgeStatus import io.github.composefluent.component.Button import io.github.composefluent.component.Icon -import io.github.composefluent.component.MenuItemSeparator import io.github.composefluent.component.NavigationDefaults -import io.github.composefluent.component.NavigationDisplayMode -import io.github.composefluent.component.NavigationView import io.github.composefluent.component.SubtleButton import io.github.composefluent.component.Text -import io.github.composefluent.component.menuItem -import io.github.composefluent.component.rememberNavigationState import io.ktor.http.Url import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch @@ -91,14 +92,6 @@ internal fun FlareApp( onStatusMedia: (AccountType, MicroBlogKey, Int) -> Unit, ) { val state by producePresenter { presenter() } - val bigScreen = isBigScreen() - val displayMode = - if (bigScreen) { - NavigationDisplayMode.Left - } else { - NavigationDisplayMode.LeftCompact - } - var selectedIndex by remember { mutableStateOf(0) } val uriHandler = LocalUriHandler.current state.tabs.onSuccess { tabs -> @@ -106,12 +99,9 @@ internal fun FlareApp( rememberStackManager( startRoute = getRoute(tabs.primary.first().tabItem), key = tabs, - topLevelRoutes = tabs.primary.map { getRoute(it.tabItem) }, + topLevelRoutes = tabs.all.map { getRoute(it.tabItem) }, ) - val currentRoute = - remember(stackManager.stack) { - stackManager.current - } + val currentRoute = stackManager.current fun navigate(route: Route) { stackManager.push(route) @@ -121,125 +111,117 @@ internal fun FlareApp( stackManager.pop() } - LaunchedEffect(selectedIndex) { - val tab = tabs.all[selectedIndex] - navigate(getRoute(tab.tabItem)) - } - val navigationState = rememberNavigationState() - LaunchedEffect(bigScreen) { - navigationState.expanded = bigScreen - } - NavigationView( - state = navigationState, - displayMode = displayMode, - backButton = { + Row { + Column( + modifier = + Modifier + .background( + FluentTheme.colors.background.layerOnMicaBaseAlt.secondary, + ).fillMaxHeight() + .width(72.dp) + .verticalScroll(rememberScrollState()), + ) { NavigationDefaults.BackButton( onClick = { goBack() }, disabled = !stackManager.canGoBack, + modifier = Modifier.fillMaxWidth(), ) - }, - menuItems = { state.user .onSuccess { user -> - item { - SubtleButton( - onClick = { - }, - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - AvatarComponent( - data = user.avatar, - modifier = - Modifier - .aspectRatio(1f), - ) - if (navigationState.expanded) { - Column { - RichText(user.name, maxLines = 1) - Text( - user.handle, - style = FluentTheme.typography.caption, - maxLines = 1, - ) - } - } - } - } - } - item { - Button( - onClick = {}, + SubtleButton( + onClick = { + }, + ) { + Column( modifier = Modifier - .padding(horizontal = 8.dp, vertical = 4.dp) .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, ) { - Icon( - FontAwesomeIcons.Solid.Pen, - contentDescription = stringResource(Res.string.home_compose), - modifier = Modifier.size(16.dp), + AvatarComponent( + data = user.avatar, + modifier = + Modifier + .aspectRatio(1f), ) - if (navigationState.expanded) { - Text(stringResource(Res.string.home_compose), maxLines = 1) - } } } + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = {}, + modifier = + Modifier + .padding(horizontal = 8.dp, vertical = 4.dp) + .fillMaxWidth(), + iconOnly = true, + ) { + Icon( + FontAwesomeIcons.Solid.Pen, + contentDescription = stringResource(Res.string.home_compose), + modifier = Modifier.size(16.dp), + ) + } }.onError { - item { - Button( - onClick = { - navigate(Route.ServiceSelect) - }, - modifier = - Modifier - .padding(horizontal = 8.dp, vertical = 4.dp) - .fillMaxWidth(), + Button( + onClick = { + navigate(Route.ServiceSelect) + }, + modifier = + Modifier + .padding(horizontal = 8.dp, vertical = 4.dp) + .fillMaxWidth(), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), ) { Icon( FontAwesomeIcons.Solid.UserPlus, contentDescription = stringResource(Res.string.home_login), modifier = Modifier.size(16.dp), ) - if (navigationState.expanded) { - Text(stringResource(Res.string.home_login), maxLines = 1) - } + Text(stringResource(Res.string.home_login), maxLines = 1) } } } - item { - MenuItemSeparator() - } - fun buildMenuItem( - tab: HomeTabsPresenter.State.HomeTabState.HomeTabItem, - index: Int, - ) { - menuItem( - selected = currentRoute == getRoute(tab.tabItem), + @Composable + fun buildMenuItem(tab: HomeTabsPresenter.State.HomeTabState.HomeTabItem) { + val selected = currentRoute == getRoute(tab.tabItem) + val color by animateColorAsState( + targetValue = + if (selected) { + FluentTheme.colors.system.attention + } else { + FluentTheme.colors.system.neutral + }, + ) + NavigationItem( onClick = { - if (selectedIndex == index) { + if (selected) { state.scrollToTopRegistry.scrollToTop() } else { - selectedIndex = index + navigate(getRoute(tab.tabItem)) } }, icon = { TabIcon( tab.tabItem, iconOnly = tabs.secondaryIconOnly, + color = color, + modifier = + Modifier + .size(24.dp), ) }, text = { - TabTitle(tab.tabItem.metaData.title) + TabTitle( + tab.tabItem.metaData.title, + color = color, + style = FluentTheme.typography.caption, + ) }, badge = if (tab.badgeCountState.isSuccess) { @@ -258,49 +240,52 @@ internal fun FlareApp( }, ) } - tabs.primary.forEachIndexed { index, tab -> - if (tab.tabItem !is SettingsTabItem) { - buildMenuItem(tab, index) - } + tabs.primary.forEachIndexed { _, tab -> + buildMenuItem(tab) } if (tabs.secondary.any()) { - item { - MenuItemSeparator() - } - tabs.secondary.forEachIndexed { index, tab -> - buildMenuItem(tab, index + tabs.primary.size) + tabs.secondary.forEachIndexed { _, tab -> + buildMenuItem(tab) } } - }, - title = { - Text(stringResource(Res.string.app_name)) - }, - contentPadding = PaddingValues(top = 8.dp), - footerItems = { - menuItem( - selected = currentRoute == Route.Settings, - onClick = { - navigate(Route.Settings) - }, - icon = { - Icon( - FontAwesomeIcons.Solid.Gear, - contentDescription = stringResource(Res.string.home_settings), - ) - }, - text = { - Text(stringResource(Res.string.home_settings)) - }, - ) - }, - ) { - val currentTab = - remember(tabs, selectedIndex) { - tabs.all.getOrNull(selectedIndex) + + state.user.onSuccess { + val selected = currentRoute == Route.Settings + val color by animateColorAsState( + targetValue = + if (selected) { + FluentTheme.colors.system.attention + } else { + FluentTheme.colors.system.neutral + }, + ) + Spacer(modifier = Modifier.weight(1f)) + NavigationItem( + icon = { + Icon( + FontAwesomeIcons.Solid.Gear, + contentDescription = stringResource(Res.string.home_settings), + modifier = Modifier.size(24.dp), + tint = color, + ) + }, + text = { + Text( + stringResource(Res.string.home_settings), + maxLines = 1, + style = FluentTheme.typography.caption, + color = color, + ) + }, + onClick = { + navigate(Route.Settings) + }, + ) } + } CompositionLocalProvider( LocalUriHandler provides - remember { + remember(uriHandler, stackManager, onRawImage, onStatusMedia) { ProxyUriHandler( stackManager = stackManager, actualUriHandler = uriHandler, @@ -310,14 +295,65 @@ internal fun FlareApp( }, LocalScrollToTopRegistry provides state.scrollToTopRegistry, ) { - Router( - manager = stackManager, - ) + Layer( + modifier = Modifier.fillMaxSize(), + ) { + Router( + manager = stackManager, + ) + } } } } } +@Composable +private fun NavigationItem( + icon: @Composable () -> Unit, + text: @Composable () -> Unit, + badge: @Composable (() -> Unit)? = null, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + SubtleButton( + onClick = onClick, + modifier = modifier, + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + NavigationItemIcon( + icon = icon, + badge = badge, + ) + text.invoke() + } + } +} + +@Composable +private fun NavigationItemIcon( + icon: @Composable () -> Unit, + badge: (@Composable () -> Unit)? = null, +) { + val iconContent = remember(icon) { movableContentOf(icon) } + if (badge != null) { + Box { + iconContent.invoke() + Badge( + status = BadgeStatus.Informational, + content = { + badge.invoke() + }, + ) + } + } else { + iconContent.invoke() + } +} + private fun getRoute(tab: TabItem): Route = when (tab) { is DiscoverTabItem -> Discover(tab.account) diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt index 7bde6d361..cd9b9e908 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt @@ -66,7 +66,7 @@ fun main(args: Array) { state = rememberWindowState( position = WindowPosition(Alignment.Center), - size = DpSize(480.dp, 720.dp), + size = DpSize(520.dp, 840.dp), ), ) { FlareTheme { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/TabIcon.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/TabIcon.kt index a1405cad2..d3d1a2ab1 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/TabIcon.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/TabIcon.kt @@ -10,6 +10,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import dev.dimension.flare.data.model.IconType import dev.dimension.flare.data.model.TabItem @@ -22,6 +25,8 @@ import dev.dimension.flare.ui.model.onSuccess import dev.dimension.flare.ui.presenter.home.UserPresenter import dev.dimension.flare.ui.presenter.invoke import io.github.composefluent.FluentTheme +import io.github.composefluent.LocalContentColor +import io.github.composefluent.LocalTextStyle import io.github.composefluent.component.Text import moe.tlaster.precompose.molecule.producePresenter import org.jetbrains.compose.resources.stringResource @@ -30,6 +35,9 @@ import org.jetbrains.compose.resources.stringResource fun TabTitle( title: TitleType, modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + style: TextStyle = LocalTextStyle.current, ) { Text( text = @@ -38,6 +46,10 @@ fun TabTitle( is TitleType.Text -> title.content }, modifier = modifier, + color = color, + fontSize = fontSize, + style = style, + maxLines = 1, ) } @@ -46,6 +58,7 @@ fun TabIcon( tabItem: TabItem, modifier: Modifier = Modifier, iconOnly: Boolean = false, + color: Color = LocalContentColor.current, ) { val accountType = tabItem.account val icon = tabItem.metaData.icon @@ -79,6 +92,7 @@ fun TabIcon( modifier = modifier .size(24.dp), + tint = color, ) } @@ -94,6 +108,7 @@ fun TabIcon( modifier = modifier .size(24.dp), + tint = color, ) } else { val userState by producePresenter(key = "$accountType:${icon.userKey}") { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt index ef0174782..084ff643c 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt @@ -4,8 +4,17 @@ import androidx.compose.animation.AnimatedContent import androidx.compose.runtime.Composable import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import dev.dimension.flare.data.model.Bluesky +import dev.dimension.flare.data.model.IconType +import dev.dimension.flare.data.model.ListTimelineTabItem +import dev.dimension.flare.data.model.TabMetaData +import dev.dimension.flare.data.model.TitleType +import dev.dimension.flare.ui.screen.feeds.FeedListScreen +import dev.dimension.flare.ui.screen.home.DiscoverScreen +import dev.dimension.flare.ui.screen.home.NotificationScreen import dev.dimension.flare.ui.screen.home.ProfileScreen import dev.dimension.flare.ui.screen.home.TimelineScreen +import dev.dimension.flare.ui.screen.list.AllListScreen import dev.dimension.flare.ui.screen.serviceselect.ServiceSelectScreen import io.github.composefluent.component.Text @@ -23,40 +32,89 @@ internal fun Router( manager.pop() } AnimatedContent( - manager.current, + manager.currentEntry, + modifier = modifier, ) { entry -> entry.Content { route -> when (route) { is Route.AllLists -> { - Text("route") + AllListScreen( + accountType = route.accountType, + onAddList = { + }, + toList = { + navigate( + Route.Timeline( + ListTimelineTabItem( + account = route.accountType, + listId = it.id, + metaData = + TabMetaData( + title = TitleType.Text(it.title), + icon = IconType.Material(IconType.Material.MaterialIcon.List), + ), + ), + ), + ) + }, + ) } + is Route.BlueskyFeeds -> { - Text("route") + FeedListScreen( + accountType = route.accountType, + toFeed = { + navigate( + Route.Timeline( + Bluesky.FeedTabItem( + account = route.accountType, + uri = it.id, + metaData = + TabMetaData( + title = TitleType.Text(it.title), + icon = IconType.Material(IconType.Material.MaterialIcon.Feeds), + ), + ), + ), + ) + }, + ) } + is Route.DirectMessage -> { Text("route") } + is Route.Discover -> { - Text("route") + DiscoverScreen( + accountType = route.accountType, + ) } + is Route.MeRoute -> { ProfileScreen( accountType = route.accountType, userKey = null, ) } + is Route.Notification -> { - Text("route") + NotificationScreen( + accountType = route.accountType, + ) } + is Route.Profile -> { ProfileScreen( accountType = route.accountType, userKey = route.userKey, ) } + Route.Rss -> { - Text("rss") + Text("route") } + Route.ServiceSelect -> { ServiceSelectScreen( onBack = ::onBack, @@ -66,9 +124,11 @@ internal fun Router( }, ) } + Route.Settings -> { Text("route") } + is Route.Timeline -> { TimelineScreen( route.tabItem, diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/StackManager.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/StackManager.kt index b1e3a6ed6..426a45d28 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/StackManager.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/StackManager.kt @@ -3,11 +3,14 @@ package dev.dimension.flare.ui.route import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.SaveableStateHolder import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.runtime.setValue import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ViewModel @@ -42,38 +45,40 @@ internal fun rememberStackManager( } } +@Stable internal class StackManager( private val startRoute: Route, private val topLevelRoutes: List, private val navControllerViewModel: NavControllerViewModel, private val savableStateHolder: SaveableStateHolder, ) { - val stack: MutableList = mutableStateListOf() - - private var _current = - mutableStateOf( - stack.lastOrNull() ?: Entry( + private val stack: MutableList = + mutableStateListOf( + Entry( route = startRoute, viewModelStoreProvider = navControllerViewModel, savableStateHolder = savableStateHolder, ), ) - val current: Entry - get() = _current.value + private var _current by + mutableStateOf( + stack.last(), + ) + + val current: Route + get() = _current.route - private var _canGoBack = mutableStateOf(false) + val currentEntry: Entry + get() = _current - val canGoBack: Boolean - get() = _canGoBack.value + private var _canGoBack by mutableStateOf(false) - init { - // Initialize the stack with the start route - push(startRoute) - } + val canGoBack: Boolean + get() = _canGoBack fun push(route: Route) { - if (stack.isNotEmpty() && stack.last().route == route) { + if (current == route) { return } if (route in topLevelRoutes) { @@ -122,16 +127,16 @@ internal class StackManager( private fun updateEntry() { if (stack.isNotEmpty()) { - _current.value = stack.last() + _current = stack.last() } else { - _current.value = + _current = Entry( route = startRoute, viewModelStoreProvider = navControllerViewModel, savableStateHolder = savableStateHolder, ) } - _canGoBack.value = stack.size > 1 + _canGoBack = stack.size > 1 } fun clear() { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedListScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedListScreen.kt index b7839bf70..8f04bc716 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedListScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedListScreen.kt @@ -2,6 +2,7 @@ package dev.dimension.flare.ui.screen.feeds import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height @@ -23,14 +24,13 @@ import dev.dimension.flare.model.AccountType import dev.dimension.flare.ui.common.items import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.UiListItem +import dev.dimension.flare.ui.component.platform.PlatformListItem import dev.dimension.flare.ui.component.status.StatusPlaceholder import dev.dimension.flare.ui.component.uiListItemComponent import dev.dimension.flare.ui.model.UiList import dev.dimension.flare.ui.presenter.home.bluesky.BlueskyFeedsPresenter import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.theme.screenHorizontalPadding -import io.github.composefluent.component.ListItem -import io.github.composefluent.component.ListItemDefaults import io.github.composefluent.component.SubtleButton import io.github.composefluent.component.Text import moe.tlaster.precompose.molecule.producePresenter @@ -47,19 +47,23 @@ internal fun FeedListScreen( Column( modifier = Modifier - .fillMaxSize(), + .fillMaxSize() + .padding(horizontal = screenHorizontalPadding), verticalArrangement = Arrangement.spacedBy(8.dp), ) { LazyColumn( + contentPadding = + PaddingValues( + vertical = 8.dp, + ), modifier = Modifier .fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(2.dp), ) { item { - ListItem( - onClick = {}, - colors = ListItemDefaults.selectedListItemColors(), - text = { + PlatformListItem( + headlineContent = { Text(stringResource(Res.string.feeds_my_feeds_title)) }, ) @@ -70,10 +74,8 @@ internal fun FeedListScreen( ) item { - ListItem( - onClick = {}, - colors = ListItemDefaults.selectedListItemColors(), - text = { + PlatformListItem( + headlineContent = { Text(stringResource(Res.string.feeds_discover_feeds_title)) }, ) diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/ProfileScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/ProfileScreen.kt index 5184896ef..692e2d26a 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/ProfileScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/ProfileScreen.kt @@ -22,6 +22,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import dev.dimension.flare.RegisterTabCallback import dev.dimension.flare.Res @@ -49,6 +51,7 @@ import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.presenter.profile.ProfilePresenter import dev.dimension.flare.ui.presenter.profile.ProfileState import dev.dimension.flare.ui.presenter.settings.AccountsPresenter +import dev.dimension.flare.ui.theme.screenHorizontalPadding import io.github.composefluent.FluentTheme import io.github.composefluent.component.SegmentedButton import io.github.composefluent.component.SegmentedControl @@ -135,6 +138,14 @@ internal fun ProfileScreen( span = StaggeredGridItemSpan.FullLine, ) { ProfileHeader( + modifier = + Modifier.let { + if (isBigScreen) { + it + } else { + it.ignoreHorizontalParentPadding(screenHorizontalPadding) + } + }, state = state.state, menu = { ProfileMenu( @@ -303,3 +314,12 @@ private fun presenter( } } } + +fun Modifier.ignoreHorizontalParentPadding(horizontal: Dp): Modifier = + this.layout { measurable, constraints -> + val overridenWidth = constraints.maxWidth + 2 * horizontal.roundToPx() + val placeable = measurable.measure(constraints.copy(maxWidth = overridenWidth)) + layout(placeable.width, placeable.height) { + placeable.place(0, 0) + } + } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/list/AllListScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/list/AllListScreen.kt index bbb485d87..36be3e9b2 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/list/AllListScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/list/AllListScreen.kt @@ -1,11 +1,9 @@ package dev.dimension.flare.ui.screen.list -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.runtime.Composable @@ -16,9 +14,7 @@ import androidx.compose.ui.unit.dp import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.EllipsisVertical -import compose.icons.fontawesomeicons.solid.Plus import dev.dimension.flare.Res -import dev.dimension.flare.list_add import dev.dimension.flare.model.AccountType import dev.dimension.flare.more import dev.dimension.flare.ui.component.FAIcon @@ -26,7 +22,7 @@ import dev.dimension.flare.ui.component.uiListItemComponent import dev.dimension.flare.ui.model.UiList import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.presenter.list.AllListPresenter -import io.github.composefluent.FluentTheme +import dev.dimension.flare.ui.theme.screenHorizontalPadding import io.github.composefluent.component.SubtleButton import moe.tlaster.precompose.molecule.producePresenter import org.jetbrains.compose.resources.stringResource @@ -43,44 +39,48 @@ internal fun AllListScreen( Column( modifier = Modifier - .fillMaxSize() - .background(FluentTheme.colors.background.mica.base), + .fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Row( - horizontalArrangement = Arrangement.End, - modifier = - Modifier - .background(FluentTheme.colors.background.layer.default) - .padding(8.dp) - .fillMaxWidth(), - ) { +// Row( +// horizontalArrangement = Arrangement.End, +// modifier = +// Modifier +// .background(FluentTheme.colors.background.layer.default) +// .padding(8.dp) +// .fillMaxWidth(), +// ) { +// // SubtleButton( +// // onClick = { +// // state.refresh() +// // } +// // ) { +// // FAIcon( +// // FontAwesomeIcons.Solid.ArrowsRotate, +// // contentDescription = stringResource(Res.string.refresh), +// // ) +// // } // SubtleButton( // onClick = { -// state.refresh() -// } +// onAddList.invoke() +// }, // ) { // FAIcon( -// FontAwesomeIcons.Solid.ArrowsRotate, -// contentDescription = stringResource(Res.string.refresh), +// FontAwesomeIcons.Solid.Plus, +// contentDescription = stringResource(Res.string.list_add), // ) // } - SubtleButton( - onClick = { - onAddList.invoke() - }, - ) { - FAIcon( - FontAwesomeIcons.Solid.Plus, - contentDescription = stringResource(Res.string.list_add), - ) - } - } +// } LazyColumn( + contentPadding = + PaddingValues( + vertical = 8.dp, + ), modifier = Modifier .fillMaxSize() - .background(FluentTheme.colors.background.layer.default), + .padding(horizontal = screenHorizontalPadding), + verticalArrangement = Arrangement.spacedBy(2.dp), ) { uiListItemComponent( state.items, diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt index 59f92a271..574c881c6 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt @@ -1,6 +1,7 @@ package dev.dimension.flare.ui.screen.serviceselect import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -10,7 +11,6 @@ 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.LazyVerticalStaggeredGrid import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -21,7 +21,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.TextFieldValue @@ -31,6 +31,9 @@ import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.CircleQuestion import dev.dimension.flare.Res +import dev.dimension.flare.bluesky_login_2fa +import dev.dimension.flare.bluesky_login_password +import dev.dimension.flare.bluesky_login_username import dev.dimension.flare.common.onEmpty import dev.dimension.flare.common.onLoading import dev.dimension.flare.common.onSuccess @@ -45,6 +48,8 @@ import dev.dimension.flare.service_select_welcome_title import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.NetworkImage import dev.dimension.flare.ui.component.platform.placeholder +import dev.dimension.flare.ui.component.status.AdaptiveCard +import dev.dimension.flare.ui.component.status.LazyStatusVerticalStaggeredGrid import dev.dimension.flare.ui.model.UiInstance import dev.dimension.flare.ui.model.isSuccess import dev.dimension.flare.ui.model.onError @@ -60,7 +65,6 @@ import io.github.composefluent.component.ProgressBar import io.github.composefluent.component.ProgressRing import io.github.composefluent.component.Text import io.github.composefluent.component.TextField -import io.github.composefluent.surface.Card import kotlinx.coroutines.flow.distinctUntilChanged import moe.tlaster.precompose.molecule.producePresenter import org.apache.commons.lang3.SystemUtils @@ -91,7 +95,10 @@ internal fun ServiceSelectScreen( verticalArrangement = Arrangement.spacedBy(16.dp), ) { Spacer(Modifier) - Text(stringResource(Res.string.service_select_welcome_title), style = FluentTheme.typography.title) + Text( + stringResource(Res.string.service_select_welcome_title), + style = FluentTheme.typography.title, + ) Text( stringResource(Res.string.service_select_welcome_message, SystemUtils.OS_NAME), textAlign = TextAlign.Center, @@ -165,6 +172,7 @@ internal fun ServiceSelectScreen( Text(it) } } + PlatformType.Misskey -> { state.misskeyLoginState.resumedState ?.onLoading { @@ -197,9 +205,11 @@ internal fun ServiceSelectScreen( Text(it) } } + PlatformType.Bluesky -> { var userName by remember { mutableStateOf(TextFieldValue("")) } var password by remember { mutableStateOf(TextFieldValue("")) } + var verifyCode by remember { mutableStateOf(TextFieldValue("")) } Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), @@ -207,18 +217,27 @@ internal fun ServiceSelectScreen( TextField( value = userName, onValueChange = { userName = it }, - placeholder = { Text("Username") }, + placeholder = { Text(stringResource(Res.string.bluesky_login_username)) }, maxLines = 1, modifier = Modifier.width(300.dp), ) TextField( value = password, onValueChange = { password = it }, - placeholder = { Text("Password") }, + placeholder = { Text(stringResource(Res.string.bluesky_login_password)) }, maxLines = 1, modifier = Modifier.width(300.dp), visualTransformation = remember { PasswordVisualTransformation() }, ) + AnimatedVisibility(state.blueskyLoginState.require2FA) { + TextField( + value = verifyCode, + onValueChange = { verifyCode = it }, + placeholder = { Text(stringResource(Res.string.bluesky_login_2fa)) }, + maxLines = 1, + modifier = Modifier.width(300.dp), + ) + } AccentButton( onClick = { state.blueskyLoginState.login( @@ -227,10 +246,16 @@ internal fun ServiceSelectScreen( .toString(), password.text .toString(), + verifyCode.text + .takeIf { it.isNotEmpty() }, ) }, modifier = Modifier.width(300.dp), - disabled = state.blueskyLoginState.loading || userName.text.isEmpty() || password.text.isEmpty(), + disabled = + state.blueskyLoginState.loading || + userName.text.isEmpty() || + password.text.isEmpty() || + (state.blueskyLoginState.require2FA && verifyCode.text.isEmpty()), ) { Text( text = stringResource(Res.string.service_select_next_button), @@ -238,6 +263,7 @@ internal fun ServiceSelectScreen( } } } + PlatformType.xQt -> { AccentButton( onClick = onXQT, @@ -248,6 +274,7 @@ internal fun ServiceSelectScreen( ) } } + PlatformType.VVo -> { AccentButton( onClick = onVVO, @@ -258,11 +285,12 @@ internal fun ServiceSelectScreen( ) } } + null -> Unit } } } - LazyVerticalStaggeredGrid( + LazyStatusVerticalStaggeredGrid( modifier = Modifier .weight(1f) @@ -273,7 +301,7 @@ internal fun ServiceSelectScreen( 8.dp, Alignment.CenterHorizontally, ), - verticalItemSpacing = 8.dp, + verticalItemSpacing = 0.dp, ) { state.instances .onSuccess { @@ -283,6 +311,8 @@ internal fun ServiceSelectScreen( val instance = get(it) ServiceSelectItem( instance = instance, + index = it, + totalCount = itemCount, onClicked = { if (instance != null) { host = TextFieldValue(instance.domain) @@ -295,6 +325,8 @@ internal fun ServiceSelectScreen( ServiceSelectItem( instance = null, onClicked = {}, + index = it, + totalCount = 10, ) } }.onEmpty { @@ -312,63 +344,68 @@ internal fun ServiceSelectScreen( @Composable private fun ServiceSelectItem( instance: UiInstance?, - modifier: Modifier = Modifier, + index: Int, + totalCount: Int, onClicked: () -> Unit, + modifier: Modifier = Modifier, ) { - Card( + AdaptiveCard( modifier = modifier, - onClick = onClicked, + index = index, + totalCount = totalCount, ) { - Box { + Column( + modifier = + Modifier + .clickable { + onClicked.invoke() + }.fillMaxWidth() + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { instance?.bannerUrl?.let { NetworkImage( it, contentDescription = null, modifier = Modifier - .matchParentSize() - .alpha(0.15f), + .clip(FluentTheme.shapes.control), ) } - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(8.dp), - horizontalAlignment = Alignment.CenterHorizontally, + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { + if (instance?.iconUrl.isNullOrEmpty() != true) { NetworkImage( - instance?.iconUrl ?: "", + instance.iconUrl, contentDescription = null, modifier = Modifier - .size(24.dp) - .placeholder(instance == null), - ) - Text( - text = instance?.name ?: "Loading...", - style = FluentTheme.typography.subtitle, - modifier = Modifier.placeholder(instance == null), + .size(24.dp), ) } Text( - text = instance?.domain ?: "Loading...", - style = FluentTheme.typography.caption, - modifier = Modifier.placeholder(instance == null), - ) - Text( - text = - instance?.description - ?: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", - style = FluentTheme.typography.body, + text = instance?.name ?: "Loading...", + style = FluentTheme.typography.subtitle, modifier = Modifier.placeholder(instance == null), - maxLines = 3, - overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, ) } + Text( + text = instance?.domain ?: "Loading...", + style = FluentTheme.typography.body, + modifier = Modifier.placeholder(instance == null), + ) + Text( + text = + instance?.description + ?: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + style = FluentTheme.typography.caption, + modifier = Modifier.placeholder(instance == null), + maxLines = 3, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + ) } } } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt index 22c2d5f3d..4136a136a 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -19,7 +18,6 @@ import androidx.compose.ui.graphics.drawscope.ContentDrawScope import androidx.compose.ui.node.DelegatableNode import androidx.compose.ui.node.DrawModifierNode import androidx.compose.ui.node.invalidateDraw -import androidx.compose.ui.unit.dp import androidx.compose.ui.window.FrameWindowScope import dev.dimension.flare.ui.component.ComponentAppearance import dev.dimension.flare.ui.component.LocalComponentAppearance @@ -78,14 +76,14 @@ internal fun FrameWindowScope.FlareTheme( modifier = Modifier .fillMaxSize() - .background(FluentTheme.colors.background.mica.base) - .let { - if (SystemUtils.IS_OS_MAC) { - it.padding(top = 24.dp) - } else { - it - } - }, + .background(FluentTheme.colors.background.mica.base), +// .let { +// if (SystemUtils.IS_OS_MAC) { +// it.padding(top = 24.dp) +// } else { +// it +// } +// }, ) { content.invoke() } diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformListItem.jvm.kt b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformListItem.jvm.kt index f9be3c58f..2600a72a7 100644 --- a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformListItem.jvm.kt +++ b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformListItem.jvm.kt @@ -1,11 +1,13 @@ package dev.dimension.flare.ui.component.platform +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import dev.dimension.flare.ui.component.status.ListComponent @Composable -internal actual fun PlatformListItem( +public actual fun PlatformListItem( headlineContent: @Composable () -> Unit, modifier: Modifier, leadingContent: @Composable () -> Unit, @@ -16,7 +18,7 @@ internal actual fun PlatformListItem( headlineContent = { headlineContent.invoke() }, - modifier = modifier, + modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp), leadingContent = { leadingContent.invoke() }, From db31cb36bae2fadb932e8e5707aec6a5a68a74fd Mon Sep 17 00:00:00 2001 From: Tlaster Date: Tue, 19 Aug 2025 14:15:36 +0900 Subject: [PATCH 06/33] add more desktop route --- .../main/kotlin/dev/dimension/flare/App.kt | 32 ++- .../dev/dimension/flare/ui/route/Route.kt | 196 +++++++++----- .../dev/dimension/flare/ui/route/Router.kt | 240 ++++++++++++------ .../dimension/flare/ui/route/StackManager.kt | 33 +-- .../flare/ui/screen/feeds/FeedListScreen.kt | 4 +- .../flare/ui/screen/home/DiscoverScreen.kt | 4 +- .../ui/screen/home/NotificationScreen.kt | 4 +- .../flare/ui/screen/home/ProfileScreen.kt | 5 +- .../flare/ui/screen/home/TimelineScreen.kt | 4 +- .../serviceselect/ServiceSelectScreen.kt | 4 +- .../flare/ui/screen/status/StatusScreen.kt | 5 +- .../ui/screen/status/VVOCommentScreen.kt | 5 +- .../flare/ui/screen/status/VVOStatusScreen.kt | 20 +- .../dimension/flare/ui/theme/FlareTheme.kt | 12 +- .../flare/ui/model/mapper/Bluesky.kt | 87 +++++-- 15 files changed, 448 insertions(+), 207 deletions(-) diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt index 186c2b240..bdb0eb4cd 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio @@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -84,6 +86,7 @@ import io.ktor.http.Url import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import moe.tlaster.precompose.molecule.producePresenter +import org.apache.commons.lang3.SystemUtils import org.jetbrains.compose.resources.stringResource @Composable @@ -101,7 +104,7 @@ internal fun FlareApp( key = tabs, topLevelRoutes = tabs.all.map { getRoute(it.tabItem) }, ) - val currentRoute = stackManager.current + val currentRoute = stackManager.currentRoute fun navigate(route: Route) { stackManager.push(route) @@ -115,7 +118,13 @@ internal fun FlareApp( Column( modifier = Modifier - .background( + .let { + if (SystemUtils.IS_OS_MAC) { + it.padding(top = 24.dp) + } else { + it + } + }.background( FluentTheme.colors.background.layerOnMicaBaseAlt.secondary, ).fillMaxHeight() .width(72.dp) @@ -294,9 +303,23 @@ internal fun FlareApp( ) }, LocalScrollToTopRegistry provides state.scrollToTopRegistry, + LocalContentPadding provides + if (SystemUtils.IS_OS_MAC) { + PaddingValues( + start = 0.dp, + top = 24.dp, + end = 0.dp, + bottom = 0.dp, + ) + } else { + PaddingValues(0.dp) + }, ) { Layer( modifier = Modifier.fillMaxSize(), + color = FluentTheme.colors.background.mica.base, + shape = RoundedCornerShape(0), + border = null, ) { Router( manager = stackManager, @@ -441,6 +464,11 @@ private class ScrollToTopRegistry { } } +val LocalContentPadding = + androidx.compose.runtime.staticCompositionLocalOf { + PaddingValues(0.dp) + } + private val LocalScrollToTopRegistry = androidx.compose.runtime.staticCompositionLocalOf { null diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt index aab4f6181..4f4c262f3 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt @@ -8,55 +8,111 @@ import kotlinx.serialization.Serializable @Serializable internal sealed interface Route { + sealed interface FloatingRoute : Route + + sealed interface ScreenRoute : Route + @Serializable data class Timeline( val tabItem: TimelineTabItem, - ) : Route + ) : ScreenRoute @Serializable data class Discover( val accountType: AccountType, - ) : Route + ) : ScreenRoute @Serializable data class Notification( val accountType: AccountType, - ) : Route + ) : ScreenRoute @Serializable - data object Settings : Route + data object Settings : ScreenRoute @Serializable - data object Rss : Route + data object Rss : ScreenRoute @Serializable data class Profile( val accountType: AccountType, val userKey: MicroBlogKey, - ) : Route + ) : ScreenRoute @Serializable data class MeRoute( val accountType: AccountType, - ) : Route + ) : ScreenRoute @Serializable - data object ServiceSelect : Route + data object ServiceSelect : ScreenRoute @Serializable data class AllLists( val accountType: AccountType, - ) : Route + ) : ScreenRoute @Serializable data class BlueskyFeeds( val accountType: AccountType, - ) : Route + ) : ScreenRoute @Serializable data class DirectMessage( val accountType: AccountType, - ) : Route + ) : ScreenRoute + + @Serializable + data class StatusDetail( + val accountType: AccountType, + val statusKey: MicroBlogKey, + ) : ScreenRoute + + data object VVO { + @Serializable + data class StatusDetail( + val accountType: AccountType, + val statusKey: MicroBlogKey, + ) : ScreenRoute + + @Serializable + data class CommentDetail( + val accountType: AccountType, + val statusKey: MicroBlogKey, + ) : ScreenRoute + } + + @Serializable + data class AddReaction( + val accountType: AccountType, + val statusKey: MicroBlogKey, + ) : FloatingRoute + + @Serializable + data class DeleteStatus( + val accountType: AccountType, + val statusKey: MicroBlogKey, + ) : FloatingRoute + + @Serializable + data class BlueskyReport( + val accountType: AccountType, + val statusKey: MicroBlogKey, + ) : FloatingRoute + + @Serializable + data class MastodonReport( + val accountType: AccountType, + val statusKey: MicroBlogKey, + val userKey: MicroBlogKey, + ) : FloatingRoute + + @Serializable + data class MisskeyReport( + val accountType: AccountType, + val statusKey: MicroBlogKey, + val userKey: MicroBlogKey, + ) : FloatingRoute companion object { public fun parse(url: String): Route? { @@ -77,7 +133,8 @@ internal sealed interface Route { "Search" -> { val accountKey = data.parameters["accountKey"]?.let { MicroBlogKey.valueOf(it) } val keyword = data.segments.getOrNull(0) ?: return null - val accountType = accountKey?.let { AccountType.Specific(it) } ?: AccountType.Guest + val accountType = + accountKey?.let { AccountType.Specific(it) } ?: AccountType.Guest // Route.Search(accountType, keyword) null } @@ -85,7 +142,8 @@ internal sealed interface Route { "Profile" -> { val accountKey = data.parameters["accountKey"]?.let { MicroBlogKey.valueOf(it) } val userKey = MicroBlogKey.valueOf(data.segments.getOrNull(0) ?: return null) - val accountType = accountKey?.let { AccountType.Specific(it) } ?: AccountType.Guest + val accountType = + accountKey?.let { AccountType.Specific(it) } ?: AccountType.Guest Route.Profile(accountType, userKey) } @@ -93,7 +151,8 @@ internal sealed interface Route { val accountKey = data.parameters["accountKey"]?.let { MicroBlogKey.valueOf(it) } val userName = data.segments.getOrNull(0) ?: return null val host = data.segments.getOrNull(1) ?: return null - val accountType = accountKey?.let { AccountType.Specific(it) } ?: AccountType.Guest + val accountType = + accountKey?.let { AccountType.Specific(it) } ?: AccountType.Guest // Route.Profile.UserNameWithHost(accountType, userName, host) null } @@ -101,29 +160,34 @@ internal sealed interface Route { "StatusDetail" -> { val accountKey = data.parameters["accountKey"]?.let { MicroBlogKey.valueOf(it) } val statusKey = MicroBlogKey.valueOf(data.segments.getOrNull(0) ?: return null) - val accountType = accountKey?.let { AccountType.Specific(it) } ?: AccountType.Guest -// Route.Status.Detail(statusKey, accountType) - null + val accountType = + accountKey?.let { AccountType.Specific(it) } ?: AccountType.Guest + Route.StatusDetail(statusKey = statusKey, accountType = accountType) } "Compose" -> when (data.segments.getOrNull(0)) { "Reply" -> { - val accountKey = MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) - val statusKey = MicroBlogKey.valueOf(data.segments.getOrNull(2) ?: return null) + val accountKey = + MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) + val statusKey = + MicroBlogKey.valueOf(data.segments.getOrNull(2) ?: return null) // Route.Compose.Reply(accountKey, statusKey) null } "Quote" -> { - val accountKey = MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) - val statusKey = MicroBlogKey.valueOf(data.segments.getOrNull(2) ?: return null) + val accountKey = + MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) + val statusKey = + MicroBlogKey.valueOf(data.segments.getOrNull(2) ?: return null) // Route.Compose.Quote(accountKey, statusKey) null } "New" -> { - val accountKey = MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) + val accountKey = + MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) // Route.Compose.New(AccountType.Specific(accountKey)) null } @@ -140,22 +204,32 @@ internal sealed interface Route { "VVO" -> when (data.segments.getOrNull(0)) { "StatusDetail" -> { - val accountKey = MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) - val statusKey = MicroBlogKey.valueOf(data.segments.getOrNull(2) ?: return null) -// Route.Status.VVOStatus(statusKey, AccountType.Specific(accountKey)) - null + val accountKey = + MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) + val statusKey = + MicroBlogKey.valueOf(data.segments.getOrNull(2) ?: return null) + Route.VVO.StatusDetail( + statusKey = statusKey, + accountType = AccountType.Specific(accountKey), + ) } "CommentDetail" -> { - val accountKey = MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) - val statusKey = MicroBlogKey.valueOf(data.segments.getOrNull(2) ?: return null) -// Route.Status.VVOComment(statusKey, AccountType.Specific(accountKey)) - null + val accountKey = + MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) + val statusKey = + MicroBlogKey.valueOf(data.segments.getOrNull(2) ?: return null) + Route.VVO.CommentDetail( + statusKey = statusKey, + accountType = AccountType.Specific(accountKey), + ) } "ReplyToComment" -> { - val accountKey = MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) - val replyTo = MicroBlogKey.valueOf(data.segments.getOrNull(2) ?: return null) + val accountKey = + MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) + val replyTo = + MicroBlogKey.valueOf(data.segments.getOrNull(2) ?: return null) val rootId = data.segments.getOrNull(3) ?: return null // Route.Compose.VVOReplyComment(accountKey, replyTo, rootId) null @@ -167,24 +241,23 @@ internal sealed interface Route { "DeleteStatus" -> { val accountKey = MicroBlogKey.valueOf(data.segments.getOrNull(0) ?: return null) val statusKey = MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) -// Route.Status.DeleteConfirm(statusKey, AccountType.Specific(accountKey)) - null + Route.DeleteStatus(statusKey = statusKey, accountType = AccountType.Specific(accountKey)) } "AddReaction" -> { val accountKey = MicroBlogKey.valueOf(data.segments.getOrNull(0) ?: return null) val statusKey = MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) -// Route.Status.AddReaction(statusKey, AccountType.Specific(accountKey)) - null + Route.AddReaction(statusKey = statusKey, accountType = AccountType.Specific(accountKey)) } "Bluesky" -> when (data.segments.getOrNull(0)) { "ReportStatus" -> { - val accountKey = MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) - val statusKey = MicroBlogKey.valueOf(data.segments.getOrNull(2) ?: return null) -// Route.Status.BlueskyReport(statusKey, AccountType.Specific(accountKey)) - null + val accountKey = + MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) + val statusKey = + MicroBlogKey.valueOf(data.segments.getOrNull(2) ?: return null) + Route.BlueskyReport(statusKey = statusKey, accountType = AccountType.Specific(accountKey)) } else -> null @@ -193,15 +266,17 @@ internal sealed interface Route { "Mastodon" -> when (data.segments.getOrNull(0)) { "ReportStatus" -> { - val accountKey = MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) - val statusKey = MicroBlogKey.valueOf(data.segments.getOrNull(2) ?: return null) - val userKey = MicroBlogKey.valueOf(data.segments.getOrNull(3) ?: return null) -// Route.Status.MastodonReport( -// statusKey = statusKey, -// userKey = userKey, -// accountType = AccountType.Specific(accountKey), -// ) - null + val accountKey = + MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) + val statusKey = + MicroBlogKey.valueOf(data.segments.getOrNull(2) ?: return null) + val userKey = + MicroBlogKey.valueOf(data.segments.getOrNull(3) ?: return null) + Route.MastodonReport( + statusKey = statusKey, + userKey = userKey, + accountType = AccountType.Specific(accountKey), + ) } else -> null @@ -210,15 +285,17 @@ internal sealed interface Route { "Misskey" -> when (data.segments.getOrNull(0)) { "ReportStatus" -> { - val accountKey = MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) - val statusKey = MicroBlogKey.valueOf(data.segments.getOrNull(2) ?: return null) - val userKey = MicroBlogKey.valueOf(data.segments.getOrNull(3) ?: return null) -// Route.Status.MisskeyReport( -// accountType = AccountType.Specific(accountKey), -// statusKey = statusKey, -// userKey = userKey, -// ) - null + val accountKey = + MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) + val statusKey = + MicroBlogKey.valueOf(data.segments.getOrNull(2) ?: return null) + val userKey = + MicroBlogKey.valueOf(data.segments.getOrNull(3) ?: return null) + Route.MisskeyReport( + accountType = AccountType.Specific(accountKey), + statusKey = statusKey, + userKey = userKey, + ) } else -> null @@ -228,7 +305,8 @@ internal sealed interface Route { val accountKey = data.parameters["accountKey"]?.let { MicroBlogKey.valueOf(it) } val statusKey = MicroBlogKey.valueOf(data.segments.getOrNull(0) ?: return null) val index = data.segments.getOrNull(1)?.toIntOrNull() ?: return null - val accountType = accountKey?.let { AccountType.Specific(it) } ?: AccountType.Guest + val accountType = + accountKey?.let { AccountType.Specific(it) } ?: AccountType.Guest val preview = data.parameters["preview"] // Route.Media.StatusMedia(accountType = accountType, statusKey = statusKey, index = index, preview = preview) null diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt index 084ff643c..de2b5f014 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt @@ -4,11 +4,13 @@ import androidx.compose.animation.AnimatedContent import androidx.compose.runtime.Composable import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import dev.dimension.flare.data.model.Bluesky +import dev.dimension.flare.data.model.Bluesky.FeedTabItem import dev.dimension.flare.data.model.IconType +import dev.dimension.flare.data.model.IconType.Material import dev.dimension.flare.data.model.ListTimelineTabItem import dev.dimension.flare.data.model.TabMetaData import dev.dimension.flare.data.model.TitleType +import dev.dimension.flare.ui.route.Route.Timeline import dev.dimension.flare.ui.screen.feeds.FeedListScreen import dev.dimension.flare.ui.screen.home.DiscoverScreen import dev.dimension.flare.ui.screen.home.NotificationScreen @@ -16,6 +18,14 @@ import dev.dimension.flare.ui.screen.home.ProfileScreen import dev.dimension.flare.ui.screen.home.TimelineScreen import dev.dimension.flare.ui.screen.list.AllListScreen import dev.dimension.flare.ui.screen.serviceselect.ServiceSelectScreen +import dev.dimension.flare.ui.screen.status.StatusScreen +import dev.dimension.flare.ui.screen.status.VVOCommentScreen +import dev.dimension.flare.ui.screen.status.VVOStatusScreen +import dev.dimension.flare.ui.screen.status.action.AddReactionSheet +import dev.dimension.flare.ui.screen.status.action.BlueskyReportStatusDialog +import dev.dimension.flare.ui.screen.status.action.DeleteStatusConfirmDialog +import dev.dimension.flare.ui.screen.status.action.MastodonReportDialog +import dev.dimension.flare.ui.screen.status.action.MisskeyReportDialog import io.github.composefluent.component.Text @OptIn(ExperimentalComposeUiApi::class) @@ -32,106 +42,170 @@ internal fun Router( manager.pop() } AnimatedContent( - manager.currentEntry, + manager.currentScreenEntry, modifier = modifier, ) { entry -> entry.Content { route -> - when (route) { - is Route.AllLists -> { - AllListScreen( - accountType = route.accountType, - onAddList = { - }, - toList = { - navigate( - Route.Timeline( - ListTimelineTabItem( - account = route.accountType, - listId = it.id, - metaData = - TabMetaData( - title = TitleType.Text(it.title), - icon = IconType.Material(IconType.Material.MaterialIcon.List), - ), + if (route is Route.ScreenRoute) { + when (route) { + is Route.AllLists -> { + AllListScreen( + accountType = route.accountType, + onAddList = { + }, + toList = { + navigate( + Timeline( + ListTimelineTabItem( + account = route.accountType, + listId = it.id, + metaData = + TabMetaData( + title = TitleType.Text(it.title), + icon = Material(IconType.Material.MaterialIcon.List), + ), + ), ), - ), - ) - }, - ) - } + ) + }, + ) + } - is Route.BlueskyFeeds -> { - FeedListScreen( - accountType = route.accountType, - toFeed = { - navigate( - Route.Timeline( - Bluesky.FeedTabItem( - account = route.accountType, - uri = it.id, - metaData = - TabMetaData( - title = TitleType.Text(it.title), - icon = IconType.Material(IconType.Material.MaterialIcon.Feeds), - ), + is Route.BlueskyFeeds -> { + FeedListScreen( + accountType = route.accountType, + toFeed = { + navigate( + Timeline( + FeedTabItem( + account = route.accountType, + uri = it.id, + metaData = + TabMetaData( + title = TitleType.Text(it.title), + icon = Material(IconType.Material.MaterialIcon.Feeds), + ), + ), ), - ), - ) - }, - ) - } + ) + }, + ) + } - is Route.DirectMessage -> { - Text("route") - } + is Route.DirectMessage -> { + Text("route") + } - is Route.Discover -> { - DiscoverScreen( - accountType = route.accountType, - ) - } + is Route.Discover -> { + DiscoverScreen( + accountType = route.accountType, + ) + } - is Route.MeRoute -> { - ProfileScreen( - accountType = route.accountType, - userKey = null, - ) - } + is Route.MeRoute -> { + ProfileScreen( + accountType = route.accountType, + userKey = null, + ) + } - is Route.Notification -> { - NotificationScreen( - accountType = route.accountType, - ) - } + is Route.Notification -> { + NotificationScreen( + accountType = route.accountType, + ) + } + + is Route.Profile -> { + ProfileScreen( + accountType = route.accountType, + userKey = route.userKey, + ) + } + + Route.Rss -> { + Text("route") + } + + Route.ServiceSelect -> { + ServiceSelectScreen( + onBack = ::onBack, + onVVO = { + }, + onXQT = { + }, + ) + } + + Route.Settings -> { + Text("route") + } - is Route.Profile -> { - ProfileScreen( - accountType = route.accountType, - userKey = route.userKey, + is Route.Timeline -> { + TimelineScreen( + route.tabItem, + ) + } + + is Route.StatusDetail -> { + StatusScreen( + statusKey = route.statusKey, + accountType = route.accountType, + ) + } + is Route.VVO.CommentDetail -> { + VVOCommentScreen( + commentKey = route.statusKey, + accountType = route.accountType, + ) + } + is Route.VVO.StatusDetail -> { + VVOStatusScreen( + statusKey = route.statusKey, + accountType = route.accountType, + ) + } + } + } + } + } + manager.currentFloatingEntry?.let { entry -> + if (entry.route is Route.FloatingRoute) { + when (entry.route) { + is Route.AddReaction -> { + AddReactionSheet( + accountType = entry.route.accountType, + statusKey = entry.route.statusKey, + onBack = ::onBack, ) } - - Route.Rss -> { - Text("route") + is Route.BlueskyReport -> { + BlueskyReportStatusDialog( + accountType = entry.route.accountType, + statusKey = entry.route.statusKey, + onBack = ::onBack, + ) } - - Route.ServiceSelect -> { - ServiceSelectScreen( + is Route.DeleteStatus -> { + DeleteStatusConfirmDialog( + accountType = entry.route.accountType, + statusKey = entry.route.statusKey, onBack = ::onBack, - onVVO = { - }, - onXQT = { - }, ) } - - Route.Settings -> { - Text("route") + is Route.MastodonReport -> { + MastodonReportDialog( + accountType = entry.route.accountType, + statusKey = entry.route.statusKey, + onBack = ::onBack, + userKey = entry.route.userKey, + ) } - - is Route.Timeline -> { - TimelineScreen( - route.tabItem, + is Route.MisskeyReport -> { + MisskeyReportDialog( + accountType = entry.route.accountType, + statusKey = entry.route.statusKey, + onBack = ::onBack, + userKey = entry.route.userKey, ) } } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/StackManager.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/StackManager.kt index 426a45d28..224698fc6 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/StackManager.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/StackManager.kt @@ -61,24 +61,25 @@ internal class StackManager( ), ) - private var _current by + private var currentEntry by mutableStateOf( stack.last(), ) - val current: Route - get() = _current.route + val currentRoute: Route + get() = currentEntry.route - val currentEntry: Entry - get() = _current + var currentScreenEntry: Entry by mutableStateOf(currentEntry) + private set - private var _canGoBack by mutableStateOf(false) + var currentFloatingEntry: Entry? by mutableStateOf(null) + private set - val canGoBack: Boolean - get() = _canGoBack + var canGoBack: Boolean by mutableStateOf(false) + private set fun push(route: Route) { - if (current == route) { + if (currentRoute == route) { return } if (route in topLevelRoutes) { @@ -126,17 +127,19 @@ internal class StackManager( } private fun updateEntry() { - if (stack.isNotEmpty()) { - _current = stack.last() - } else { - _current = + currentEntry = + if (stack.isNotEmpty()) { + stack.last() + } else { Entry( route = startRoute, viewModelStoreProvider = navControllerViewModel, savableStateHolder = savableStateHolder, ) - } - _canGoBack = stack.size > 1 + } + canGoBack = stack.any { it.route !in topLevelRoutes } + currentScreenEntry = stack.last { it.route is Route.ScreenRoute } + currentFloatingEntry = stack.lastOrNull { it.route is Route.FloatingRoute } } fun clear() { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedListScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedListScreen.kt index 8f04bc716..386232d34 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedListScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedListScreen.kt @@ -17,11 +17,13 @@ import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.Plus import compose.icons.fontawesomeicons.solid.Trash +import dev.dimension.flare.LocalContentPadding import dev.dimension.flare.Res import dev.dimension.flare.feeds_discover_feeds_title import dev.dimension.flare.feeds_my_feeds_title import dev.dimension.flare.model.AccountType import dev.dimension.flare.ui.common.items +import dev.dimension.flare.ui.common.plus import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.UiListItem import dev.dimension.flare.ui.component.platform.PlatformListItem @@ -55,7 +57,7 @@ internal fun FeedListScreen( contentPadding = PaddingValues( vertical = 8.dp, - ), + ) + LocalContentPadding.current, modifier = Modifier .fillMaxSize(), diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt index 39ee89de8..70530ad15 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt @@ -19,10 +19,12 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import dev.dimension.flare.LocalContentPadding import dev.dimension.flare.RegisterTabCallback import dev.dimension.flare.common.onLoading import dev.dimension.flare.common.onSuccess import dev.dimension.flare.model.AccountType +import dev.dimension.flare.ui.common.plus import dev.dimension.flare.ui.component.platform.placeholder import dev.dimension.flare.ui.component.status.CommonStatusHeaderComponent import dev.dimension.flare.ui.component.status.LazyStatusVerticalStaggeredGrid @@ -51,7 +53,7 @@ internal fun DiscoverScreen(accountType: AccountType) { LazyStatusVerticalStaggeredGrid( modifier = Modifier.fillMaxSize(), state = lazyListState, - contentPadding = PaddingValues(vertical = 8.dp), + contentPadding = PaddingValues(vertical = 8.dp) + LocalContentPadding.current, ) { if (true) { state.users diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/NotificationScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/NotificationScreen.kt index 7b013715f..b74cbf838 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/NotificationScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/NotificationScreen.kt @@ -13,9 +13,11 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import dev.dimension.flare.LocalContentPadding import dev.dimension.flare.RegisterTabCallback import dev.dimension.flare.common.isRefreshing import dev.dimension.flare.model.AccountType +import dev.dimension.flare.ui.common.plus import dev.dimension.flare.ui.component.status.LazyStatusVerticalStaggeredGrid import dev.dimension.flare.ui.component.status.status import dev.dimension.flare.ui.model.onSuccess @@ -49,7 +51,7 @@ internal fun NotificationScreen(accountType: AccountType) { contentPadding = PaddingValues( vertical = 8.dp, - ), + ) + LocalContentPadding.current, state = listState, ) { state.state.allTypes.onSuccess { types -> diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/ProfileScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/ProfileScreen.kt index 692e2d26a..56e2f6015 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/ProfileScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/ProfileScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.layout.layout import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import dev.dimension.flare.LocalContentPadding import dev.dimension.flare.RegisterTabCallback import dev.dimension.flare.Res import dev.dimension.flare.data.datasource.microblog.ProfileTab @@ -35,6 +36,7 @@ import dev.dimension.flare.profile_tab_media import dev.dimension.flare.profile_tab_timeline import dev.dimension.flare.profile_tab_timeline_with_reply import dev.dimension.flare.ui.common.items +import dev.dimension.flare.ui.common.plus import dev.dimension.flare.ui.component.ComponentAppearance import dev.dimension.flare.ui.component.LocalComponentAppearance import dev.dimension.flare.ui.component.ProfileHeader @@ -87,6 +89,7 @@ internal fun ProfileScreen( Column( modifier = Modifier + .padding(LocalContentPadding.current) .padding( vertical = 16.dp, ).padding( @@ -130,7 +133,7 @@ internal fun ProfileScreen( } else { 0.dp }, - ), + ) + LocalContentPadding.current, state = listState, ) { if (!isBigScreen) { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt index 56f9eebf2..bd0bfbe47 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.unit.dp import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.AnglesUp +import dev.dimension.flare.LocalContentPadding import dev.dimension.flare.RegisterTabCallback import dev.dimension.flare.Res import dev.dimension.flare.common.PagingState @@ -35,6 +36,7 @@ import dev.dimension.flare.common.isRefreshing import dev.dimension.flare.common.onSuccess import dev.dimension.flare.data.model.TimelineTabItem import dev.dimension.flare.home_timeline_new_toots +import dev.dimension.flare.ui.common.plus import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.status.LazyStatusVerticalStaggeredGrid import dev.dimension.flare.ui.component.status.status @@ -72,7 +74,7 @@ internal fun TimelineScreen(tabItem: TimelineTabItem) { contentPadding = PaddingValues( vertical = 8.dp, - ), + ) + LocalContentPadding.current, state = listState, ) { status(state.listState) diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt index 574c881c6..fce2d3fe8 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column 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.size @@ -30,6 +29,7 @@ import androidx.compose.ui.unit.dp import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.CircleQuestion +import dev.dimension.flare.LocalContentPadding import dev.dimension.flare.Res import dev.dimension.flare.bluesky_login_2fa import dev.dimension.flare.bluesky_login_password @@ -91,10 +91,10 @@ internal fun ServiceSelectScreen( } } Column( + modifier = Modifier.padding(LocalContentPadding.current), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), ) { - Spacer(Modifier) Text( stringResource(Res.string.service_select_welcome_title), style = FluentTheme.typography.title, diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/StatusScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/StatusScreen.kt index 614c9889b..ab3338eea 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/StatusScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/StatusScreen.kt @@ -16,9 +16,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import dev.dimension.flare.LocalContentPadding import dev.dimension.flare.RegisterTabCallback import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.common.plus import dev.dimension.flare.ui.component.status.LazyStatusVerticalStaggeredGrid import dev.dimension.flare.ui.component.status.status import dev.dimension.flare.ui.presenter.invoke @@ -30,7 +32,6 @@ import moe.tlaster.precompose.molecule.producePresenter @Composable internal fun StatusScreen( statusKey: MicroBlogKey, - onBack: () -> Unit, accountType: AccountType, ) { val state by producePresenter(statusKey.toString()) { @@ -51,7 +52,7 @@ internal fun StatusScreen( contentPadding = PaddingValues( vertical = 8.dp, - ), + ) + LocalContentPadding.current, state = listState, ) { status( diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOCommentScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOCommentScreen.kt index 3a61008d3..bcdc55efa 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOCommentScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOCommentScreen.kt @@ -15,10 +15,12 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import dev.dimension.flare.LocalContentPadding import dev.dimension.flare.RegisterTabCallback import dev.dimension.flare.common.onSuccess import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.common.plus import dev.dimension.flare.ui.component.status.LazyStatusVerticalStaggeredGrid import dev.dimension.flare.ui.component.status.StatusItem import dev.dimension.flare.ui.component.status.status @@ -33,7 +35,6 @@ import moe.tlaster.precompose.molecule.producePresenter @Composable internal fun VVOCommentScreen( commentKey: MicroBlogKey, - onBack: () -> Unit, accountType: AccountType, ) { val state by producePresenter("comment_$commentKey") { @@ -55,7 +56,7 @@ internal fun VVOCommentScreen( contentPadding = PaddingValues( vertical = 8.dp, - ), + ) + LocalContentPadding.current, state = listState, ) { item { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOStatusScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOStatusScreen.kt index 14a58d24e..4849f397b 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOStatusScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOStatusScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.FileCircleExclamation +import dev.dimension.flare.LocalContentPadding import dev.dimension.flare.Res import dev.dimension.flare.common.PagingState import dev.dimension.flare.model.AccountType @@ -31,6 +32,7 @@ import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.status_detail_comment import dev.dimension.flare.status_detail_repost import dev.dimension.flare.status_loadmore_error_retry +import dev.dimension.flare.ui.common.plus import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.platform.isBigScreen import dev.dimension.flare.ui.component.status.LazyStatusVerticalStaggeredGrid @@ -56,7 +58,6 @@ import org.jetbrains.compose.resources.stringResource @Composable internal fun VVOStatusScreen( statusKey: MicroBlogKey, - onBack: () -> Unit, accountType: AccountType, ) { val state by producePresenter(key = "status_detail_${statusKey}_$accountType") { @@ -75,9 +76,15 @@ internal fun VVOStatusScreen( Modifier .verticalScroll(rememberScrollState()) .width(432.dp) - .padding(PaddingValues(horizontal = screenHorizontalPadding)), + .padding(PaddingValues(horizontal = screenHorizontalPadding)) + .padding(LocalContentPadding.current), ) - LazyStatusVerticalStaggeredGrid { + LazyStatusVerticalStaggeredGrid( + contentPadding = + PaddingValues( + vertical = 8.dp, + ) + LocalContentPadding.current, + ) { reactionContent( comment = state.comment, repost = state.repost, @@ -87,7 +94,12 @@ internal fun VVOStatusScreen( } } } else { - LazyStatusVerticalStaggeredGrid { + LazyStatusVerticalStaggeredGrid( + contentPadding = + PaddingValues( + vertical = 8.dp, + ) + LocalContentPadding.current, + ) { item { StatusContent(statusState = state.status, detailStatusKey = statusKey) } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt index 4136a136a..124fadab7 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt @@ -70,20 +70,16 @@ internal fun FrameWindowScope.FlareTheme( hover = Color.Transparent, pressed = Color.Transparent, ), - LocalComponentAppearance provides ComponentAppearance(), + LocalComponentAppearance provides + ComponentAppearance( + videoAutoplay = ComponentAppearance.VideoAutoplay.ALWAYS, + ), ) { Box( modifier = Modifier .fillMaxSize() .background(FluentTheme.colors.background.mica.base), -// .let { -// if (SystemUtils.IS_OS_MAC) { -// it.padding(top = 24.dp) -// } else { -// it -// } -// }, ) { content.invoke() } 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 3a0efabe0..c7ce14b0e 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 @@ -132,7 +132,8 @@ private fun parseBluesky( if (start - codePointIndex < 0) { continue } - val beforeFacetText = codePoints.drop(codePointIndex).take(start - codePointIndex).stringify() + val beforeFacetText = + codePoints.drop(codePointIndex).take(start - codePointIndex).stringify() element.appendTextWithBr(beforeFacetText) if (end - start < 0) { continue @@ -222,9 +223,14 @@ internal fun FeedViewPostReasonUnion.render( icon = UiTimeline.TopMessage.Icon.Pin, type = UiTimeline.TopMessage.MessageType.Bluesky.Pinned, onClicked = { }, - statusKey = MicroBlogKey(id = data?.uri?.atUri.orEmpty(), host = accountKey.host), + statusKey = + MicroBlogKey( + id = data?.uri?.atUri.orEmpty(), + host = accountKey.host, + ), ) } + is FeedViewPostReasonUnion.ReasonRepost -> { val user = value.by.render(accountKey) UiTimeline.TopMessage( @@ -239,9 +245,14 @@ internal fun FeedViewPostReasonUnion.render( ), ) }, - statusKey = MicroBlogKey(id = data?.uri?.atUri.orEmpty(), host = accountKey.host), + statusKey = + MicroBlogKey( + id = data?.uri?.atUri.orEmpty(), + host = accountKey.host, + ), ) } + is FeedViewPostReasonUnion.Unknown -> null } return UiTimeline( @@ -264,7 +275,12 @@ internal fun StatusContent.BlueskyNotification.renderBlueskyNotification( icon = data.reason.icon, type = data.reason.type, onClicked = { - launcher.launch(AppDeepLink.Profile(accountKey = accountKey, userKey = user.key)) + launcher.launch( + AppDeepLink.Profile( + accountKey = accountKey, + userKey = user.key, + ), + ) }, statusKey = MicroBlogKey(id = data.uri.atUri, host = accountKey.host), ) @@ -277,8 +293,13 @@ internal fun StatusContent.BlueskyNotification.renderBlueskyNotification( content = content, ) } + is StatusContent.BlueskyNotification.Post -> - references[ReferenceType.Notification]?.firstOrNull()?.render(event) ?: post.render(accountKey, event = event) + references[ReferenceType.Notification]?.firstOrNull()?.render(event) ?: post.render( + accountKey, + event = event, + ) + is StatusContent.BlueskyNotification.UserList -> { val reason = this.data.firstOrNull()?.reason val uri = @@ -302,12 +323,19 @@ internal fun StatusContent.BlueskyNotification.renderBlueskyNotification( } val content = UiTimeline.ItemContent.UserList( - users = this.data.map { it.author.render(accountKey = accountKey) }.toImmutableList(), + users = + this.data + .map { it.author.render(accountKey = accountKey) } + .toImmutableList(), status = - references[ReferenceType.Notification]?.firstOrNull()?.let { it as? StatusContent.Bluesky }?.data?.renderStatus( - accountKey, - event, - ), + references[ReferenceType.Notification] + ?.firstOrNull() + ?.let { it as? StatusContent.Bluesky } + ?.data + ?.renderStatus( + accountKey, + event, + ), ) return UiTimeline( topMessage = topMessage, @@ -630,8 +658,20 @@ private fun findCard(postView: PostView): UiCard? = val embed = postView.embed as PostViewEmbedUnion.ExternalView UiCard( url = embed.value.external.uri.uri, - title = embed.value.external.title, - description = embed.value.external.description, + title = + embed.value.external.title.takeIf { + it.isNotEmpty() + } ?: embed.value.external.uri.uri, + description = + embed.value.external.description.takeIf { + it.isNotEmpty() + } ?: if (embed.value.external.title + .isEmpty() + ) { + null + } else { + embed.value.external.uri.uri + }, media = embed.value.external.thumb?.let { UiMedia.Image( @@ -687,6 +727,7 @@ private fun findMedias(postView: PostView): ImmutableList = is RecordWithMediaViewMediaUnion.ExternalView -> { findMediaFromExternal(media.value) } + is RecordWithMediaViewMediaUnion.ImagesView -> media.value.images .map { @@ -699,6 +740,7 @@ private fun findMedias(postView: PostView): ImmutableList = sensitive = false, ) }.toPersistentList() + is RecordWithMediaViewMediaUnion.VideoView -> persistentListOf( UiMedia.Video( @@ -722,18 +764,6 @@ private fun findMedias(postView: PostView): ImmutableList = is PostViewEmbedUnion.ExternalView -> { findMediaFromExternal(embed.value) - persistentListOf( - UiMedia.Image( - url = embed.value.external.uri.uri, - previewUrl = - embed.value.external.thumb - ?.uri ?: "", - description = embed.value.external.description, - width = 0f, - height = 0f, - sensitive = false, - ), - ) } else -> persistentListOf() @@ -754,7 +784,12 @@ private fun findMediaFromExternal(value: ExternalView): PersistentList height = height, ), ) - } else { + } else if ( + it.endsWith(".png", ignoreCase = true) || + it.endsWith(".jpg", ignoreCase = true) || + it.endsWith(".jpeg", ignoreCase = true) || + it.endsWith(".webp", ignoreCase = true) + ) { persistentListOf( UiMedia.Image( url = value.external.uri.uri, @@ -765,6 +800,8 @@ private fun findMediaFromExternal(value: ExternalView): PersistentList sensitive = false, ), ) + } else { + persistentListOf() } } ?: persistentListOf() } From 1f17d34fb5893a2bd8aee4e4662d7990980cd52f Mon Sep 17 00:00:00 2001 From: Tlaster Date: Tue, 19 Aug 2025 19:54:00 +0900 Subject: [PATCH 07/33] add settings --- .../dimension/flare/data/model/AppSettings.kt | 42 +- .../flare/data/model/AppearanceSettings.kt | 66 -- desktopApp/build.gradle.kts | 2 + .../main/composeResources/values/strings.xml | 90 ++- .../main/kotlin/dev/dimension/flare/App.kt | 41 +- .../main/kotlin/dev/dimension/flare/Main.kt | 136 ++-- .../dimension/flare/common/DeeplinkHandler.kt | 35 + .../data/repository/SettingsRepository.kt | 55 ++ .../dev/dimension/flare/di/DesktopModule.kt | 2 + .../flare/ui/component/AccountItem.kt | 121 +++ .../dev/dimension/flare/ui/route/Route.kt | 7 +- .../dev/dimension/flare/ui/route/Router.kt | 24 +- .../flare/ui/screen/home/ProfileScreen.kt | 7 +- .../serviceselect/ServiceSelectScreen.kt | 503 ++++++------ .../ui/screen/settings/SettingsScreen.kt | 735 ++++++++++++++++++ .../flare/ui/theme/DarkThemeDetector.kt | 2 +- .../dimension/flare/ui/theme/FlareTheme.kt | 74 +- .../dimension/flare/data/model/AppSettings.kt | 43 + .../flare/data/model/AppearanceSettings.kt | 70 ++ .../dimension/flare/data/model/TabSettings.kt | 13 +- .../flare/ui/presenter/HomeTabsPresenter.kt | 152 ++-- .../dev/dimension/flare/ui/model/UiAccount.kt | 1 + .../flare/common/FileSystemUtilsExt.kt | 6 +- .../PlatformFlyoutContainer.android.kt | 18 + .../platform/PlatformFlyoutContainer.kt | 11 + .../component/status/StatusMediaComponent.kt | 52 +- .../ui/component/platform/PlaceHolder.jvm.kt | 5 +- .../platform/PlatformFlyoutContainer.jvm.kt | 28 + 28 files changed, 1800 insertions(+), 541 deletions(-) create mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/common/DeeplinkHandler.kt create mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/data/repository/SettingsRepository.kt create mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/AccountItem.kt create mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt create mode 100644 shared/src/androidJvmMain/kotlin/dev/dimension/flare/data/model/AppSettings.kt create mode 100644 shared/src/androidJvmMain/kotlin/dev/dimension/flare/data/model/AppearanceSettings.kt create mode 100644 shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformFlyoutContainer.android.kt create mode 100644 shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformFlyoutContainer.kt create mode 100644 shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformFlyoutContainer.jvm.kt diff --git a/app/src/main/java/dev/dimension/flare/data/model/AppSettings.kt b/app/src/main/java/dev/dimension/flare/data/model/AppSettings.kt index 247b792ad..81d128f84 100644 --- a/app/src/main/java/dev/dimension/flare/data/model/AppSettings.kt +++ b/app/src/main/java/dev/dimension/flare/data/model/AppSettings.kt @@ -2,49 +2,9 @@ package dev.dimension.flare.data.model import android.content.Context import androidx.datastore.core.DataStore -import androidx.datastore.core.Serializer import androidx.datastore.dataStore -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.Serializable -import kotlinx.serialization.decodeFromByteArray -import kotlinx.serialization.encodeToByteArray -import kotlinx.serialization.protobuf.ProtoBuf -import java.io.InputStream -import java.io.OutputStream - -@Serializable -internal data class AppSettings( - val version: String, - val aiConfig: AiConfig = AiConfig(), -) { - @Serializable - data class AiConfig( - val translation: Boolean = false, - val tldr: Boolean = true, - ) -} - -@OptIn(ExperimentalSerializationApi::class) -private object PreferencesSerializer : Serializer { - override suspend fun readFrom(input: InputStream): AppSettings = ProtoBuf.decodeFromByteArray(input.readBytes()) - - override suspend fun writeTo( - t: AppSettings, - output: OutputStream, - ) = withContext(Dispatchers.IO) { - output.write(ProtoBuf.encodeToByteArray(t)) - } - - override val defaultValue: AppSettings - get() = - AppSettings( - version = "", - ) -} internal val Context.appSettings: DataStore by dataStore( fileName = "app_settings.pb", - serializer = PreferencesSerializer, + serializer = AppSettingsSerializer, ) diff --git a/app/src/main/java/dev/dimension/flare/data/model/AppearanceSettings.kt b/app/src/main/java/dev/dimension/flare/data/model/AppearanceSettings.kt index c90d5a199..0e6c2a689 100644 --- a/app/src/main/java/dev/dimension/flare/data/model/AppearanceSettings.kt +++ b/app/src/main/java/dev/dimension/flare/data/model/AppearanceSettings.kt @@ -1,74 +1,8 @@ package dev.dimension.flare.data.model import android.content.Context -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.graphics.Color import androidx.datastore.core.DataStore -import androidx.datastore.core.Serializer import androidx.datastore.dataStore -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.Serializable -import kotlinx.serialization.decodeFromByteArray -import kotlinx.serialization.encodeToByteArray -import kotlinx.serialization.protobuf.ProtoBuf -import java.io.InputStream -import java.io.OutputStream - -val LocalAppearanceSettings = staticCompositionLocalOf { AppearanceSettings() } - -@Serializable -data class AppearanceSettings( - val theme: Theme = Theme.SYSTEM, - val dynamicTheme: Boolean = true, - val colorSeed: ULong = Color(red = 103, green = 80, blue = 164).value, - val avatarShape: AvatarShape = AvatarShape.CIRCLE, - val showActions: Boolean = true, - val pureColorMode: Boolean = true, - val showNumbers: Boolean = true, - val showLinkPreview: Boolean = true, - val showMedia: Boolean = true, - val showSensitiveContent: Boolean = false, - val videoAutoplay: VideoAutoplay = VideoAutoplay.WIFI, - val expandMediaSize: Boolean = false, - val compatLinkPreview: Boolean = false, -) - -@Serializable -enum class Theme { - LIGHT, - DARK, - SYSTEM, -} - -@Serializable -enum class AvatarShape { - CIRCLE, - SQUARE, -} - -@Serializable -enum class VideoAutoplay { - ALWAYS, - WIFI, - NEVER, -} - -@OptIn(ExperimentalSerializationApi::class) -private object AccountPreferencesSerializer : Serializer { - override suspend fun readFrom(input: InputStream): AppearanceSettings = ProtoBuf.decodeFromByteArray(input.readBytes()) - - override suspend fun writeTo( - t: AppearanceSettings, - output: OutputStream, - ) = withContext(Dispatchers.IO) { - output.write(ProtoBuf.encodeToByteArray(t)) - } - - override val defaultValue: AppearanceSettings - get() = AppearanceSettings() -} internal val Context.appearanceSettings: DataStore by dataStore( fileName = "appearance_settings.pb", diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index e68419774..b52f34373 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -32,8 +32,10 @@ dependencies { implementation(libs.ksoup) implementation(platform(libs.koin.bom)) implementation(libs.koin.core) + implementation(libs.koin.compose) implementation(libs.commons.lang3) implementation(libs.zoomable) + implementation(libs.datastore) } compose.desktop { diff --git a/desktopApp/src/main/composeResources/values/strings.xml b/desktopApp/src/main/composeResources/values/strings.xml index fdca647ae..314077152 100644 --- a/desktopApp/src/main/composeResources/values/strings.xml +++ b/desktopApp/src/main/composeResources/values/strings.xml @@ -123,5 +123,93 @@ Username Password Two-factor authentication code - + Login with OAuth + Use Password + Bluesky OAuth might not stable enough, please use password login if you encounter any issues. + + Login session expired + Click to login again + Login again + Error + Failed to load account + Add account + Remove account + + Account Management + Appearance + Status Appearance + Customize the appearance of status + Customize the look and feel of Flare + Storage + Manage Flare\'s storage + About + Learn more about Flare + Side Panel customization + Customize the side panel + Local filter + Local filter settings for timeline + Guest Mode Host + Set the host for guest mode + Local history + View or search your browsing history + App logging + Save log + Clear log + Enable network logging + AI Configuration + Configure AI settings + Server URL + Enter the server URL + You can use the official server or self-host your own server + Enable AI translation + Replace Google Translate with AI translation, might take longer time + Enable AI Summarization + Enable AI summarization for long posts, only available in post that longer than 500 characters + + + Generic + + Show actions + Show actions on the bottom of the status + Show media + Show media in the status + Show numbers + Show numbers on the bottom of the status + Show sensitive content + Always show sensitive content in the status + Expand media to full size + Keep the aspectradio of the media in timeline + Video autoplay + Automatically play videos in the status + Wi-Fi only + Always + Never + Avatar shape + Change the shape of the avatar + Round + Square + Theme + Change the theme of the app + Light + Dark + Auto + Dynamic theme + Change the theme of the app based on the device\'s wallpaper + Show link previews + Show link previews in the status + Simplify link previews + Show link previews in simplify mode in the status + Theme color + Change the theme color of the app + High contrast Mode + Increase contrast between background and text + + Source code + Telegram + Join our Telegram group + Line + Join our Line group + Crowdin + Help us translate Flare + Privacy Policy \ No newline at end of file diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt index bdb0eb4cd..63d5f98c5 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt @@ -118,25 +118,20 @@ internal fun FlareApp( Column( modifier = Modifier + .background( + FluentTheme.colors.background.layerOnMicaBaseAlt.secondary, + ).fillMaxHeight() + .width(72.dp) + .verticalScroll(rememberScrollState()) + .padding(top = 16.dp) .let { if (SystemUtils.IS_OS_MAC) { it.padding(top = 24.dp) } else { it } - }.background( - FluentTheme.colors.background.layerOnMicaBaseAlt.secondary, - ).fillMaxHeight() - .width(72.dp) - .verticalScroll(rememberScrollState()), + }, ) { - NavigationDefaults.BackButton( - onClick = { - goBack() - }, - disabled = !stackManager.canGoBack, - modifier = Modifier.fillMaxWidth(), - ) state.user .onSuccess { user -> SubtleButton( @@ -179,10 +174,13 @@ internal fun FlareApp( }, modifier = Modifier - .padding(horizontal = 8.dp, vertical = 4.dp) + .padding(vertical = 4.dp) .fillMaxWidth(), ) { Column( + modifier = + Modifier + .padding(vertical = 4.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp), ) { @@ -321,9 +319,20 @@ internal fun FlareApp( shape = RoundedCornerShape(0), border = null, ) { - Router( - manager = stackManager, - ) + Box { + Router( + manager = stackManager, + ) + if (stackManager.canGoBack) { + NavigationDefaults.BackButton( + onClick = { + goBack() + }, + disabled = !stackManager.canGoBack, + modifier = Modifier.align(Alignment.TopStart), + ) + } + } } } } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt index cd9b9e908..70d198d1d 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt @@ -15,20 +15,29 @@ import androidx.compose.ui.window.rememberWindowState import coil3.ImageLoader import coil3.compose.setSingletonImageLoaderFactory import coil3.request.crossfade +import dev.dimension.flare.common.DeeplinkHandler import dev.dimension.flare.di.KoinHelper import dev.dimension.flare.di.desktopModule import dev.dimension.flare.ui.route.FloatingWindowState import dev.dimension.flare.ui.route.WindowRoute import dev.dimension.flare.ui.route.WindowRouter import dev.dimension.flare.ui.theme.FlareTheme +import dev.dimension.flare.ui.theme.ProvideThemeSettings +import org.apache.commons.lang3.SystemUtils import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.koin.core.context.startKoin +import java.awt.Desktop fun main(args: Array) { startKoin { modules(desktopModule + KoinHelper.modules()) } + if (SystemUtils.IS_OS_MAC_OSX) { + Desktop.getDesktop().setOpenURIHandler { + DeeplinkHandler.handleDeeplink(it.uri.toString()) + } + } application { setSingletonImageLoaderFactory { context -> ImageLoader @@ -51,78 +60,71 @@ fun main(args: Array) { ) } } -// val navController = rememberNavController() -// LaunchedEffect(Unit) { -// if (SystemUtils.IS_OS_MAC_OSX) { -// Desktop.getDesktop().setOpenURIHandler { -// navController.navigate(it.uri.toString()) -// } -// } -// } - Window( - onCloseRequest = ::exitApplication, - title = stringResource(Res.string.app_name), - icon = painterResource(Res.drawable.flare_logo), - state = - rememberWindowState( - position = WindowPosition(Alignment.Center), - size = DpSize(520.dp, 840.dp), - ), - ) { - FlareTheme { - FlareApp( -// navController = navController, - onRawImage = { url -> - openWindow( - url, - WindowRoute.RawImage(url), - ) - }, - onStatusMedia = { accountType, statusKey, index -> - openWindow( - "$accountType/$statusKey", - WindowRoute.StatusMedia( - accountType = accountType, - statusKey = statusKey, - index = index, - ), - ) - }, - ) - } - } - - extraWindowRoutes.forEach { (key, value) -> + ProvideThemeSettings { Window( + onCloseRequest = ::exitApplication, title = stringResource(Res.string.app_name), icon = painterResource(Res.drawable.flare_logo), - onCloseRequest = { - extraWindowRoutes.remove(key) - }, - onKeyEvent = { - if (it.key == Key.Escape) { + state = + rememberWindowState( + position = WindowPosition(Alignment.Center), + size = DpSize(520.dp, 840.dp), + ), + ) { + FlareTheme { + FlareApp( + onRawImage = { url -> + openWindow( + url, + WindowRoute.RawImage(url), + ) + }, + onStatusMedia = { accountType, statusKey, index -> + openWindow( + "$accountType/$statusKey", + WindowRoute.StatusMedia( + accountType = accountType, + statusKey = statusKey, + index = index, + ), + ) + }, + ) + } + } + + extraWindowRoutes.forEach { (key, value) -> + Window( + title = stringResource(Res.string.app_name), + icon = painterResource(Res.drawable.flare_logo), + onCloseRequest = { extraWindowRoutes.remove(key) - true - } else { - false - } - }, - content = { - LaunchedEffect(key) { - value.bringToFront = { - window.toFront() + }, + onKeyEvent = { + if (it.key == Key.Escape) { + extraWindowRoutes.remove(key) + true + } else { + false + } + }, + content = { + LaunchedEffect(key) { + value.bringToFront = { + window.toFront() + } } - } - FlareTheme { - WindowRouter( - route = value.route, - onBack = { - extraWindowRoutes.remove(key) - }, - ) - } - }, - ) + FlareTheme { + WindowRouter( + route = value.route, + onBack = { + extraWindowRoutes.remove(key) + }, + ) + } + }, + ) + } } } } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/common/DeeplinkHandler.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/common/DeeplinkHandler.kt new file mode 100644 index 000000000..42d4c0a1f --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/common/DeeplinkHandler.kt @@ -0,0 +1,35 @@ +package dev.dimension.flare.common + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect + +internal object DeeplinkHandler { + private val handlers = mutableListOf<(String) -> Boolean>() + + fun registerHandler(handler: (String) -> Boolean) { + handlers.add(handler) + } + + fun unregisterHandler(handler: (String) -> Boolean) { + handlers.remove(handler) + } + + fun handleDeeplink(deeplink: String): Boolean { + for (handler in handlers) { + if (handler(deeplink)) { + return true + } + } + return false + } +} + +@Composable +internal fun OnDeepLink(handler: (String) -> Boolean) { + DisposableEffect(handler) { + DeeplinkHandler.registerHandler(handler) + onDispose { + DeeplinkHandler.unregisterHandler(handler) + } + } +} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/data/repository/SettingsRepository.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/data/repository/SettingsRepository.kt new file mode 100644 index 000000000..465952ad8 --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/data/repository/SettingsRepository.kt @@ -0,0 +1,55 @@ +package dev.dimension.flare.data.repository + +import dev.dimension.flare.common.FileSystemUtilsExt +import dev.dimension.flare.data.model.AccountPreferencesSerializer +import dev.dimension.flare.data.model.AppSettings +import dev.dimension.flare.data.model.AppSettingsSerializer +import dev.dimension.flare.data.model.AppearanceSettings +import dev.dimension.flare.data.model.TabSettings +import dev.dimension.flare.data.model.TabSettingsSerializer +import java.io.File +import kotlin.getValue + +internal class SettingsRepository { + private val appearanceSettingsStore by lazy { + androidx.datastore.core.DataStoreFactory.create( + serializer = AccountPreferencesSerializer, + produceFile = { File(FileSystemUtilsExt.flareDirectory(), "appearance.pb") }, + ) + } + val appearanceSettings by lazy { + appearanceSettingsStore.data + } + private val appSettingsStore by lazy { + androidx.datastore.core.DataStoreFactory.create( + serializer = AppSettingsSerializer, + produceFile = { File(FileSystemUtilsExt.flareDirectory(), "app_settings.pb") }, + ) + } + val appSettings by lazy { + appSettingsStore.data + } + + suspend fun updateAppearanceSettings(block: AppearanceSettings.() -> AppearanceSettings) { + appearanceSettingsStore.updateData(block) + } + + private val tabSettingsStore by lazy { + androidx.datastore.core.DataStoreFactory.create( + serializer = TabSettingsSerializer, + produceFile = { File(FileSystemUtilsExt.flareDirectory(), "tab_settings.pb") }, + ) + } + + val tabSettings by lazy { + tabSettingsStore.data + } + + suspend fun updateTabSettings(block: TabSettings.() -> TabSettings) { + tabSettingsStore.updateData(block) + } + + suspend fun updateAppSettings(block: AppSettings.() -> AppSettings) { + appSettingsStore.updateData(block) + } +} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/di/DesktopModule.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/di/DesktopModule.kt index b0a26abfd..5408d8c58 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/di/DesktopModule.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/di/DesktopModule.kt @@ -2,6 +2,7 @@ package dev.dimension.flare.di import dev.dimension.flare.common.InAppNotification import dev.dimension.flare.common.Message +import dev.dimension.flare.data.repository.SettingsRepository import dev.dimension.flare.ui.component.platform.VideoPlayerPool import org.koin.core.module.dsl.singleOf import org.koin.dsl.binds @@ -29,4 +30,5 @@ val desktopModule = } } binds arrayOf(InAppNotification::class) singleOf(::VideoPlayerPool) + singleOf(::SettingsRepository) } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/AccountItem.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/AccountItem.kt new file mode 100644 index 000000000..cfa1ebdff --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/AccountItem.kt @@ -0,0 +1,121 @@ +package dev.dimension.flare.ui.component + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.FaceSadTear +import dev.dimension.flare.Res +import dev.dimension.flare.account_item_error_message +import dev.dimension.flare.account_item_error_title +import dev.dimension.flare.data.repository.LoginExpiredException +import dev.dimension.flare.login_expired +import dev.dimension.flare.login_expired_relogin +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.component.platform.placeholder +import dev.dimension.flare.ui.model.UiState +import dev.dimension.flare.ui.model.UiUserV2 +import dev.dimension.flare.ui.model.onError +import dev.dimension.flare.ui.model.onLoading +import dev.dimension.flare.ui.model.onSuccess +import io.github.composefluent.component.CardExpanderItem +import io.github.composefluent.component.SubtleButton +import io.github.composefluent.component.Text +import org.jetbrains.compose.resources.stringResource + +@Composable +fun AccountItem( + userState: UiState, + onClick: (MicroBlogKey) -> Unit, + toLogin: () -> Unit, + modifier: Modifier = Modifier, + trailingContent: @Composable (UiUserV2?) -> Unit = { }, + headlineContent: @Composable (UiUserV2) -> Unit = { + RichText(text = it.name, maxLines = 1) + }, + supportingContent: @Composable (UiUserV2) -> Unit = { + Text(text = it.handle, maxLines = 1) + }, + avatarSize: Dp = 24.dp, +) { + userState + .onSuccess { data -> + CardExpanderItem( + heading = { + headlineContent.invoke(data) + }, + onClick = { + onClick.invoke(data.key) + }, + modifier = modifier, + icon = { + AvatarComponent(data = data.avatar, size = avatarSize) + }, + trailing = { + trailingContent.invoke(data) + }, + caption = { + supportingContent.invoke(data) + }, + ) + }.onLoading { + CardExpanderItem( + heading = { + Text(text = "Loading...", modifier = Modifier.placeholder(true)) + }, + modifier = modifier, + icon = { + AvatarComponent( + data = null, + modifier = Modifier.placeholder(true, shape = CircleShape), + size = avatarSize, + ) + }, + caption = { + Text(text = "Loading...", modifier = Modifier.placeholder(true)) + }, + ) + }.onError { throwable -> + CardExpanderItem( + heading = { + if (throwable is LoginExpiredException) { + Text( + text = + stringResource( + Res.string.login_expired, + throwable.accountKey.toString(), + ), + ) + } else { + Text(text = stringResource(Res.string.account_item_error_title)) + } + }, + modifier = modifier, + icon = { + FAIcon( + FontAwesomeIcons.Solid.FaceSadTear, + contentDescription = stringResource(Res.string.account_item_error_title), + ) + }, + caption = { + if (throwable is LoginExpiredException) { + Text(text = throwable.accountKey.toString()) + } else { + Text(text = stringResource(Res.string.account_item_error_message)) + } + }, + trailing = { + if (throwable is LoginExpiredException) { + SubtleButton(onClick = toLogin) { + Text(text = stringResource(Res.string.login_expired_relogin)) + } + } else { + trailingContent.invoke(null) + } + }, + ) + } +} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt index 4f4c262f3..ab1e3380e 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt @@ -114,6 +114,11 @@ internal sealed interface Route { val userKey: MicroBlogKey, ) : FloatingRoute + @Serializable + data class AltText( + val text: String, + ) : FloatingRoute + companion object { public fun parse(url: String): Route? { val data = Url(url) @@ -322,7 +327,7 @@ internal sealed interface Route { "AltText" -> { val text = data.segments.getOrNull(0) ?: return null -// Route.Status.AltText(text) +// Route.AltText(text) null } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt index de2b5f014..5b140cf54 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt @@ -1,9 +1,11 @@ package dev.dimension.flare.ui.route import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import dev.dimension.flare.data.model.Bluesky.FeedTabItem import dev.dimension.flare.data.model.IconType import dev.dimension.flare.data.model.IconType.Material @@ -18,6 +20,7 @@ import dev.dimension.flare.ui.screen.home.ProfileScreen import dev.dimension.flare.ui.screen.home.TimelineScreen import dev.dimension.flare.ui.screen.list.AllListScreen import dev.dimension.flare.ui.screen.serviceselect.ServiceSelectScreen +import dev.dimension.flare.ui.screen.settings.SettingsScreen import dev.dimension.flare.ui.screen.status.StatusScreen import dev.dimension.flare.ui.screen.status.VVOCommentScreen import dev.dimension.flare.ui.screen.status.VVOStatusScreen @@ -26,6 +29,7 @@ import dev.dimension.flare.ui.screen.status.action.BlueskyReportStatusDialog import dev.dimension.flare.ui.screen.status.action.DeleteStatusConfirmDialog import dev.dimension.flare.ui.screen.status.action.MastodonReportDialog import dev.dimension.flare.ui.screen.status.action.MisskeyReportDialog +import io.github.composefluent.component.Flyout import io.github.composefluent.component.Text @OptIn(ExperimentalComposeUiApi::class) @@ -137,7 +141,11 @@ internal fun Router( } Route.Settings -> { - Text("route") + SettingsScreen( + toLogin = { + navigate(Route.ServiceSelect) + }, + ) } is Route.Timeline -> { @@ -208,6 +216,20 @@ internal fun Router( userKey = entry.route.userKey, ) } + + is Route.AltText -> { + Flyout( + visible = true, + onDismissRequest = { + onBack() + }, + ) { + Text( + text = entry.route.text, + modifier = Modifier.padding(16.dp), + ) + } + } } } } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/ProfileScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/ProfileScreen.kt index 56e2f6015..918ca4e5b 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/ProfileScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/ProfileScreen.kt @@ -133,7 +133,12 @@ internal fun ProfileScreen( } else { 0.dp }, - ) + LocalContentPadding.current, + ) + + if (isBigScreen) { + LocalContentPadding.current + } else { + PaddingValues() + }, state = listState, ) { if (!isBigScreen) { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt index fce2d3fe8..838cc8b2b 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt @@ -6,11 +6,14 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -32,8 +35,12 @@ import compose.icons.fontawesomeicons.solid.CircleQuestion import dev.dimension.flare.LocalContentPadding import dev.dimension.flare.Res import dev.dimension.flare.bluesky_login_2fa +import dev.dimension.flare.bluesky_login_oauth_button +import dev.dimension.flare.bluesky_login_oauth_hint import dev.dimension.flare.bluesky_login_password +import dev.dimension.flare.bluesky_login_use_password_button import dev.dimension.flare.bluesky_login_username +import dev.dimension.flare.common.OnDeepLink import dev.dimension.flare.common.onEmpty import dev.dimension.flare.common.onLoading import dev.dimension.flare.common.onSuccess @@ -63,6 +70,9 @@ import io.github.composefluent.FluentTheme import io.github.composefluent.component.AccentButton import io.github.composefluent.component.ProgressBar import io.github.composefluent.component.ProgressRing +import io.github.composefluent.component.SegmentedButton +import io.github.composefluent.component.SegmentedControl +import io.github.composefluent.component.SegmentedItemPosition import io.github.composefluent.component.Text import io.github.composefluent.component.TextField import kotlinx.coroutines.flow.distinctUntilChanged @@ -90,254 +100,313 @@ internal fun ServiceSelectScreen( state.setFilter(it.text) } } - Column( - modifier = Modifier.padding(LocalContentPadding.current), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), + LazyStatusVerticalStaggeredGrid( + modifier = + Modifier + .padding(horizontal = 16.dp), + columns = StaggeredGridCells.Adaptive(300.dp), + horizontalArrangement = + Arrangement.spacedBy( + 8.dp, + Alignment.CenterHorizontally, + ), + verticalItemSpacing = 0.dp, + contentPadding = LocalContentPadding.current, ) { - Text( - stringResource(Res.string.service_select_welcome_title), - style = FluentTheme.typography.title, - ) - Text( - stringResource(Res.string.service_select_welcome_message, SystemUtils.OS_NAME), - textAlign = TextAlign.Center, - ) - TextField( - value = host, - onValueChange = { host = it }, - placeholder = { stringResource(Res.string.service_select_instance_input_placeholder) }, - trailing = { - Box( - modifier = Modifier.padding(4.dp), - ) { - state.detectedPlatformType - .onSuccess { - NetworkImage( - it.logoUrl, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - }.onError { - FAIcon( - imageVector = FontAwesomeIcons.Solid.CircleQuestion, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - }.onLoading { - ProgressRing( - modifier = Modifier.size(16.dp), - ) - } - } - }, - maxLines = 1, - modifier = Modifier.width(300.dp), - ) - AnimatedVisibility(state.canNext && state.detectedPlatformType.isSuccess) { + item( + span = StaggeredGridItemSpan.FullLine, + ) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), ) { - when (state.detectedPlatformType.takeSuccess()) { - PlatformType.Mastodon -> { - state.mastodonLoginState.resumedState - ?.onLoading { - Text( - text = stringResource(Res.string.mastodon_login_verify_message), - ) - ProgressBar() - }?.onError { - Text( - text = it.message ?: "Unknown error", - ) + Text( + stringResource(Res.string.service_select_welcome_title), + style = FluentTheme.typography.title, + ) + Text( + stringResource(Res.string.service_select_welcome_message, SystemUtils.OS_NAME), + textAlign = TextAlign.Center, + ) + TextField( + value = host, + onValueChange = { host = it }, + placeholder = { stringResource(Res.string.service_select_instance_input_placeholder) }, + trailing = { + Box( + modifier = Modifier.padding(4.dp), + ) { + state.detectedPlatformType + .onSuccess { + NetworkImage( + it.logoUrl, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }.onError { + FAIcon( + imageVector = FontAwesomeIcons.Solid.CircleQuestion, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }.onLoading { + ProgressRing( + modifier = Modifier.size(16.dp), + ) + } + } + }, + maxLines = 1, + modifier = Modifier.width(300.dp), + ) + AnimatedVisibility(state.canNext && state.detectedPlatformType.isSuccess) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + when (state.detectedPlatformType.takeSuccess()) { + PlatformType.Mastodon -> { + OnDeepLink { + state.mastodonLoginState.resume(it) + true + } + state.mastodonLoginState.resumedState + ?.onLoading { + Text( + text = stringResource(Res.string.mastodon_login_verify_message), + ) + ProgressBar() + }?.onError { + Text( + text = it.message ?: "Unknown error", + ) + } + ?: run { + AccentButton( + onClick = { + state.mastodonLoginState.login( + host.text, + launchUrl = uriHandler::openUri, + ) + }, + modifier = Modifier.width(300.dp), + disabled = state.mastodonLoginState.loading, + ) { + Text( + stringResource(Res.string.service_select_next_button), + ) + } + } + state.mastodonLoginState.error?.let { + Text(it) + } } - ?: run { - AccentButton( - onClick = { - state.mastodonLoginState.login( - host.text, - launchUrl = uriHandler::openUri, + + PlatformType.Misskey -> { + OnDeepLink { + state.misskeyLoginState.resume(it) + true + } + state.misskeyLoginState.resumedState + ?.onLoading { + Text( + text = stringResource(Res.string.mastodon_login_verify_message), ) - }, - modifier = Modifier.width(300.dp), - disabled = state.mastodonLoginState.loading, + ProgressBar() + }?.onError { + Text( + text = it.message ?: "Unknown error", + ) + } + ?: run { + AccentButton( + onClick = { + state.misskeyLoginState.login( + host.text, + launchUrl = uriHandler::openUri, + ) + }, + modifier = Modifier.width(300.dp), + disabled = state.misskeyLoginState.loading, + ) { + Text( + stringResource(Res.string.service_select_next_button), + ) + } + } + state.misskeyLoginState.error?.let { + Text(it) + } + } + + PlatformType.Bluesky -> { + var userName by remember { mutableStateOf(TextFieldValue("")) } + var password by remember { mutableStateOf(TextFieldValue("")) } + var verifyCode by remember { mutableStateOf(TextFieldValue("")) } + var useOAuth by remember { mutableStateOf(false) } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - Text( - stringResource(Res.string.service_select_next_button), + SegmentedControl { + SegmentedButton( + checked = !useOAuth, + onCheckedChanged = { + useOAuth = true + }, + position = SegmentedItemPosition.Start, + ) { + Text(text = stringResource(Res.string.bluesky_login_use_password_button)) + } + SegmentedButton( + checked = useOAuth, + onCheckedChanged = { + useOAuth = false + }, + position = SegmentedItemPosition.End, + ) { + Text(text = stringResource(Res.string.bluesky_login_oauth_button)) + } + } + TextField( + value = userName, + onValueChange = { userName = it }, + placeholder = { Text(stringResource(Res.string.bluesky_login_username)) }, + maxLines = 1, + modifier = Modifier.width(300.dp), ) + AnimatedVisibility(!useOAuth) { + TextField( + value = password, + onValueChange = { password = it }, + placeholder = { Text(stringResource(Res.string.bluesky_login_password)) }, + maxLines = 1, + modifier = Modifier.width(300.dp), + visualTransformation = remember { PasswordVisualTransformation() }, + ) + } + AnimatedVisibility(state.blueskyLoginState.require2FA) { + TextField( + value = verifyCode, + onValueChange = { verifyCode = it }, + placeholder = { Text(stringResource(Res.string.bluesky_login_2fa)) }, + maxLines = 1, + modifier = Modifier.width(300.dp), + ) + } + AnimatedVisibility(useOAuth) { + Text(stringResource(Res.string.bluesky_login_oauth_hint)) + } + if (useOAuth) { + OnDeepLink { + state.blueskyOauthLoginState.resume(it) + true + } + } + AccentButton( + onClick = { + if (useOAuth) { + state.blueskyOauthLoginState.login( + userName.text, + launchUrl = uriHandler::openUri, + ) + } else { + state.blueskyLoginState.login( + "https://${host.text}", + userName.text + .toString(), + password.text + .toString(), + verifyCode.text + .takeIf { it.isNotEmpty() }, + ) + } + }, + modifier = Modifier.width(300.dp), + disabled = + state.blueskyLoginState.loading || + userName.text.isEmpty() || + (useOAuth && password.text.isEmpty()) || + (state.blueskyLoginState.require2FA && verifyCode.text.isEmpty()) || + state.blueskyOauthLoginState.loading, + ) { + Text( + text = stringResource(Res.string.service_select_next_button), + ) + } + AnimatedVisibility(state.blueskyOauthLoginState.loading || state.blueskyLoginState.loading) { + ProgressBar() + } } } - state.mastodonLoginState.error?.let { - Text(it) - } - } - PlatformType.Misskey -> { - state.misskeyLoginState.resumedState - ?.onLoading { - Text( - text = stringResource(Res.string.mastodon_login_verify_message), - ) - ProgressBar() - }?.onError { - Text( - text = it.message ?: "Unknown error", - ) - } - ?: run { + PlatformType.xQt -> { AccentButton( - onClick = { - state.misskeyLoginState.login( - host.text, - launchUrl = uriHandler::openUri, - ) - }, + onClick = onXQT, modifier = Modifier.width(300.dp), - disabled = state.misskeyLoginState.loading, ) { Text( - stringResource(Res.string.service_select_next_button), + text = stringResource(Res.string.service_select_next_button), ) } } - state.misskeyLoginState.error?.let { - Text(it) - } - } - PlatformType.Bluesky -> { - var userName by remember { mutableStateOf(TextFieldValue("")) } - var password by remember { mutableStateOf(TextFieldValue("")) } - var verifyCode by remember { mutableStateOf(TextFieldValue("")) } - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - TextField( - value = userName, - onValueChange = { userName = it }, - placeholder = { Text(stringResource(Res.string.bluesky_login_username)) }, - maxLines = 1, - modifier = Modifier.width(300.dp), - ) - TextField( - value = password, - onValueChange = { password = it }, - placeholder = { Text(stringResource(Res.string.bluesky_login_password)) }, - maxLines = 1, - modifier = Modifier.width(300.dp), - visualTransformation = remember { PasswordVisualTransformation() }, - ) - AnimatedVisibility(state.blueskyLoginState.require2FA) { - TextField( - value = verifyCode, - onValueChange = { verifyCode = it }, - placeholder = { Text(stringResource(Res.string.bluesky_login_2fa)) }, - maxLines = 1, + PlatformType.VVo -> { + AccentButton( + onClick = onVVO, modifier = Modifier.width(300.dp), - ) - } - AccentButton( - onClick = { - state.blueskyLoginState.login( - "https://${host.text}", - userName.text - .toString(), - password.text - .toString(), - verifyCode.text - .takeIf { it.isNotEmpty() }, + ) { + Text( + text = stringResource(Res.string.service_select_next_button), ) - }, - modifier = Modifier.width(300.dp), - disabled = - state.blueskyLoginState.loading || - userName.text.isEmpty() || - password.text.isEmpty() || - (state.blueskyLoginState.require2FA && verifyCode.text.isEmpty()), - ) { - Text( - text = stringResource(Res.string.service_select_next_button), - ) + } } - } - } - PlatformType.xQt -> { - AccentButton( - onClick = onXQT, - modifier = Modifier.width(300.dp), - ) { - Text( - text = stringResource(Res.string.service_select_next_button), - ) - } - } - - PlatformType.VVo -> { - AccentButton( - onClick = onVVO, - modifier = Modifier.width(300.dp), - ) { - Text( - text = stringResource(Res.string.service_select_next_button), - ) + null -> Unit } } - - null -> Unit } } } - LazyStatusVerticalStaggeredGrid( - modifier = - Modifier - .weight(1f) - .padding(horizontal = 16.dp), - columns = StaggeredGridCells.Adaptive(300.dp), - horizontalArrangement = - Arrangement.spacedBy( - 8.dp, - Alignment.CenterHorizontally, - ), - verticalItemSpacing = 0.dp, + item( + span = StaggeredGridItemSpan.FullLine, ) { - state.instances - .onSuccess { - items( - count = itemCount, - ) { - val instance = get(it) - ServiceSelectItem( - instance = instance, - index = it, - totalCount = itemCount, - onClicked = { - if (instance != null) { - host = TextFieldValue(instance.domain) - } - }, - ) - } - }.onLoading { - items(10) { - ServiceSelectItem( - instance = null, - onClicked = {}, - index = it, - totalCount = 10, - ) - } - }.onEmpty { - items(1) { - Text( - text = stringResource(Res.string.service_select_empty_message), - style = FluentTheme.typography.subtitle, - ) - } - } + Spacer(modifier = Modifier.height(16.dp)) } + state.instances + .onSuccess { + items( + count = itemCount, + ) { + val instance = get(it) + ServiceSelectItem( + instance = instance, + index = it, + totalCount = itemCount, + onClicked = { + if (instance != null) { + host = TextFieldValue(instance.domain) + } + }, + ) + } + }.onLoading { + items(10) { + ServiceSelectItem( + instance = null, + onClicked = {}, + index = it, + totalCount = 10, + ) + } + }.onEmpty { + items(1) { + Text( + text = stringResource(Res.string.service_select_empty_message), + style = FluentTheme.typography.subtitle, + ) + } + } } } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt new file mode 100644 index 000000000..97733fddf --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt @@ -0,0 +1,735 @@ +package dev.dimension.flare.ui.screen.settings + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +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.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.unit.dp +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Brands +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.brands.Github +import compose.icons.fontawesomeicons.brands.Line +import compose.icons.fontawesomeicons.brands.Telegram +import compose.icons.fontawesomeicons.solid.EllipsisVertical +import compose.icons.fontawesomeicons.solid.Language +import compose.icons.fontawesomeicons.solid.Lock +import compose.icons.fontawesomeicons.solid.Plus +import compose.icons.fontawesomeicons.solid.Trash +import dev.dimension.flare.LocalContentPadding +import dev.dimension.flare.Res +import dev.dimension.flare.add_account +import dev.dimension.flare.app_name +import dev.dimension.flare.data.model.AppearanceSettings +import dev.dimension.flare.data.model.AvatarShape +import dev.dimension.flare.data.model.LocalAppearanceSettings +import dev.dimension.flare.data.model.TabSettings +import dev.dimension.flare.data.model.Theme +import dev.dimension.flare.data.repository.SettingsRepository +import dev.dimension.flare.home_login +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.remove_account +import dev.dimension.flare.settings_about_line +import dev.dimension.flare.settings_about_line_description +import dev.dimension.flare.settings_about_localization +import dev.dimension.flare.settings_about_localization_description +import dev.dimension.flare.settings_about_source_code +import dev.dimension.flare.settings_about_telegram +import dev.dimension.flare.settings_about_telegram_description +import dev.dimension.flare.settings_about_title +import dev.dimension.flare.settings_accounts_title +import dev.dimension.flare.settings_appearance_avatar_shape +import dev.dimension.flare.settings_appearance_avatar_shape_description +import dev.dimension.flare.settings_appearance_avatar_shape_round +import dev.dimension.flare.settings_appearance_avatar_shape_square +import dev.dimension.flare.settings_appearance_compat_link_previews +import dev.dimension.flare.settings_appearance_compat_link_previews_description +import dev.dimension.flare.settings_appearance_expand_media +import dev.dimension.flare.settings_appearance_expand_media_description +import dev.dimension.flare.settings_appearance_show_actions +import dev.dimension.flare.settings_appearance_show_actions_description +import dev.dimension.flare.settings_appearance_show_cw_img +import dev.dimension.flare.settings_appearance_show_cw_img_description +import dev.dimension.flare.settings_appearance_show_link_previews +import dev.dimension.flare.settings_appearance_show_link_previews_description +import dev.dimension.flare.settings_appearance_show_media +import dev.dimension.flare.settings_appearance_show_media_description +import dev.dimension.flare.settings_appearance_show_numbers +import dev.dimension.flare.settings_appearance_show_numbers_description +import dev.dimension.flare.settings_appearance_theme +import dev.dimension.flare.settings_appearance_theme_auto +import dev.dimension.flare.settings_appearance_theme_dark +import dev.dimension.flare.settings_appearance_theme_description +import dev.dimension.flare.settings_appearance_theme_light +import dev.dimension.flare.settings_appearance_title +import dev.dimension.flare.settings_privacy_policy +import dev.dimension.flare.settings_status_appearance_subtitle +import dev.dimension.flare.settings_status_appearance_title +import dev.dimension.flare.ui.component.AccountItem +import dev.dimension.flare.ui.component.AvatarComponent +import dev.dimension.flare.ui.component.FAIcon +import dev.dimension.flare.ui.component.RichText +import dev.dimension.flare.ui.model.onError +import dev.dimension.flare.ui.model.onSuccess +import dev.dimension.flare.ui.presenter.home.ActiveAccountPresenter +import dev.dimension.flare.ui.presenter.home.UserState +import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.presenter.settings.AccountsPresenter +import dev.dimension.flare.ui.presenter.settings.AccountsState +import dev.dimension.flare.ui.theme.screenHorizontalPadding +import io.github.composefluent.FluentTheme +import io.github.composefluent.component.CardExpanderItem +import io.github.composefluent.component.DropDownButton +import io.github.composefluent.component.Expander +import io.github.composefluent.component.ExpanderItem +import io.github.composefluent.component.FlyoutPlacement +import io.github.composefluent.component.MenuFlyoutContainer +import io.github.composefluent.component.MenuFlyoutItem +import io.github.composefluent.component.RadioButton +import io.github.composefluent.component.ScrollbarContainer +import io.github.composefluent.component.SubtleButton +import io.github.composefluent.component.Switcher +import io.github.composefluent.component.Text +import kotlinx.coroutines.launch +import moe.tlaster.precompose.molecule.producePresenter +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject + +@Composable +internal fun SettingsScreen(toLogin: () -> Unit) { + val uriHandler = LocalUriHandler.current + val state by producePresenter { presenter() } + + val scrollState = rememberScrollState() + ScrollbarContainer( + modifier = Modifier.fillMaxSize(), + adapter = rememberScrollbarAdapter(scrollState), + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(LocalContentPadding.current) + .padding(horizontal = screenHorizontalPadding), + ) { + state.accountState.user + .onSuccess { activeAccount -> + Header(stringResource(Res.string.settings_accounts_title)) + Expander( + expanded = state.accountState.expanded, + onExpandedChanged = { + state.accountState.setExpanded(it) + }, + heading = { + RichText(activeAccount.name) + }, + icon = { + AvatarComponent(data = activeAccount.avatar, size = 24.dp) + }, + caption = { + Text(text = activeAccount.handle) + }, + ) { + state.accountState.accounts.onSuccess { accounts -> + repeat(accounts.size) { index -> + val account = accounts[index] + AccountItem( + account.second, + onClick = { + state.accountState.setActiveAccount(it) + }, + toLogin = toLogin, + trailingContent = { + Row( + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (it == null) { + SubtleButton( + onClick = { + state.accountState.logout(account.first.accountKey) + }, + ) { + FAIcon( + FontAwesomeIcons.Solid.Trash, + contentDescription = stringResource(Res.string.remove_account), + tint = FluentTheme.colors.system.critical, + ) + } + } else { + RadioButton( + selected = activeAccount.key == it.key, + onClick = { + state.accountState.setActiveAccount(it.key) + }, + ) + MenuFlyoutContainer( + flyout = { + MenuFlyoutItem( + text = { + Text( + stringResource(Res.string.remove_account), + color = FluentTheme.colors.system.critical, + ) + }, + onClick = { + state.accountState.logout(it.key) + }, + icon = { + FAIcon( + FontAwesomeIcons.Solid.Trash, + contentDescription = + stringResource( + Res.string.remove_account, + ), + tint = FluentTheme.colors.system.critical, + ) + }, + ) + }, + ) { + SubtleButton( + onClick = { + isFlyoutVisible = !isFlyoutVisible + }, + iconOnly = true, + ) { + FAIcon( + FontAwesomeIcons.Solid.EllipsisVertical, + contentDescription = stringResource(Res.string.remove_account), + ) + } + } + } + } + }, + ) + } + } + ExpanderItem( + heading = { + Text(stringResource(Res.string.add_account)) + }, + modifier = + Modifier.clickable { + toLogin.invoke() + }, + icon = { + FAIcon( + FontAwesomeIcons.Solid.Plus, + contentDescription = stringResource(Res.string.add_account), + ) + }, + ) + } + }.onError { + CardExpanderItem( + heading = { + Text(stringResource(Res.string.home_login)) + }, + onClick = { + toLogin.invoke() + }, + ) + } + + Header(stringResource(Res.string.settings_appearance_title)) + + CardExpanderItem( + icon = null, + heading = { + Text(stringResource(Res.string.settings_appearance_theme)) + }, + caption = { + Text(stringResource(Res.string.settings_appearance_theme_description)) + }, + trailing = { + MenuFlyoutContainer( + flyout = { + MenuFlyoutItem( + text = { Text(stringResource(Res.string.settings_appearance_theme_auto)) }, + onClick = { + state.appearanceState.updateSettings { + copy(theme = Theme.SYSTEM) + } + isFlyoutVisible = false + }, + ) + MenuFlyoutItem( + text = { Text(stringResource(Res.string.settings_appearance_theme_dark)) }, + onClick = { + isFlyoutVisible = false + state.appearanceState.updateSettings { + copy(theme = Theme.DARK) + } + }, + ) + MenuFlyoutItem( + text = { Text(stringResource(Res.string.settings_appearance_theme_light)) }, + onClick = { + isFlyoutVisible = false + state.appearanceState.updateSettings { + copy(theme = Theme.LIGHT) + } + }, + ) + }, + content = { + DropDownButton( + onClick = { isFlyoutVisible = !isFlyoutVisible }, + content = { + Text( + when (LocalAppearanceSettings.current.theme) { + Theme.SYSTEM -> stringResource(Res.string.settings_appearance_theme_auto) + Theme.DARK -> stringResource(Res.string.settings_appearance_theme_dark) + Theme.LIGHT -> stringResource(Res.string.settings_appearance_theme_light) + }, + ) + }, + ) + }, + adaptivePlacement = true, + placement = FlyoutPlacement.BottomAlignedEnd, + ) + }, + ) + + Expander( + icon = null, + expanded = state.appearanceState.expanded, + onExpandedChanged = state.appearanceState::setExpanded, + heading = { + Text(stringResource(Res.string.settings_status_appearance_title)) + }, + caption = { + Text(stringResource(Res.string.settings_status_appearance_subtitle)) + }, + ) { + ExpanderItem( + heading = { + Text(stringResource(Res.string.settings_appearance_avatar_shape)) + }, + caption = { + Text(stringResource(Res.string.settings_appearance_avatar_shape_description)) + }, + trailing = { + MenuFlyoutContainer( + flyout = { + MenuFlyoutItem( + text = { Text(stringResource(Res.string.settings_appearance_avatar_shape_round)) }, + onClick = { + state.appearanceState.updateSettings { + copy(avatarShape = AvatarShape.CIRCLE) + } + isFlyoutVisible = false + }, + ) + MenuFlyoutItem( + text = { Text(stringResource(Res.string.settings_appearance_avatar_shape_square)) }, + onClick = { + state.appearanceState.updateSettings { + copy(avatarShape = AvatarShape.SQUARE) + } + isFlyoutVisible = false + }, + ) + }, + content = { + DropDownButton( + onClick = { isFlyoutVisible = !isFlyoutVisible }, + content = { + Text( + when (LocalAppearanceSettings.current.avatarShape) { + AvatarShape.CIRCLE -> + stringResource( + Res.string.settings_appearance_avatar_shape_round, + ) + AvatarShape.SQUARE -> + stringResource( + Res.string.settings_appearance_avatar_shape_square, + ) + }, + ) + }, + ) + }, + adaptivePlacement = true, + placement = FlyoutPlacement.BottomAlignedEnd, + ) + }, + ) + + ExpanderItem( + heading = { + Text(stringResource(Res.string.settings_appearance_show_actions)) + }, + caption = { + Text(stringResource(Res.string.settings_appearance_show_actions_description)) + }, + trailing = { + Switcher( + checked = LocalAppearanceSettings.current.showActions, + { + state.appearanceState.updateSettings { + copy(showActions = it) + } + }, + textBefore = true, + ) + }, + ) + AnimatedVisibility(LocalAppearanceSettings.current.showActions) { + ExpanderItem( + heading = { + Text(stringResource(Res.string.settings_appearance_show_numbers)) + }, + caption = { + Text(stringResource(Res.string.settings_appearance_show_numbers_description)) + }, + trailing = { + Switcher( + checked = LocalAppearanceSettings.current.showNumbers, + { + state.appearanceState.updateSettings { + copy(showNumbers = it) + } + }, + textBefore = true, + ) + }, + ) + } + ExpanderItem( + heading = { + Text(stringResource(Res.string.settings_appearance_show_link_previews)) + }, + caption = { + Text(stringResource(Res.string.settings_appearance_show_link_previews_description)) + }, + trailing = { + Switcher( + checked = LocalAppearanceSettings.current.showLinkPreview, + { + state.appearanceState.updateSettings { + copy(showLinkPreview = it) + } + }, + textBefore = true, + ) + }, + ) + AnimatedVisibility(LocalAppearanceSettings.current.showLinkPreview) { + ExpanderItem( + heading = { + Text(stringResource(Res.string.settings_appearance_compat_link_previews)) + }, + caption = { + Text(stringResource(Res.string.settings_appearance_compat_link_previews_description)) + }, + trailing = { + Switcher( + checked = LocalAppearanceSettings.current.compatLinkPreview, + { + state.appearanceState.updateSettings { + copy(compatLinkPreview = it) + } + }, + textBefore = true, + ) + }, + ) + } + ExpanderItem( + heading = { + Text(stringResource(Res.string.settings_appearance_show_media)) + }, + caption = { + Text(stringResource(Res.string.settings_appearance_show_media_description)) + }, + trailing = { + Switcher( + checked = LocalAppearanceSettings.current.showMedia, + { + state.appearanceState.updateSettings { + copy(showMedia = it) + } + }, + textBefore = true, + ) + }, + ) + AnimatedVisibility(LocalAppearanceSettings.current.showMedia) { + ExpanderItem( + heading = { + Text(stringResource(Res.string.settings_appearance_show_cw_img)) + }, + caption = { + Text(stringResource(Res.string.settings_appearance_show_cw_img_description)) + }, + trailing = { + Switcher( + checked = LocalAppearanceSettings.current.showSensitiveContent, + { + state.appearanceState.updateSettings { + copy(showSensitiveContent = it) + } + }, + textBefore = true, + ) + }, + ) + } + AnimatedVisibility(LocalAppearanceSettings.current.showMedia) { + ExpanderItem( + heading = { + Text(stringResource(Res.string.settings_appearance_expand_media)) + }, + caption = { + Text(stringResource(Res.string.settings_appearance_expand_media_description)) + }, + trailing = { + Switcher( + checked = LocalAppearanceSettings.current.expandMediaSize, + { + state.appearanceState.updateSettings { + copy(expandMediaSize = it) + } + }, + textBefore = true, + ) + }, + ) + } + } + + Header(stringResource(Res.string.settings_about_title)) + Expander( + icon = null, + heading = { + Text(stringResource(Res.string.app_name)) + }, + caption = { + Text(System.getProperty("jpackage.app-version")) + }, + expanded = state.aboutExpanded, + onExpandedChanged = { state.setAboutExpanded(it) }, + ) { + CardExpanderItem( + heading = { + Text(text = stringResource(resource = Res.string.settings_about_source_code)) + }, + caption = { + Text( + text = "https://github.com/DimensionDev/Flare", + ) + }, + onClick = { + uriHandler.openUri("https://github.com/DimensionDev/Flare") + }, + icon = { + FAIcon( + imageVector = FontAwesomeIcons.Brands.Github, + contentDescription = "GitHub", + modifier = Modifier.size(24.dp), + ) + }, + ) + CardExpanderItem( + heading = { + Text(text = stringResource(resource = Res.string.settings_about_telegram)) + }, + caption = { + Text( + text = stringResource(resource = Res.string.settings_about_telegram_description), + ) + }, + onClick = { + uriHandler.openUri("https://t.me/+0UtcP6_qcDoyOWE1") + }, + icon = { + FAIcon( + imageVector = FontAwesomeIcons.Brands.Telegram, + contentDescription = stringResource(resource = Res.string.settings_about_telegram), + modifier = Modifier.size(24.dp), + ) + }, + ) + CardExpanderItem( + heading = { + Text(text = stringResource(resource = Res.string.settings_about_line)) + }, + caption = { + Text( + text = stringResource(resource = Res.string.settings_about_line_description), + ) + }, + onClick = { + uriHandler.openUri("https://line.me/ti/g/hf95HyGJ9k") + }, + icon = { + FAIcon( + imageVector = FontAwesomeIcons.Brands.Line, + contentDescription = stringResource(resource = Res.string.settings_about_telegram), + modifier = Modifier.size(24.dp), + ) + }, + ) + CardExpanderItem( + heading = { + Text(text = stringResource(resource = Res.string.settings_about_localization)) + }, + caption = { + Text( + text = stringResource(resource = Res.string.settings_about_localization_description), + ) + }, + onClick = { + uriHandler.openUri("https://crowdin.com/project/flareapp") + }, + icon = { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Language, + contentDescription = stringResource(resource = Res.string.settings_about_localization), + modifier = Modifier.size(24.dp), + ) + }, + ) + CardExpanderItem( + heading = { + Text(text = stringResource(resource = Res.string.settings_privacy_policy)) + }, + caption = { + Text( + text = "https://legal.mask.io/maskbook", + ) + }, + onClick = { + uriHandler.openUri("https://legal.mask.io/maskbook/") + }, + icon = { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Lock, + contentDescription = stringResource(resource = Res.string.settings_privacy_policy), + modifier = Modifier.size(24.dp), + ) + }, + ) + } + } + } +} + +@Composable +private fun Header(text: String) { + Text( + text = text, + style = FluentTheme.typography.bodyStrong, + modifier = Modifier.padding(top = 16.dp, bottom = 4.dp), + ) +} + +@Composable +private fun presenter() = + run { + val accountState = accountsPresenter() + val appearanceState = appearancePresenter() + var aboutExpanded by remember { mutableStateOf(false) } + + object { + val accountState = accountState + val appearanceState = appearanceState + + val aboutExpanded = aboutExpanded + + fun setAboutExpanded(value: Boolean) { + aboutExpanded = value + } + } + } + +@Composable +private fun appearancePresenter() = + run { + var expanded by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val settingsRepository = koinInject() + object { + val expanded = expanded + + fun setExpanded(value: Boolean) { + expanded = value + } + + fun updateSettings(block: AppearanceSettings.() -> AppearanceSettings) { + scope.launch { + settingsRepository.updateAppearanceSettings(block) + } + } + } + } + +@Composable +private fun accountsPresenter(settingsRepository: SettingsRepository = koinInject()) = + run { + val scope = rememberCoroutineScope() + var expanded by remember { mutableStateOf(false) } + val activeAccountState = remember { ActiveAccountPresenter() }.invoke() + val state = + remember { + AccountsPresenter() + }.invoke() + + object : AccountsState by state, UserState by activeAccountState { + val expanded = expanded + + fun setExpanded(value: Boolean) { + expanded = value + } + + fun logout(accountKey: MicroBlogKey) { + accounts.onSuccess { accountList -> + if (accountList.size == 1) { + // is Last account + scope.launch { + settingsRepository.updateTabSettings { + TabSettings() + } + } + } else { + scope.launch { + settingsRepository.updateTabSettings { + copy( + secondaryItems = + secondaryItems?.filter { + (it.account as? AccountType.Specific)?.accountKey != accountKey + }, + mainTabs = + mainTabs.filter { + (it.account as? AccountType.Specific)?.accountKey != accountKey + }, + ) + } + } + } + } + removeAccount(accountKey) + } + } + } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/DarkThemeDetector.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/DarkThemeDetector.kt index 79c7639e5..c4d8a4c6a 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/DarkThemeDetector.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/DarkThemeDetector.kt @@ -12,7 +12,7 @@ import java.util.function.Consumer private val detector = OsThemeDetector.getDetector() @Composable -internal fun isDarkTheme(): Boolean { +internal fun isSystemInDarkTheme(): Boolean { var isDarkTheme by remember { mutableStateOf(detector.isDark) } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt index 124fadab7..a6538bdd3 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt @@ -7,11 +7,15 @@ import androidx.compose.foundation.interaction.FocusInteraction import androidx.compose.foundation.interaction.HoverInteraction import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.ContentDrawScope @@ -19,6 +23,13 @@ import androidx.compose.ui.node.DelegatableNode import androidx.compose.ui.node.DrawModifierNode import androidx.compose.ui.node.invalidateDraw import androidx.compose.ui.window.FrameWindowScope +import dev.dimension.flare.data.model.AppSettings +import dev.dimension.flare.data.model.AppearanceSettings +import dev.dimension.flare.data.model.AvatarShape +import dev.dimension.flare.data.model.LocalAppearanceSettings +import dev.dimension.flare.data.model.Theme +import dev.dimension.flare.data.model.VideoAutoplay +import dev.dimension.flare.data.repository.SettingsRepository import dev.dimension.flare.ui.component.ComponentAppearance import dev.dimension.flare.ui.component.LocalComponentAppearance import io.github.composefluent.ExperimentalFluentApi @@ -27,6 +38,7 @@ import io.github.composefluent.darkColors import io.github.composefluent.lightColors import kotlinx.coroutines.launch import org.apache.commons.lang3.SystemUtils +import org.koin.compose.koinInject @OptIn(ExperimentalFluentApi::class) @Composable @@ -43,17 +55,6 @@ internal fun FrameWindowScope.FlareTheme( lightColors() }, ) { -// val micaBase = FluentTheme.colors.background.mica.base -// LaunchedEffect(window, isDarkTheme) { -// WindowStyleManager( -// window = window, -// isDarkTheme = isDarkTheme, -// frameStyle = -// WindowFrameStyle( -// titleBarColor = micaBase, -// ), -// ) -// } if (SystemUtils.IS_OS_MAC) { LaunchedEffect(window) { window.rootPane.apply { @@ -70,10 +71,6 @@ internal fun FrameWindowScope.FlareTheme( hover = Color.Transparent, pressed = Color.Transparent, ), - LocalComponentAppearance provides - ComponentAppearance( - videoAutoplay = ComponentAppearance.VideoAutoplay.ALWAYS, - ), ) { Box( modifier = @@ -158,3 +155,50 @@ private class FluentIndication( } } } + +@Composable +private fun isDarkTheme(): Boolean = + LocalAppearanceSettings.current.theme == Theme.DARK || + (LocalAppearanceSettings.current.theme == Theme.SYSTEM && isSystemInDarkTheme()) + +@Composable +internal fun ProvideThemeSettings(content: @Composable () -> Unit) { + val settingsRepository = koinInject() + val appearanceSettings by settingsRepository.appearanceSettings.collectAsState( + AppearanceSettings(), + ) + val appSettings by settingsRepository.appSettings.collectAsState(AppSettings("")) + CompositionLocalProvider( + LocalAppearanceSettings provides appearanceSettings, + LocalComponentAppearance provides + remember(appearanceSettings, appSettings.aiConfig) { + ComponentAppearance( + dynamicTheme = appearanceSettings.dynamicTheme, + avatarShape = + when (appearanceSettings.avatarShape) { + AvatarShape.CIRCLE -> ComponentAppearance.AvatarShape.CIRCLE + AvatarShape.SQUARE -> ComponentAppearance.AvatarShape.SQUARE + }, + showActions = appearanceSettings.showActions, + showNumbers = appearanceSettings.showNumbers, + showLinkPreview = appearanceSettings.showLinkPreview, + showMedia = appearanceSettings.showMedia, + showSensitiveContent = appearanceSettings.showSensitiveContent, + videoAutoplay = + when (appearanceSettings.videoAutoplay) { + VideoAutoplay.ALWAYS -> ComponentAppearance.VideoAutoplay.ALWAYS + VideoAutoplay.WIFI -> ComponentAppearance.VideoAutoplay.WIFI + VideoAutoplay.NEVER -> ComponentAppearance.VideoAutoplay.NEVER + }, + expandMediaSize = appearanceSettings.expandMediaSize, + compatLinkPreview = appearanceSettings.compatLinkPreview, + aiConfig = + ComponentAppearance.AiConfig( + translation = appSettings.aiConfig.translation, + tldr = appSettings.aiConfig.tldr, + ), + ) + }, + content = content, + ) +} diff --git a/shared/src/androidJvmMain/kotlin/dev/dimension/flare/data/model/AppSettings.kt b/shared/src/androidJvmMain/kotlin/dev/dimension/flare/data/model/AppSettings.kt new file mode 100644 index 000000000..70bedb6bd --- /dev/null +++ b/shared/src/androidJvmMain/kotlin/dev/dimension/flare/data/model/AppSettings.kt @@ -0,0 +1,43 @@ +package dev.dimension.flare.data.model + +import androidx.datastore.core.Serializer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray +import kotlinx.serialization.protobuf.ProtoBuf +import java.io.InputStream +import java.io.OutputStream + +@Serializable +public data class AppSettings( + val version: String, + val aiConfig: AiConfig = AiConfig(), +) { + @Serializable + public data class AiConfig( + val translation: Boolean = false, + val tldr: Boolean = true, + ) +} + +@OptIn(ExperimentalSerializationApi::class) +public object AppSettingsSerializer : Serializer { + override suspend fun readFrom(input: InputStream): AppSettings = ProtoBuf.decodeFromByteArray(input.readBytes()) + + override suspend fun writeTo( + t: AppSettings, + output: OutputStream, + ): Unit = + withContext(Dispatchers.IO) { + output.write(ProtoBuf.encodeToByteArray(t)) + } + + override val defaultValue: AppSettings + get() = + AppSettings( + version = "", + ) +} diff --git a/shared/src/androidJvmMain/kotlin/dev/dimension/flare/data/model/AppearanceSettings.kt b/shared/src/androidJvmMain/kotlin/dev/dimension/flare/data/model/AppearanceSettings.kt new file mode 100644 index 000000000..21de91f10 --- /dev/null +++ b/shared/src/androidJvmMain/kotlin/dev/dimension/flare/data/model/AppearanceSettings.kt @@ -0,0 +1,70 @@ +package dev.dimension.flare.data.model + +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.datastore.core.Serializer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray +import kotlinx.serialization.protobuf.ProtoBuf +import java.io.InputStream +import java.io.OutputStream + +public val LocalAppearanceSettings: ProvidableCompositionLocal = staticCompositionLocalOf { AppearanceSettings() } + +@Serializable +public data class AppearanceSettings( + val theme: Theme = Theme.SYSTEM, + val dynamicTheme: Boolean = true, + val colorSeed: ULong = Color(red = 103, green = 80, blue = 164).value, + val avatarShape: AvatarShape = AvatarShape.CIRCLE, + val showActions: Boolean = true, + val pureColorMode: Boolean = true, + val showNumbers: Boolean = true, + val showLinkPreview: Boolean = true, + val showMedia: Boolean = true, + val showSensitiveContent: Boolean = false, + val videoAutoplay: VideoAutoplay = VideoAutoplay.WIFI, + val expandMediaSize: Boolean = false, + val compatLinkPreview: Boolean = false, +) + +@Serializable +public enum class Theme { + LIGHT, + DARK, + SYSTEM, +} + +@Serializable +public enum class AvatarShape { + CIRCLE, + SQUARE, +} + +@Serializable +public enum class VideoAutoplay { + ALWAYS, + WIFI, + NEVER, +} + +@OptIn(ExperimentalSerializationApi::class) +public object AccountPreferencesSerializer : Serializer { + override suspend fun readFrom(input: InputStream): AppearanceSettings = ProtoBuf.decodeFromByteArray(input.readBytes()) + + override suspend fun writeTo( + t: AppearanceSettings, + output: OutputStream, + ): Unit = + withContext(Dispatchers.IO) { + output.write(ProtoBuf.encodeToByteArray(t)) + } + + override val defaultValue: AppearanceSettings + get() = AppearanceSettings() +} diff --git a/shared/src/androidJvmMain/kotlin/dev/dimension/flare/data/model/TabSettings.kt b/shared/src/androidJvmMain/kotlin/dev/dimension/flare/data/model/TabSettings.kt index c0f156502..38c9647f3 100644 --- a/shared/src/androidJvmMain/kotlin/dev/dimension/flare/data/model/TabSettings.kt +++ b/shared/src/androidJvmMain/kotlin/dev/dimension/flare/data/model/TabSettings.kt @@ -4,6 +4,7 @@ import androidx.datastore.core.Serializer import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.ui.model.UiAccount import dev.dimension.flare.ui.model.UiRssSource import dev.dimension.flare.ui.model.UiUserV2 import dev.dimension.flare.ui.presenter.home.HomeTimelinePresenter @@ -240,7 +241,7 @@ public sealed interface TimelineTabItem : TabItem { PlatformType.VVo -> vvo(user.key) } - public fun defaultSecondary(user: UiUserV2): ImmutableList { + public fun defaultSecondary(user: UiAccount): ImmutableList { val result = listOf( RssTabItem( @@ -253,11 +254,11 @@ public sealed interface TimelineTabItem : TabItem { ), ) + when (user.platformType) { - PlatformType.Mastodon -> defaultMastodonSecondaryItems(user.key) - PlatformType.Misskey -> defaultMisskeySecondaryItems(user.key) - PlatformType.Bluesky -> defaultBlueskySecondaryItems(user.key) - PlatformType.xQt -> defaultXqtSecondaryItems(user.key) - PlatformType.VVo -> defaultVVOSecondaryItems(user.key) + PlatformType.Mastodon -> defaultMastodonSecondaryItems(user.accountKey) + PlatformType.Misskey -> defaultMisskeySecondaryItems(user.accountKey) + PlatformType.Bluesky -> defaultBlueskySecondaryItems(user.accountKey) + PlatformType.xQt -> defaultXqtSecondaryItems(user.accountKey) + PlatformType.VVo -> defaultVVOSecondaryItems(user.accountKey) } return result.toImmutableList() } diff --git a/shared/src/androidJvmMain/kotlin/dev/dimension/flare/ui/presenter/HomeTabsPresenter.kt b/shared/src/androidJvmMain/kotlin/dev/dimension/flare/ui/presenter/HomeTabsPresenter.kt index eee45e499..197a25253 100644 --- a/shared/src/androidJvmMain/kotlin/dev/dimension/flare/ui/presenter/HomeTabsPresenter.kt +++ b/shared/src/androidJvmMain/kotlin/dev/dimension/flare/ui/presenter/HomeTabsPresenter.kt @@ -2,7 +2,6 @@ package dev.dimension.flare.ui.presenter import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import dev.dimension.flare.data.model.DirectMessageTabItem import dev.dimension.flare.data.model.NotificationTabItem @@ -10,23 +9,28 @@ import dev.dimension.flare.data.model.ProfileTabItem import dev.dimension.flare.data.model.TabItem import dev.dimension.flare.data.model.TabSettings import dev.dimension.flare.data.model.TimelineTabItem +import dev.dimension.flare.data.repository.AccountRepository import dev.dimension.flare.model.AccountType 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.flattenUiState import dev.dimension.flare.ui.model.map import dev.dimension.flare.ui.presenter.HomeTabsPresenter.State.HomeTabState.HomeTabItem -import dev.dimension.flare.ui.presenter.home.ActiveAccountPresenter import dev.dimension.flare.ui.presenter.home.DirectMessageBadgePresenter import dev.dimension.flare.ui.presenter.home.NotificationBadgePresenter import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChangedBy +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject public class HomeTabsPresenter( private val tabSettings: Flow, -) : PresenterBase() { +) : PresenterBase(), + KoinComponent { public interface State { public val tabs: UiState @@ -52,93 +56,65 @@ public class HomeTabsPresenter( } } + private val accountRepository by inject() + private val tabsFlow by lazy { + accountRepository.activeAccount + .distinctUntilChangedBy { + when (it) { + is UiState.Success -> it.data.accountKey + is UiState.Error -> it.throwable + is UiState.Loading -> null + } + }.combine(tabSettings) { user, tabSettings -> + user.flatMap( + onError = { + UiState.Success( + State.HomeTabState( + primary = + TimelineTabItem.guest + .map { + HomeTabItem(it) + }.toImmutableList(), + secondary = persistentListOf(), + extraProfileRoute = null, + secondaryIconOnly = true, + ), + ) + }, + ) { user -> + val secondary = + tabSettings.secondaryItems ?: TimelineTabItem.defaultSecondary(user) + UiState.Success( + State.HomeTabState( + primary = + TimelineTabItem.default + .map { + HomeTabItem(it) + }.toImmutableList(), + secondary = + secondary + .map { + HomeTabItem(it) + }.toImmutableList(), + extraProfileRoute = + HomeTabItem( + tabItem = + ProfileTabItem( + accountKey = user.accountKey, + userKey = user.accountKey, + ), + ), + secondaryIconOnly = tabSettings.secondaryItems == null, + ), + ) + } + } + } + @Composable override fun body(): State { - val account = - remember { - ActiveAccountPresenter() - }.invoke() - val settings by tabSettings.collectAsUiState() - val tabs = - remember( - account, - settings, - ) { - account.user - .flatMap( - onError = { - UiState.Success( - State.HomeTabState( - primary = - TimelineTabItem.guest - .map { - HomeTabItem(it) - }.toImmutableList(), - secondary = persistentListOf(), - extraProfileRoute = null, - secondaryIconOnly = true, - ), - ) - }, - ) { user -> - settings.flatMap( - onError = { - UiState.Success( - State.HomeTabState( - primary = - TimelineTabItem - .defaultPrimary(user) - .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( - State.HomeTabState( - primary = - TimelineTabItem.default - .map { - HomeTabItem(it) - }.toImmutableList(), - secondary = - secondary - .map { - HomeTabItem(it) - }.toImmutableList(), - extraProfileRoute = - HomeTabItem( - tabItem = - ProfileTabItem( - accountKey = user.key, - userKey = user.key, - ), - ), - secondaryIconOnly = tabSettings.secondaryItems == null, - ), - ) - } - } - }.map { + tabsFlow.flattenUiState().value.map { it.copy( primary = it.primary diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiAccount.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiAccount.kt index 12e7c3d08..35691aca1 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiAccount.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiAccount.kt @@ -56,6 +56,7 @@ public sealed class UiAccount { accountKey = accountKey, instance = instance, ) + Credential.ForkType.Pleroma -> PleromaDataSource( accountKey = accountKey, diff --git a/shared/src/jvmMain/kotlin/dev/dimension/flare/common/FileSystemUtilsExt.kt b/shared/src/jvmMain/kotlin/dev/dimension/flare/common/FileSystemUtilsExt.kt index 4c70181e5..d471194b5 100644 --- a/shared/src/jvmMain/kotlin/dev/dimension/flare/common/FileSystemUtilsExt.kt +++ b/shared/src/jvmMain/kotlin/dev/dimension/flare/common/FileSystemUtilsExt.kt @@ -4,15 +4,15 @@ import okio.FileSystem import org.apache.commons.lang3.SystemUtils import java.io.File -internal object FileSystemUtilsExt { - fun flareDirectory(): File = +public object FileSystemUtilsExt { + public fun flareDirectory(): File = File(SystemUtils.getUserHome(), ".flare").also { if (!it.exists()) { it.mkdirs() } } - fun flareCacheDirectory(): File = + public fun flareCacheDirectory(): File = File(FileSystem.SYSTEM_TEMPORARY_DIRECTORY.toFile(), ".flare").also { if (!it.exists()) { it.mkdirs() diff --git a/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformFlyoutContainer.android.kt b/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformFlyoutContainer.android.kt new file mode 100644 index 000000000..659de4c65 --- /dev/null +++ b/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformFlyoutContainer.android.kt @@ -0,0 +1,18 @@ +package dev.dimension.flare.ui.component.platform + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +internal actual fun PlatformFlyoutContainer( + content: @Composable (requestShowFlyout: () -> Boolean) -> Unit, + flyout: @Composable (() -> Unit), + modifier: Modifier, +) { + Box( + modifier = modifier, + ) { + content.invoke({ false }) + } +} diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformFlyoutContainer.kt b/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformFlyoutContainer.kt new file mode 100644 index 000000000..cb0667b4d --- /dev/null +++ b/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformFlyoutContainer.kt @@ -0,0 +1,11 @@ +package dev.dimension.flare.ui.component.platform + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +internal expect fun PlatformFlyoutContainer( + content: @Composable (requestShowFlyout: () -> Boolean) -> Unit, + flyout: @Composable () -> Unit, + modifier: Modifier = Modifier, +) diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusMediaComponent.kt b/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusMediaComponent.kt index 8177054d0..19b9e9a19 100644 --- a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusMediaComponent.kt +++ b/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusMediaComponent.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue @@ -39,6 +40,7 @@ import dev.dimension.flare.ui.component.LocalComponentAppearance import dev.dimension.flare.ui.component.NetworkImage import dev.dimension.flare.ui.component.Res import dev.dimension.flare.ui.component.platform.PlatformCircularProgressIndicator +import dev.dimension.flare.ui.component.platform.PlatformFlyoutContainer import dev.dimension.flare.ui.component.platform.PlatformIconButton import dev.dimension.flare.ui.component.platform.PlatformText import dev.dimension.flare.ui.component.platform.PlatformVideoPlayer @@ -112,24 +114,42 @@ internal fun StatusMediaComponent( ) } if (!media.description.isNullOrEmpty()) { - PlatformText( - text = "ALT", + PlatformFlyoutContainer( modifier = Modifier - .padding(16.dp) - .align(Alignment.BottomEnd) - .background( - color = Color.Black.copy(alpha = 0.75f), - shape = PlatformTheme.shapes.medium, - ).padding( - horizontal = 8.dp, - vertical = 2.dp, - ).clickable { - media.description?.let { - uriHandler.openUri(AppDeepLink.AltText(it)) - } - }, - color = Color.White, + .align(Alignment.BottomEnd), + content = { requestShowFlyout -> + PlatformText( + text = "ALT", + modifier = + Modifier + .pointerHoverIcon(PointerIcon.Hand) + .padding(16.dp) + .background( + color = Color.Black.copy(alpha = 0.75f), + shape = PlatformTheme.shapes.medium, + ).padding( + horizontal = 8.dp, + vertical = 2.dp, + ).clickable { + if (!requestShowFlyout.invoke()) { + media.description?.let { + uriHandler.openUri(AppDeepLink.AltText(it)) + } + } + }, + color = Color.White, + ) + }, + flyout = { + PlatformText( + text = media.description ?: "", + modifier = + Modifier + .padding(8.dp) + .widthIn(max = 240.dp), + ) + }, ) } } diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlaceHolder.jvm.kt b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlaceHolder.jvm.kt index e64b434bc..c6d0ec287 100644 --- a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlaceHolder.jvm.kt +++ b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlaceHolder.jvm.kt @@ -38,4 +38,7 @@ internal actual fun Modifier.placeholder( ) } -public fun Modifier.placeholder(visible: Boolean): Modifier = placeholder(visible, Color.Unspecified) +public fun Modifier.placeholder( + visible: Boolean, + shape: Shape? = null, +): Modifier = placeholder(visible, Color.Unspecified, shape = shape) diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformFlyoutContainer.jvm.kt b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformFlyoutContainer.jvm.kt new file mode 100644 index 000000000..356a2a1a9 --- /dev/null +++ b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformFlyoutContainer.jvm.kt @@ -0,0 +1,28 @@ +package dev.dimension.flare.ui.component.platform + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import io.github.composefluent.component.FlyoutContainer +import io.github.composefluent.component.FlyoutPlacement + +@Composable +internal actual fun PlatformFlyoutContainer( + content: @Composable (requestShowFlyout: () -> Boolean) -> Unit, + flyout: @Composable (() -> Unit), + modifier: Modifier, +) { + FlyoutContainer( + flyout = { + flyout.invoke() + }, + content = { + content.invoke { + isFlyoutVisible = true + true + } + }, + modifier = modifier, + adaptivePlacement = true, + placement = FlyoutPlacement.TopAlignedEnd, + ) +} From 47caab604c7303472ef97017e2acde2dc1d5aedd Mon Sep 17 00:00:00 2001 From: Tlaster Date: Tue, 19 Aug 2025 22:03:58 +0900 Subject: [PATCH 08/33] add search screen --- .../main/composeResources/values/strings.xml | 5 + .../main/kotlin/dev/dimension/flare/App.kt | 1 + .../dimension/flare/ui/component/Header.kt | 17 + .../dev/dimension/flare/ui/route/Route.kt | 38 ++- .../dev/dimension/flare/ui/route/Router.kt | 60 +++- .../flare/ui/screen/feeds/FeedListScreen.kt | 15 +- .../flare/ui/screen/home/DiscoverScreen.kt | 192 ++++++++--- .../flare/ui/screen/home/ProfileScreen.kt | 100 ++++++ .../flare/ui/screen/home/SearchScreen.kt | 317 ++++++++++++++++++ .../flare/ui/screen/home/TimelineScreen.kt | 5 +- .../flare/ui/screen/list/AllListScreen.kt | 34 +- .../ui/screen/settings/SettingsScreen.kt | 12 +- .../platform/PlatformListItem.jvm.kt | 2 +- 13 files changed, 688 insertions(+), 110 deletions(-) create mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/Header.kt create mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/SearchScreen.kt diff --git a/desktopApp/src/main/composeResources/values/strings.xml b/desktopApp/src/main/composeResources/values/strings.xml index 314077152..915b4a4dc 100644 --- a/desktopApp/src/main/composeResources/values/strings.xml +++ b/desktopApp/src/main/composeResources/values/strings.xml @@ -212,4 +212,9 @@ Crowdin Help us translate Flare Privacy Policy + + Users + Hashtags + Statuses + \ No newline at end of file diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt index 63d5f98c5..fdef554c2 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt @@ -136,6 +136,7 @@ internal fun FlareApp( .onSuccess { user -> SubtleButton( onClick = { + navigate(MeRoute(AccountType.Specific(user.key))) }, ) { Column( diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/Header.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/Header.kt new file mode 100644 index 000000000..a47984b90 --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/Header.kt @@ -0,0 +1,17 @@ +package dev.dimension.flare.ui.component + +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import io.github.composefluent.FluentTheme +import io.github.composefluent.component.Text + +@Composable +fun Header(text: String) { + Text( + text = text, + style = FluentTheme.typography.bodyStrong, + modifier = Modifier.padding(top = 16.dp, bottom = 4.dp), + ) +} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt index ab1e3380e..90c772dce 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt @@ -39,6 +39,13 @@ internal sealed interface Route { val userKey: MicroBlogKey, ) : ScreenRoute + @Serializable + data class ProfileWithNameAndHost( + val accountType: AccountType, + val userName: String, + val host: String, + ) : ScreenRoute + @Serializable data class MeRoute( val accountType: AccountType, @@ -119,6 +126,12 @@ internal sealed interface Route { val text: String, ) : FloatingRoute + @Serializable + data class Search( + val accountType: AccountType, + val keyword: String, + ) : ScreenRoute + companion object { public fun parse(url: String): Route? { val data = Url(url) @@ -140,8 +153,7 @@ internal sealed interface Route { val keyword = data.segments.getOrNull(0) ?: return null val accountType = accountKey?.let { AccountType.Specific(it) } ?: AccountType.Guest -// Route.Search(accountType, keyword) - null + Route.Search(accountType = accountType, keyword = keyword) } "Profile" -> { @@ -158,8 +170,11 @@ internal sealed interface Route { val host = data.segments.getOrNull(1) ?: return null val accountType = accountKey?.let { AccountType.Specific(it) } ?: AccountType.Guest -// Route.Profile.UserNameWithHost(accountType, userName, host) - null + Route.ProfileWithNameAndHost( + accountType = accountType, + userName = userName, + host = host, + ) } "StatusDetail" -> { @@ -246,13 +261,19 @@ internal sealed interface Route { "DeleteStatus" -> { val accountKey = MicroBlogKey.valueOf(data.segments.getOrNull(0) ?: return null) val statusKey = MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) - Route.DeleteStatus(statusKey = statusKey, accountType = AccountType.Specific(accountKey)) + Route.DeleteStatus( + statusKey = statusKey, + accountType = AccountType.Specific(accountKey), + ) } "AddReaction" -> { val accountKey = MicroBlogKey.valueOf(data.segments.getOrNull(0) ?: return null) val statusKey = MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) - Route.AddReaction(statusKey = statusKey, accountType = AccountType.Specific(accountKey)) + Route.AddReaction( + statusKey = statusKey, + accountType = AccountType.Specific(accountKey), + ) } "Bluesky" -> @@ -262,7 +283,10 @@ internal sealed interface Route { MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) val statusKey = MicroBlogKey.valueOf(data.segments.getOrNull(2) ?: return null) - Route.BlueskyReport(statusKey = statusKey, accountType = AccountType.Specific(accountKey)) + Route.BlueskyReport( + statusKey = statusKey, + accountType = AccountType.Specific(accountKey), + ) } else -> null diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt index 5b140cf54..88677749f 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt @@ -7,7 +7,6 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import dev.dimension.flare.data.model.Bluesky.FeedTabItem -import dev.dimension.flare.data.model.IconType import dev.dimension.flare.data.model.IconType.Material import dev.dimension.flare.data.model.ListTimelineTabItem import dev.dimension.flare.data.model.TabMetaData @@ -17,6 +16,8 @@ import dev.dimension.flare.ui.screen.feeds.FeedListScreen import dev.dimension.flare.ui.screen.home.DiscoverScreen import dev.dimension.flare.ui.screen.home.NotificationScreen import dev.dimension.flare.ui.screen.home.ProfileScreen +import dev.dimension.flare.ui.screen.home.ProfileWithUserNameAndHostDeeplinkRoute +import dev.dimension.flare.ui.screen.home.SearchScreen import dev.dimension.flare.ui.screen.home.TimelineScreen import dev.dimension.flare.ui.screen.list.AllListScreen import dev.dimension.flare.ui.screen.serviceselect.ServiceSelectScreen @@ -66,7 +67,7 @@ internal fun Router( metaData = TabMetaData( title = TitleType.Text(it.title), - icon = Material(IconType.Material.MaterialIcon.List), + icon = Material(Material.MaterialIcon.List), ), ), ), @@ -87,7 +88,7 @@ internal fun Router( metaData = TabMetaData( title = TitleType.Text(it.title), - icon = Material(IconType.Material.MaterialIcon.Feeds), + icon = Material(Material.MaterialIcon.Feeds), ), ), ), @@ -103,6 +104,37 @@ internal fun Router( is Route.Discover -> { DiscoverScreen( accountType = route.accountType, + toUser = { + navigate( + Route.Profile( + accountType = route.accountType, + userKey = it, + ), + ) + }, + toSearch = { + navigate( + Route.Search( + accountType = route.accountType, + keyword = it, + ), + ) + }, + ) + } + + is Route.Search -> { + SearchScreen( + initialQuery = route.keyword, + accountType = route.accountType, + toUser = { + navigate( + Route.Profile( + accountType = route.accountType, + userKey = it, + ), + ) + }, ) } @@ -148,7 +180,7 @@ internal fun Router( ) } - is Route.Timeline -> { + is Timeline -> { TimelineScreen( route.tabItem, ) @@ -160,18 +192,34 @@ internal fun Router( accountType = route.accountType, ) } + is Route.VVO.CommentDetail -> { VVOCommentScreen( commentKey = route.statusKey, accountType = route.accountType, ) } + is Route.VVO.StatusDetail -> { VVOStatusScreen( statusKey = route.statusKey, accountType = route.accountType, ) } + + is Route.ProfileWithNameAndHost -> { + ProfileWithUserNameAndHostDeeplinkRoute( + userName = route.userName, + host = route.host, + accountType = route.accountType, + onBack = ::onBack, + toEditAccountList = {}, + toSearchUserUsingAccount = { _, _ -> }, + toStartMessage = {}, + onFollowListClick = {}, + onFansListClick = {}, + ) + } } } } @@ -186,6 +234,7 @@ internal fun Router( onBack = ::onBack, ) } + is Route.BlueskyReport -> { BlueskyReportStatusDialog( accountType = entry.route.accountType, @@ -193,6 +242,7 @@ internal fun Router( onBack = ::onBack, ) } + is Route.DeleteStatus -> { DeleteStatusConfirmDialog( accountType = entry.route.accountType, @@ -200,6 +250,7 @@ internal fun Router( onBack = ::onBack, ) } + is Route.MastodonReport -> { MastodonReportDialog( accountType = entry.route.accountType, @@ -208,6 +259,7 @@ internal fun Router( userKey = entry.route.userKey, ) } + is Route.MisskeyReport -> { MisskeyReportDialog( accountType = entry.route.accountType, diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedListScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedListScreen.kt index 386232d34..518bed190 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedListScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedListScreen.kt @@ -25,8 +25,8 @@ import dev.dimension.flare.model.AccountType import dev.dimension.flare.ui.common.items import dev.dimension.flare.ui.common.plus import dev.dimension.flare.ui.component.FAIcon +import dev.dimension.flare.ui.component.Header import dev.dimension.flare.ui.component.UiListItem -import dev.dimension.flare.ui.component.platform.PlatformListItem import dev.dimension.flare.ui.component.status.StatusPlaceholder import dev.dimension.flare.ui.component.uiListItemComponent import dev.dimension.flare.ui.model.UiList @@ -34,7 +34,6 @@ import dev.dimension.flare.ui.presenter.home.bluesky.BlueskyFeedsPresenter import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.theme.screenHorizontalPadding import io.github.composefluent.component.SubtleButton -import io.github.composefluent.component.Text import moe.tlaster.precompose.molecule.producePresenter import org.jetbrains.compose.resources.stringResource @@ -64,11 +63,7 @@ internal fun FeedListScreen( verticalArrangement = Arrangement.spacedBy(2.dp), ) { item { - PlatformListItem( - headlineContent = { - Text(stringResource(Res.string.feeds_my_feeds_title)) - }, - ) + Header(stringResource(Res.string.feeds_my_feeds_title)) } uiListItemComponent( state.myFeeds, @@ -76,11 +71,7 @@ internal fun FeedListScreen( ) item { - PlatformListItem( - headlineContent = { - Text(stringResource(Res.string.feeds_discover_feeds_title)) - }, - ) + Header(stringResource(Res.string.feeds_discover_feeds_title)) } items( state.popularFeeds, diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt index 70530ad15..dc7e768ae 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt @@ -6,42 +6,82 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState 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.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.Trash import dev.dimension.flare.LocalContentPadding import dev.dimension.flare.RegisterTabCallback +import dev.dimension.flare.Res import dev.dimension.flare.common.onLoading import dev.dimension.flare.common.onSuccess +import dev.dimension.flare.common.refreshSuspend +import dev.dimension.flare.delete +import dev.dimension.flare.hashtags import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.statues import dev.dimension.flare.ui.common.plus +import dev.dimension.flare.ui.component.FAIcon +import dev.dimension.flare.ui.component.Header import dev.dimension.flare.ui.component.platform.placeholder import dev.dimension.flare.ui.component.status.CommonStatusHeaderComponent import dev.dimension.flare.ui.component.status.LazyStatusVerticalStaggeredGrid import dev.dimension.flare.ui.component.status.UserPlaceholder import dev.dimension.flare.ui.component.status.status +import dev.dimension.flare.ui.model.onSuccess import dev.dimension.flare.ui.presenter.home.DiscoverPresenter import dev.dimension.flare.ui.presenter.home.DiscoverState +import dev.dimension.flare.ui.presenter.home.SearchHistoryPresenter +import dev.dimension.flare.ui.presenter.home.SearchHistoryState import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.theme.screenHorizontalPadding +import dev.dimension.flare.users +import io.github.composefluent.ExperimentalFluentApi +import io.github.composefluent.component.AutoSuggestBoxDefaults +import io.github.composefluent.component.AutoSuggestionBox import io.github.composefluent.component.ListItem +import io.github.composefluent.component.SubtleButton import io.github.composefluent.component.Text +import io.github.composefluent.component.TextField import io.github.composefluent.surface.Card +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import moe.tlaster.precompose.molecule.producePresenter +import org.jetbrains.compose.resources.stringResource -@OptIn(ExperimentalLayoutApi::class) +@OptIn(ExperimentalLayoutApi::class, ExperimentalFluentApi::class) @Composable -internal fun DiscoverScreen(accountType: AccountType) { +internal fun DiscoverScreen( + accountType: AccountType, + toUser: (MicroBlogKey) -> Unit, + toSearch: (String) -> Unit, +) { val state by producePresenter( key = "discover_$accountType", ) { @@ -56,17 +96,82 @@ internal fun DiscoverScreen(accountType: AccountType) { contentPadding = PaddingValues(vertical = 8.dp) + LocalContentPadding.current, ) { if (true) { + item( + span = StaggeredGridItemSpan.FullLine, + ) { + Box( + contentAlignment = Alignment.Center, + ) { + AutoSuggestionBox( + expanded = state.isHistoryExpanded, + onExpandedChange = state::setHistoryExpanded, + ) { + TextField( + state = state.textState, + shape = AutoSuggestBoxDefaults.textFieldShape(state.isHistoryExpanded), + modifier = Modifier.widthIn(300.dp).flyoutAnchor(), + lineLimits = TextFieldLineLimits.SingleLine, + onKeyboardAction = { + if (state.textState.text.isNotBlank()) { + toSearch(state.textState.text.toString()) + } + }, + keyboardOptions = + KeyboardOptions( + imeAction = ImeAction.Search, + ), + ) + state.searchHistories.onSuccess { history -> + val searchResult by remember(history) { + snapshotFlow { state.textState.text }.map { + history.toImmutableList().filter { item -> + item.keyword.contains( + it, + ignoreCase = true, + ) + } + } + }.collectAsState(emptyList()) + + AutoSuggestBoxDefaults.suggestFlyout( + expanded = state.isHistoryExpanded, + onDismissRequest = { state.setHistoryExpanded(false) }, + itemsContent = { + items(searchResult) { + ListItem( + onClick = { + toSearch(it.keyword) + state.setHistoryExpanded(false) + }, + text = { Text(it.keyword, maxLines = 1) }, + modifier = Modifier.fillMaxWidth(), + trailing = { + SubtleButton( + onClick = { + state.deleteSearchHistory(it.keyword) + }, + ) { + FAIcon( + FontAwesomeIcons.Solid.Trash, + contentDescription = stringResource(Res.string.delete), + ) + } + }, + ) + } + }, + modifier = Modifier.flyoutSize(matchAnchorWidth = true), + ) + } + } + } + } state.users .onSuccess { item( span = StaggeredGridItemSpan.FullLine, ) { - ListItem( - text = { - Text(text = "Users") - }, - onClick = {}, - ) + Header(stringResource(Res.string.users)) } item( span = StaggeredGridItemSpan.FullLine, @@ -92,7 +197,7 @@ internal fun DiscoverScreen(accountType: AccountType) { if (user != null) { CommonStatusHeaderComponent( data = user, - onUserClick = {}, + onUserClick = toUser, modifier = Modifier.padding(8.dp), ) } else { @@ -108,12 +213,7 @@ internal fun DiscoverScreen(accountType: AccountType) { item( span = StaggeredGridItemSpan.FullLine, ) { - ListItem( - text = { - Text(text = "Users") - }, - onClick = {}, - ) + Header(stringResource(Res.string.users)) } item( @@ -144,35 +244,15 @@ internal fun DiscoverScreen(accountType: AccountType) { item( span = StaggeredGridItemSpan.FullLine, ) { - ListItem( - text = { - Text(text = "Hashtags") - }, - onClick = {}, - ) + Header(stringResource(Res.string.hashtags)) } item( span = StaggeredGridItemSpan.FullLine, ) { -// val maxItemsInEachRow = -// when (windowInfo.windowSizeClass.windowWidthSizeClass) { -// WindowWidthSizeClass.COMPACT -> { -// 2 -// } -// -// WindowWidthSizeClass.MEDIUM -> { -// 4 -// } -// -// else -> { -// 8 -// } -// } FlowRow( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(horizontal = screenHorizontalPadding), -// maxItemsInEachRow = maxItemsInEachRow, ) { repeat( itemCount, @@ -182,9 +262,7 @@ internal fun DiscoverScreen(accountType: AccountType) { modifier = Modifier.weight(1f), onClick = { hashtag?.searchContent?.let { it1 -> -// state.commitSearch( -// it1, -// ) + toSearch(it1) } }, ) { @@ -221,24 +299,14 @@ internal fun DiscoverScreen(accountType: AccountType) { item( span = StaggeredGridItemSpan.FullLine, ) { - ListItem( - text = { - Text(text = "status") - }, - onClick = {}, - ) + Header(stringResource(Res.string.statues)) } status(state.status) }.onLoading { item( span = StaggeredGridItemSpan.FullLine, ) { - ListItem( - text = { - Text(text = "status") - }, - onClick = {}, - ) + Header(stringResource(Res.string.statues)) } status(state.status) } @@ -250,9 +318,29 @@ internal fun DiscoverScreen(accountType: AccountType) { private fun presenter(accountType: AccountType) = run { val state = remember(accountType) { DiscoverPresenter(accountType = accountType) }.invoke() + val scope = rememberCoroutineScope() + + val textState = rememberTextFieldState() + val searchHistory = + remember { + SearchHistoryPresenter() + }.invoke() + var isHistoryExpanded by remember { mutableStateOf(false) } + object : DiscoverState by state, SearchHistoryState by searchHistory { + val textState = textState + + val isHistoryExpanded = isHistoryExpanded + + fun setHistoryExpanded(expanded: Boolean) { + isHistoryExpanded = expanded + } - object : DiscoverState by state { fun refresh() { + scope.launch { + state.users.refreshSuspend() + state.hashtags.refreshSuspend() + state.status.refreshSuspend() + } } } } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/ProfileScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/ProfileScreen.kt index 918ca4e5b..35923a226 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/ProfileScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/ProfileScreen.kt @@ -1,13 +1,16 @@ package dev.dimension.flare.ui.screen.home 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.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState import androidx.compose.foundation.rememberScrollState @@ -19,6 +22,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds @@ -40,18 +44,23 @@ import dev.dimension.flare.ui.common.plus import dev.dimension.flare.ui.component.ComponentAppearance import dev.dimension.flare.ui.component.LocalComponentAppearance import dev.dimension.flare.ui.component.ProfileHeader +import dev.dimension.flare.ui.component.ProfileHeaderLoading import dev.dimension.flare.ui.component.ProfileMenu import dev.dimension.flare.ui.component.platform.isBigScreen import dev.dimension.flare.ui.component.platform.placeholder import dev.dimension.flare.ui.component.status.LazyStatusVerticalStaggeredGrid import dev.dimension.flare.ui.component.status.MediaItem +import dev.dimension.flare.ui.component.status.StatusPlaceholder import dev.dimension.flare.ui.component.status.status import dev.dimension.flare.ui.model.UiTimeline import dev.dimension.flare.ui.model.map +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.invoke import dev.dimension.flare.ui.presenter.profile.ProfilePresenter import dev.dimension.flare.ui.presenter.profile.ProfileState +import dev.dimension.flare.ui.presenter.profile.ProfileWithUserNameAndHostPresenter import dev.dimension.flare.ui.presenter.settings.AccountsPresenter import dev.dimension.flare.ui.theme.screenHorizontalPadding import io.github.composefluent.FluentTheme @@ -66,6 +75,96 @@ import moe.tlaster.precompose.molecule.producePresenter import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource +@Composable +internal fun ProfileWithUserNameAndHostDeeplinkRoute( + userName: String, + host: String, + accountType: AccountType, + toEditAccountList: (userKey: MicroBlogKey) -> Unit, + toSearchUserUsingAccount: (String, MicroBlogKey) -> Unit, + toStartMessage: (MicroBlogKey) -> Unit, + onFollowListClick: (userKey: MicroBlogKey) -> Unit, + onFansListClick: (userKey: MicroBlogKey) -> Unit, + onBack: () -> Unit = {}, +) { + val state by producePresenter(key = "acct_${accountType}_$userName@$host") { + profileWithUserNameAndHostPresenter( + userName = userName, + host = host, + accountType = accountType, + ) + } + state + .onSuccess { + ProfileScreen( + accountType = accountType, + toEditAccountList = { + toEditAccountList(it.key) + }, + toSearchUserUsingAccount = toSearchUserUsingAccount, + toStartMessage = toStartMessage, + onFollowListClick = onFollowListClick, + onFansListClick = onFansListClick, + userKey = it.key, + ) + }.onLoading { + ProfileLoadingScreen( + onBack = onBack, + ) + }.onError { + ProfileErrorScreen( + onBack = onBack, + ) + } +} + +@Composable +private fun ProfileErrorScreen(onBack: () -> Unit) { + Box( + modifier = + Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text(text = "Error") + } +} + +@Composable +private fun ProfileLoadingScreen(onBack: () -> Unit) { + LazyColumn( + contentPadding = LocalContentPadding.current, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + item { + ProfileHeaderLoading(withStatusBarHeight = false) + } + items(5) { + StatusPlaceholder( + modifier = Modifier.padding(horizontal = screenHorizontalPadding), + ) + } + } +} + +@Composable +private fun profileWithUserNameAndHostPresenter( + userName: String, + host: String, + accountType: AccountType, +) = run { + remember( + userName, + host, + ) { + ProfileWithUserNameAndHostPresenter( + userName = userName, + host = host, + accountType = accountType, + ) + }.invoke().user +} + @Composable internal fun ProfileScreen( accountType: AccountType, @@ -251,6 +350,7 @@ internal fun ProfileScreen( } } } + is ProfileState.Tab.Timeline -> { status(tab.data) } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/SearchScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/SearchScreen.kt new file mode 100644 index 000000000..2228b0fe7 --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/SearchScreen.kt @@ -0,0 +1,317 @@ +package dev.dimension.flare.ui.screen.home + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.delete +import androidx.compose.foundation.text.input.rememberTextFieldState +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.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.Trash +import dev.dimension.flare.LocalContentPadding +import dev.dimension.flare.RegisterTabCallback +import dev.dimension.flare.Res +import dev.dimension.flare.common.isRefreshing +import dev.dimension.flare.common.onLoading +import dev.dimension.flare.common.onSuccess +import dev.dimension.flare.delete +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.statues +import dev.dimension.flare.ui.common.plus +import dev.dimension.flare.ui.component.FAIcon +import dev.dimension.flare.ui.component.Header +import dev.dimension.flare.ui.component.status.CommonStatusHeaderComponent +import dev.dimension.flare.ui.component.status.LazyStatusVerticalStaggeredGrid +import dev.dimension.flare.ui.component.status.UserPlaceholder +import dev.dimension.flare.ui.component.status.status +import dev.dimension.flare.ui.model.onSuccess +import dev.dimension.flare.ui.presenter.home.SearchHistoryPresenter +import dev.dimension.flare.ui.presenter.home.SearchHistoryState +import dev.dimension.flare.ui.presenter.home.SearchPresenter +import dev.dimension.flare.ui.presenter.home.SearchState +import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.theme.screenHorizontalPadding +import dev.dimension.flare.users +import io.github.composefluent.ExperimentalFluentApi +import io.github.composefluent.component.AutoSuggestBoxDefaults +import io.github.composefluent.component.AutoSuggestionBox +import io.github.composefluent.component.ListItem +import io.github.composefluent.component.ProgressBar +import io.github.composefluent.component.SubtleButton +import io.github.composefluent.component.Text +import io.github.composefluent.component.TextField +import io.github.composefluent.surface.Card +import kotlinx.coroutines.flow.map +import moe.tlaster.precompose.molecule.producePresenter +import org.jetbrains.compose.resources.stringResource + +@OptIn(ExperimentalFluentApi::class) +@Composable +fun SearchScreen( + initialQuery: String?, + accountType: AccountType, + toUser: (MicroBlogKey) -> Unit, +) { + val state by producePresenter("search_${accountType}_$initialQuery") { + presenter(initialQuery, accountType) + } + val lazyListState = rememberLazyStaggeredGridState() + RegisterTabCallback(lazyListState = lazyListState, onRefresh = state::refresh) + + Box( + modifier = Modifier.fillMaxSize(), + ) { + LazyStatusVerticalStaggeredGrid( + modifier = Modifier.fillMaxSize(), + state = lazyListState, + contentPadding = PaddingValues(vertical = 8.dp) + LocalContentPadding.current, + ) { + item( + span = StaggeredGridItemSpan.FullLine, + ) { + Box( + contentAlignment = Alignment.Center, + ) { + AutoSuggestionBox( + expanded = state.isHistoryExpanded, + onExpandedChange = state::setHistoryExpanded, + ) { + TextField( + state = state.textState, + shape = AutoSuggestBoxDefaults.textFieldShape(state.isHistoryExpanded), + modifier = Modifier.widthIn(300.dp).flyoutAnchor(), + lineLimits = TextFieldLineLimits.SingleLine, + onKeyboardAction = { + if (state.textState.text.isNotBlank()) { + state.commitSearch(state.textState.text.toString()) + } + }, + keyboardOptions = + KeyboardOptions( + imeAction = ImeAction.Search, + ), + ) + state.searchHistories.onSuccess { history -> + val searchResult by remember(history) { + snapshotFlow { state.textState.text }.map { + history.toImmutableList().filter { item -> + item.keyword.contains( + it, + ignoreCase = true, + ) + } + } + }.collectAsState(emptyList()) + + AutoSuggestBoxDefaults.suggestFlyout( + expanded = state.isHistoryExpanded, + onDismissRequest = { state.setHistoryExpanded(false) }, + itemsContent = { + items(searchResult) { + ListItem( + onClick = { + state.commitSearch(it.keyword) + state.setHistoryExpanded(false) + }, + text = { Text(it.keyword, maxLines = 1) }, + modifier = Modifier.fillMaxWidth(), + trailing = { + SubtleButton( + onClick = { + state.deleteSearchHistory(it.keyword) + }, + ) { + FAIcon( + FontAwesomeIcons.Solid.Trash, + contentDescription = stringResource(Res.string.delete), + ) + } + }, + ) + } + }, + modifier = Modifier.flyoutSize(matchAnchorWidth = true), + ) + } + } + } + } + + state.users + .onSuccess { + item( + span = StaggeredGridItemSpan.FullLine, + ) { + Header(stringResource(Res.string.users)) + } + item( + span = StaggeredGridItemSpan.FullLine, + ) { + LazyHorizontalGrid( + modifier = Modifier.height(128.dp), + rows = GridCells.Fixed(2), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(horizontal = screenHorizontalPadding), + ) { + items( + itemCount, + ) { + val user = get(it) + Card( + modifier = + Modifier + .width(256.dp), + onClick = { + }, + ) { + if (user != null) { + CommonStatusHeaderComponent( + data = user, + onUserClick = toUser, + modifier = Modifier.padding(8.dp), + ) + } else { + UserPlaceholder( + modifier = Modifier.padding(8.dp), + ) + } + } + } + } + } + }.onLoading { + item( + span = StaggeredGridItemSpan.FullLine, + ) { + Header(stringResource(Res.string.users)) + } + + item( + span = StaggeredGridItemSpan.FullLine, + ) { + LazyHorizontalGrid( + modifier = Modifier.height(128.dp), + rows = GridCells.Fixed(2), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(horizontal = screenHorizontalPadding), + ) { + items(10) { + Card( + modifier = + Modifier + .width(256.dp), + ) { + UserPlaceholder( + modifier = Modifier.padding(8.dp), + ) + } + } + } + } + } + state.status + .onSuccess { + item( + span = StaggeredGridItemSpan.FullLine, + ) { + Header(stringResource(Res.string.statues)) + } + status(state.status) + }.onLoading { + item( + span = StaggeredGridItemSpan.FullLine, + ) { + Header(stringResource(Res.string.statues)) + } + status(state.status) + } + } + + if (state.refreshing) { + ProgressBar( + modifier = + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth(), + ) + } + } +} + +@Composable +private fun presenter( + initialQuery: String?, + accountType: AccountType, +) = run { + val textState = rememberTextFieldState(initialText = initialQuery.orEmpty()) + val searchState = + remember(initialQuery, accountType) { + SearchPresenter(accountType = accountType, initialQuery.orEmpty()) + }.invoke() + + val searchHistory = + remember { + SearchHistoryPresenter() + }.invoke() + var isHistoryExpanded by remember { mutableStateOf(false) } + + LaunchedEffect(initialQuery) { + if (initialQuery != null) { + searchHistory.addSearchHistory(initialQuery) + } + } + + object : SearchState by searchState, SearchHistoryState by searchHistory { + val refreshing = + searchState.users.isRefreshing || + searchState.status.isRefreshing + val textState = textState + + val isHistoryExpanded = isHistoryExpanded + + fun setHistoryExpanded(expanded: Boolean) { + isHistoryExpanded = expanded + } + + fun refresh() { + searchState.search(textState.text.toString()) + } + + fun commitSearch(new: String) { + textState.edit { + this.delete(0, this.length) + this.append(new) + } + addSearchHistory(new) + searchState.search(new) + } + } +} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt index bd0bfbe47..5fb1dddca 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt @@ -63,8 +63,7 @@ internal fun TimelineScreen(tabItem: TimelineTabItem) { ) { presenter(tabItem) } - val listState = rememberLazyStaggeredGridState() - RegisterTabCallback(listState, onRefresh = state::refreshSync) + RegisterTabCallback(state.lazyListState, onRefresh = state::refreshSync) Box( modifier = Modifier @@ -75,7 +74,7 @@ internal fun TimelineScreen(tabItem: TimelineTabItem) { PaddingValues( vertical = 8.dp, ) + LocalContentPadding.current, - state = listState, + state = state.lazyListState, ) { status(state.listState) } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/list/AllListScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/list/AllListScreen.kt index 36be3e9b2..4cf0e42ac 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/list/AllListScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/list/AllListScreen.kt @@ -11,21 +11,13 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import compose.icons.FontAwesomeIcons -import compose.icons.fontawesomeicons.Solid -import compose.icons.fontawesomeicons.solid.EllipsisVertical -import dev.dimension.flare.Res import dev.dimension.flare.model.AccountType -import dev.dimension.flare.more -import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.uiListItemComponent import dev.dimension.flare.ui.model.UiList import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.presenter.list.AllListPresenter import dev.dimension.flare.ui.theme.screenHorizontalPadding -import io.github.composefluent.component.SubtleButton import moe.tlaster.precompose.molecule.producePresenter -import org.jetbrains.compose.resources.stringResource @Composable internal fun AllListScreen( @@ -85,19 +77,19 @@ internal fun AllListScreen( uiListItemComponent( state.items, onClicked = toList, - trailingContent = { item -> - if (!item.readonly) { - SubtleButton( - onClick = { - }, - ) { - FAIcon( - FontAwesomeIcons.Solid.EllipsisVertical, - contentDescription = stringResource(Res.string.more), - ) - } - } - }, +// trailingContent = { item -> +// if (!item.readonly) { +// SubtleButton( +// onClick = { +// }, +// ) { +// FAIcon( +// FontAwesomeIcons.Solid.EllipsisVertical, +// contentDescription = stringResource(Res.string.more), +// ) +// } +// } +// }, ) } } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt index 97733fddf..eda1ef1b1 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt @@ -84,6 +84,7 @@ import dev.dimension.flare.settings_status_appearance_title import dev.dimension.flare.ui.component.AccountItem import dev.dimension.flare.ui.component.AvatarComponent import dev.dimension.flare.ui.component.FAIcon +import dev.dimension.flare.ui.component.Header import dev.dimension.flare.ui.component.RichText import dev.dimension.flare.ui.model.onError import dev.dimension.flare.ui.model.onSuccess @@ -526,7 +527,7 @@ internal fun SettingsScreen(toLogin: () -> Unit) { Text(stringResource(Res.string.app_name)) }, caption = { - Text(System.getProperty("jpackage.app-version")) + Text(System.getProperty("jpackage.app-version", "1.0.0")) }, expanded = state.aboutExpanded, onExpandedChanged = { state.setAboutExpanded(it) }, @@ -636,15 +637,6 @@ internal fun SettingsScreen(toLogin: () -> Unit) { } } -@Composable -private fun Header(text: String) { - Text( - text = text, - style = FluentTheme.typography.bodyStrong, - modifier = Modifier.padding(top = 16.dp, bottom = 4.dp), - ) -} - @Composable private fun presenter() = run { diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformListItem.jvm.kt b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformListItem.jvm.kt index 2600a72a7..321bb8cca 100644 --- a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformListItem.jvm.kt +++ b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformListItem.jvm.kt @@ -7,7 +7,7 @@ import androidx.compose.ui.unit.dp import dev.dimension.flare.ui.component.status.ListComponent @Composable -public actual fun PlatformListItem( +internal actual fun PlatformListItem( headlineContent: @Composable () -> Unit, modifier: Modifier, leadingContent: @Composable () -> Unit, From 7e41aa4464e308c5c034291084e3ca0ce15c5808 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Tue, 19 Aug 2025 23:28:53 +0900 Subject: [PATCH 09/33] add compose dialog --- .../main/composeResources/values/strings.xml | 28 + .../main/kotlin/dev/dimension/flare/App.kt | 69 +- .../main/kotlin/dev/dimension/flare/Main.kt | 20 +- .../flare/ui/route/FloatingWindowState.kt | 7 + .../dev/dimension/flare/ui/route/Route.kt | 59 +- .../dimension/flare/ui/route/WindowRoute.kt | 22 - .../dimension/flare/ui/route/WindowRouter.kt | 35 +- .../flare/ui/screen/compose/ComposeDialog.kt | 936 ++++++++++++++++++ .../flare/ui/screen/feeds/FeedListScreen.kt | 4 +- .../flare/ui/screen/home/DiscoverScreen.kt | 4 +- .../ui/screen/home/NotificationScreen.kt | 4 +- .../flare/ui/screen/home/ProfileScreen.kt | 8 +- .../flare/ui/screen/home/SearchScreen.kt | 4 +- .../flare/ui/screen/home/TimelineScreen.kt | 4 +- .../serviceselect/ServiceSelectScreen.kt | 4 +- .../ui/screen/settings/SettingsScreen.kt | 4 +- .../flare/ui/screen/status/StatusScreen.kt | 4 +- .../ui/screen/status/VVOCommentScreen.kt | 4 +- .../flare/ui/screen/status/VVOStatusScreen.kt | 8 +- .../dimension/flare/ui/theme/FlareTheme.kt | 14 + 20 files changed, 1115 insertions(+), 127 deletions(-) create mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/FloatingWindowState.kt delete mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/WindowRoute.kt create mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt diff --git a/desktopApp/src/main/composeResources/values/strings.xml b/desktopApp/src/main/composeResources/values/strings.xml index 915b4a4dc..d8d575354 100644 --- a/desktopApp/src/main/composeResources/values/strings.xml +++ b/desktopApp/src/main/composeResources/values/strings.xml @@ -217,4 +217,32 @@ Hashtags Statuses + + Compose + What\'s happening? + Option %1$s + Single choice + Multiple choice + Expiration at: %1$s + 5 minutes + 30 minutes + 1 hour + 6 hours + 12 hours + 1 day + 3 days + 7 days + Content warning + Mark media as sensitive + Sending toot + + Public + Home + Followers + Specified + Anyone can see and boost this toot + Only users on this instance can see this toot + Only followers can see this toot + Only mentioned users can see this toot + \ No newline at end of file diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt index fdef554c2..6d3403765 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt @@ -51,7 +51,6 @@ import dev.dimension.flare.data.model.TabItem import dev.dimension.flare.data.model.TabSettings import dev.dimension.flare.data.model.TimelineTabItem import dev.dimension.flare.model.AccountType -import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.component.AvatarComponent import dev.dimension.flare.ui.component.TabIcon import dev.dimension.flare.ui.component.TabTitle @@ -90,10 +89,7 @@ import org.apache.commons.lang3.SystemUtils import org.jetbrains.compose.resources.stringResource @Composable -internal fun FlareApp( - onRawImage: (String) -> Unit, - onStatusMedia: (AccountType, MicroBlogKey, Int) -> Unit, -) { +internal fun FlareApp(onWindowRoute: (Route.WindowRoute) -> Unit) { val state by producePresenter { presenter() } val uriHandler = LocalUriHandler.current @@ -155,7 +151,13 @@ internal fun FlareApp( } Spacer(modifier = Modifier.height(8.dp)) Button( - onClick = {}, + onClick = { + onWindowRoute.invoke( + Route.Compose.New( + accountType = AccountType.Specific(user.key), + ), + ) + }, modifier = Modifier .padding(horizontal = 8.dp, vertical = 4.dp) @@ -293,26 +295,14 @@ internal fun FlareApp( } CompositionLocalProvider( LocalUriHandler provides - remember(uriHandler, stackManager, onRawImage, onStatusMedia) { + remember(uriHandler, stackManager, onWindowRoute) { ProxyUriHandler( stackManager = stackManager, actualUriHandler = uriHandler, - onRawImage = onRawImage, - onStatusMedia = onStatusMedia, + onWindowRoute = onWindowRoute, ) }, LocalScrollToTopRegistry provides state.scrollToTopRegistry, - LocalContentPadding provides - if (SystemUtils.IS_OS_MAC) { - PaddingValues( - start = 0.dp, - top = 24.dp, - end = 0.dp, - bottom = 0.dp, - ) - } else { - PaddingValues(0.dp) - }, ) { Layer( modifier = Modifier.fillMaxSize(), @@ -418,39 +408,22 @@ private fun presenter() = private class ProxyUriHandler( private val stackManager: StackManager, private val actualUriHandler: UriHandler, - private val onRawImage: (String) -> Unit, - private val onStatusMedia: (AccountType, MicroBlogKey, Int) -> Unit, + private val onWindowRoute: (Route.WindowRoute) -> Unit, ) : UriHandler { override fun openUri(uri: String) { if (uri.startsWith("flare://")) { val data = Url(uri) - when (data.host) { - "RawImage" -> { - val rawImage = data.segments.getOrNull(0) - if (rawImage != null) { - onRawImage(rawImage) - } - } - - "StatusMedia" -> { - val accountKey = data.parameters["accountKey"]?.let(MicroBlogKey::valueOf) - val statusKey = data.segments.getOrNull(0)?.let(MicroBlogKey::valueOf) - val index = data.segments.getOrNull(1)?.toIntOrNull() - val accountType = accountKey?.let(AccountType::Specific) ?: AccountType.Guest - if (statusKey != null && index != null) { - onStatusMedia(accountType, statusKey, index) - } - } - else -> { - Route.parse(uri)?.let { - stackManager.push(it) - } ?: run { - // If the URI does not match any known route, we can handle it as a custom URI scheme - // For example, you might want to log it or show an error - println("Unhandled URI: $uri") - } + Route.parse(uri)?.let { + if (it is Route.WindowRoute) { + onWindowRoute.invoke(it) + } else { + stackManager.push(it) } + } ?: run { + // If the URI does not match any known route, we can handle it as a custom URI scheme + // For example, you might want to log it or show an error + println("Unhandled URI: $uri") } } else { actualUriHandler.openUri(uri) @@ -474,7 +447,7 @@ private class ScrollToTopRegistry { } } -val LocalContentPadding = +val LocalWindowPadding = androidx.compose.runtime.staticCompositionLocalOf { PaddingValues(0.dp) } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt index 70d198d1d..d1753d9d3 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt @@ -19,7 +19,7 @@ import dev.dimension.flare.common.DeeplinkHandler import dev.dimension.flare.di.KoinHelper import dev.dimension.flare.di.desktopModule import dev.dimension.flare.ui.route.FloatingWindowState -import dev.dimension.flare.ui.route.WindowRoute +import dev.dimension.flare.ui.route.Route import dev.dimension.flare.ui.route.WindowRouter import dev.dimension.flare.ui.theme.FlareTheme import dev.dimension.flare.ui.theme.ProvideThemeSettings @@ -49,7 +49,7 @@ fun main(args: Array) { fun openWindow( key: String, - route: WindowRoute, + route: Route.WindowRoute, ) { if (extraWindowRoutes.containsKey(key)) { extraWindowRoutes[key]?.bringToFront?.invoke() @@ -73,20 +73,10 @@ fun main(args: Array) { ) { FlareTheme { FlareApp( - onRawImage = { url -> + onWindowRoute = { openWindow( - url, - WindowRoute.RawImage(url), - ) - }, - onStatusMedia = { accountType, statusKey, index -> - openWindow( - "$accountType/$statusKey", - WindowRoute.StatusMedia( - accountType = accountType, - statusKey = statusKey, - index = index, - ), + it.toString(), + it, ) }, ) diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/FloatingWindowState.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/FloatingWindowState.kt new file mode 100644 index 000000000..8d1a6e38f --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/FloatingWindowState.kt @@ -0,0 +1,7 @@ +package dev.dimension.flare.ui.route + +internal data class FloatingWindowState( + val route: Route.WindowRoute, +) { + var bringToFront: () -> Unit = {} +} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt index 90c772dce..d4448b43f 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt @@ -12,6 +12,8 @@ internal sealed interface Route { sealed interface ScreenRoute : Route + sealed interface WindowRoute : Route + @Serializable data class Timeline( val tabItem: TimelineTabItem, @@ -132,6 +134,45 @@ internal sealed interface Route { val keyword: String, ) : ScreenRoute + @Serializable + data class StatusMedia( + val accountType: AccountType, + val statusKey: MicroBlogKey, + val index: Int, + val preview: String? = null, + ) : WindowRoute + + @Serializable + data class RawImage( + val rawImage: String, + ) : WindowRoute + + data object Compose { + @Serializable + data class Reply( + val accountKey: MicroBlogKey, + val statusKey: MicroBlogKey, + ) : WindowRoute + + @Serializable + data class Quote( + val accountKey: MicroBlogKey, + val statusKey: MicroBlogKey, + ) : WindowRoute + + @Serializable + data class New( + val accountType: AccountType, + ) : WindowRoute + + @Serializable + data class VVOReplyComment( + val accountKey: MicroBlogKey, + val replyTo: MicroBlogKey, + val rootId: String, + ) : WindowRoute + } + companion object { public fun parse(url: String): Route? { val data = Url(url) @@ -192,8 +233,7 @@ internal sealed interface Route { MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) val statusKey = MicroBlogKey.valueOf(data.segments.getOrNull(2) ?: return null) -// Route.Compose.Reply(accountKey, statusKey) - null + Route.Compose.Reply(accountKey, statusKey) } "Quote" -> { @@ -201,15 +241,13 @@ internal sealed interface Route { MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) val statusKey = MicroBlogKey.valueOf(data.segments.getOrNull(2) ?: return null) -// Route.Compose.Quote(accountKey, statusKey) - null + Route.Compose.Quote(accountKey, statusKey) } "New" -> { val accountKey = MicroBlogKey.valueOf(data.segments.getOrNull(1) ?: return null) -// Route.Compose.New(AccountType.Specific(accountKey)) - null + Route.Compose.New(AccountType.Specific(accountKey)) } else -> null @@ -217,8 +255,7 @@ internal sealed interface Route { "RawImage" -> { val rawImage = data.segments.getOrNull(0) ?: return null -// Route.Media.Image(rawImage, previewUrl = null) - null + Route.RawImage(rawImage) } "VVO" -> @@ -251,8 +288,7 @@ internal sealed interface Route { val replyTo = MicroBlogKey.valueOf(data.segments.getOrNull(2) ?: return null) val rootId = data.segments.getOrNull(3) ?: return null -// Route.Compose.VVOReplyComment(accountKey, replyTo, rootId) - null + Route.Compose.VVOReplyComment(accountKey, replyTo, rootId) } else -> null @@ -337,8 +373,7 @@ internal sealed interface Route { val accountType = accountKey?.let { AccountType.Specific(it) } ?: AccountType.Guest val preview = data.parameters["preview"] -// Route.Media.StatusMedia(accountType = accountType, statusKey = statusKey, index = index, preview = preview) - null + Route.StatusMedia(accountType = accountType, statusKey = statusKey, index = index, preview = preview) } "Podcast" -> { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/WindowRoute.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/WindowRoute.kt deleted file mode 100644 index cec66b4e1..000000000 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/WindowRoute.kt +++ /dev/null @@ -1,22 +0,0 @@ -package dev.dimension.flare.ui.route - -import dev.dimension.flare.model.AccountType -import dev.dimension.flare.model.MicroBlogKey - -internal sealed interface WindowRoute { - data class RawImage( - val url: String, - ) : WindowRoute - - data class StatusMedia( - val accountType: AccountType, - val statusKey: MicroBlogKey, - val index: Int, - ) : WindowRoute -} - -internal data class FloatingWindowState( - val route: WindowRoute, -) { - var bringToFront: () -> Unit = {} -} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/WindowRouter.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/WindowRouter.kt index 9f8b9f29b..06730c639 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/WindowRouter.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/WindowRouter.kt @@ -2,23 +2,50 @@ package dev.dimension.flare.ui.route import androidx.compose.runtime.Composable import androidx.compose.ui.window.FrameWindowScope +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.ui.presenter.compose.ComposeStatus +import dev.dimension.flare.ui.screen.compose.ComposeDialog import dev.dimension.flare.ui.screen.media.RawMediaScreen import dev.dimension.flare.ui.screen.media.StatusMediaScreen @Composable internal fun FrameWindowScope.WindowRouter( - route: WindowRoute, + route: Route.WindowRoute, onBack: () -> Unit, ) { when (route) { - is WindowRoute.RawImage -> - RawMediaScreen(url = route.url) - is WindowRoute.StatusMedia -> + is Route.RawImage -> + RawMediaScreen(url = route.rawImage) + is Route.StatusMedia -> StatusMediaScreen( accountType = route.accountType, statusKey = route.statusKey, index = route.index, window = window, ) + + is Route.Compose.New -> + ComposeDialog( + onBack = onBack, + accountType = route.accountType, + ) + is Route.Compose.Quote -> + ComposeDialog( + onBack = onBack, + status = ComposeStatus.Quote(route.statusKey), + accountType = AccountType.Specific(accountKey = route.accountKey), + ) + is Route.Compose.Reply -> + ComposeDialog( + onBack = onBack, + status = ComposeStatus.Reply(route.statusKey), + accountType = AccountType.Specific(accountKey = route.accountKey), + ) + is Route.Compose.VVOReplyComment -> + ComposeDialog( + onBack = onBack, + accountType = AccountType.Specific(accountKey = route.accountKey), + status = ComposeStatus.VVOComment(route.replyTo, route.rootId), + ) } } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt new file mode 100644 index 000000000..b2a21415c --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt @@ -0,0 +1,936 @@ +package dev.dimension.flare.ui.screen.compose + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.material.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.unit.dp +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.FaceSmile +import compose.icons.fontawesomeicons.solid.Image +import compose.icons.fontawesomeicons.solid.PaperPlane +import compose.icons.fontawesomeicons.solid.Plus +import compose.icons.fontawesomeicons.solid.SquarePollHorizontal +import compose.icons.fontawesomeicons.solid.TriangleExclamation +import compose.icons.fontawesomeicons.solid.Xmark +import dev.dimension.flare.LocalWindowPadding +import dev.dimension.flare.Res +import dev.dimension.flare.common.FileItem +import dev.dimension.flare.compose_content_warning_hint +import dev.dimension.flare.compose_media_sensitive +import dev.dimension.flare.compose_poll_expiration_12_hours +import dev.dimension.flare.compose_poll_expiration_1_day +import dev.dimension.flare.compose_poll_expiration_1_hour +import dev.dimension.flare.compose_poll_expiration_30_minutes +import dev.dimension.flare.compose_poll_expiration_3_days +import dev.dimension.flare.compose_poll_expiration_5_minutes +import dev.dimension.flare.compose_poll_expiration_6_hours +import dev.dimension.flare.compose_poll_expiration_7_days +import dev.dimension.flare.compose_poll_expiration_at +import dev.dimension.flare.compose_poll_multiple_choice +import dev.dimension.flare.compose_poll_option_hint +import dev.dimension.flare.compose_poll_single_choice +import dev.dimension.flare.data.datasource.microblog.ComposeConfig +import dev.dimension.flare.data.datasource.microblog.ComposeData +import dev.dimension.flare.misskey_visibility_followers +import dev.dimension.flare.misskey_visibility_followers_description +import dev.dimension.flare.misskey_visibility_home +import dev.dimension.flare.misskey_visibility_home_description +import dev.dimension.flare.misskey_visibility_public +import dev.dimension.flare.misskey_visibility_public_description +import dev.dimension.flare.misskey_visibility_specified +import dev.dimension.flare.misskey_visibility_specified_description +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.ui.component.AvatarComponent +import dev.dimension.flare.ui.component.EmojiPicker +import dev.dimension.flare.ui.component.FAIcon +import dev.dimension.flare.ui.component.NetworkImage +import dev.dimension.flare.ui.component.status.QuotedStatus +import dev.dimension.flare.ui.component.status.StatusVisibilityComponent +import dev.dimension.flare.ui.model.UiEmoji +import dev.dimension.flare.ui.model.UiState +import dev.dimension.flare.ui.model.UiTimeline +import dev.dimension.flare.ui.model.map +import dev.dimension.flare.ui.model.mapNotNull +import dev.dimension.flare.ui.model.onError +import dev.dimension.flare.ui.model.onSuccess +import dev.dimension.flare.ui.model.takeSuccess +import dev.dimension.flare.ui.model.takeSuccessOr +import dev.dimension.flare.ui.presenter.compose.ComposePresenter +import dev.dimension.flare.ui.presenter.compose.ComposeStatus +import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.theme.screenHorizontalPadding +import io.github.composefluent.FluentTheme +import io.github.composefluent.component.AccentButton +import io.github.composefluent.component.Button +import io.github.composefluent.component.CheckBox +import io.github.composefluent.component.FlyoutContainer +import io.github.composefluent.component.MenuFlyoutContainer +import io.github.composefluent.component.MenuFlyoutItem +import io.github.composefluent.component.PillButton +import io.github.composefluent.component.RadioButton +import io.github.composefluent.component.Text +import io.github.composefluent.component.TextField +import io.github.composefluent.surface.Card +import kotlinx.collections.immutable.toImmutableList +import moe.tlaster.precompose.molecule.producePresenter +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import java.io.File +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +@Composable +fun ComposeDialog( + onBack: () -> Unit, + accountType: AccountType, + modifier: Modifier = Modifier, + status: ComposeStatus? = null, + initialText: String = "", +) { + val state by producePresenter(key = "compose_$accountType") { + composePresenter( + accountType = accountType, + status = status, + initialText = initialText, + ) + } + + val focusRequester = remember { FocusRequester() } + val contentWarningFocusRequester = remember { FocusRequester() } + state.contentWarningState + .onSuccess { + LaunchedEffect(it.enabled) { + if (it.enabled) { + contentWarningFocusRequester.requestFocus() + } else { + focusRequester.requestFocus() + } + } + }.onError { + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + } + Column( + modifier = + modifier + .padding( + LocalWindowPadding.current, + ).padding(top = 8.dp), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = + Modifier + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + state.state.selectedUsers.onSuccess { selectedUsers -> + for (i in 0 until selectedUsers.size) { + val (user, account) = selectedUsers[i] + user.onSuccess { + PillButton( +// onClick = { +// state.state.selectAccount(account) +// }, + selected = false, + onSelectedChanged = { + state.state.selectAccount(account) + }, + content = { + AvatarComponent(it.avatar, size = 24.dp) + Text(it.handle) + }, + ) + } + } + state.state.otherAccounts.onSuccess { others -> + if (others.size > 0) { + MenuFlyoutContainer( + flyout = { + for (i in 0 until others.size) { + val (user, account) = others[i] + user.onSuccess { data -> + MenuFlyoutItem( + text = { + Text(text = data.handle) + }, + onClick = { + state.state.selectAccount(account) + }, + icon = { + AvatarComponent( + data.avatar, + size = 24.dp, + ) + }, + ) + } + } + }, + ) { + PillButton( + selected = isFlyoutVisible, + onSelectedChanged = { + isFlyoutVisible = !isFlyoutVisible + }, + content = { + FAIcon( + FontAwesomeIcons.Solid.Plus, + contentDescription = null, + ) + }, + ) + } + } + } + } + } + state.contentWarningState.onSuccess { + AnimatedVisibility(it.enabled) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextField( + state = it.textFieldState, + modifier = + Modifier + .fillMaxWidth() + .focusRequester( + focusRequester = contentWarningFocusRequester, + ), + placeholder = { + Text(text = stringResource(Res.string.compose_content_warning_hint)) + }, + ) +// HorizontalDivider() + } + } + } + Box( + modifier = + Modifier + .fillMaxWidth(), + ) { + BasicTextField( + state = state.textFieldState, + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 120.dp) + .padding( + horizontal = screenHorizontalPadding, + ).focusRequester( + focusRequester = focusRequester, + ), +// placeholder = { +// Text(text = stringResource(Res.string.compose_hint)) +// }, + ) + } + state.mediaState.onSuccess { mediaState -> + AnimatedVisibility(mediaState.medias.isNotEmpty()) { + Column { + Row( + modifier = + Modifier + .padding(horizontal = screenHorizontalPadding) + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + mediaState.medias.forEach { uri -> + Box { + NetworkImage( + model = uri.absolutePath, + contentDescription = null, + modifier = + Modifier + .size(128.dp) + .clip(RoundedCornerShape(8.dp)), + ) + IconButton( + onClick = { + mediaState.removeMedia(uri) + }, + modifier = + Modifier + .align(Alignment.TopEnd) + .background( + color = Color.Black.copy(alpha = 0.3f), + shape = CircleShape, + ), + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Xmark, + contentDescription = null, + ) + } + } + } + } + if (mediaState.canSensitive) { + val sensitiveInteractionSource = remember { MutableInteractionSource() } + Row( + modifier = + Modifier + .padding(horizontal = screenHorizontalPadding) + .fillMaxWidth() + .clickable( + interactionSource = sensitiveInteractionSource, + indication = null, + ) { + mediaState.setMediaSensitive(!mediaState.isMediaSensitive) + }, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CheckBox( + checked = mediaState.isMediaSensitive, + onCheckStateChange = { mediaState.setMediaSensitive(it) }, +// interactionSource = sensitiveInteractionSource, + ) + Text(text = stringResource(Res.string.compose_media_sensitive)) + } + } + } + } + } + state.pollState.onSuccess { pollState -> + AnimatedVisibility(pollState.enabled) { + Column( + modifier = + Modifier + .padding(horizontal = screenHorizontalPadding) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val items = + mapOf( + true to stringResource(Res.string.compose_poll_single_choice), + false to stringResource(Res.string.compose_poll_multiple_choice), + ) + Row( + modifier = + Modifier + .weight(1f), + ) { + items.forEach { + RadioButton( + selected = pollState.pollSingleChoice == it.key, + onClick = { + pollState.setPollSingleChoice(it.key) + }, + modifier = + Modifier + .weight(1f) + .padding(horizontal = 4.dp), + label = it.value, + ) + } + } + Button( + onClick = { + pollState.addPollOption() + }, + disabled = !pollState.canAddPollOption, + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Plus, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + } + } + pollState.options.forEachIndexed { index, textFieldState -> + PollOption( + textFieldState = textFieldState, + index = index, + onRemove = { + pollState.removePollOption(index) + }, + ) + } + MenuFlyoutContainer( + flyout = { + PollExpiration.entries.forEach { expiration -> + MenuFlyoutItem( + onClick = { + pollState.setExpiredAt(expiration) + isFlyoutVisible = false + }, + text = { + Text(text = stringResource(expiration.textRes)) + }, + ) + } + }, + ) { + Button( + onClick = { + isFlyoutVisible = true + }, + modifier = + Modifier + .align(Alignment.End), + ) { + Text( + text = + stringResource( + Res.string.compose_poll_expiration_at, + stringResource(pollState.expiredAt.textRes), + ), + ) + } + } + } + } + } + + state.state.replyState?.let { replyState -> + replyState.onSuccess { state -> + val content = state.content + if (content is UiTimeline.ItemContent.Status) { + Card( + modifier = + Modifier + .padding(horizontal = screenHorizontalPadding) + .sizeIn(maxWidth = 300.dp), + ) { + QuotedStatus( + data = content, + modifier = + Modifier + .padding(horizontal = screenHorizontalPadding) + .padding(vertical = 8.dp) + .fillMaxWidth(), + ) + } + } + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Box( + modifier = + Modifier + .background(FluentTheme.colors.control.default) + .heightIn(min = 16.dp) + .fillMaxWidth(), +// tonalElevation = 8.dp, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + state.mediaState.onSuccess { + if (it.enabled) { + IconButton( + onClick = { +// photoPickerLauncher.launch( +// PickVisualMediaRequest( +// ActivityResultContracts.PickVisualMedia.ImageAndVideo, +// ), +// ) + }, + enabled = state.canMedia, + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Image, + contentDescription = null, + ) + } + } + } + state.pollState.onSuccess { + IconButton( + onClick = { + it.togglePoll() + }, + enabled = state.canPoll, + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.SquarePollHorizontal, + contentDescription = null, + ) + } + } + state.state.visibilityState.onSuccess { visibilityState -> + MenuFlyoutContainer( + flyout = { + visibilityState.allVisibilities.forEach { visibility -> + MenuFlyoutItem( + onClick = { + visibilityState.setVisibility(visibility) + isFlyoutVisible = false + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.Start, + ) { + Text( + text = stringResource(visibility.localName), + style = FluentTheme.typography.bodyLarge, + ) + Text( + text = stringResource(visibility.localDescription), + style = FluentTheme.typography.caption, + ) + } + }, + icon = { + StatusVisibilityComponent(visibility = visibility) + }, + ) + } + }, + ) { + IconButton( + onClick = { + isFlyoutVisible = true + }, + ) { + StatusVisibilityComponent(visibility = visibilityState.visibility) + } + } + } + state.contentWarningState.onSuccess { + IconButton( + onClick = { + it.toggle() + }, + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.TriangleExclamation, + contentDescription = null, + ) + } + } + state.state.emojiState.onSuccess { emojis -> + AnimatedVisibility(emojis.size > 0) { + FlyoutContainer( + flyout = { + val actualAccountType = + remember( + state.state.selectedAccounts, + ) { + state.state.selectedAccounts + .firstOrNull() + ?.accountKey + ?.let(AccountType::Specific) + } + EmojiPicker( + data = emojis.data, + onEmojiSelected = state::selectEmoji, + accountType = actualAccountType ?: accountType, + modifier = + Modifier + .padding(8.dp), + ) + }, + ) { + IconButton( + onClick = { + isFlyoutVisible = !isFlyoutVisible + }, + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.FaceSmile, + contentDescription = null, + ) + } + } + } + } + + Spacer(modifier = Modifier.weight(1f)) + + state.remainingLength.onSuccess { + Text( + it, + style = FluentTheme.typography.caption, + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + AccentButton( + onClick = { + state.send() + onBack.invoke() + }, + disabled = !state.canSend, + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.PaperPlane, + contentDescription = null, + ) + } + Spacer(modifier = Modifier.width(8.dp)) + } + } + } +} + +@Composable +private fun PollOption( + textFieldState: TextFieldState, + index: Int, + onRemove: () -> Unit, + modifier: Modifier = Modifier, +) { + TextField( + state = textFieldState, + modifier = + modifier + .fillMaxWidth(), + placeholder = { + Text(text = stringResource(Res.string.compose_poll_option_hint, index + 1)) + }, + trailing = { + IconButton( + onClick = onRemove, + enabled = index > 1, + ) { + FAIcon(imageVector = FontAwesomeIcons.Solid.Xmark, contentDescription = null) + } + }, + ) +} + +@Composable +private fun composePresenter( + accountType: AccountType, + status: ComposeStatus? = null, + initialText: String = "", +) = run { + val state = + remember(status, accountType) { + ComposePresenter(accountType = accountType, status) + }.invoke() + val textFieldState by remember { + mutableStateOf(TextFieldState(initialText)) + } + + val remainingLength = + state.composeConfig + .mapNotNull { + it.text + }.map { + it.maxLength - textFieldState.text.length + } + + val pollState = + state.composeConfig + .mapNotNull { + it.poll + }.map { + pollPresenter(it) + } + + val mediaState = + state.composeConfig + .mapNotNull { + it.media + }.map { + mediaPresenter(it) + } + + val contentWarningState = + state.composeConfig + .mapNotNull { + it.contentWarning + }.map { + contentWarningPresenter() + } + + state.initialTextState?.onSuccess { + LaunchedEffect(it) { + if (it.text.isNotEmpty()) { + textFieldState.edit { + append(it.text) + selection = TextRange(it.cursorPosition) + } + } + } + } + + val canSend = + remember(textFieldState.text, state.account) { + textFieldState.text.isNotBlank() && + textFieldState.text.isNotEmpty() && + state.account is UiState.Success && + remainingLength.takeSuccessOr(0) >= 0 + } + val canPoll = + remember(mediaState) { + mediaState !is UiState.Success || mediaState.data.medias.isEmpty() + } + val canMedia = + remember(mediaState, pollState) { + (mediaState is UiState.Success && mediaState.data.canAddMedia) && + !(pollState is UiState.Success && pollState.data.enabled) + } + object { + val remainingLength = + remainingLength.map { + it.toString() + } + val textFieldState = textFieldState + val canSend = canSend + val canPoll = canPoll + val canMedia = canMedia + val pollState = pollState + val mediaState = mediaState + val contentWarningState = contentWarningState + val state = state + + fun selectEmoji(emoji: UiEmoji) { + textFieldState.edit { + append(" ${emoji.shortcode} ") + } + } + + fun send() { + state.selectedAccounts.forEach { + val data = + ComposeData( + content = textFieldState.text.toString(), + medias = + mediaState.takeSuccess()?.medias.orEmpty().map { + FileItem(it) + }, + poll = + pollState.takeSuccess()?.takeIf { it.enabled }?.let { + ComposeData.Poll( + multiple = !it.pollSingleChoice, + expiredAfter = it.expiredAt.duration.inWholeMilliseconds, + options = + it.options.map { option -> + option.text.toString() + }, + ) + }, + sensitive = mediaState.takeSuccess()?.isMediaSensitive ?: false, + spoilerText = + contentWarningState + .takeSuccess() + ?.textFieldState + ?.text + ?.toString(), + visibility = + state.visibilityState.takeSuccess()?.visibility + ?: UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Public, + account = it, + referenceStatus = + status?.let { + state.replyState?.takeSuccess()?.let { item -> + ComposeData.ReferenceStatus( + data = item, + composeStatus = status, + ) + } + }, + ) + state.send(data) + } + } + } +} + +@Composable +private fun contentWarningPresenter() = + run { + val textFieldState by remember { + mutableStateOf(TextFieldState("")) + } + var enabled by remember { + mutableStateOf(false) + } + object { + val textFieldState = textFieldState + val enabled = enabled + + fun toggle() { + enabled = !enabled + } + } + } + +@Composable +private fun mediaPresenter(config: ComposeConfig.Media) = + run { + var medias by remember { + mutableStateOf(listOf()) + } + var isMediaSensitive by remember { + mutableStateOf(false) + } + + object { + val medias = medias.toImmutableList() + val isMediaSensitive = isMediaSensitive + val canAddMedia = medias.size < config.maxCount + val canSensitive = config.canSensitive + val enabled = config.maxCount > 0 + + fun addMedia(uris: List) { + medias = (medias + uris).distinct().takeLast(config.maxCount) + } + + fun removeMedia(uri: File) { + medias = medias.filterNot { it == uri } + if (medias.isEmpty()) { + isMediaSensitive = false + } + } + + fun setMediaSensitive(value: Boolean) { + isMediaSensitive = value + } + } + } + +@Composable +private fun pollPresenter(config: ComposeConfig.Poll) = + run { + var enabled by remember { + mutableStateOf(false) + } + var options by remember { + mutableStateOf(listOf(TextFieldState(), TextFieldState())) + } + var pollSingleChoice by remember { + mutableStateOf(true) + } + var expiredAt by remember { + mutableStateOf(PollExpiration.Minutes5) + } + val canAddPollOption = + remember(options) { + options.size < config.maxOptions + } + + object { + val enabled = enabled + val options = options.toImmutableList() + val pollSingleChoice = pollSingleChoice + val canAddPollOption = canAddPollOption + val expiredAt = expiredAt + + fun togglePoll() { + enabled = !enabled + if (!enabled) { + options = listOf(TextFieldState(), TextFieldState()) + pollSingleChoice = true + expiredAt = PollExpiration.Minutes5 + } + } + + fun addPollOption() { + options = options + TextFieldState() + } + + fun removePollOption(index: Int) { + options = options.filterIndexed { i, _ -> i != index } + } + + fun setPollSingleChoice(singleChoice: Boolean) { + pollSingleChoice = singleChoice + } + + fun setExpiredAt(value: PollExpiration) { + expiredAt = value + } + } + } + +internal enum class PollExpiration( + val textRes: StringResource, + val duration: Duration, +) { + Minutes5(Res.string.compose_poll_expiration_5_minutes, 5.minutes), + Minutes30(Res.string.compose_poll_expiration_30_minutes, 30.minutes), + Hours1(Res.string.compose_poll_expiration_1_hour, 1.hours), + Hours6(Res.string.compose_poll_expiration_6_hours, 6.hours), + Hours12(Res.string.compose_poll_expiration_12_hours, 12.hours), + Days1(Res.string.compose_poll_expiration_1_day, 1.days), + Days3(Res.string.compose_poll_expiration_3_days, 3.days), + Days7(Res.string.compose_poll_expiration_7_days, 7.days), +} + +internal val UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.localName: StringResource + get() = + when (this) { + UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Public -> + Res.string.misskey_visibility_public + + UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Home -> + Res.string.misskey_visibility_home + + UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Followers -> + Res.string.misskey_visibility_followers + + UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Specified -> + Res.string.misskey_visibility_specified + } + +internal val UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.localDescription: StringResource + get() = + when (this) { + UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Public -> + Res.string.misskey_visibility_public_description + + UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Home -> + Res.string.misskey_visibility_home_description + + UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Followers -> + Res.string.misskey_visibility_followers_description + + UiTimeline.ItemContent.Status.TopEndContent.Visibility.Type.Specified -> + Res.string.misskey_visibility_specified_description + } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedListScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedListScreen.kt index 518bed190..f3d79ab31 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedListScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedListScreen.kt @@ -17,7 +17,7 @@ import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.Plus import compose.icons.fontawesomeicons.solid.Trash -import dev.dimension.flare.LocalContentPadding +import dev.dimension.flare.LocalWindowPadding import dev.dimension.flare.Res import dev.dimension.flare.feeds_discover_feeds_title import dev.dimension.flare.feeds_my_feeds_title @@ -56,7 +56,7 @@ internal fun FeedListScreen( contentPadding = PaddingValues( vertical = 8.dp, - ) + LocalContentPadding.current, + ) + LocalWindowPadding.current, modifier = Modifier .fillMaxSize(), diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt index dc7e768ae..e853b7223 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt @@ -35,7 +35,7 @@ import androidx.compose.ui.unit.dp import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.Trash -import dev.dimension.flare.LocalContentPadding +import dev.dimension.flare.LocalWindowPadding import dev.dimension.flare.RegisterTabCallback import dev.dimension.flare.Res import dev.dimension.flare.common.onLoading @@ -93,7 +93,7 @@ internal fun DiscoverScreen( LazyStatusVerticalStaggeredGrid( modifier = Modifier.fillMaxSize(), state = lazyListState, - contentPadding = PaddingValues(vertical = 8.dp) + LocalContentPadding.current, + contentPadding = PaddingValues(vertical = 8.dp) + LocalWindowPadding.current, ) { if (true) { item( diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/NotificationScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/NotificationScreen.kt index b74cbf838..0aaa13568 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/NotificationScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/NotificationScreen.kt @@ -13,7 +13,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import dev.dimension.flare.LocalContentPadding +import dev.dimension.flare.LocalWindowPadding import dev.dimension.flare.RegisterTabCallback import dev.dimension.flare.common.isRefreshing import dev.dimension.flare.model.AccountType @@ -51,7 +51,7 @@ internal fun NotificationScreen(accountType: AccountType) { contentPadding = PaddingValues( vertical = 8.dp, - ) + LocalContentPadding.current, + ) + LocalWindowPadding.current, state = listState, ) { state.state.allTypes.onSuccess { types -> diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/ProfileScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/ProfileScreen.kt index 35923a226..a02b25a9b 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/ProfileScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/ProfileScreen.kt @@ -29,7 +29,7 @@ import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.layout.layout import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import dev.dimension.flare.LocalContentPadding +import dev.dimension.flare.LocalWindowPadding import dev.dimension.flare.RegisterTabCallback import dev.dimension.flare.Res import dev.dimension.flare.data.datasource.microblog.ProfileTab @@ -133,7 +133,7 @@ private fun ProfileErrorScreen(onBack: () -> Unit) { @Composable private fun ProfileLoadingScreen(onBack: () -> Unit) { LazyColumn( - contentPadding = LocalContentPadding.current, + contentPadding = LocalWindowPadding.current, verticalArrangement = Arrangement.spacedBy(8.dp), ) { item { @@ -188,7 +188,7 @@ internal fun ProfileScreen( Column( modifier = Modifier - .padding(LocalContentPadding.current) + .padding(LocalWindowPadding.current) .padding( vertical = 16.dp, ).padding( @@ -234,7 +234,7 @@ internal fun ProfileScreen( }, ) + if (isBigScreen) { - LocalContentPadding.current + LocalWindowPadding.current } else { PaddingValues() }, diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/SearchScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/SearchScreen.kt index 2228b0fe7..7c630bf1c 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/SearchScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/SearchScreen.kt @@ -33,7 +33,7 @@ import androidx.compose.ui.unit.dp import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.Trash -import dev.dimension.flare.LocalContentPadding +import dev.dimension.flare.LocalWindowPadding import dev.dimension.flare.RegisterTabCallback import dev.dimension.flare.Res import dev.dimension.flare.common.isRefreshing @@ -90,7 +90,7 @@ fun SearchScreen( LazyStatusVerticalStaggeredGrid( modifier = Modifier.fillMaxSize(), state = lazyListState, - contentPadding = PaddingValues(vertical = 8.dp) + LocalContentPadding.current, + contentPadding = PaddingValues(vertical = 8.dp) + LocalWindowPadding.current, ) { item( span = StaggeredGridItemSpan.FullLine, diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt index 5fb1dddca..b83d7d576 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt @@ -28,7 +28,7 @@ import androidx.compose.ui.unit.dp import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.AnglesUp -import dev.dimension.flare.LocalContentPadding +import dev.dimension.flare.LocalWindowPadding import dev.dimension.flare.RegisterTabCallback import dev.dimension.flare.Res import dev.dimension.flare.common.PagingState @@ -73,7 +73,7 @@ internal fun TimelineScreen(tabItem: TimelineTabItem) { contentPadding = PaddingValues( vertical = 8.dp, - ) + LocalContentPadding.current, + ) + LocalWindowPadding.current, state = state.lazyListState, ) { status(state.listState) diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt index 838cc8b2b..0630a9c1b 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt @@ -32,7 +32,7 @@ import androidx.compose.ui.unit.dp import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.CircleQuestion -import dev.dimension.flare.LocalContentPadding +import dev.dimension.flare.LocalWindowPadding import dev.dimension.flare.Res import dev.dimension.flare.bluesky_login_2fa import dev.dimension.flare.bluesky_login_oauth_button @@ -111,7 +111,7 @@ internal fun ServiceSelectScreen( Alignment.CenterHorizontally, ), verticalItemSpacing = 0.dp, - contentPadding = LocalContentPadding.current, + contentPadding = LocalWindowPadding.current, ) { item( span = StaggeredGridItemSpan.FullLine, diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt index eda1ef1b1..7f4233ca2 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt @@ -31,7 +31,7 @@ import compose.icons.fontawesomeicons.solid.Language import compose.icons.fontawesomeicons.solid.Lock import compose.icons.fontawesomeicons.solid.Plus import compose.icons.fontawesomeicons.solid.Trash -import dev.dimension.flare.LocalContentPadding +import dev.dimension.flare.LocalWindowPadding import dev.dimension.flare.Res import dev.dimension.flare.add_account import dev.dimension.flare.app_name @@ -127,7 +127,7 @@ internal fun SettingsScreen(toLogin: () -> Unit) { Modifier .fillMaxSize() .verticalScroll(scrollState) - .padding(LocalContentPadding.current) + .padding(LocalWindowPadding.current) .padding(horizontal = screenHorizontalPadding), ) { state.accountState.user diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/StatusScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/StatusScreen.kt index ab3338eea..d4f06d954 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/StatusScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/StatusScreen.kt @@ -16,7 +16,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import dev.dimension.flare.LocalContentPadding +import dev.dimension.flare.LocalWindowPadding import dev.dimension.flare.RegisterTabCallback import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey @@ -52,7 +52,7 @@ internal fun StatusScreen( contentPadding = PaddingValues( vertical = 8.dp, - ) + LocalContentPadding.current, + ) + LocalWindowPadding.current, state = listState, ) { status( diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOCommentScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOCommentScreen.kt index bcdc55efa..2812b0065 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOCommentScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOCommentScreen.kt @@ -15,7 +15,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import dev.dimension.flare.LocalContentPadding +import dev.dimension.flare.LocalWindowPadding import dev.dimension.flare.RegisterTabCallback import dev.dimension.flare.common.onSuccess import dev.dimension.flare.model.AccountType @@ -56,7 +56,7 @@ internal fun VVOCommentScreen( contentPadding = PaddingValues( vertical = 8.dp, - ) + LocalContentPadding.current, + ) + LocalWindowPadding.current, state = listState, ) { item { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOStatusScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOStatusScreen.kt index 4849f397b..249d72e0e 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOStatusScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOStatusScreen.kt @@ -24,7 +24,7 @@ import androidx.compose.ui.unit.dp import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.FileCircleExclamation -import dev.dimension.flare.LocalContentPadding +import dev.dimension.flare.LocalWindowPadding import dev.dimension.flare.Res import dev.dimension.flare.common.PagingState import dev.dimension.flare.model.AccountType @@ -77,13 +77,13 @@ internal fun VVOStatusScreen( .verticalScroll(rememberScrollState()) .width(432.dp) .padding(PaddingValues(horizontal = screenHorizontalPadding)) - .padding(LocalContentPadding.current), + .padding(LocalWindowPadding.current), ) LazyStatusVerticalStaggeredGrid( contentPadding = PaddingValues( vertical = 8.dp, - ) + LocalContentPadding.current, + ) + LocalWindowPadding.current, ) { reactionContent( comment = state.comment, @@ -98,7 +98,7 @@ internal fun VVOStatusScreen( contentPadding = PaddingValues( vertical = 8.dp, - ) + LocalContentPadding.current, + ) + LocalWindowPadding.current, ) { item { StatusContent(statusState = state.status, detailStatusKey = statusKey) diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt index a6538bdd3..2c109ec49 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -22,7 +23,9 @@ import androidx.compose.ui.graphics.drawscope.ContentDrawScope import androidx.compose.ui.node.DelegatableNode import androidx.compose.ui.node.DrawModifierNode import androidx.compose.ui.node.invalidateDraw +import androidx.compose.ui.unit.dp import androidx.compose.ui.window.FrameWindowScope +import dev.dimension.flare.LocalWindowPadding import dev.dimension.flare.data.model.AppSettings import dev.dimension.flare.data.model.AppearanceSettings import dev.dimension.flare.data.model.AvatarShape @@ -71,6 +74,17 @@ internal fun FrameWindowScope.FlareTheme( hover = Color.Transparent, pressed = Color.Transparent, ), + LocalWindowPadding provides + if (SystemUtils.IS_OS_MAC) { + PaddingValues( + start = 0.dp, + top = 24.dp, + end = 0.dp, + bottom = 0.dp, + ) + } else { + PaddingValues(0.dp) + }, ) { Box( modifier = From c97e8a6e2218f6334a6abb276e7783ce911cad98 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Tue, 19 Aug 2025 23:31:50 +0900 Subject: [PATCH 10/33] fix android build --- .../java/dev/dimension/flare/data/model/AppSettings.kt | 10 ---------- .../dimension/flare/data/model/AppearanceSettings.kt | 10 ---------- .../flare/data/model/{TabSettings.kt => DataStore.kt} | 9 +++++++++ 3 files changed, 9 insertions(+), 20 deletions(-) delete mode 100644 app/src/main/java/dev/dimension/flare/data/model/AppSettings.kt delete mode 100644 app/src/main/java/dev/dimension/flare/data/model/AppearanceSettings.kt rename app/src/main/java/dev/dimension/flare/data/model/{TabSettings.kt => DataStore.kt} (93%) diff --git a/app/src/main/java/dev/dimension/flare/data/model/AppSettings.kt b/app/src/main/java/dev/dimension/flare/data/model/AppSettings.kt deleted file mode 100644 index 81d128f84..000000000 --- a/app/src/main/java/dev/dimension/flare/data/model/AppSettings.kt +++ /dev/null @@ -1,10 +0,0 @@ -package dev.dimension.flare.data.model - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.dataStore - -internal val Context.appSettings: DataStore by dataStore( - fileName = "app_settings.pb", - serializer = AppSettingsSerializer, -) diff --git a/app/src/main/java/dev/dimension/flare/data/model/AppearanceSettings.kt b/app/src/main/java/dev/dimension/flare/data/model/AppearanceSettings.kt deleted file mode 100644 index 0e6c2a689..000000000 --- a/app/src/main/java/dev/dimension/flare/data/model/AppearanceSettings.kt +++ /dev/null @@ -1,10 +0,0 @@ -package dev.dimension.flare.data.model - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.dataStore - -internal val Context.appearanceSettings: DataStore by dataStore( - fileName = "appearance_settings.pb", - serializer = AccountPreferencesSerializer, -) diff --git a/app/src/main/java/dev/dimension/flare/data/model/TabSettings.kt b/app/src/main/java/dev/dimension/flare/data/model/DataStore.kt similarity index 93% rename from app/src/main/java/dev/dimension/flare/data/model/TabSettings.kt rename to app/src/main/java/dev/dimension/flare/data/model/DataStore.kt index 16f19b3bc..16bc95f48 100644 --- a/app/src/main/java/dev/dimension/flare/data/model/TabSettings.kt +++ b/app/src/main/java/dev/dimension/flare/data/model/DataStore.kt @@ -27,6 +27,15 @@ import compose.icons.fontawesomeicons.solid.Users import dev.dimension.flare.R import dev.dimension.flare.ui.icons.Misskey +internal val Context.appearanceSettings: DataStore by dataStore( + fileName = "appearance_settings.pb", + serializer = AccountPreferencesSerializer, +) +internal val Context.appSettings: DataStore by dataStore( + fileName = "app_settings.pb", + serializer = AppSettingsSerializer, +) + internal val TitleType.Localized.resId: Int get() = when (key) { From 198c7a7482e388faaed49eb1a2ced30efd9d700f Mon Sep 17 00:00:00 2001 From: Tlaster Date: Wed, 20 Aug 2025 15:20:16 +0900 Subject: [PATCH 11/33] add rss screen --- .../main/composeResources/values/strings.xml | 23 +- .../main/kotlin/dev/dimension/flare/App.kt | 7 +- .../dev/dimension/flare/ui/route/Route.kt | 33 +- .../dev/dimension/flare/ui/route/Router.kt | 190 +++++-- .../flare/ui/screen/home/DiscoverScreen.kt | 4 - .../flare/ui/screen/home/SearchScreen.kt | 3 - .../flare/ui/screen/home/TimelineScreen.kt | 14 +- .../ui/screen/rss/EditRssSourceScreen.kt | 520 ++++++++++++++++++ .../flare/ui/screen/rss/RssListScreen.kt | 281 ++++++++++ .../serviceselect/ServiceSelectScreen.kt | 3 - .../ui/screen/settings/SettingsScreen.kt | 25 + .../flare/ui/theme/PlatformShapes.jvm.kt | 21 +- 12 files changed, 1033 insertions(+), 91 deletions(-) create mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/rss/EditRssSourceScreen.kt create mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/rss/RssListScreen.kt diff --git a/desktopApp/src/main/composeResources/values/strings.xml b/desktopApp/src/main/composeResources/values/strings.xml index d8d575354..ab07fadc2 100644 --- a/desktopApp/src/main/composeResources/values/strings.xml +++ b/desktopApp/src/main/composeResources/values/strings.xml @@ -22,7 +22,6 @@ List Feeds Direct Messages - Rss New toots Back @@ -245,4 +244,26 @@ Only followers can see this toot Only mentioned users can see this toot + + Rss Sources + Add Rss Source + Edit Rss Source + Delete Rss Source + + Title + Url + Rss + No rss sources + RssHub Host + You need to set the RssHub host if you want to use RssHub, or select the public RssHub server + Discovered Rss Sources + Pin to home tabs + + Add + Drag + Edit + Remove + Default + List + Feeds \ No newline at end of file diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt index 6d3403765..b9bf93f19 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt @@ -313,6 +313,7 @@ internal fun FlareApp(onWindowRoute: (Route.WindowRoute) -> Unit) { Box { Router( manager = stackManager, + onWindowRoute = onWindowRoute, ) if (stackManager.canGoBack) { NavigationDefaults.BackButton( @@ -387,8 +388,8 @@ private fun getRoute(tab: TabItem): Route = is AllListTabItem -> AllLists(tab.account) is Bluesky.FeedsTabItem -> BlueskyFeeds(tab.account) is DirectMessageTabItem -> DirectMessage(tab.account) - is RssTabItem -> Route.Rss - is Misskey.AntennasListTabItem -> Route.Rss + is RssTabItem -> Route.RssList + is Misskey.AntennasListTabItem -> Route.RssList } @Composable @@ -417,6 +418,8 @@ private class ProxyUriHandler( Route.parse(uri)?.let { if (it is Route.WindowRoute) { onWindowRoute.invoke(it) + } else if (it is Route.UrlRoute) { + actualUriHandler.openUri(it.url) } else { stackManager.push(it) } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt index d4448b43f..376cdc27c 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt @@ -14,6 +14,10 @@ internal sealed interface Route { sealed interface WindowRoute : Route + sealed interface UrlRoute : Route { + val url: String + } + @Serializable data class Timeline( val tabItem: TimelineTabItem, @@ -32,9 +36,6 @@ internal sealed interface Route { @Serializable data object Settings : ScreenRoute - @Serializable - data object Rss : ScreenRoute - @Serializable data class Profile( val accountType: AccountType, @@ -173,6 +174,29 @@ internal sealed interface Route { ) : WindowRoute } + @Serializable + data object RssList : ScreenRoute + + @Serializable + data class RssTimeline( + val id: Int, + val url: String, + val title: String?, + ) : ScreenRoute + + @Serializable + data class EditRssSource( + val id: Int, + ) : FloatingRoute + + @Serializable + data object CreateRssSource : FloatingRoute + + @Serializable + data class RssDetail( + override val url: String, + ) : UrlRoute + companion object { public fun parse(url: String): Route? { val data = Url(url) @@ -392,8 +416,7 @@ internal sealed interface Route { "RSS" -> { val feedUrl = data.segments.getOrNull(0) ?: return null -// Route.Rss.Detail(feedUrl) - null + Route.RssDetail(feedUrl) } else -> null diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt index 88677749f..02b14fa39 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt @@ -9,8 +9,12 @@ import androidx.compose.ui.unit.dp import dev.dimension.flare.data.model.Bluesky.FeedTabItem import dev.dimension.flare.data.model.IconType.Material import dev.dimension.flare.data.model.ListTimelineTabItem +import dev.dimension.flare.data.model.RssTimelineTabItem import dev.dimension.flare.data.model.TabMetaData import dev.dimension.flare.data.model.TitleType +import dev.dimension.flare.model.AccountType.Specific +import dev.dimension.flare.ui.route.Route.Profile +import dev.dimension.flare.ui.route.Route.Search import dev.dimension.flare.ui.route.Route.Timeline import dev.dimension.flare.ui.screen.feeds.FeedListScreen import dev.dimension.flare.ui.screen.home.DiscoverScreen @@ -20,6 +24,8 @@ import dev.dimension.flare.ui.screen.home.ProfileWithUserNameAndHostDeeplinkRout import dev.dimension.flare.ui.screen.home.SearchScreen import dev.dimension.flare.ui.screen.home.TimelineScreen import dev.dimension.flare.ui.screen.list.AllListScreen +import dev.dimension.flare.ui.screen.rss.EditRssSourceScreen +import dev.dimension.flare.ui.screen.rss.RssListScreen import dev.dimension.flare.ui.screen.serviceselect.ServiceSelectScreen import dev.dimension.flare.ui.screen.settings.SettingsScreen import dev.dimension.flare.ui.screen.status.StatusScreen @@ -37,10 +43,15 @@ import io.github.composefluent.component.Text @Composable internal fun Router( manager: StackManager, + onWindowRoute: (Route.WindowRoute) -> Unit, modifier: Modifier = Modifier, ) { fun navigate(route: Route) { - manager.push(route) + if (route is Route.WindowRoute) { + onWindowRoute(route) + } else { + manager.push(route) + } } fun onBack() { @@ -106,7 +117,7 @@ internal fun Router( accountType = route.accountType, toUser = { navigate( - Route.Profile( + Profile( accountType = route.accountType, userKey = it, ), @@ -114,7 +125,7 @@ internal fun Router( }, toSearch = { navigate( - Route.Search( + Search( accountType = route.accountType, keyword = it, ), @@ -129,7 +140,7 @@ internal fun Router( accountType = route.accountType, toUser = { navigate( - Route.Profile( + Profile( accountType = route.accountType, userKey = it, ), @@ -155,13 +166,21 @@ internal fun Router( ProfileScreen( accountType = route.accountType, userKey = route.userKey, + toEditAccountList = {}, + toSearchUserUsingAccount = { keyword, accountKey -> + navigate( + Search( + accountType = Specific(accountKey), + keyword = keyword, + ), + ) + }, + toStartMessage = {}, + onFollowListClick = {}, + onFansListClick = {}, ) } - Route.Rss -> { - Text("route") - } - Route.ServiceSelect -> { ServiceSelectScreen( onBack = ::onBack, @@ -214,71 +233,126 @@ internal fun Router( accountType = route.accountType, onBack = ::onBack, toEditAccountList = {}, - toSearchUserUsingAccount = { _, _ -> }, + toSearchUserUsingAccount = { keyword, accountKey -> + navigate( + Search( + accountType = Specific(accountKey), + keyword = keyword, + ), + ) + }, toStartMessage = {}, onFollowListClick = {}, onFansListClick = {}, ) } + + Route.RssList -> + RssListScreen( + toItem = { + navigate( + Route.RssTimeline( + url = it.url, + title = it.title, + id = it.id, + ), + ) + }, + onEdit = { + navigate( + Route.EditRssSource( + id = it.id, + ), + ) + }, + onAdd = { + navigate(Route.CreateRssSource) + }, + ) + + is Route.RssTimeline -> { + TimelineScreen( + RssTimelineTabItem( + feedUrl = route.url, + title = route.title.orEmpty(), + ), + ) + } } } } } manager.currentFloatingEntry?.let { entry -> - if (entry.route is Route.FloatingRoute) { - when (entry.route) { - is Route.AddReaction -> { - AddReactionSheet( - accountType = entry.route.accountType, - statusKey = entry.route.statusKey, - onBack = ::onBack, - ) - } + entry.Content { route -> + if (route is Route.FloatingRoute) { + when (route) { + is Route.AddReaction -> { + AddReactionSheet( + accountType = route.accountType, + statusKey = route.statusKey, + onBack = ::onBack, + ) + } - is Route.BlueskyReport -> { - BlueskyReportStatusDialog( - accountType = entry.route.accountType, - statusKey = entry.route.statusKey, - onBack = ::onBack, - ) - } + is Route.BlueskyReport -> { + BlueskyReportStatusDialog( + accountType = route.accountType, + statusKey = route.statusKey, + onBack = ::onBack, + ) + } - is Route.DeleteStatus -> { - DeleteStatusConfirmDialog( - accountType = entry.route.accountType, - statusKey = entry.route.statusKey, - onBack = ::onBack, - ) - } + is Route.DeleteStatus -> { + DeleteStatusConfirmDialog( + accountType = route.accountType, + statusKey = route.statusKey, + onBack = ::onBack, + ) + } - is Route.MastodonReport -> { - MastodonReportDialog( - accountType = entry.route.accountType, - statusKey = entry.route.statusKey, - onBack = ::onBack, - userKey = entry.route.userKey, - ) - } + is Route.MastodonReport -> { + MastodonReportDialog( + accountType = route.accountType, + statusKey = route.statusKey, + onBack = ::onBack, + userKey = route.userKey, + ) + } - is Route.MisskeyReport -> { - MisskeyReportDialog( - accountType = entry.route.accountType, - statusKey = entry.route.statusKey, - onBack = ::onBack, - userKey = entry.route.userKey, - ) - } + is Route.MisskeyReport -> { + MisskeyReportDialog( + accountType = route.accountType, + statusKey = route.statusKey, + onBack = ::onBack, + userKey = route.userKey, + ) + } + + is Route.AltText -> { + Flyout( + visible = true, + onDismissRequest = { + onBack() + }, + ) { + Text( + text = route.text, + modifier = Modifier.padding(16.dp), + ) + } + } + + Route.CreateRssSource -> { + EditRssSourceScreen( + onDismissRequest = ::onBack, + id = null, + ) + } - is Route.AltText -> { - Flyout( - visible = true, - onDismissRequest = { - onBack() - }, - ) { - Text( - text = entry.route.text, - modifier = Modifier.padding(16.dp), + is Route.EditRssSource -> { + EditRssSourceScreen( + onDismissRequest = ::onBack, + id = route.id, ) } } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt index e853b7223..fe5b3294a 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt @@ -60,7 +60,6 @@ import dev.dimension.flare.ui.presenter.home.DiscoverState import dev.dimension.flare.ui.presenter.home.SearchHistoryPresenter import dev.dimension.flare.ui.presenter.home.SearchHistoryState import dev.dimension.flare.ui.presenter.invoke -import dev.dimension.flare.ui.theme.screenHorizontalPadding import dev.dimension.flare.users import io.github.composefluent.ExperimentalFluentApi import io.github.composefluent.component.AutoSuggestBoxDefaults @@ -181,7 +180,6 @@ internal fun DiscoverScreen( rows = GridCells.Fixed(2), verticalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(horizontal = screenHorizontalPadding), ) { items( itemCount, @@ -224,7 +222,6 @@ internal fun DiscoverScreen( rows = GridCells.Fixed(2), verticalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(horizontal = screenHorizontalPadding), ) { items(10) { Card( @@ -252,7 +249,6 @@ internal fun DiscoverScreen( FlowRow( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.padding(horizontal = screenHorizontalPadding), ) { repeat( itemCount, diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/SearchScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/SearchScreen.kt index 7c630bf1c..2b6662b0c 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/SearchScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/SearchScreen.kt @@ -56,7 +56,6 @@ import dev.dimension.flare.ui.presenter.home.SearchHistoryState import dev.dimension.flare.ui.presenter.home.SearchPresenter import dev.dimension.flare.ui.presenter.home.SearchState import dev.dimension.flare.ui.presenter.invoke -import dev.dimension.flare.ui.theme.screenHorizontalPadding import dev.dimension.flare.users import io.github.composefluent.ExperimentalFluentApi import io.github.composefluent.component.AutoSuggestBoxDefaults @@ -178,7 +177,6 @@ fun SearchScreen( rows = GridCells.Fixed(2), verticalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(horizontal = screenHorizontalPadding), ) { items( itemCount, @@ -221,7 +219,6 @@ fun SearchScreen( rows = GridCells.Fixed(2), verticalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(horizontal = screenHorizontalPadding), ) { items(10) { Card( diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt index b83d7d576..f282a2195 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt @@ -1,6 +1,8 @@ package dev.dimension.flare.ui.screen.home import androidx.compose.animation.AnimatedVisibility +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.Box @@ -8,6 +10,7 @@ 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 @@ -89,11 +92,13 @@ internal fun TimelineScreen(tabItem: TimelineTabItem) { state.listState.onSuccess { AnimatedVisibility( state.showNewToots, - enter = slideInVertically { -it }, - exit = slideOutVertically { -it }, + enter = slideInVertically { -it } + fadeIn(), + exit = slideOutVertically { -it } + fadeOut(), modifier = Modifier - .align(Alignment.TopCenter), + .align(Alignment.TopCenter) + .padding(LocalWindowPadding.current) + .padding(top = 8.dp), ) { AccentButton( onClick = { @@ -162,7 +167,8 @@ internal fun timelineItemPresenter(timelineTabItem: TimelineTabItem): TimelineIt val lazyListState = rememberLazyStaggeredGridState() val isAtTheTop by remember { derivedStateOf { - lazyListState.firstVisibleItemIndex == 0 + lazyListState.firstVisibleItemIndex == 0 && + lazyListState.firstVisibleItemScrollOffset == 0 } } LaunchedEffect(isAtTheTop, showNewToots) { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/rss/EditRssSourceScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/rss/EditRssSourceScreen.kt new file mode 100644 index 000000000..8670677c5 --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/rss/EditRssSourceScreen.kt @@ -0,0 +1,520 @@ +package dev.dimension.flare.ui.screen.rss + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.delete +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material.CircularProgressIndicator +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.text.input.ImeAction +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.CircleChevronDown +import compose.icons.fontawesomeicons.solid.CircleXmark +import dev.dimension.flare.Res +import dev.dimension.flare.add_rss_source +import dev.dimension.flare.cancel +import dev.dimension.flare.data.model.RssTimelineTabItem +import dev.dimension.flare.data.repository.SettingsRepository +import dev.dimension.flare.edit_rss_source +import dev.dimension.flare.ok +import dev.dimension.flare.rss_sources_discovered_rss_sources +import dev.dimension.flare.rss_sources_pinned_in_tabs +import dev.dimension.flare.rss_sources_rss_hub_host_hint +import dev.dimension.flare.rss_sources_rss_hub_host_label +import dev.dimension.flare.rss_sources_title_label +import dev.dimension.flare.rss_sources_url_label +import dev.dimension.flare.ui.component.FAIcon +import dev.dimension.flare.ui.component.NetworkImage +import dev.dimension.flare.ui.component.listCard +import dev.dimension.flare.ui.model.UiRssSource +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.onError +import dev.dimension.flare.ui.model.onLoading +import dev.dimension.flare.ui.model.onSuccess +import dev.dimension.flare.ui.model.takeSuccess +import dev.dimension.flare.ui.presenter.home.rss.CheckRssSourcePresenter +import dev.dimension.flare.ui.presenter.home.rss.EditRssSourcePresenter +import dev.dimension.flare.ui.presenter.invoke +import io.github.composefluent.FluentTheme +import io.github.composefluent.component.CardExpanderItem +import io.github.composefluent.component.CheckBox +import io.github.composefluent.component.ContentDialog +import io.github.composefluent.component.ContentDialogButton +import io.github.composefluent.component.ProgressRing +import io.github.composefluent.component.Text +import io.github.composefluent.component.TextField +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import moe.tlaster.precompose.molecule.producePresenter +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject + +@Composable +fun EditRssSourceScreen( + onDismissRequest: () -> Unit, + id: Int?, + initialUrl: String? = null, +) { + val state by producePresenter("rss_source_edit_${id}_$initialUrl") { presenter(id, initialUrl) } + + ContentDialog( + title = + if (id == null) { + stringResource(Res.string.add_rss_source) + } else { + stringResource(Res.string.edit_rss_source) + }, + visible = true, + primaryButtonText = stringResource(Res.string.ok), + closeButtonText = stringResource(Res.string.cancel), + onButtonClick = { + when (it) { + ContentDialogButton.Primary -> { + state.inputState.onSuccess { inputState -> + when (inputState) { + is EditRssSourcePresenter.State.RssInputState.RssFeed -> { + inputState.save( + title = state.title.text.toString(), + ) + state.save( + sources = + listOf( + state.url.text.toString() to + state.title.text.toString().ifEmpty { + null + }, + ), + ) + onDismissRequest.invoke() + } + + is EditRssSourcePresenter.State.RssInputState.RssHub -> { + if (inputState.checkState.isSuccess && + inputState.checkState.takeSuccess() is CheckRssSourcePresenter.State.RssState.RssFeed + ) { + inputState.save( + title = state.title.text.toString(), + ) + state.save( + sources = + listOf( + inputState.actualUrl to + state.title.text.toString().ifEmpty { + null + }, + ), + ) + onDismissRequest.invoke() + } + } + + is EditRssSourcePresenter.State.RssInputState.RssSources -> { + if (state.selectedSource.isNotEmpty()) { + inputState.save( + sources = state.selectedSource, + ) + state.save( + sources = + state.selectedSource.map { source -> + source.url to source.title + }, + ) + onDismissRequest.invoke() + } + } + } + } + } + + ContentDialogButton.Secondary -> Unit + ContentDialogButton.Close -> { + onDismissRequest.invoke() + } + } + }, + content = { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + Column( + verticalArrangement = + androidx.compose.foundation.layout.Arrangement + .spacedBy(8.dp), + ) { + TextField( + state = state.url, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + header = { Text(text = stringResource(Res.string.rss_sources_url_label)) }, + lineLimits = TextFieldLineLimits.SingleLine, + trailing = { + state.checkState + .onSuccess { + when (it) { + is CheckRssSourcePresenter.State.RssState.RssFeed -> + FAIcon( + FontAwesomeIcons.Solid.CircleCheck, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + + CheckRssSourcePresenter.State.RssState.RssHub -> + FAIcon( + FontAwesomeIcons.Solid.CircleChevronDown, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + + is CheckRssSourcePresenter.State.RssState.RssSources -> + FAIcon( + FontAwesomeIcons.Solid.CircleChevronDown, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + } + }.onError { + FAIcon( + FontAwesomeIcons.Solid.CircleXmark, + contentDescription = null, + tint = FluentTheme.colors.system.critical, + modifier = Modifier.size(24.dp), + ) + }.onLoading { + ProgressRing( + modifier = Modifier.size(24.dp), + ) + } + }, + ) + + state.checkState.onSuccess { rssState -> + when (rssState) { + is CheckRssSourcePresenter.State.RssState.RssFeed -> { + TextField( + state = state.title, + header = { Text(text = stringResource(Res.string.rss_sources_title_label)) }, + modifier = Modifier.fillMaxWidth(), + lineLimits = TextFieldLineLimits.SingleLine, + keyboardOptions = + KeyboardOptions( + imeAction = ImeAction.Done, + autoCorrectEnabled = false, + ), + ) + } + + CheckRssSourcePresenter.State.RssState.RssHub -> { + TextField( + state = state.title, + header = { Text(text = stringResource(Res.string.rss_sources_title_label)) }, + modifier = Modifier.fillMaxWidth(), + lineLimits = TextFieldLineLimits.SingleLine, + ) + TextField( + state = state.rssHubHostText, + header = { Text(text = stringResource(Res.string.rss_sources_rss_hub_host_label)) }, + modifier = Modifier.fillMaxWidth(), + lineLimits = TextFieldLineLimits.SingleLine, + trailing = { + state.inputState.onSuccess { + if (it is EditRssSourcePresenter.State.RssInputState.RssHub) { + it.checkState + .onSuccess { + when (it) { + is CheckRssSourcePresenter.State.RssState.RssFeed -> + FAIcon( + FontAwesomeIcons.Solid.CircleCheck, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + + CheckRssSourcePresenter.State.RssState.RssHub -> + FAIcon( + FontAwesomeIcons.Solid.CircleChevronDown, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + + is CheckRssSourcePresenter.State.RssState.RssSources -> + FAIcon( + FontAwesomeIcons.Solid.CircleChevronDown, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + } + }.onError { + FAIcon( + FontAwesomeIcons.Solid.CircleXmark, + contentDescription = null, + tint = FluentTheme.colors.system.critical, + modifier = Modifier.size(24.dp), + ) + }.onLoading { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + ) + } + } else { + FAIcon( + FontAwesomeIcons.Solid.CircleXmark, + contentDescription = null, + tint = FluentTheme.colors.system.critical, + modifier = Modifier.size(24.dp), + ) + } + } + }, + placeholder = { + Text(text = stringResource(Res.string.rss_sources_rss_hub_host_hint)) + }, + ) + publicRssHubServer.forEachIndexed { index, it -> + CardExpanderItem( + heading = { + Text(text = it) + }, + onClick = { + state.rssHubHostText.edit { + delete(0, state.rssHubHostText.text.length) + append(it) + } + }, + ) + } + } + + is CheckRssSourcePresenter.State.RssState.RssSources -> { + Text(stringResource(Res.string.rss_sources_discovered_rss_sources)) + rssState.sources.forEachIndexed { index, source -> + CardExpanderItem( + icon = { + NetworkImage( + source.favIcon, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + }, + heading = { + Text(text = source.title.orEmpty()) + }, + caption = { + Text(text = source.url) + }, + trailing = { + CheckBox( + checked = state.selectedSource.contains(source), + onCheckStateChange = { + state.selectSource(source) + }, + ) + }, + modifier = + Modifier + .listCard( + index = index, + totalCount = rssState.sources.size, + ).clickable { + state.selectSource(source) + }, + ) + } + } + } + } + state.inputState.onSuccess { inputState -> + Row( + horizontalArrangement = + androidx.compose.foundation.layout.Arrangement + .spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .clickable { + state.setPinnedInTabs(!state.pinnedInTabs) + }, + ) { + CheckBox( + checked = state.pinnedInTabs, + onCheckStateChange = { state.setPinnedInTabs(it) }, + ) + Text( + text = stringResource(Res.string.rss_sources_pinned_in_tabs), + ) + } + + if (inputState is EditRssSourcePresenter.State.RssInputState.RssHub) { + LaunchedEffect(state.rssHubHostText.text) { + inputState.checkWithServer(state.rssHubHostText.text.toString()) + } + inputState.checkState.onSuccess { + if (it is CheckRssSourcePresenter.State.RssState.RssFeed) { + DisposableEffect(it) { + if (state.title.text.isEmpty()) { + state.title.edit { + append(it.title) + } + } + onDispose { + if (state.title.text == it) { + state.title.edit { + delete(0, it.title.length) + } + } + } + } + } + } + } + } + } + }, + ) +} + +@Composable +private fun presenter( + id: Int?, + initialUrl: String?, + settingsRepository: SettingsRepository = koinInject(), + appScope: CoroutineScope = koinInject(), +) = run { + val tabSettings by settingsRepository.tabSettings.collectAsUiState() + val state = remember(id) { EditRssSourcePresenter(id) }.invoke() + val titleText = rememberTextFieldState() + val urlText = rememberTextFieldState(initialText = initialUrl.orEmpty()) + val rssHubHostText = rememberTextFieldState() + val selectedSource = remember { mutableStateListOf() } + var pinnedInTabs by remember { mutableStateOf(false) } + state.data.onSuccess { + LaunchedEffect(Unit) { + titleText.edit { + append(it.title) + } + urlText.edit { + append(it.url) + } + } + } + + val currentTabs = + remember(state.data, tabSettings) { + state.data.flatMap { rssSource -> + tabSettings.map { + it.mainTabs + .filterIsInstance() + .filter { tab -> tab.feedUrl == rssSource.url } + .toImmutableList() + } + } + } + currentTabs.onSuccess { + LaunchedEffect(it) { + pinnedInTabs = it.isNotEmpty() + } + } + state.checkState.onSuccess { + if (it is CheckRssSourcePresenter.State.RssState.RssFeed) { + DisposableEffect(it) { + if (titleText.text.isEmpty()) { + titleText.edit { + append(it.title) + } + } + onDispose { + if (titleText.text == it) { + titleText.edit { + delete(0, it.title.length) + } + } + } + } + } + } + LaunchedEffect(urlText.text) { + state.checkUrl(urlText.text.toString()) + } + object : EditRssSourcePresenter.State by state { + val pinnedInTabs = pinnedInTabs + val selectedSource = selectedSource + val rssHubHostText = rssHubHostText + + fun setPinnedInTabs(value: Boolean) { + pinnedInTabs = value + } + + fun selectSource(source: UiRssSource) { + if (selectedSource.contains(source)) { + selectedSource.remove(source) + } else { + selectedSource.add(source) + } + } + + fun save(sources: List>) { + appScope.launch { + settingsRepository.updateTabSettings { + if (pinnedInTabs) { + copy( + mainTabs = + mainTabs + .filterNot { tab -> + tab is RssTimelineTabItem && sources.any { it.first == tab.feedUrl } + } + + sources.map { source -> + RssTimelineTabItem( + feedUrl = source.first, + title = source.second.orEmpty(), + ) + }, + ) + } else { + copy( + mainTabs = + mainTabs + .filterNot { tab -> + tab is RssTimelineTabItem && sources.any { it.first == tab.feedUrl } + }, + ) + } + } + } + } + + val title = titleText + val url = urlText + } +} + +private val publicRssHubServer = + listOf( + "https://rsshub.rssforever.com", + "https://hub.slarker.me", + "https://rsshub.pseudoyu.com", + ) diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/rss/RssListScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/rss/RssListScreen.kt new file mode 100644 index 000000000..afb9b44a8 --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/rss/RssListScreen.kt @@ -0,0 +1,281 @@ +package dev.dimension.flare.ui.screen.rss + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.EllipsisVertical +import compose.icons.fontawesomeicons.solid.File +import compose.icons.fontawesomeicons.solid.Pen +import compose.icons.fontawesomeicons.solid.Plus +import compose.icons.fontawesomeicons.solid.Thumbtack +import compose.icons.fontawesomeicons.solid.ThumbtackSlash +import compose.icons.fontawesomeicons.solid.Trash +import dev.dimension.flare.LocalWindowPadding +import dev.dimension.flare.Res +import dev.dimension.flare.add_rss_source +import dev.dimension.flare.data.model.RssTimelineTabItem +import dev.dimension.flare.data.repository.SettingsRepository +import dev.dimension.flare.delete_rss_source +import dev.dimension.flare.edit_rss_source +import dev.dimension.flare.empty_rss_sources +import dev.dimension.flare.more +import dev.dimension.flare.tab_settings_add +import dev.dimension.flare.tab_settings_remove +import dev.dimension.flare.ui.common.itemsIndexed +import dev.dimension.flare.ui.common.plus +import dev.dimension.flare.ui.component.FAIcon +import dev.dimension.flare.ui.component.NetworkImage +import dev.dimension.flare.ui.model.UiRssSource +import dev.dimension.flare.ui.model.collectAsUiState +import dev.dimension.flare.ui.model.map +import dev.dimension.flare.ui.model.onSuccess +import dev.dimension.flare.ui.presenter.home.rss.RssSourcesPresenter +import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.theme.screenHorizontalPadding +import io.github.composefluent.FluentTheme +import io.github.composefluent.component.AccentButton +import io.github.composefluent.component.CardExpanderItem +import io.github.composefluent.component.MenuFlyoutContainer +import io.github.composefluent.component.MenuFlyoutItem +import io.github.composefluent.component.SubtleButton +import io.github.composefluent.component.Text +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import moe.tlaster.precompose.molecule.producePresenter +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject + +@Composable +internal fun RssListScreen( + toItem: (UiRssSource) -> Unit, + onEdit: (UiRssSource) -> Unit, + onAdd: () -> Unit, + modifier: Modifier = Modifier, +) { + val state by producePresenter { presenter() } + + LazyColumn( + modifier = + modifier + .padding(horizontal = screenHorizontalPadding), + verticalArrangement = Arrangement.spacedBy(2.dp), + contentPadding = LocalWindowPadding.current + PaddingValues(top = 8.dp), + ) { + item { + Box( + contentAlignment = Alignment.CenterEnd, + modifier = Modifier.fillParentMaxWidth(), + ) { + AccentButton( + onClick = { + onAdd.invoke() + }, + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Plus, + contentDescription = stringResource(Res.string.add_rss_source), + ) + Text( + text = stringResource(Res.string.add_rss_source), + ) + } + } + } + item { + Spacer(modifier = Modifier.height(6.dp)) + } + itemsIndexed( + state.sources, + emptyContent = { + Column( + modifier = + Modifier + .fillParentMaxSize(), + verticalArrangement = + Arrangement.spacedBy( + 8.dp, + Alignment.CenterVertically, + ), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + FAIcon( + FontAwesomeIcons.Solid.File, + contentDescription = stringResource(Res.string.empty_rss_sources), + modifier = Modifier.size(48.dp), + ) + Text( + text = stringResource(Res.string.empty_rss_sources), + style = FluentTheme.typography.subtitle, + ) + } + }, + ) { index, itemCount, it -> + CardExpanderItem( + onClick = { + toItem.invoke(it) + }, + heading = { + it.title?.let { + Text(text = it) + } + }, + caption = { + Text(it.url) + }, + icon = { + NetworkImage( + model = it.favIcon, + contentDescription = it.title, + modifier = Modifier.size(24.dp), + ) + }, + trailing = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + state.currentTabs.onSuccess { currentTabs -> + val isPinned = + remember( + it, + currentTabs, + ) { + currentTabs.contains(it.url) + } + SubtleButton( + iconOnly = true, + onClick = { + if (isPinned) { + state.unpinSource(it) + } else { + state.pinSource(it) + } + }, + ) { + AnimatedContent(isPinned) { + if (it) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.ThumbtackSlash, + contentDescription = stringResource(Res.string.tab_settings_add), + ) + } else { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Thumbtack, + contentDescription = stringResource(Res.string.tab_settings_remove), + ) + } + } + } + } + + MenuFlyoutContainer( + flyout = { + MenuFlyoutItem( + text = { + Text( + text = stringResource(Res.string.edit_rss_source), + ) + }, + onClick = { + onEdit.invoke(it) + isFlyoutVisible = false + }, + icon = { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Pen, + contentDescription = stringResource(Res.string.edit_rss_source), + ) + }, + ) + MenuFlyoutItem( + text = { + Text( + text = stringResource(Res.string.delete_rss_source), + color = FluentTheme.colors.system.critical, + ) + }, + onClick = { + state.delete(it.id) + isFlyoutVisible = false + }, + icon = { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Trash, + contentDescription = stringResource(Res.string.delete_rss_source), + tint = FluentTheme.colors.system.critical, + ) + }, + ) + }, + ) { + SubtleButton(onClick = { isFlyoutVisible = true }) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.EllipsisVertical, + contentDescription = stringResource(Res.string.more), + ) + } + } + } + }, + ) + } + } +} + +@Composable +private fun presenter( + settingsRepository: SettingsRepository = koinInject(), + appScope: CoroutineScope = koinInject(), +) = run { + val state = remember { RssSourcesPresenter() }.invoke() + val tabSettings by settingsRepository.tabSettings.collectAsUiState() + val currentTabs = + tabSettings.map { + it.mainTabs + .filterIsInstance() + .map { it.feedUrl } + .toImmutableList() + } + object : RssSourcesPresenter.State by state { + val currentTabs = currentTabs + + fun pinSource(source: UiRssSource) { + appScope.launch { + settingsRepository.updateTabSettings { + copy( + mainTabs = + mainTabs + RssTimelineTabItem(source), + ) + } + } + } + + fun unpinSource(source: UiRssSource) { + appScope.launch { + settingsRepository.updateTabSettings { + copy( + mainTabs = + mainTabs.filterNot { it is RssTimelineTabItem && it.feedUrl == source.url }, + ) + } + } + } + } +} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt index 0630a9c1b..5be5269ec 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt @@ -101,9 +101,6 @@ internal fun ServiceSelectScreen( } } LazyStatusVerticalStaggeredGrid( - modifier = - Modifier - .padding(horizontal = 16.dp), columns = StaggeredGridCells.Adaptive(300.dp), horizontalArrangement = Arrangement.spacedBy( diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt index 7f4233ca2..49e8ab0ee 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt @@ -40,6 +40,7 @@ import dev.dimension.flare.data.model.AvatarShape import dev.dimension.flare.data.model.LocalAppearanceSettings import dev.dimension.flare.data.model.TabSettings import dev.dimension.flare.data.model.Theme +import dev.dimension.flare.data.model.VideoAutoplay import dev.dimension.flare.data.repository.SettingsRepository import dev.dimension.flare.home_login import dev.dimension.flare.model.AccountType @@ -78,6 +79,8 @@ import dev.dimension.flare.settings_appearance_theme_dark import dev.dimension.flare.settings_appearance_theme_description import dev.dimension.flare.settings_appearance_theme_light import dev.dimension.flare.settings_appearance_title +import dev.dimension.flare.settings_appearance_video_autoplay +import dev.dimension.flare.settings_appearance_video_autoplay_description import dev.dimension.flare.settings_privacy_policy import dev.dimension.flare.settings_status_appearance_subtitle import dev.dimension.flare.settings_status_appearance_title @@ -518,6 +521,28 @@ internal fun SettingsScreen(toLogin: () -> Unit) { }, ) } + ExpanderItem( + heading = { + Text(stringResource(Res.string.settings_appearance_video_autoplay)) + }, + caption = { + Text(stringResource(Res.string.settings_appearance_video_autoplay_description)) + }, + trailing = { + Switcher( + checked = LocalAppearanceSettings.current.videoAutoplay in listOf(VideoAutoplay.ALWAYS, VideoAutoplay.WIFI), + { + state.appearanceState.updateSettings { + copy( + videoAutoplay = + if (it) VideoAutoplay.ALWAYS else VideoAutoplay.NEVER, + ) + } + }, + textBefore = true, + ) + }, + ) } Header(stringResource(Res.string.settings_about_title)) diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.jvm.kt b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.jvm.kt index 9b20aab29..94143bb74 100644 --- a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.jvm.kt +++ b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.jvm.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.shape.CornerBasedShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.unit.dp import io.github.composefluent.FluentTheme internal actual object PlatformShapes { @@ -24,24 +23,24 @@ internal actual object PlatformShapes { @Composable get() = RoundedCornerShape( - topStart = 8.dp, - topEnd = 8.dp, - bottomStart = 4.dp, - bottomEnd = 4.dp, + topStart = FluentTheme.cornerRadius.overlay, + topEnd = FluentTheme.cornerRadius.overlay, + bottomStart = FluentTheme.cornerRadius.control, + bottomEnd = FluentTheme.cornerRadius.control, ) actual val bottomCardShape: Shape @Composable get() = RoundedCornerShape( - topStart = 4.dp, - topEnd = 4.dp, - bottomStart = 8.dp, - bottomEnd = 8.dp, + topStart = FluentTheme.cornerRadius.control, + topEnd = FluentTheme.cornerRadius.control, + bottomStart = FluentTheme.cornerRadius.overlay, + bottomEnd = FluentTheme.cornerRadius.overlay, ) actual val listCardContainerShape: CornerBasedShape @Composable - get() = RoundedCornerShape(4.dp) + get() = RoundedCornerShape(FluentTheme.cornerRadius.overlay) actual val listCardItemShape: CornerBasedShape @Composable - get() = RoundedCornerShape(4.dp) + get() = RoundedCornerShape(FluentTheme.cornerRadius.control) } From 5ed1fca63eaf1e2b44ac394b4da15d95c174f87d Mon Sep 17 00:00:00 2001 From: Tlaster Date: Wed, 20 Aug 2025 17:29:44 +0900 Subject: [PATCH 12/33] make compose dialog into floating route --- desktopApp/build.gradle.kts | 3 +- desktopApp/proguard-rules.pro | 70 +- .../main/kotlin/dev/dimension/flare/App.kt | 9 +- .../dev/dimension/flare/ui/route/Route.kt | 8 +- .../dev/dimension/flare/ui/route/Router.kt | 629 ++++++++++-------- .../dimension/flare/ui/route/WindowRouter.kt | 45 +- .../flare/ui/screen/compose/ComposeDialog.kt | 31 +- .../ui/screen/media/StatusMediaScreen.kt | 3 +- .../dimension/flare/ui/theme/FlareTheme.kt | 8 + 9 files changed, 461 insertions(+), 345 deletions(-) diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index b52f34373..b9055b042 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -18,7 +18,6 @@ dependencies { implementation(compose.ui) implementation(compose.desktop.common) implementation(compose.components.resources) - implementation(compose.components.uiToolingPreview) implementation(libs.precompose.molecule) implementation(libs.lifecycle.runtime.compose) implementation(compose.desktop.currentOs) @@ -58,7 +57,7 @@ compose.desktop { buildTypes { release { proguard { - version.set("7.6.1") + version.set("7.7.0") this.configurationFiles.from( file("proguard-rules.pro") ) diff --git a/desktopApp/proguard-rules.pro b/desktopApp/proguard-rules.pro index 9e7ebd674..7ac8848e2 100644 --- a/desktopApp/proguard-rules.pro +++ b/desktopApp/proguard-rules.pro @@ -4,7 +4,7 @@ -dontwarn org.bouncycastle.** -dontwarn org.openjsse.** -dontwarn androidx.compose.runtime.** - +-dontnote ** -dontwarn org.slf4j.impl.StaticLoggerBinder -keep class app.bsky.** { *; } @@ -27,19 +27,22 @@ void traceEventEnd(); } --keep class kotlinx.coroutines.internal.MainDispatcherFactory { *; } --keep class kotlinx.coroutines.swing.SwingDispatcherFactory { *; } - -dontwarn javax.annotation.** -keep class * extends androidx.room.RoomDatabase { void (); } --keep @kotlinx.serialization.Serializable class * {*;} - # Keep `Companion` object fields of serializable classes. # This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects. -if @kotlinx.serialization.Serializable class ** -keepclassmembers class <1> { - static <1>$Companion Companion; + static <1>$* Companion; +} + +# Keep names for named companion object from obfuscation +# Names of a class and of a field are important in lookup of named companion in runtime +-keepnames @kotlinx.serialization.internal.NamedCompanion class * +-if @kotlinx.serialization.internal.NamedCompanion class * +-keepclassmembernames class * { + static <1> *; } # Keep `serializer()` on companion objects (both default and named) of serializable classes. @@ -77,4 +80,55 @@ private ** descriptor; } --keep class com.mayakapps.compose.** { *; } \ No newline at end of file +-keep class com.mayakapps.compose.** { *; } + +-dontwarn dev.dimension.flare.data.network.xqt.model.** +-dontwarn com.jetbrains.** +-dontwarn com.sun.jna.** + +-keep class dev.whyoleg.cryptography.* +-keep class dev.whyoleg.cryptography.providers.jdk.* + +# ServiceLoader support +-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} +-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} + +# Most of volatile fields are updated with AFU and should not be mangled +-keepclassmembers class kotlinx.coroutines.** { + volatile ; +} + +-keep class io.ktor.serialization.** { *; } +-keep class kotlinx.coroutines.** { *; } +-keep class coil3.** { *; } +-keep class okio.** { *; } +-keep class io.ktor.serialization.** { *; } +-keep @kotlinx.serialization.Serializable class * {*;} +-keep class io.ktor.** { *; } +-keep class kotlin.reflect.jvm.internal.** { *; } +#-keep class kotlin.Metadata { *; } +#-keepattributes Kotlin +-keepattributes Annotation +-keepattributes RuntimeVisibleAnnotations +-keep class nl.adaptivity.xmlutil.** { *; } +-keep class * extends coil3.util.DecoderServiceLoaderTarget { *; } +-keep class * extends coil3.util.FetcherServiceLoaderTarget { *; } +-keep class dev.dimension.flare.data.network.rss.model.** { *; } +# Same story for the standard library's SafeContinuation that also uses AtomicReferenceFieldUpdater +-keepclassmembers class kotlin.coroutines.SafeContinuation { + volatile ; +} + +# These classes are only required by kotlinx.coroutines.debug.internal.AgentPremain, which is only loaded when +# kotlinx-coroutines-core is used as a Java agent, so these are not needed in contexts where ProGuard is used. +-dontwarn java.lang.instrument.ClassFileTransformer +-dontwarn sun.misc.SignalHandler +-dontwarn java.lang.instrument.Instrumentation +-dontwarn sun.misc.Signal + +# Only used in `kotlinx.coroutines.internal.ExceptionsConstructor`. +# The case when it is not available is hidden in a `try`-`catch`, as well as a check for Android. +-dontwarn java.lang.ClassValue + +# An annotation used for build tooling, won't be directly accessed. +-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement \ No newline at end of file diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt index b9bf93f19..2ef4be3a1 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt @@ -100,10 +100,15 @@ internal fun FlareApp(onWindowRoute: (Route.WindowRoute) -> Unit) { key = tabs, topLevelRoutes = tabs.all.map { getRoute(it.tabItem) }, ) + val currentRoute = stackManager.currentRoute fun navigate(route: Route) { - stackManager.push(route) + if (route is Route.WindowRoute) { + onWindowRoute.invoke(route) + } else { + stackManager.push(route) + } } fun goBack() { @@ -152,7 +157,7 @@ internal fun FlareApp(onWindowRoute: (Route.WindowRoute) -> Unit) { Spacer(modifier = Modifier.height(8.dp)) Button( onClick = { - onWindowRoute.invoke( + navigate( Route.Compose.New( accountType = AccountType.Specific(user.key), ), diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt index 376cdc27c..2b54e5496 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt @@ -153,25 +153,25 @@ internal sealed interface Route { data class Reply( val accountKey: MicroBlogKey, val statusKey: MicroBlogKey, - ) : WindowRoute + ) : FloatingRoute @Serializable data class Quote( val accountKey: MicroBlogKey, val statusKey: MicroBlogKey, - ) : WindowRoute + ) : FloatingRoute @Serializable data class New( val accountType: AccountType, - ) : WindowRoute + ) : FloatingRoute @Serializable data class VVOReplyComment( val accountKey: MicroBlogKey, val replyTo: MicroBlogKey, val rootId: String, - ) : WindowRoute + ) : FloatingRoute } @Serializable diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt index 02b14fa39..932143cd3 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt @@ -3,19 +3,22 @@ package dev.dimension.flare.ui.route import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import dev.dimension.flare.common.OnDeepLink import dev.dimension.flare.data.model.Bluesky.FeedTabItem import dev.dimension.flare.data.model.IconType.Material import dev.dimension.flare.data.model.ListTimelineTabItem import dev.dimension.flare.data.model.RssTimelineTabItem import dev.dimension.flare.data.model.TabMetaData import dev.dimension.flare.data.model.TitleType +import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.AccountType.Specific +import dev.dimension.flare.ui.presenter.compose.ComposeStatus import dev.dimension.flare.ui.route.Route.Profile import dev.dimension.flare.ui.route.Route.Search import dev.dimension.flare.ui.route.Route.Timeline +import dev.dimension.flare.ui.screen.compose.ComposeDialog import dev.dimension.flare.ui.screen.feeds.FeedListScreen import dev.dimension.flare.ui.screen.home.DiscoverScreen import dev.dimension.flare.ui.screen.home.NotificationScreen @@ -24,6 +27,8 @@ import dev.dimension.flare.ui.screen.home.ProfileWithUserNameAndHostDeeplinkRout import dev.dimension.flare.ui.screen.home.SearchScreen import dev.dimension.flare.ui.screen.home.TimelineScreen import dev.dimension.flare.ui.screen.list.AllListScreen +import dev.dimension.flare.ui.screen.media.RawMediaScreen +import dev.dimension.flare.ui.screen.media.StatusMediaScreen import dev.dimension.flare.ui.screen.rss.EditRssSourceScreen import dev.dimension.flare.ui.screen.rss.RssListScreen import dev.dimension.flare.ui.screen.serviceselect.ServiceSelectScreen @@ -36,10 +41,10 @@ import dev.dimension.flare.ui.screen.status.action.BlueskyReportStatusDialog import dev.dimension.flare.ui.screen.status.action.DeleteStatusConfirmDialog import dev.dimension.flare.ui.screen.status.action.MastodonReportDialog import dev.dimension.flare.ui.screen.status.action.MisskeyReportDialog +import io.github.composefluent.component.FluentDialog import io.github.composefluent.component.Flyout import io.github.composefluent.component.Text -@OptIn(ExperimentalComposeUiApi::class) @Composable internal fun Router( manager: StackManager, @@ -57,306 +62,372 @@ internal fun Router( fun onBack() { manager.pop() } + OnDeepLink { + val route = Route.parse(it) + if (route != null) { + navigate(route) + } + route != null + } AnimatedContent( manager.currentScreenEntry, modifier = modifier, ) { entry -> entry.Content { route -> - if (route is Route.ScreenRoute) { - when (route) { - is Route.AllLists -> { - AllListScreen( - accountType = route.accountType, - onAddList = { - }, - toList = { - navigate( - Timeline( - ListTimelineTabItem( - account = route.accountType, - listId = it.id, - metaData = - TabMetaData( - title = TitleType.Text(it.title), - icon = Material(Material.MaterialIcon.List), - ), - ), - ), - ) - }, - ) - } + RouteContent( + route = route, + onBack = ::onBack, + navigate = ::navigate, + ) + } + } + manager.currentFloatingEntry?.let { entry -> + entry.Content { route -> + RouteContent( + route = route, + onBack = ::onBack, + navigate = ::navigate, + ) + } + } +} - is Route.BlueskyFeeds -> { - FeedListScreen( - accountType = route.accountType, - toFeed = { - navigate( - Timeline( - FeedTabItem( - account = route.accountType, - uri = it.id, - metaData = - TabMetaData( - title = TitleType.Text(it.title), - icon = Material(Material.MaterialIcon.Feeds), - ), - ), - ), - ) - }, - ) - } +@Composable +internal fun RouteContent( + route: Route, + onBack: () -> Unit, + navigate: (Route) -> Unit, +) { + when (route) { + is Route.RssDetail -> Unit + is Route.AddReaction -> { + AddReactionSheet( + accountType = route.accountType, + statusKey = route.statusKey, + onBack = onBack, + ) + } - is Route.DirectMessage -> { - Text("route") - } + is Route.BlueskyReport -> { + BlueskyReportStatusDialog( + accountType = route.accountType, + statusKey = route.statusKey, + onBack = onBack, + ) + } - is Route.Discover -> { - DiscoverScreen( - accountType = route.accountType, - toUser = { - navigate( - Profile( - accountType = route.accountType, - userKey = it, - ), - ) - }, - toSearch = { - navigate( - Search( - accountType = route.accountType, - keyword = it, - ), - ) - }, - ) - } - - is Route.Search -> { - SearchScreen( - initialQuery = route.keyword, - accountType = route.accountType, - toUser = { - navigate( - Profile( - accountType = route.accountType, - userKey = it, - ), - ) - }, - ) - } + is Route.DeleteStatus -> { + DeleteStatusConfirmDialog( + accountType = route.accountType, + statusKey = route.statusKey, + onBack = onBack, + ) + } - is Route.MeRoute -> { - ProfileScreen( - accountType = route.accountType, - userKey = null, - ) - } + is Route.MastodonReport -> { + MastodonReportDialog( + accountType = route.accountType, + statusKey = route.statusKey, + onBack = onBack, + userKey = route.userKey, + ) + } - is Route.Notification -> { - NotificationScreen( - accountType = route.accountType, - ) - } + is Route.MisskeyReport -> { + MisskeyReportDialog( + accountType = route.accountType, + statusKey = route.statusKey, + onBack = onBack, + userKey = route.userKey, + ) + } - is Route.Profile -> { - ProfileScreen( - accountType = route.accountType, - userKey = route.userKey, - toEditAccountList = {}, - toSearchUserUsingAccount = { keyword, accountKey -> - navigate( - Search( - accountType = Specific(accountKey), - keyword = keyword, - ), - ) - }, - toStartMessage = {}, - onFollowListClick = {}, - onFansListClick = {}, - ) - } - - Route.ServiceSelect -> { - ServiceSelectScreen( - onBack = ::onBack, - onVVO = { - }, - onXQT = { - }, - ) - } - - Route.Settings -> { - SettingsScreen( - toLogin = { - navigate(Route.ServiceSelect) - }, - ) - } - - is Timeline -> { - TimelineScreen( - route.tabItem, - ) - } - - is Route.StatusDetail -> { - StatusScreen( - statusKey = route.statusKey, - accountType = route.accountType, - ) - } + is Route.AltText -> { + Flyout( + visible = true, + onDismissRequest = { + onBack() + }, + ) { + Text( + text = route.text, + modifier = Modifier.padding(16.dp), + ) + } + } - is Route.VVO.CommentDetail -> { - VVOCommentScreen( - commentKey = route.statusKey, - accountType = route.accountType, - ) - } + Route.CreateRssSource -> { + EditRssSourceScreen( + onDismissRequest = onBack, + id = null, + ) + } - is Route.VVO.StatusDetail -> { - VVOStatusScreen( - statusKey = route.statusKey, - accountType = route.accountType, - ) - } + is Route.EditRssSource -> { + EditRssSourceScreen( + onDismissRequest = onBack, + id = route.id, + ) + } + is Route.Compose.New -> + FluentDialog( + visible = true, + ) { + ComposeDialog( + onBack = onBack, + accountType = route.accountType, + ) + } - is Route.ProfileWithNameAndHost -> { - ProfileWithUserNameAndHostDeeplinkRoute( - userName = route.userName, - host = route.host, - accountType = route.accountType, - onBack = ::onBack, - toEditAccountList = {}, - toSearchUserUsingAccount = { keyword, accountKey -> - navigate( - Search( - accountType = Specific(accountKey), - keyword = keyword, - ), - ) - }, - toStartMessage = {}, - onFollowListClick = {}, - onFansListClick = {}, - ) - } - - Route.RssList -> - RssListScreen( - toItem = { - navigate( - Route.RssTimeline( - url = it.url, - title = it.title, - id = it.id, + is Route.Compose.Quote -> + FluentDialog(visible = true) { + ComposeDialog( + onBack = onBack, + status = ComposeStatus.Quote(route.statusKey), + accountType = AccountType.Specific(accountKey = route.accountKey), + ) + } + + is Route.Compose.Reply -> + FluentDialog(visible = true) { + ComposeDialog( + onBack = onBack, + status = ComposeStatus.Reply(route.statusKey), + accountType = AccountType.Specific(accountKey = route.accountKey), + ) + } + + is Route.Compose.VVOReplyComment -> + FluentDialog(visible = true) { + ComposeDialog( + onBack = onBack, + accountType = AccountType.Specific(accountKey = route.accountKey), + status = ComposeStatus.VVOComment(route.replyTo, route.rootId), + ) + } + + is Route.AllLists -> { + AllListScreen( + accountType = route.accountType, + onAddList = { + }, + toList = { + navigate( + Timeline( + ListTimelineTabItem( + account = route.accountType, + listId = it.id, + metaData = + TabMetaData( + title = TitleType.Text(it.title), + icon = Material(Material.MaterialIcon.List), ), - ) - }, - onEdit = { - navigate( - Route.EditRssSource( - id = it.id, + ), + ), + ) + }, + ) + } + + is Route.BlueskyFeeds -> { + FeedListScreen( + accountType = route.accountType, + toFeed = { + navigate( + Timeline( + FeedTabItem( + account = route.accountType, + uri = it.id, + metaData = + TabMetaData( + title = TitleType.Text(it.title), + icon = Material(Material.MaterialIcon.Feeds), ), - ) - }, - onAdd = { - navigate(Route.CreateRssSource) - }, - ) - - is Route.RssTimeline -> { - TimelineScreen( - RssTimelineTabItem( - feedUrl = route.url, - title = route.title.orEmpty(), ), - ) - } - } - } + ), + ) + }, + ) } - } - manager.currentFloatingEntry?.let { entry -> - entry.Content { route -> - if (route is Route.FloatingRoute) { - when (route) { - is Route.AddReaction -> { - AddReactionSheet( - accountType = route.accountType, - statusKey = route.statusKey, - onBack = ::onBack, - ) - } - is Route.BlueskyReport -> { - BlueskyReportStatusDialog( - accountType = route.accountType, - statusKey = route.statusKey, - onBack = ::onBack, - ) - } + is Route.DirectMessage -> { + Text("route") + } - is Route.DeleteStatus -> { - DeleteStatusConfirmDialog( + is Route.Discover -> { + DiscoverScreen( + accountType = route.accountType, + toUser = { + navigate( + Profile( accountType = route.accountType, - statusKey = route.statusKey, - onBack = ::onBack, - ) - } - - is Route.MastodonReport -> { - MastodonReportDialog( + userKey = it, + ), + ) + }, + toSearch = { + navigate( + Search( accountType = route.accountType, - statusKey = route.statusKey, - onBack = ::onBack, - userKey = route.userKey, - ) - } - - is Route.MisskeyReport -> { - MisskeyReportDialog( + keyword = it, + ), + ) + }, + ) + } + + is Route.Search -> { + SearchScreen( + initialQuery = route.keyword, + accountType = route.accountType, + toUser = { + navigate( + Profile( accountType = route.accountType, - statusKey = route.statusKey, - onBack = ::onBack, - userKey = route.userKey, - ) - } - - is Route.AltText -> { - Flyout( - visible = true, - onDismissRequest = { - onBack() - }, - ) { - Text( - text = route.text, - modifier = Modifier.padding(16.dp), - ) - } - } - - Route.CreateRssSource -> { - EditRssSourceScreen( - onDismissRequest = ::onBack, - id = null, - ) - } - - is Route.EditRssSource -> { - EditRssSourceScreen( - onDismissRequest = ::onBack, - id = route.id, - ) - } - } - } + userKey = it, + ), + ) + }, + ) + } + + is Route.MeRoute -> { + ProfileScreen( + accountType = route.accountType, + userKey = null, + ) + } + + is Route.Notification -> { + NotificationScreen( + accountType = route.accountType, + ) } + + is Route.Profile -> { + ProfileScreen( + accountType = route.accountType, + userKey = route.userKey, + toEditAccountList = {}, + toSearchUserUsingAccount = { keyword, accountKey -> + navigate( + Search( + accountType = Specific(accountKey), + keyword = keyword, + ), + ) + }, + toStartMessage = {}, + onFollowListClick = {}, + onFansListClick = {}, + ) + } + + Route.ServiceSelect -> { + ServiceSelectScreen( + onBack = onBack, + onVVO = { + }, + onXQT = { + }, + ) + } + + Route.Settings -> { + SettingsScreen( + toLogin = { + navigate(Route.ServiceSelect) + }, + ) + } + + is Timeline -> { + TimelineScreen( + route.tabItem, + ) + } + + is Route.StatusDetail -> { + StatusScreen( + statusKey = route.statusKey, + accountType = route.accountType, + ) + } + + is Route.VVO.CommentDetail -> { + VVOCommentScreen( + commentKey = route.statusKey, + accountType = route.accountType, + ) + } + + is Route.VVO.StatusDetail -> { + VVOStatusScreen( + statusKey = route.statusKey, + accountType = route.accountType, + ) + } + + is Route.ProfileWithNameAndHost -> { + ProfileWithUserNameAndHostDeeplinkRoute( + userName = route.userName, + host = route.host, + accountType = route.accountType, + onBack = onBack, + toEditAccountList = {}, + toSearchUserUsingAccount = { keyword, accountKey -> + navigate( + Search( + accountType = Specific(accountKey), + keyword = keyword, + ), + ) + }, + toStartMessage = {}, + onFollowListClick = {}, + onFansListClick = {}, + ) + } + + Route.RssList -> + RssListScreen( + toItem = { + navigate( + Route.RssTimeline( + url = it.url, + title = it.title, + id = it.id, + ), + ) + }, + onEdit = { + navigate( + Route.EditRssSource( + id = it.id, + ), + ) + }, + onAdd = { + navigate(Route.CreateRssSource) + }, + ) + + is Route.RssTimeline -> { + TimelineScreen( + RssTimelineTabItem( + feedUrl = route.url, + title = route.title.orEmpty(), + ), + ) + } + + is Route.RawImage -> + RawMediaScreen(url = route.rawImage) + is Route.StatusMedia -> + StatusMediaScreen( + accountType = route.accountType, + statusKey = route.statusKey, + index = route.index, + ) } } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/WindowRouter.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/WindowRouter.kt index 06730c639..4ac762447 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/WindowRouter.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/WindowRouter.kt @@ -2,50 +2,15 @@ package dev.dimension.flare.ui.route import androidx.compose.runtime.Composable import androidx.compose.ui.window.FrameWindowScope -import dev.dimension.flare.model.AccountType -import dev.dimension.flare.ui.presenter.compose.ComposeStatus -import dev.dimension.flare.ui.screen.compose.ComposeDialog -import dev.dimension.flare.ui.screen.media.RawMediaScreen -import dev.dimension.flare.ui.screen.media.StatusMediaScreen @Composable internal fun FrameWindowScope.WindowRouter( route: Route.WindowRoute, onBack: () -> Unit, ) { - when (route) { - is Route.RawImage -> - RawMediaScreen(url = route.rawImage) - is Route.StatusMedia -> - StatusMediaScreen( - accountType = route.accountType, - statusKey = route.statusKey, - index = route.index, - window = window, - ) - - is Route.Compose.New -> - ComposeDialog( - onBack = onBack, - accountType = route.accountType, - ) - is Route.Compose.Quote -> - ComposeDialog( - onBack = onBack, - status = ComposeStatus.Quote(route.statusKey), - accountType = AccountType.Specific(accountKey = route.accountKey), - ) - is Route.Compose.Reply -> - ComposeDialog( - onBack = onBack, - status = ComposeStatus.Reply(route.statusKey), - accountType = AccountType.Specific(accountKey = route.accountKey), - ) - is Route.Compose.VVOReplyComment -> - ComposeDialog( - onBack = onBack, - accountType = AccountType.Specific(accountKey = route.accountKey), - status = ComposeStatus.VVOComment(route.replyTo, route.rootId), - ) - } + RouteContent( + route = route, + onBack = onBack, + navigate = {}, + ) } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt index b2a21415c..f3ec506df 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt @@ -46,7 +46,6 @@ import compose.icons.fontawesomeicons.solid.Plus import compose.icons.fontawesomeicons.solid.SquarePollHorizontal import compose.icons.fontawesomeicons.solid.TriangleExclamation import compose.icons.fontawesomeicons.solid.Xmark -import dev.dimension.flare.LocalWindowPadding import dev.dimension.flare.Res import dev.dimension.flare.common.FileItem import dev.dimension.flare.compose_content_warning_hint @@ -74,6 +73,7 @@ import dev.dimension.flare.misskey_visibility_public_description import dev.dimension.flare.misskey_visibility_specified import dev.dimension.flare.misskey_visibility_specified_description import dev.dimension.flare.model.AccountType +import dev.dimension.flare.navigate_back import dev.dimension.flare.ui.component.AvatarComponent import dev.dimension.flare.ui.component.EmojiPicker import dev.dimension.flare.ui.component.FAIcon @@ -98,10 +98,12 @@ import io.github.composefluent.component.AccentButton import io.github.composefluent.component.Button import io.github.composefluent.component.CheckBox import io.github.composefluent.component.FlyoutContainer +import io.github.composefluent.component.FlyoutPlacement import io.github.composefluent.component.MenuFlyoutContainer import io.github.composefluent.component.MenuFlyoutItem import io.github.composefluent.component.PillButton import io.github.composefluent.component.RadioButton +import io.github.composefluent.component.SubtleButton import io.github.composefluent.component.Text import io.github.composefluent.component.TextField import io.github.composefluent.surface.Card @@ -147,13 +149,20 @@ fun ComposeDialog( focusRequester.requestFocus() } } - Column( - modifier = - modifier - .padding( - LocalWindowPadding.current, - ).padding(top = 8.dp), - ) { + Column { + SubtleButton( + onClick = onBack, + modifier = + Modifier + .align(Alignment.Start) + .padding(8.dp), + iconOnly = true, + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Xmark, + contentDescription = stringResource(Res.string.navigate_back), + ) + } Column( verticalArrangement = Arrangement.spacedBy(8.dp), ) { @@ -554,6 +563,7 @@ fun ComposeDialog( state.state.emojiState.onSuccess { emojis -> AnimatedVisibility(emojis.size > 0) { FlyoutContainer( + placement = FlyoutPlacement.Bottom, flyout = { val actualAccountType = remember( @@ -570,7 +580,10 @@ fun ComposeDialog( accountType = actualAccountType ?: accountType, modifier = Modifier - .padding(8.dp), + .sizeIn( + maxWidth = 300.dp, + maxHeight = 200.dp, + ), ) }, ) { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt index 5b7749404..384e93643 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt @@ -55,6 +55,7 @@ import dev.dimension.flare.ui.model.onSuccess import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.presenter.status.StatusPresenter import dev.dimension.flare.ui.presenter.status.StatusState +import dev.dimension.flare.ui.theme.LocalComposeWindow import io.github.composefluent.FluentTheme import io.github.composefluent.component.GridViewItem import io.github.composefluent.component.HorizontalFlipView @@ -76,9 +77,9 @@ internal fun StatusMediaScreen( accountType: AccountType, statusKey: MicroBlogKey, index: Int, - window: ComposeWindow, ) { val scope = rememberCoroutineScope() + val window = LocalComposeWindow.current val state by producePresenter( "StatusMediaScreen_${accountType}_$statusKey", ) { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt index 2c109ec49..b3b3ab84a 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt @@ -17,7 +17,9 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.ComposeWindow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.ContentDrawScope import androidx.compose.ui.node.DelegatableNode @@ -43,6 +45,11 @@ import kotlinx.coroutines.launch import org.apache.commons.lang3.SystemUtils import org.koin.compose.koinInject +internal val LocalComposeWindow = + staticCompositionLocalOf { + error("No ComposeWindow provided") + } + @OptIn(ExperimentalFluentApi::class) @Composable internal fun FrameWindowScope.FlareTheme( @@ -85,6 +92,7 @@ internal fun FrameWindowScope.FlareTheme( } else { PaddingValues(0.dp) }, + LocalComposeWindow provides window, ) { Box( modifier = From 059b7899f7316cd7a3c6322f46be59d74505667c Mon Sep 17 00:00:00 2001 From: Tlaster Date: Wed, 20 Aug 2025 18:10:25 +0900 Subject: [PATCH 13/33] add sandbox helpers --- desktopApp/build.gradle.kts | 5 ++ desktopApp/download-sql-bundle.gradle.kts | 68 +++++++++++++++++++ .../main/kotlin/dev/dimension/flare/Main.kt | 2 + .../dimension/flare/common/SandboxHelper.kt | 11 +++ .../flare/common/FileSystemUtilsExt.kt | 3 +- 5 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 desktopApp/download-sql-bundle.gradle.kts create mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/common/SandboxHelper.kt diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index b9055b042..0efa91f65 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -98,3 +98,8 @@ val macExtraPlistKeys: String """ +extra["sqliteVersion"] = libs.versions.sqlite.get() +extra["sqliteOsArch"] = "osx_arm64" + +apply(from = File(projectDir, "download-sql-bundle.gradle.kts")) + diff --git a/desktopApp/download-sql-bundle.gradle.kts b/desktopApp/download-sql-bundle.gradle.kts new file mode 100644 index 000000000..6b06ab4ad --- /dev/null +++ b/desktopApp/download-sql-bundle.gradle.kts @@ -0,0 +1,68 @@ +import java.net.URL +import java.nio.file.Files +import java.nio.file.StandardCopyOption.REPLACE_EXISTING +import java.util.zip.ZipFile + +fun Project.installSqliteJni( + version: String = "2.5.2", + osArch: String = "osx_arm64", + destDir: File = + project.layout.projectDirectory + .dir("resources") + .asFile, +) { + val baseUrl = + "https://dl.google.com/android/maven2/androidx/sqlite/sqlite-bundled-jvm/$version" + val jarName = "sqlite-bundled-jvm-$version.jar" + val jarUrl = "$baseUrl/$jarName" + + val workDir = + layout.buildDirectory + .dir("sqliteJni/$version") + .get() + .asFile + val jarFile = workDir.resolve(jarName) + val destFile = destDir.resolve("libsqliteJni.dylib") + + val taskName = "installSqliteJni_$version" + tasks.register(taskName) { + group = "setup" + description = "Download $jarName and copy natives/$osArch/libsqliteJni.dylib to resources." + + inputs.property("version", version) + inputs.property("osArch", osArch) + outputs.file(destFile) + + doLast { + workDir.mkdirs() + destDir.mkdirs() + + if (!jarFile.exists()) { + logger.lifecycle("Downloading $jarUrl …") + URL(jarUrl).openStream().use { input -> + Files.copy(input, jarFile.toPath(), REPLACE_EXISTING) + } + } else { + logger.lifecycle("Using cached ${jarFile.absolutePath}") + } + + val entryPath = "natives/$osArch/libsqliteJni.dylib" + ZipFile(jarFile).use { zip -> + val entry = + zip.getEntry(entryPath) + ?: throw GradleException("Entry not found in jar: $entryPath") + zip.getInputStream(entry).use { zin -> + Files.copy(zin, destFile.toPath(), REPLACE_EXISTING) + } + } + logger.lifecycle("Copied -> ${destFile.relativeTo(projectDir)}") + } + } + + tasks.named("compileKotlin").configure { dependsOn(taskName) } +} + +val v = (findProperty("sqliteVersion") as String?) ?: "2.5.2" +val arch = (findProperty("sqliteOsArch") as String?) ?: "osx_arm64" + +installSqliteJni(version = v, osArch = arch, destDir = file("resources")) diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt index d1753d9d3..2e663fe84 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt @@ -16,6 +16,7 @@ import coil3.ImageLoader import coil3.compose.setSingletonImageLoaderFactory import coil3.request.crossfade import dev.dimension.flare.common.DeeplinkHandler +import dev.dimension.flare.common.SandboxHelper import dev.dimension.flare.di.KoinHelper import dev.dimension.flare.di.desktopModule import dev.dimension.flare.ui.route.FloatingWindowState @@ -30,6 +31,7 @@ import org.koin.core.context.startKoin import java.awt.Desktop fun main(args: Array) { + SandboxHelper.configureSQLiteDriver() startKoin { modules(desktopModule + KoinHelper.modules()) } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/common/SandboxHelper.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/common/SandboxHelper.kt new file mode 100644 index 000000000..e46f6cc40 --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/common/SandboxHelper.kt @@ -0,0 +1,11 @@ +package dev.dimension.flare.common + +object SandboxHelper { + fun configureSQLiteDriver() { + val isSandboxed = System.getenv("APP_SANDBOX_CONTAINER_ID") != null + if (isSandboxed) { + val resourcesPath = System.getProperty("compose.application.resources.dir") + System.setProperty("androidx.sqlite.driver.bundled.path", resourcesPath) + } + } +} diff --git a/shared/src/jvmMain/kotlin/dev/dimension/flare/common/FileSystemUtilsExt.kt b/shared/src/jvmMain/kotlin/dev/dimension/flare/common/FileSystemUtilsExt.kt index d471194b5..83de44f99 100644 --- a/shared/src/jvmMain/kotlin/dev/dimension/flare/common/FileSystemUtilsExt.kt +++ b/shared/src/jvmMain/kotlin/dev/dimension/flare/common/FileSystemUtilsExt.kt @@ -1,6 +1,5 @@ package dev.dimension.flare.common -import okio.FileSystem import org.apache.commons.lang3.SystemUtils import java.io.File @@ -13,7 +12,7 @@ public object FileSystemUtilsExt { } public fun flareCacheDirectory(): File = - File(FileSystem.SYSTEM_TEMPORARY_DIRECTORY.toFile(), ".flare").also { + File(SystemUtils.getJavaIoTmpDir(), ".flare").also { if (!it.exists()) { it.mkdirs() } From 88b6f4ee6310c9e75a1ac02be9590a9c45847aea Mon Sep 17 00:00:00 2001 From: Tlaster Date: Thu, 21 Aug 2025 14:19:03 +0900 Subject: [PATCH 14/33] [WIP] testflight release --- app/build.gradle.kts | 6 +- desktopApp/.gitignore | 4 +- desktopApp/build.gradle.kts | 58 +++++++-- desktopApp/download-sql-bundle.gradle.kts | 68 ----------- desktopApp/install-native-libs.gradle.kts | 114 ++++++++++++++++++ .../flare/ui/screen/compose/ComposeDialog.kt | 71 +++++++++-- .../flare/ui/theme/DarkThemeDetector.kt | 32 ----- .../dimension/flare/ui/theme/FlareTheme.kt | 4 +- gradle/libs.versions.toml | 2 + shared/ui/component/build.gradle.kts | 2 +- 10 files changed, 229 insertions(+), 132 deletions(-) delete mode 100644 desktopApp/download-sql-bundle.gradle.kts create mode 100644 desktopApp/install-native-libs.gradle.kts delete mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/DarkThemeDetector.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index df0db15dc..9699da656 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,6 @@ import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsPlugin import com.google.gms.googleservices.GoogleServicesPlugin +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import java.util.Properties plugins { @@ -20,6 +21,8 @@ if (project.file("google-services.json").exists()) { apply() } +kotlin.compilerOptions.jvmTarget.set(JvmTarget.fromTarget(libs.versions.java.get())) + android { namespace = "dev.dimension.flare" compileSdk = libs.versions.compileSdk.get().toInt() @@ -84,9 +87,6 @@ android { sourceCompatibility = JavaVersion.toVersion(libs.versions.java.get()) targetCompatibility = JavaVersion.toVersion(libs.versions.java.get()) } - kotlinOptions { - jvmTarget = libs.versions.java.get() - } buildFeatures { compose = true buildConfig = true diff --git a/desktopApp/.gitignore b/desktopApp/.gitignore index 5676bcbad..c52e36b3b 100644 --- a/desktopApp/.gitignore +++ b/desktopApp/.gitignore @@ -1 +1,3 @@ -resources/* \ No newline at end of file +resources/* +signing/* +signing.properties \ No newline at end of file diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 0efa91f65..f7c5eb402 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -1,5 +1,5 @@ - import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import java.util.Properties plugins { alias(libs.plugins.kotlin.jvm) @@ -23,7 +23,7 @@ dependencies { implementation(compose.desktop.currentOs) implementation(libs.kotlinx.coroutines.swing) implementation(libs.fluent.ui) - implementation(libs.jSystemThemeDetector) + implementation("io.github.kdroidfilter:platformtools.darkmodedetector:0.5.0") implementation(libs.composeIcons.fontAwesome) implementation(libs.lifecycle.viewmodel.compose) implementation(libs.bundles.coil3) @@ -42,14 +42,44 @@ compose.desktop { mainClass = "dev.dimension.flare.MainKt" nativeDistributions { - targetFormats(TargetFormat.Pkg, TargetFormat.Msi, TargetFormat.Deb) + targetFormats(TargetFormat.Pkg) packageName = "dev.dimension.flare" - packageVersion = "1.0.0" + packageVersion = System.getenv("BUILD_VERSION") ?: "1.0.0" macOS { + + val file = project.file("signing.properties") + val hasSigningProps = file.exists() + + packageBuildVersion = System.getenv("BUILD_NUMBER") ?: "12" bundleID = "dev.dimension.flare" + minimumSystemVersion = "12.0" + appStore = hasSigningProps + + jvmArgs( + "-Djna.nosys=false", + "-Djna.nounpack=true", +// "-Djna.boot.library=\$APP_ROOT/Contents/app/resources:\$APP_ROOT/Contents/app:\$APP_ROOT/Contents/runtime/Contents/MacOS:\$APP_ROOT/Contents/runtime/Contents/Home/lib:\$APP_ROOT/Contents/runtime/Contents/Home/lib/server:/System/Library/Frameworks/Foundation.framework/Foundation", +// "-Djna.library.path=\$APP_ROOT/Contents/app/resources:\$APP_ROOT/Contents/app:\$APP_ROOT/Contents/runtime/Contents/MacOS:\$APP_ROOT/Contents/runtime/Contents/Home/lib:\$APP_ROOT/Contents/runtime/Contents/Home/lib/server:/System/Library/Frameworks/Foundation.framework/Foundation", + ) + infoPlist { extraKeysRawXml = macExtraPlistKeys } + + if (hasSigningProps) { + val signingProp = Properties() + signingProp.load(file.inputStream()) + signing { + sign.set(true) + identity.set(signingProp.getProperty("identity")) + } + + entitlementsFile.set(project.file(signingProp.getProperty("entitlementsFile"))) + runtimeEntitlementsFile.set(project.file(signingProp.getProperty("runtimeEntitlementsFile"))) + provisioningProfile.set(project.file(signingProp.getProperty("provisioningProfile"))) + runtimeProvisioningProfile.set(project.file(signingProp.getProperty("runtimeProvisioningProfile"))) + } + // iconFile.set(project.file("src/jvmMain/resources/icon/ic_launcher.icns")) } appResourcesRootDir.set(file("resources")) @@ -57,10 +87,11 @@ compose.desktop { buildTypes { release { proguard { - version.set("7.7.0") - this.configurationFiles.from( - file("proguard-rules.pro") - ) + this.isEnabled.set(false) +// version.set("7.7.0") +// this.configurationFiles.from( +// file("proguard-rules.pro") +// ) } } } @@ -71,9 +102,6 @@ compose.resources { packageOfResClass = "dev.dimension.flare" } -tasks.withType { - systemProperty("compose.application.resources.dir", file("resources").absolutePath) -} ktlint { version.set(libs.versions.ktlint) @@ -96,10 +124,14 @@ val macExtraPlistKeys: String + ITSAppUsesNonExemptEncryption + """ + extra["sqliteVersion"] = libs.versions.sqlite.get() extra["sqliteOsArch"] = "osx_arm64" +extra["composeMediaPlayerVersion"] = libs.versions.composemediaplayer.get() +extra["nativeDestDir"] = "resources/macos-arm64" -apply(from = File(projectDir, "download-sql-bundle.gradle.kts")) - +apply(from = File(projectDir, "install-native-libs.gradle.kts")) diff --git a/desktopApp/download-sql-bundle.gradle.kts b/desktopApp/download-sql-bundle.gradle.kts deleted file mode 100644 index 6b06ab4ad..000000000 --- a/desktopApp/download-sql-bundle.gradle.kts +++ /dev/null @@ -1,68 +0,0 @@ -import java.net.URL -import java.nio.file.Files -import java.nio.file.StandardCopyOption.REPLACE_EXISTING -import java.util.zip.ZipFile - -fun Project.installSqliteJni( - version: String = "2.5.2", - osArch: String = "osx_arm64", - destDir: File = - project.layout.projectDirectory - .dir("resources") - .asFile, -) { - val baseUrl = - "https://dl.google.com/android/maven2/androidx/sqlite/sqlite-bundled-jvm/$version" - val jarName = "sqlite-bundled-jvm-$version.jar" - val jarUrl = "$baseUrl/$jarName" - - val workDir = - layout.buildDirectory - .dir("sqliteJni/$version") - .get() - .asFile - val jarFile = workDir.resolve(jarName) - val destFile = destDir.resolve("libsqliteJni.dylib") - - val taskName = "installSqliteJni_$version" - tasks.register(taskName) { - group = "setup" - description = "Download $jarName and copy natives/$osArch/libsqliteJni.dylib to resources." - - inputs.property("version", version) - inputs.property("osArch", osArch) - outputs.file(destFile) - - doLast { - workDir.mkdirs() - destDir.mkdirs() - - if (!jarFile.exists()) { - logger.lifecycle("Downloading $jarUrl …") - URL(jarUrl).openStream().use { input -> - Files.copy(input, jarFile.toPath(), REPLACE_EXISTING) - } - } else { - logger.lifecycle("Using cached ${jarFile.absolutePath}") - } - - val entryPath = "natives/$osArch/libsqliteJni.dylib" - ZipFile(jarFile).use { zip -> - val entry = - zip.getEntry(entryPath) - ?: throw GradleException("Entry not found in jar: $entryPath") - zip.getInputStream(entry).use { zin -> - Files.copy(zin, destFile.toPath(), REPLACE_EXISTING) - } - } - logger.lifecycle("Copied -> ${destFile.relativeTo(projectDir)}") - } - } - - tasks.named("compileKotlin").configure { dependsOn(taskName) } -} - -val v = (findProperty("sqliteVersion") as String?) ?: "2.5.2" -val arch = (findProperty("sqliteOsArch") as String?) ?: "osx_arm64" - -installSqliteJni(version = v, osArch = arch, destDir = file("resources")) diff --git a/desktopApp/install-native-libs.gradle.kts b/desktopApp/install-native-libs.gradle.kts new file mode 100644 index 000000000..2cc8d938e --- /dev/null +++ b/desktopApp/install-native-libs.gradle.kts @@ -0,0 +1,114 @@ +import org.gradle.api.Task +import org.gradle.api.tasks.TaskProvider +import java.io.File +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Duration +import java.util.zip.ZipFile +import java.nio.file.Files +import java.nio.file.StandardCopyOption.REPLACE_EXISTING + +val httpClient: HttpClient = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(Duration.ofSeconds(20)) + .build() + +fun Project.registerExtractFromJarTask( + taskName: String, + jarUrl: String, + cacheSubDir: String, + jarFileName: String, + entryPathInJar: String, + destFile: File +): TaskProvider { + val workDir = layout.buildDirectory.dir("native-extract/$cacheSubDir").get().asFile + val jarFile = workDir.resolve(jarFileName) + + return tasks.register(taskName) { + group = "setup" + description = "Download $jarFileName and extract $entryPathInJar -> $destFile" + + inputs.property("jarUrl", jarUrl) + inputs.property("entryPathInJar", entryPathInJar) + outputs.file(destFile) + + doLast { + workDir.mkdirs() + destFile.parentFile.mkdirs() + + if (!jarFile.exists()) { + logger.lifecycle("Downloading $jarUrl …") + val request = HttpRequest.newBuilder(URI.create(jarUrl)) + .GET() + .timeout(Duration.ofMinutes(2)) + .build() + + val tmp = Files.createTempFile(workDir.toPath(), "download-", ".jar") + val resp = httpClient.send(request, HttpResponse.BodyHandlers.ofFile(tmp)) + if (resp.statusCode() !in 200..299) { + try { + Files.deleteIfExists(tmp) + } catch (_: Exception) { + } + throw GradleException("Failed to download $jarUrl: HTTP ${resp.statusCode()}") + } + Files.move(tmp, jarFile.toPath(), REPLACE_EXISTING) + } else { + logger.lifecycle("Using cached ${jarFile.absolutePath}") + } + + val entryPath = entryPathInJar + ZipFile(jarFile).use { zip -> + val entry = zip.getEntry(entryPath) + ?: throw GradleException("Entry not found in jar: $entryPath") + zip.getInputStream(entry).use { zin -> + Files.copy(zin, destFile.toPath(), REPLACE_EXISTING) + } + } + logger.lifecycle("Extracted -> ${destFile.toPath().toAbsolutePath().normalize()}") + } + } +} + +val nativeDestDirPath = (findProperty("nativeDestDir") as String?) ?: "resources/macos-arm64" +val nativeDestDir = file(nativeDestDirPath) + +val sqliteVersion = (findProperty("sqliteVersion") as String?) ?: "2.5.2" +val sqliteOsArch = (findProperty("sqliteOsArch") as String?) ?: "osx_arm64" + +val sqliteJarName = "sqlite-bundled-jvm-$sqliteVersion.jar" +val sqliteJarUrl = + "https://dl.google.com/android/maven2/androidx/sqlite/sqlite-bundled-jvm/$sqliteVersion/$sqliteJarName" +val sqliteEntry = "natives/$sqliteOsArch/libsqliteJni.dylib" + +val sqliteTask = registerExtractFromJarTask( + taskName = "installSqliteJni_$sqliteVersion", + jarUrl = sqliteJarUrl, + cacheSubDir = "sqliteJni/$sqliteVersion", + jarFileName = sqliteJarName, + entryPathInJar = sqliteEntry, + destFile = nativeDestDir.resolve("libsqliteJni.dylib") +) + +val cmpVersion = (findProperty("composeMediaPlayerVersion") as String?) ?: "0.8.1" +val cmpJarName = "composemediaplayer-jvm-$cmpVersion.jar" +val cmpJarUrl = + "https://repo1.maven.org/maven2/io/github/kdroidfilter/composemediaplayer-jvm/$cmpVersion/$cmpJarName" +val cmpEntry = "darwin-aarch64/libNativeVideoPlayer.dylib" + +val cmpTask = registerExtractFromJarTask( + taskName = "installNativeVideoPlayer_$cmpVersion", + jarUrl = cmpJarUrl, + cacheSubDir = "composeMediaPlayer/$cmpVersion", + jarFileName = cmpJarName, + entryPathInJar = cmpEntry, + destFile = nativeDestDir.resolve("libNativeVideoPlayer.dylib") +) + +val installNativeLibs = tasks.register("installNativeLibs") { + group = "setup" + description = "Install sqliteJni + NativeVideoPlayer dylibs to $nativeDestDirPath" + dependsOn(sqliteTask, cmpTask) +} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt index f3ec506df..43ca1baf7 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt @@ -1,6 +1,8 @@ package dev.dimension.flare.ui.screen.compose import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll @@ -35,6 +37,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.TextRange import androidx.compose.ui.unit.dp import compose.icons.FontAwesomeIcons @@ -49,6 +52,7 @@ import compose.icons.fontawesomeicons.solid.Xmark import dev.dimension.flare.Res import dev.dimension.flare.common.FileItem import dev.dimension.flare.compose_content_warning_hint +import dev.dimension.flare.compose_hint import dev.dimension.flare.compose_media_sensitive import dev.dimension.flare.compose_poll_expiration_12_hours import dev.dimension.flare.compose_poll_expiration_1_day @@ -94,6 +98,7 @@ import dev.dimension.flare.ui.presenter.compose.ComposeStatus import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.theme.screenHorizontalPadding import io.github.composefluent.FluentTheme +import io.github.composefluent.LocalTextStyle import io.github.composefluent.component.AccentButton import io.github.composefluent.component.Button import io.github.composefluent.component.CheckBox @@ -106,6 +111,7 @@ import io.github.composefluent.component.RadioButton import io.github.composefluent.component.SubtleButton import io.github.composefluent.component.Text import io.github.composefluent.component.TextField +import io.github.composefluent.component.TextFieldDefaults import io.github.composefluent.surface.Card import kotlinx.collections.immutable.toImmutableList import moe.tlaster.precompose.molecule.producePresenter @@ -239,17 +245,45 @@ fun ComposeDialog( Column( verticalArrangement = Arrangement.spacedBy(8.dp), ) { - TextField( - state = it.textFieldState, + Box( + contentAlignment = Alignment.CenterStart, modifier = Modifier - .fillMaxWidth() - .focusRequester( - focusRequester = contentWarningFocusRequester, + .padding( + horizontal = screenHorizontalPadding, ), - placeholder = { - Text(text = stringResource(Res.string.compose_content_warning_hint)) - }, + ) { + androidx.compose.animation.AnimatedVisibility( + it.textFieldState.text.isEmpty(), + enter = fadeIn(), + exit = fadeOut(), + ) { + Text( + text = stringResource(Res.string.compose_content_warning_hint), + color = TextFieldDefaults.defaultTextFieldColors().default.placeholderColor, + ) + } + BasicTextField( + state = it.textFieldState, + modifier = + Modifier + .fillMaxWidth() + .focusRequester( + focusRequester = contentWarningFocusRequester, + ), +// placeholder = { +// Text(text = stringResource(Res.string.compose_content_warning_hint)) +// }, + textStyle = LocalTextStyle.current.copy(color = FluentTheme.colors.text.text.primary), + cursorBrush = SolidColor(FluentTheme.colors.text.text.primary), + ) + } + Box( + modifier = + Modifier + .height(1.dp) + .fillMaxWidth() + .background(FluentTheme.colors.stroke.divider.default), ) // HorizontalDivider() } @@ -258,19 +292,32 @@ fun ComposeDialog( Box( modifier = Modifier - .fillMaxWidth(), + .padding( + horizontal = screenHorizontalPadding, + ).fillMaxWidth(), + contentAlignment = Alignment.TopStart, ) { + androidx.compose.animation.AnimatedVisibility( + state.textFieldState.text.isEmpty(), + enter = fadeIn(), + exit = fadeOut(), + ) { + Text( + text = stringResource(Res.string.compose_hint), + color = TextFieldDefaults.defaultTextFieldColors().default.placeholderColor, + ) + } BasicTextField( state = state.textFieldState, modifier = Modifier .fillMaxWidth() .heightIn(min = 120.dp) - .padding( - horizontal = screenHorizontalPadding, - ).focusRequester( + .focusRequester( focusRequester = focusRequester, ), + textStyle = LocalTextStyle.current.copy(color = FluentTheme.colors.text.text.primary), + cursorBrush = SolidColor(FluentTheme.colors.text.text.primary), // placeholder = { // Text(text = stringResource(Res.string.compose_hint)) // }, diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/DarkThemeDetector.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/DarkThemeDetector.kt deleted file mode 100644 index c4d8a4c6a..000000000 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/DarkThemeDetector.kt +++ /dev/null @@ -1,32 +0,0 @@ -package dev.dimension.flare.ui.theme - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import com.jthemedetecor.OsThemeDetector -import java.util.function.Consumer - -private val detector = OsThemeDetector.getDetector() - -@Composable -internal fun isSystemInDarkTheme(): Boolean { - var isDarkTheme by remember { - mutableStateOf(detector.isDark) - } - val listener = - remember { - Consumer { isDark -> - isDarkTheme = isDark - } - } - DisposableEffect(Unit) { - detector.registerListener(listener) - onDispose { - detector.removeListener(listener) - } - } - return isDarkTheme -} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt index b3b3ab84a..0d066b915 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.interaction.FocusInteraction import androidx.compose.foundation.interaction.HoverInteraction import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.interaction.PressInteraction -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -41,6 +40,7 @@ import io.github.composefluent.ExperimentalFluentApi import io.github.composefluent.FluentTheme import io.github.composefluent.darkColors import io.github.composefluent.lightColors +import io.github.kdroidfilter.platformtools.darkmodedetector.isSystemInDarkMode import kotlinx.coroutines.launch import org.apache.commons.lang3.SystemUtils import org.koin.compose.koinInject @@ -181,7 +181,7 @@ private class FluentIndication( @Composable private fun isDarkTheme(): Boolean = LocalAppearanceSettings.current.theme == Theme.DARK || - (LocalAppearanceSettings.current.theme == Theme.SYSTEM && isSystemInDarkTheme()) + (LocalAppearanceSettings.current.theme == Theme.SYSTEM && isSystemInDarkMode()) @Composable internal fun ProvideThemeSettings(content: @Composable () -> Unit) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 780206a31..7ec5449d8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,6 +2,7 @@ clikt = "5.0.3" collection = "1.5.0" compileSdk = "36" +composemediaplayer = "0.8.1" haze = "1.6.10" lifecycleViewmodelComposeVersion = "2.9.2" minSdk = "23" @@ -59,6 +60,7 @@ androidx-collection = { module = "androidx.collection:collection", version.ref = bluesky = { module = "moe.tlaster.ozone:bluesky", version.ref = "bluesky" } bluesky-oauth = { module = "moe.tlaster.ozone:oauth", version.ref = "bluesky" } clikt = { module = "com.github.ajalt.clikt:clikt", version.ref = "clikt" } +composemediaplayer = { module = "io.github.kdroidfilter:composemediaplayer", version.ref = "composemediaplayer" } core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" } haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } diff --git a/shared/ui/component/build.gradle.kts b/shared/ui/component/build.gradle.kts index 517e2f295..c924f6a13 100644 --- a/shared/ui/component/build.gradle.kts +++ b/shared/ui/component/build.gradle.kts @@ -65,7 +65,7 @@ kotlin { implementation(libs.fluent.ui) implementation(libs.koin.compose) implementation(compose("org.jetbrains.compose.material3:material3-window-size-class")) - implementation("io.github.kdroidfilter:composemediaplayer:0.8.1") + implementation(libs.composemediaplayer) implementation(libs.androidx.collection) } } From 7c47983a74e100d0579170c0cf5c73f4adb2a835 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Thu, 21 Aug 2025 19:49:09 +0900 Subject: [PATCH 15/33] add in app notification --- build.gradle.kts | 7 - desktopApp/build.gradle.kts | 15 +- desktopApp/install-native-libs.gradle.kts | 82 ++++--- .../main/composeResources/values/strings.xml | 4 +- .../main/kotlin/dev/dimension/flare/App.kt | 6 + .../main/kotlin/dev/dimension/flare/Main.kt | 2 + .../flare/common/ImageClipboardManager.kt | 114 ++++++++++ .../flare/common/ImageDragAndDropTarget.kt | 60 +++++ .../dimension/flare/common/SandboxHelper.kt | 2 + .../dev/dimension/flare/di/DesktopModule.kt | 22 +- .../ui/component/ComposeInAppNotification.kt | 138 ++++++++++++ .../flare/ui/screen/compose/ComposeDialog.kt | 120 +++++++--- .../ui/screen/settings/SettingsScreen.kt | 209 +++++++++++++++++- .../dimension/flare/ui/theme/FlareTheme.kt | 4 +- gradle/libs.versions.toml | 3 + .../dev/dimension/flare/common/Event.kt | 6 +- .../component/status/StatusMediaComponent.kt | 8 +- 17 files changed, 692 insertions(+), 110 deletions(-) create mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/common/ImageClipboardManager.kt create mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/common/ImageDragAndDropTarget.kt create mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/ComposeInAppNotification.kt rename {app/src/main/java => shared/src/androidJvmMain/kotlin}/dev/dimension/flare/common/Event.kt (76%) diff --git a/build.gradle.kts b/build.gradle.kts index a923b40dd..0ee9ee759 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -36,11 +36,4 @@ allprojects { ) } } - configurations.all { - resolutionStrategy.eachDependency { - if (requested.group == "net.java.dev.jna" && requested.name == "jna") { - useTarget("net.java.dev.jna:jna:5.12.1") - } - } - } } diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index f7c5eb402..09cfda647 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -1,5 +1,5 @@ -import org.jetbrains.compose.desktop.application.dsl.TargetFormat import java.util.Properties +import org.jetbrains.compose.desktop.application.dsl.TargetFormat plugins { alias(libs.plugins.kotlin.jvm) @@ -23,7 +23,6 @@ dependencies { implementation(compose.desktop.currentOs) implementation(libs.kotlinx.coroutines.swing) implementation(libs.fluent.ui) - implementation("io.github.kdroidfilter:platformtools.darkmodedetector:0.5.0") implementation(libs.composeIcons.fontAwesome) implementation(libs.lifecycle.viewmodel.compose) implementation(libs.bundles.coil3) @@ -35,6 +34,8 @@ dependencies { implementation(libs.commons.lang3) implementation(libs.zoomable) implementation(libs.datastore) + implementation(libs.filekit.dialogs.compose) + implementation(libs.filekit.coil) } compose.desktop { @@ -46,18 +47,17 @@ compose.desktop { packageName = "dev.dimension.flare" packageVersion = System.getenv("BUILD_VERSION") ?: "1.0.0" macOS { - val file = project.file("signing.properties") val hasSigningProps = file.exists() - packageBuildVersion = System.getenv("BUILD_NUMBER") ?: "12" bundleID = "dev.dimension.flare" minimumSystemVersion = "12.0" appStore = hasSigningProps jvmArgs( - "-Djna.nosys=false", - "-Djna.nounpack=true", + "-Dapple.awt.application.appearance=system", +// "-Djna.nosys=false", +// "-Djna.nounpack=true", // "-Djna.boot.library=\$APP_ROOT/Contents/app/resources:\$APP_ROOT/Contents/app:\$APP_ROOT/Contents/runtime/Contents/MacOS:\$APP_ROOT/Contents/runtime/Contents/Home/lib:\$APP_ROOT/Contents/runtime/Contents/Home/lib/server:/System/Library/Frameworks/Foundation.framework/Foundation", // "-Djna.library.path=\$APP_ROOT/Contents/app/resources:\$APP_ROOT/Contents/app:\$APP_ROOT/Contents/runtime/Contents/MacOS:\$APP_ROOT/Contents/runtime/Contents/Home/lib:\$APP_ROOT/Contents/runtime/Contents/Home/lib/server:/System/Library/Frameworks/Foundation.framework/Foundation", ) @@ -82,6 +82,9 @@ compose.desktop { // iconFile.set(project.file("src/jvmMain/resources/icon/ic_launcher.icns")) } + linux { + modules("jdk.security.auth") + } appResourcesRootDir.set(file("resources")) } buildTypes { diff --git a/desktopApp/install-native-libs.gradle.kts b/desktopApp/install-native-libs.gradle.kts index 2cc8d938e..3f5530057 100644 --- a/desktopApp/install-native-libs.gradle.kts +++ b/desktopApp/install-native-libs.gradle.kts @@ -5,15 +5,17 @@ import java.net.URI import java.net.http.HttpClient import java.net.http.HttpRequest import java.net.http.HttpResponse -import java.time.Duration -import java.util.zip.ZipFile import java.nio.file.Files import java.nio.file.StandardCopyOption.REPLACE_EXISTING +import java.time.Duration +import java.util.zip.ZipFile -val httpClient: HttpClient = HttpClient.newBuilder() - .followRedirects(HttpClient.Redirect.NORMAL) - .connectTimeout(Duration.ofSeconds(20)) - .build() +val httpClient: HttpClient = + HttpClient + .newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(Duration.ofSeconds(20)) + .build() fun Project.registerExtractFromJarTask( taskName: String, @@ -21,9 +23,13 @@ fun Project.registerExtractFromJarTask( cacheSubDir: String, jarFileName: String, entryPathInJar: String, - destFile: File + destFile: File, ): TaskProvider { - val workDir = layout.buildDirectory.dir("native-extract/$cacheSubDir").get().asFile + val workDir = + layout.buildDirectory + .dir("native-extract/$cacheSubDir") + .get() + .asFile val jarFile = workDir.resolve(jarFileName) return tasks.register(taskName) { @@ -40,10 +46,12 @@ fun Project.registerExtractFromJarTask( if (!jarFile.exists()) { logger.lifecycle("Downloading $jarUrl …") - val request = HttpRequest.newBuilder(URI.create(jarUrl)) - .GET() - .timeout(Duration.ofMinutes(2)) - .build() + val request = + HttpRequest + .newBuilder(URI.create(jarUrl)) + .GET() + .timeout(Duration.ofMinutes(2)) + .build() val tmp = Files.createTempFile(workDir.toPath(), "download-", ".jar") val resp = httpClient.send(request, HttpResponse.BodyHandlers.ofFile(tmp)) @@ -61,8 +69,9 @@ fun Project.registerExtractFromJarTask( val entryPath = entryPathInJar ZipFile(jarFile).use { zip -> - val entry = zip.getEntry(entryPath) - ?: throw GradleException("Entry not found in jar: $entryPath") + val entry = + zip.getEntry(entryPath) + ?: throw GradleException("Entry not found in jar: $entryPath") zip.getInputStream(entry).use { zin -> Files.copy(zin, destFile.toPath(), REPLACE_EXISTING) } @@ -83,14 +92,15 @@ val sqliteJarUrl = "https://dl.google.com/android/maven2/androidx/sqlite/sqlite-bundled-jvm/$sqliteVersion/$sqliteJarName" val sqliteEntry = "natives/$sqliteOsArch/libsqliteJni.dylib" -val sqliteTask = registerExtractFromJarTask( - taskName = "installSqliteJni_$sqliteVersion", - jarUrl = sqliteJarUrl, - cacheSubDir = "sqliteJni/$sqliteVersion", - jarFileName = sqliteJarName, - entryPathInJar = sqliteEntry, - destFile = nativeDestDir.resolve("libsqliteJni.dylib") -) +val sqliteTask = + registerExtractFromJarTask( + taskName = "installSqliteJni_$sqliteVersion", + jarUrl = sqliteJarUrl, + cacheSubDir = "sqliteJni/$sqliteVersion", + jarFileName = sqliteJarName, + entryPathInJar = sqliteEntry, + destFile = nativeDestDir.resolve("libsqliteJni.dylib"), + ) val cmpVersion = (findProperty("composeMediaPlayerVersion") as String?) ?: "0.8.1" val cmpJarName = "composemediaplayer-jvm-$cmpVersion.jar" @@ -98,17 +108,19 @@ val cmpJarUrl = "https://repo1.maven.org/maven2/io/github/kdroidfilter/composemediaplayer-jvm/$cmpVersion/$cmpJarName" val cmpEntry = "darwin-aarch64/libNativeVideoPlayer.dylib" -val cmpTask = registerExtractFromJarTask( - taskName = "installNativeVideoPlayer_$cmpVersion", - jarUrl = cmpJarUrl, - cacheSubDir = "composeMediaPlayer/$cmpVersion", - jarFileName = cmpJarName, - entryPathInJar = cmpEntry, - destFile = nativeDestDir.resolve("libNativeVideoPlayer.dylib") -) +val cmpTask = + registerExtractFromJarTask( + taskName = "installNativeVideoPlayer_$cmpVersion", + jarUrl = cmpJarUrl, + cacheSubDir = "composeMediaPlayer/$cmpVersion", + jarFileName = cmpJarName, + entryPathInJar = cmpEntry, + destFile = nativeDestDir.resolve("libNativeVideoPlayer.dylib"), + ) -val installNativeLibs = tasks.register("installNativeLibs") { - group = "setup" - description = "Install sqliteJni + NativeVideoPlayer dylibs to $nativeDestDirPath" - dependsOn(sqliteTask, cmpTask) -} +val installNativeLibs = + tasks.register("installNativeLibs") { + group = "setup" + description = "Install sqliteJni + NativeVideoPlayer dylibs to $nativeDestDirPath" + dependsOn(sqliteTask, cmpTask) + } diff --git a/desktopApp/src/main/composeResources/values/strings.xml b/desktopApp/src/main/composeResources/values/strings.xml index ab07fadc2..644a3731e 100644 --- a/desktopApp/src/main/composeResources/values/strings.xml +++ b/desktopApp/src/main/composeResources/values/strings.xml @@ -233,7 +233,7 @@ 7 days Content warning Mark media as sensitive - Sending toot + Sent successfully Public Home @@ -266,4 +266,6 @@ Default List Feeds + + Login expired, please login again \ No newline at end of file diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt index 2ef4be3a1..4b6b52399 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt @@ -52,6 +52,7 @@ import dev.dimension.flare.data.model.TabSettings import dev.dimension.flare.data.model.TimelineTabItem import dev.dimension.flare.model.AccountType import dev.dimension.flare.ui.component.AvatarComponent +import dev.dimension.flare.ui.component.InAppNotificationComponent import dev.dimension.flare.ui.component.TabIcon import dev.dimension.flare.ui.component.TabTitle import dev.dimension.flare.ui.model.isSuccess @@ -329,6 +330,11 @@ internal fun FlareApp(onWindowRoute: (Route.WindowRoute) -> Unit) { modifier = Modifier.align(Alignment.TopStart), ) } + InAppNotificationComponent( + modifier = + Modifier + .align(Alignment.TopCenter), + ) } } } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt index 2e663fe84..7f7f8c68b 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt @@ -24,6 +24,7 @@ import dev.dimension.flare.ui.route.Route import dev.dimension.flare.ui.route.WindowRouter import dev.dimension.flare.ui.theme.FlareTheme import dev.dimension.flare.ui.theme.ProvideThemeSettings +import io.github.vinceglb.filekit.FileKit import org.apache.commons.lang3.SystemUtils import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @@ -41,6 +42,7 @@ fun main(args: Array) { } } application { + FileKit.init(appId = "dev.dimension.flare") setSingletonImageLoaderFactory { context -> ImageLoader .Builder(context) diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/common/ImageClipboardManager.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/common/ImageClipboardManager.kt new file mode 100644 index 000000000..7968afef3 --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/common/ImageClipboardManager.kt @@ -0,0 +1,114 @@ +package dev.dimension.flare.common + +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.Clipboard +import androidx.compose.ui.platform.NativeClipboard +import androidx.compose.ui.platform.awtClipboard +import java.awt.HeadlessException +import java.awt.Image +import java.awt.Toolkit +import java.awt.datatransfer.ClipboardOwner +import java.awt.datatransfer.DataFlavor +import java.awt.datatransfer.Transferable +import java.awt.image.BufferedImage +import java.awt.image.MultiResolutionImage +import java.io.File +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +class ImageClipboardManager( + private val onImagePasted: (File) -> Unit, +) : Clipboard { + private val systemClipboard by lazy { + try { + Toolkit.getDefaultToolkit().systemClipboard + } catch (_: HeadlessException) { + null + } + } + + @OptIn(ExperimentalUuidApi::class) + override suspend fun getClipEntry(): ClipEntry? { + if (systemClipboard?.isDataFlavorAvailable(DataFlavor.imageFlavor) == true) { + systemClipboard?.getData(DataFlavor.imageFlavor)?.let { data -> + when (data) { + is BufferedImage -> { + val file = File.createTempFile(Uuid.random().toString(), ".png") + javax.imageio.ImageIO.write(data, "png", file) + onImagePasted(file) + } + + is Image -> { + val bufferedImage = toMaxResolutionBufferedImage(data) + val file = File.createTempFile(Uuid.random().toString(), ".png") + javax.imageio.ImageIO.write(bufferedImage, "png", file) + onImagePasted(file) + } + } + } + return null + } else { + val transferable = systemClipboard?.getContents(null) ?: return null + val flavors = transferable.transferDataFlavors + if (flavors?.size == 0) return null + return ClipEntry(transferable) + } + } + + override suspend fun setClipEntry(clipEntry: ClipEntry?) { + val transferable = clipEntry?.nativeClipEntry as? Transferable + if (transferable != null) { + systemClipboard?.setContents( + // contents = + transferable, + // owner = + transferable as? ClipboardOwner, + ) + } + } + + /** + * Provides an instance of a platform clipboard. + * The actual implementation may vary depending on the underlying GUI toolkit. + * See [awtClipboard] to access [java.awt.datatransfer.Clipboard]. + */ + override val nativeClipboard: NativeClipboard + get() = systemClipboard ?: error("systemClipboard is not available in headless mode") + + fun toMaxResolutionBufferedImage(image: Image): BufferedImage { + if (image is MultiResolutionImage) { + val resolutionVariants = image.resolutionVariants + + val maxImage = + resolutionVariants + .stream() + .max( + Comparator { i1: Image?, i2: Image? -> + val area1 = i1!!.getWidth(null) * i1.getHeight(null) + val area2 = i2!!.getWidth(null) * i2.getHeight(null) + area1.compareTo(area2) + }, + ).orElse(image) + + return toBufferedImage(maxImage) + } else { + return toBufferedImage(image) + } + } + + private fun toBufferedImage(img: Image): BufferedImage { + if (img is BufferedImage) { + return img + } + val bimage = + BufferedImage( + img.getWidth(null), + img.getHeight(null), + BufferedImage.TYPE_INT_ARGB, + ) + val g2d = bimage.createGraphics() + g2d.drawImage(img, 0, 0, null) + g2d.dispose() + return bimage + } +} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/common/ImageDragAndDropTarget.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/common/ImageDragAndDropTarget.kt new file mode 100644 index 000000000..9349e000a --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/common/ImageDragAndDropTarget.kt @@ -0,0 +1,60 @@ +package dev.dimension.flare.common + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.draganddrop.DragAndDropEvent +import androidx.compose.ui.draganddrop.DragAndDropTarget +import androidx.compose.ui.draganddrop.awtTransferable +import java.awt.datatransfer.DataFlavor +import java.awt.image.BufferedImage +import java.io.File +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +class ImageDragAndDropTarget( + private val onImageDropped: (List) -> Unit, +) : DragAndDropTarget { + @OptIn(ExperimentalComposeUiApi::class, ExperimentalUuidApi::class) + override fun onDrop(event: DragAndDropEvent): Boolean { + event.awtTransferable.let { + if (it.isDataFlavorSupported(DataFlavor.imageFlavor)) { + it + .getTransferData(DataFlavor.imageFlavor) + .let { image -> + if (image is BufferedImage) { + val imageFiles = mutableListOf() + val tempFile = File.createTempFile(Uuid.random().toString(), ".png") + javax.imageio.ImageIO.write(image, "png", tempFile) + imageFiles.add(tempFile) + onImageDropped(imageFiles) + } + } + return true + } else if (it.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { + it + .getTransferData(DataFlavor.javaFileListFlavor) + .let { files -> + if (files is List<*>) { + val imageFiles = + files + .filterIsInstance() + .filter { + it.extension in + listOf( + "jpg", + "jpeg", + "png", + "gif", + "webp", + ) + } + if (imageFiles.isNotEmpty()) { + onImageDropped(imageFiles) + } + } + } + return true + } + } + return false + } +} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/common/SandboxHelper.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/common/SandboxHelper.kt index e46f6cc40..c15793904 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/common/SandboxHelper.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/common/SandboxHelper.kt @@ -6,6 +6,8 @@ object SandboxHelper { if (isSandboxed) { val resourcesPath = System.getProperty("compose.application.resources.dir") System.setProperty("androidx.sqlite.driver.bundled.path", resourcesPath) + System.setProperty("jna.nounpack", "true") + System.setProperty("jna.boot.library.path", resourcesPath) } } } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/di/DesktopModule.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/di/DesktopModule.kt index 5408d8c58..a58177938 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/di/DesktopModule.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/di/DesktopModule.kt @@ -1,8 +1,8 @@ package dev.dimension.flare.di import dev.dimension.flare.common.InAppNotification -import dev.dimension.flare.common.Message import dev.dimension.flare.data.repository.SettingsRepository +import dev.dimension.flare.ui.component.ComposeInAppNotification import dev.dimension.flare.ui.component.platform.VideoPlayerPool import org.koin.core.module.dsl.singleOf import org.koin.dsl.binds @@ -10,25 +10,7 @@ import org.koin.dsl.module val desktopModule = module { - single { - object : InAppNotification { - override fun onProgress( - message: Message, - progress: Int, - total: Int, - ) { - } - - override fun onSuccess(message: Message) { - } - - override fun onError( - message: Message, - throwable: Throwable, - ) { - } - } - } binds arrayOf(InAppNotification::class) + single { ComposeInAppNotification() } binds arrayOf(InAppNotification::class) singleOf(::VideoPlayerPool) singleOf(::SettingsRepository) } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/ComposeInAppNotification.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/ComposeInAppNotification.kt new file mode 100644 index 000000000..536fed6c0 --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/ComposeInAppNotification.kt @@ -0,0 +1,138 @@ +package dev.dimension.flare.ui.component + +import androidx.compose.animation.AnimatedVisibility +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +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.Modifier +import dev.dimension.flare.LocalWindowPadding +import dev.dimension.flare.Res +import dev.dimension.flare.common.Event +import dev.dimension.flare.common.InAppNotification +import dev.dimension.flare.common.Message +import dev.dimension.flare.compose_notification_title +import dev.dimension.flare.notification_login_expired +import io.github.composefluent.component.InfoBar +import io.github.composefluent.component.InfoBarSeverity +import io.github.composefluent.component.ProgressBar +import io.github.composefluent.component.Text +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import kotlin.time.Duration.Companion.seconds + +internal sealed interface Notification { + data class Progress( + val progress: Int, + val total: Int, + ) : Notification { + val percentage: Float + get() = progress.toFloat() / total + } + + data class StringNotification( + val messageId: StringResource, + 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) { + _source.value = Event(Notification.StringNotification(message.title, success = true)) + } + + override fun onError( + message: Message, + throwable: Throwable, + ) { + _source.value = Event(Notification.StringNotification(message.title, success = false)) + } +} + +private val Message.title + get() = + when (this) { + Message.Compose -> Res.string.compose_notification_title + Message.LoginExpired -> Res.string.notification_login_expired + } + +@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 -> { + ProgressBar( + progress = it.percentage, + modifier = + modifier + .fillMaxWidth(), + ) + } + is Notification.StringNotification -> { + var showNotification by remember { mutableStateOf(false) } + LaunchedEffect(source) { + showNotification = true + delay(5.seconds) + showNotification = false + } + AnimatedVisibility( + showNotification, + modifier = + modifier + .padding( + LocalWindowPadding.current, + ), + enter = fadeIn() + slideInVertically(), + exit = fadeOut() + slideOutVertically(), + ) { + InfoBar( + title = { + Text( + stringResource(it.messageId), + ) + }, + message = { + }, + severity = + if (it.success) { + InfoBarSeverity.Success + } else { + InfoBarSeverity.Critical + }, + ) + } + } + } + } +} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt index 43ca1baf7..74404ec97 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt @@ -5,6 +5,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.draganddrop.dragAndDropTarget import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -26,18 +27,21 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.material.IconButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect 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.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.text.TextRange import androidx.compose.ui.unit.dp import compose.icons.FontAwesomeIcons @@ -51,6 +55,8 @@ import compose.icons.fontawesomeicons.solid.TriangleExclamation import compose.icons.fontawesomeicons.solid.Xmark import dev.dimension.flare.Res import dev.dimension.flare.common.FileItem +import dev.dimension.flare.common.ImageClipboardManager +import dev.dimension.flare.common.ImageDragAndDropTarget import dev.dimension.flare.compose_content_warning_hint import dev.dimension.flare.compose_hint import dev.dimension.flare.compose_media_sensitive @@ -113,6 +119,8 @@ import io.github.composefluent.component.Text import io.github.composefluent.component.TextField import io.github.composefluent.component.TextFieldDefaults import io.github.composefluent.surface.Card +import io.github.vinceglb.filekit.dialogs.FileKitType +import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher import kotlinx.collections.immutable.toImmutableList import moe.tlaster.precompose.molecule.producePresenter import org.jetbrains.compose.resources.StringResource @@ -122,12 +130,13 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes +import kotlin.uuid.ExperimentalUuidApi +@OptIn(ExperimentalComposeUiApi::class, ExperimentalUuidApi::class) @Composable fun ComposeDialog( onBack: () -> Unit, accountType: AccountType, - modifier: Modifier = Modifier, status: ComposeStatus? = null, initialText: String = "", ) { @@ -138,6 +147,16 @@ fun ComposeDialog( initialText = initialText, ) } + val launcher = + rememberFilePickerLauncher( + FileKitType.ImageAndVideo, + ) { file -> + if (file != null) { + state.mediaState.onSuccess { + it.addMedia(listOf(file.file)) + } + } + } val focusRequester = remember { FocusRequester() } val contentWarningFocusRequester = remember { FocusRequester() } @@ -155,7 +174,24 @@ fun ComposeDialog( focusRequester.requestFocus() } } - Column { + Column( + modifier = + Modifier + .dragAndDropTarget( + shouldStartDragAndDrop = { + true + }, + remember { + ImageDragAndDropTarget( + onImageDropped = { images -> + state.mediaState.onSuccess { + it.addMedia(images) + } + }, + ) + }, + ), + ) { SubtleButton( onClick = onBack, modifier = @@ -307,21 +343,35 @@ fun ComposeDialog( color = TextFieldDefaults.defaultTextFieldColors().default.placeholderColor, ) } - BasicTextField( - state = state.textFieldState, - modifier = - Modifier - .fillMaxWidth() - .heightIn(min = 120.dp) - .focusRequester( - focusRequester = focusRequester, - ), - textStyle = LocalTextStyle.current.copy(color = FluentTheme.colors.text.text.primary), - cursorBrush = SolidColor(FluentTheme.colors.text.text.primary), -// placeholder = { -// Text(text = stringResource(Res.string.compose_hint)) -// }, - ) + CompositionLocalProvider( + LocalClipboard provides + remember { + ImageClipboardManager( + onImagePasted = { file -> + state.mediaState.onSuccess { + it.addMedia( + listOf( + file, + ), + ) + } + }, + ) + }, + ) { + BasicTextField( + state = state.textFieldState, + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 120.dp) + .focusRequester( + focusRequester = focusRequester, + ), + textStyle = LocalTextStyle.current.copy(color = FluentTheme.colors.text.text.primary), + cursorBrush = SolidColor(FluentTheme.colors.text.text.primary), + ) + } } state.mediaState.onSuccess { mediaState -> AnimatedVisibility(mediaState.medias.isNotEmpty()) { @@ -525,15 +575,13 @@ fun ComposeDialog( ) { state.mediaState.onSuccess { if (it.enabled) { - IconButton( + SubtleButton( onClick = { -// photoPickerLauncher.launch( -// PickVisualMediaRequest( -// ActivityResultContracts.PickVisualMedia.ImageAndVideo, -// ), -// ) + launcher.launch() }, - enabled = state.canMedia, + disabled = !state.canMedia, + iconOnly = true, + modifier = Modifier.padding(4.dp), ) { FAIcon( imageVector = FontAwesomeIcons.Solid.Image, @@ -543,11 +591,13 @@ fun ComposeDialog( } } state.pollState.onSuccess { - IconButton( + SubtleButton( onClick = { it.togglePoll() }, - enabled = state.canPoll, + disabled = !state.canPoll, + iconOnly = true, + modifier = Modifier.padding(4.dp), ) { FAIcon( imageVector = FontAwesomeIcons.Solid.SquarePollHorizontal, @@ -586,20 +636,24 @@ fun ComposeDialog( } }, ) { - IconButton( + SubtleButton( onClick = { isFlyoutVisible = true }, + iconOnly = true, + modifier = Modifier.padding(4.dp), ) { StatusVisibilityComponent(visibility = visibilityState.visibility) } } } state.contentWarningState.onSuccess { - IconButton( + SubtleButton( onClick = { it.toggle() }, + iconOnly = true, + modifier = Modifier.padding(4.dp), ) { FAIcon( imageVector = FontAwesomeIcons.Solid.TriangleExclamation, @@ -634,10 +688,12 @@ fun ComposeDialog( ) }, ) { - IconButton( + SubtleButton( onClick = { isFlyoutVisible = !isFlyoutVisible }, + iconOnly = true, + modifier = Modifier.padding(4.dp), ) { FAIcon( imageVector = FontAwesomeIcons.Solid.FaceSmile, @@ -692,9 +748,11 @@ private fun PollOption( Text(text = stringResource(Res.string.compose_poll_option_hint, index + 1)) }, trailing = { - IconButton( + SubtleButton( onClick = onRemove, - enabled = index > 1, + disabled = index <= 1, + iconOnly = true, + modifier = Modifier.padding(4.dp), ) { FAIcon(imageVector = FontAwesomeIcons.Solid.Xmark, contentDescription = null) } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt index 49e8ab0ee..4ae501f44 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt @@ -5,18 +5,25 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +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.rememberScrollState import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.foundation.text.input.delete +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.verticalScroll 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.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.dp @@ -26,6 +33,8 @@ import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.brands.Github import compose.icons.fontawesomeicons.brands.Line import compose.icons.fontawesomeicons.brands.Telegram +import compose.icons.fontawesomeicons.solid.CircleCheck +import compose.icons.fontawesomeicons.solid.CircleXmark import compose.icons.fontawesomeicons.solid.EllipsisVertical import compose.icons.fontawesomeicons.solid.Language import compose.icons.fontawesomeicons.solid.Lock @@ -35,6 +44,8 @@ import dev.dimension.flare.LocalWindowPadding import dev.dimension.flare.Res import dev.dimension.flare.add_account import dev.dimension.flare.app_name +import dev.dimension.flare.cancel +import dev.dimension.flare.data.model.AppSettings import dev.dimension.flare.data.model.AppearanceSettings import dev.dimension.flare.data.model.AvatarShape import dev.dimension.flare.data.model.LocalAppearanceSettings @@ -45,6 +56,7 @@ import dev.dimension.flare.data.repository.SettingsRepository import dev.dimension.flare.home_login import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ok import dev.dimension.flare.remove_account import dev.dimension.flare.settings_about_line import dev.dimension.flare.settings_about_line_description @@ -55,6 +67,15 @@ import dev.dimension.flare.settings_about_telegram import dev.dimension.flare.settings_about_telegram_description import dev.dimension.flare.settings_about_title import dev.dimension.flare.settings_accounts_title +import dev.dimension.flare.settings_ai_config_description +import dev.dimension.flare.settings_ai_config_enable_tldr +import dev.dimension.flare.settings_ai_config_entable_translation +import dev.dimension.flare.settings_ai_config_server +import dev.dimension.flare.settings_ai_config_server_hint +import dev.dimension.flare.settings_ai_config_server_self_host_description +import dev.dimension.flare.settings_ai_config_title +import dev.dimension.flare.settings_ai_config_tldr_description +import dev.dimension.flare.settings_ai_config_translation_description import dev.dimension.flare.settings_appearance_avatar_shape import dev.dimension.flare.settings_appearance_avatar_shape_description import dev.dimension.flare.settings_appearance_avatar_shape_round @@ -89,27 +110,39 @@ import dev.dimension.flare.ui.component.AvatarComponent import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.Header import dev.dimension.flare.ui.component.RichText +import dev.dimension.flare.ui.model.isSuccess 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.ActiveAccountPresenter import dev.dimension.flare.ui.presenter.home.UserState import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.presenter.settings.AccountsPresenter import dev.dimension.flare.ui.presenter.settings.AccountsState +import dev.dimension.flare.ui.presenter.settings.FlareServerProviderPresenter import dev.dimension.flare.ui.theme.screenHorizontalPadding import io.github.composefluent.FluentTheme import io.github.composefluent.component.CardExpanderItem +import io.github.composefluent.component.ContentDialog +import io.github.composefluent.component.ContentDialogButton import io.github.composefluent.component.DropDownButton import io.github.composefluent.component.Expander import io.github.composefluent.component.ExpanderItem import io.github.composefluent.component.FlyoutPlacement import io.github.composefluent.component.MenuFlyoutContainer import io.github.composefluent.component.MenuFlyoutItem +import io.github.composefluent.component.ProgressRing import io.github.composefluent.component.RadioButton import io.github.composefluent.component.ScrollbarContainer import io.github.composefluent.component.SubtleButton import io.github.composefluent.component.Switcher import io.github.composefluent.component.Text +import io.github.composefluent.component.TextField +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import moe.tlaster.precompose.molecule.producePresenter import org.jetbrains.compose.resources.stringResource @@ -365,6 +398,7 @@ internal fun SettingsScreen(toLogin: () -> Unit) { stringResource( Res.string.settings_appearance_avatar_shape_round, ) + AvatarShape.SQUARE -> stringResource( Res.string.settings_appearance_avatar_shape_square, @@ -530,7 +564,12 @@ internal fun SettingsScreen(toLogin: () -> Unit) { }, trailing = { Switcher( - checked = LocalAppearanceSettings.current.videoAutoplay in listOf(VideoAutoplay.ALWAYS, VideoAutoplay.WIFI), + checked = + LocalAppearanceSettings.current.videoAutoplay in + listOf( + VideoAutoplay.ALWAYS, + VideoAutoplay.WIFI, + ), { state.appearanceState.updateSettings { copy( @@ -545,6 +584,121 @@ internal fun SettingsScreen(toLogin: () -> Unit) { ) } + Header(stringResource(Res.string.settings_ai_config_title)) + ContentDialog( + title = stringResource(Res.string.settings_ai_config_server), + visible = state.aiConfigState.showServerDialog, + content = { + Column( + modifier = Modifier.padding(16.dp), + ) { + Text( + text = stringResource(Res.string.settings_ai_config_server_self_host_description), + ) + Spacer(modifier = Modifier.size(8.dp)) + TextField( + state.aiConfigState.serverText, + placeholder = { Text(stringResource(Res.string.settings_ai_config_server_hint)) }, + modifier = Modifier.fillMaxWidth(), + trailing = { + state.aiConfigState.serverValidation + .onSuccess { + FAIcon( + imageVector = FontAwesomeIcons.Solid.CircleCheck, + contentDescription = null, + tint = FluentTheme.colors.system.neutral, + ) + }.onError { + FAIcon( + imageVector = FontAwesomeIcons.Solid.CircleXmark, + contentDescription = null, + tint = FluentTheme.colors.system.critical, + ) + }.onLoading { + ProgressRing( + modifier = Modifier.size(24.dp), + ) + } + }, + ) + } + }, + primaryButtonText = stringResource(Res.string.ok), + closeButtonText = stringResource(Res.string.cancel), + onButtonClick = { + when (it) { + ContentDialogButton.Primary -> { + if (state.aiConfigState.serverValidation.isSuccess) { + state.aiConfigState.setShowServerDialog(false) + state.aiConfigState.confirm() + } + } + else -> { + state.aiConfigState.setShowServerDialog(false) + } + } + }, + ) + Expander( + state.aiConfigState.expanded, + onExpandedChanged = state.aiConfigState::setExpanded, + heading = { + Text(stringResource(Res.string.settings_ai_config_title)) + }, + caption = { + Text(stringResource(Res.string.settings_ai_config_description)) + }, + icon = null, + ) { + CardExpanderItem( + onClick = { + state.aiConfigState.setShowServerDialog(true) + }, + heading = { + Text(stringResource(Res.string.settings_ai_config_server)) + }, + caption = { + state.aiConfigState.currentServer.onSuccess { + Text(it) + } + }, + ) + ExpanderItem( + heading = { + Text(stringResource(Res.string.settings_ai_config_entable_translation)) + }, + caption = { + Text(stringResource(Res.string.settings_ai_config_translation_description)) + }, + trailing = { + Switcher( + checked = state.aiConfigState.aiConfig.translation, + { + state.aiConfigState.update { copy(translation = it) } + }, + textBefore = true, + ) + }, + ) + ExpanderItem( + heading = { + Text(stringResource(Res.string.settings_ai_config_enable_tldr)) + }, + caption = { + Text(stringResource(Res.string.settings_ai_config_tldr_description)) + }, + trailing = { + Switcher( + checked = state.aiConfigState.aiConfig.tldr, + { + state.aiConfigState.update { copy(tldr = it) } + }, + textBefore = true, + ) + }, + ) + } + Header(stringResource(Res.string.settings_about_title)) Expander( icon = null, @@ -667,11 +821,13 @@ private fun presenter() = run { val accountState = accountsPresenter() val appearanceState = appearancePresenter() + val aiConfigState = aiConfigPresenter() var aboutExpanded by remember { mutableStateOf(false) } object { val accountState = accountState val appearanceState = appearanceState + val aiConfigState = aiConfigState val aboutExpanded = aboutExpanded @@ -750,3 +906,54 @@ private fun accountsPresenter(settingsRepository: SettingsRepository = koinInjec } } } + +@OptIn(FlowPreview::class) +@Composable +private fun aiConfigPresenter(settingsRepository: SettingsRepository = koinInject()) = + run { + var expanded by remember { mutableStateOf(false) } + var showServerDialog by remember { mutableStateOf(false) } + val serverText = rememberTextFieldState() + val scope = rememberCoroutineScope() + val state = remember { FlareServerProviderPresenter() }.invoke() + val aiConfig by remember { settingsRepository.appSettings.map { it.aiConfig } } + .collectAsState(AppSettings.AiConfig()) + state.currentServer.onSuccess { currentServer -> + LaunchedEffect(currentServer) { + serverText.edit { + delete(0, length) + append(currentServer) + } + } + } + + LaunchedEffect(Unit) { + snapshotFlow { serverText.text } + .distinctUntilChanged() + .debounce(666L) + .collectLatest { + state.checkServer(it.toString()) + } + } + + object : FlareServerProviderPresenter.State by state { + val showServerDialog = showServerDialog + val serverText = serverText + val aiConfig = aiConfig + val expanded = expanded + + fun setExpanded(value: Boolean) { + expanded = value + } + + fun update(block: AppSettings.AiConfig.() -> AppSettings.AiConfig) { + scope.launch { + settingsRepository.updateAppSettings { copy(aiConfig = block.invoke(this.aiConfig)) } + } + } + + fun setShowServerDialog(value: Boolean) { + showServerDialog = value + } + } + } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt index 0d066b915..b3b3ab84a 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.interaction.FocusInteraction import androidx.compose.foundation.interaction.HoverInteraction import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -40,7 +41,6 @@ import io.github.composefluent.ExperimentalFluentApi import io.github.composefluent.FluentTheme import io.github.composefluent.darkColors import io.github.composefluent.lightColors -import io.github.kdroidfilter.platformtools.darkmodedetector.isSystemInDarkMode import kotlinx.coroutines.launch import org.apache.commons.lang3.SystemUtils import org.koin.compose.koinInject @@ -181,7 +181,7 @@ private class FluentIndication( @Composable private fun isDarkTheme(): Boolean = LocalAppearanceSettings.current.theme == Theme.DARK || - (LocalAppearanceSettings.current.theme == Theme.SYSTEM && isSystemInDarkMode()) + (LocalAppearanceSettings.current.theme == Theme.SYSTEM && isSystemInDarkTheme()) @Composable internal fun ProvideThemeSettings(content: @Composable () -> Unit) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7ec5449d8..08c45dc71 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,7 @@ clikt = "5.0.3" collection = "1.5.0" compileSdk = "36" composemediaplayer = "0.8.1" +filekitDialogsCompose = "0.10.0" haze = "1.6.10" lifecycleViewmodelComposeVersion = "2.9.2" minSdk = "23" @@ -62,6 +63,8 @@ bluesky-oauth = { module = "moe.tlaster.ozone:oauth", version.ref = "bluesky" } clikt = { module = "com.github.ajalt.clikt:clikt", version.ref = "clikt" } composemediaplayer = { module = "io.github.kdroidfilter:composemediaplayer", version.ref = "composemediaplayer" } core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } +filekit-dialogs-compose = { module = "io.github.vinceglb:filekit-dialogs-compose", version.ref = "filekitDialogsCompose" } +filekit-coil = { module = "io.github.vinceglb:filekit-coil", version.ref = "filekitDialogsCompose" } haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" } haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } junit = { group = "junit", name = "junit", version.ref = "junit" } diff --git a/app/src/main/java/dev/dimension/flare/common/Event.kt b/shared/src/androidJvmMain/kotlin/dev/dimension/flare/common/Event.kt similarity index 76% rename from app/src/main/java/dev/dimension/flare/common/Event.kt rename to shared/src/androidJvmMain/kotlin/dev/dimension/flare/common/Event.kt index 72a31897e..e81ccd0cb 100644 --- a/app/src/main/java/dev/dimension/flare/common/Event.kt +++ b/shared/src/androidJvmMain/kotlin/dev/dimension/flare/common/Event.kt @@ -1,17 +1,17 @@ package dev.dimension.flare.common -class Event( +public class Event( private val content: T?, initialHandled: Boolean = false, ) { @Suppress("MemberVisibilityCanBePrivate") - var hasBeenHandled = initialHandled + public var hasBeenHandled: Boolean = initialHandled private set // Allow external read but not write /** * Returns the content and prevents its use again. */ - fun getContentIfNotHandled(): T? = + public fun getContentIfNotHandled(): T? = if (hasBeenHandled) { null } else { diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusMediaComponent.kt b/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusMediaComponent.kt index 19b9e9a19..fee8e8dbe 100644 --- a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusMediaComponent.kt +++ b/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusMediaComponent.kt @@ -321,7 +321,7 @@ public fun MediaItem( .padding(16.dp) .background( Color.Black.copy(alpha = 0.5f), - shape = PlatformTheme.shapes.small, + shape = PlatformTheme.shapes.medium, ).padding(horizontal = 8.dp, vertical = 4.dp) .align(Alignment.BottomStart), contentAlignment = Alignment.Center, @@ -357,7 +357,7 @@ public fun MediaItem( .padding(16.dp) .background( Color.Black.copy(alpha = 0.5f), - shape = PlatformTheme.shapes.small, + shape = PlatformTheme.shapes.medium, ).padding(horizontal = 8.dp, vertical = 4.dp) .align(Alignment.BottomStart), contentAlignment = Alignment.Center, @@ -381,7 +381,7 @@ public fun MediaItem( .padding(16.dp) .background( Color.Black.copy(alpha = 0.5f), - shape = PlatformTheme.shapes.small, + shape = PlatformTheme.shapes.medium, ).padding(horizontal = 8.dp, vertical = 4.dp) .align(Alignment.BottomStart), contentAlignment = Alignment.Center, @@ -429,7 +429,7 @@ public fun MediaItem( .padding(16.dp) .background( Color.Black.copy(alpha = 0.5f), - shape = PlatformTheme.shapes.small, + shape = PlatformTheme.shapes.medium, ).padding(horizontal = 8.dp, vertical = 4.dp) .align(Alignment.BottomStart), contentAlignment = Alignment.Center, From bfd6896f11a3c24cc05db59d99f13011a8bd39d0 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Thu, 21 Aug 2025 19:50:04 +0900 Subject: [PATCH 16/33] fix version name --- desktopApp/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 09cfda647..16af70e6a 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -45,7 +45,7 @@ compose.desktop { nativeDistributions { targetFormats(TargetFormat.Pkg) packageName = "dev.dimension.flare" - packageVersion = System.getenv("BUILD_VERSION") ?: "1.0.0" + packageVersion = "1.0.0" macOS { val file = project.file("signing.properties") val hasSigningProps = file.exists() From 215197919195077f756e0fabfdbab73b6036fdb5 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Fri, 22 Aug 2025 15:54:52 +0900 Subject: [PATCH 17/33] add dm support --- .../ui/screen/dm/DMConversationScreen.kt | 181 +---------- .../flare/ui/screen/dm/DMListScreen.kt | 214 +------------ .../main/composeResources/values/strings.xml | 10 + .../main/kotlin/dev/dimension/flare/App.kt | 40 ++- .../dev/dimension/flare/ui/route/Route.kt | 22 +- .../dev/dimension/flare/ui/route/Router.kt | 96 ++++-- .../ui/screen/dm/DmConversationScreen.kt | 288 ++++++++++++++++++ .../flare/ui/screen/dm/DmListScreen.kt | 83 +++++ .../ui/screen/dm/UserDMConversationScreen.kt | 73 +++++ .../flare/ui/screen/feeds/FeedListScreen.kt | 162 ++++++---- .../flare/ui/screen/home/DiscoverScreen.kt | 9 + .../flare/ui/screen/home/SearchScreen.kt | 9 + .../flare/ui/screen/list/AllListScreen.kt | 66 ++-- .../ui/theme/PlatformColorScheme.android.kt | 3 + .../flare/ui/theme/PlatformShapes.android.kt | 17 +- .../ui/theme/PlatformTypography.android.kt | 3 + .../composeResources/values/strings.xml | 11 + .../flare/ui/component/UiListItemComponent.kt | 3 + .../dimension/flare/ui/component/dm/DMItem.kt | 185 +++++++++++ .../flare/ui/component/dm/DmListItem.kt | 242 +++++++++++++++ .../flare/ui/theme/PlatformColorScheme.kt | 3 + .../flare/ui/theme/PlatformShapes.kt | 6 + .../flare/ui/theme/PlatformTypography.kt | 3 + .../platform/PlatformListItem.jvm.kt | 7 +- .../flare/ui/theme/PlatformColorScheme.jvm.kt | 3 + .../flare/ui/theme/PlatformShapes.jvm.kt | 19 ++ .../flare/ui/theme/PlatformTypography.jvm.kt | 3 + 27 files changed, 1259 insertions(+), 502 deletions(-) create mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DmConversationScreen.kt create mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DmListScreen.kt create mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/UserDMConversationScreen.kt create mode 100644 shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/dm/DMItem.kt create mode 100644 shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/dm/DmListItem.kt diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/dm/DMConversationScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/dm/DMConversationScreen.kt index 53a37b797..3fd13fa6a 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/dm/DMConversationScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/dm/DMConversationScreen.kt @@ -1,14 +1,9 @@ package dev.dimension.flare.ui.screen.dm -import androidx.compose.foundation.background -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.ExperimentalLayoutApi import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.consumeWindowInsets @@ -19,11 +14,9 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.clearText @@ -45,28 +38,21 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.ArrowRightFromBracket -import compose.icons.fontawesomeicons.solid.CircleExclamation import compose.icons.fontawesomeicons.solid.CircleUser import compose.icons.fontawesomeicons.solid.EllipsisVertical import compose.icons.fontawesomeicons.solid.PaperPlane import dev.dimension.flare.R -import dev.dimension.flare.common.AppDeepLink import dev.dimension.flare.common.onSuccess import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.common.items -import dev.dimension.flare.ui.component.AvatarComponent -import dev.dimension.flare.ui.component.AvatarComponentDefaults import dev.dimension.flare.ui.component.BackButton import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.FlareDividerDefaults @@ -75,12 +61,7 @@ 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.RichText -import dev.dimension.flare.ui.component.status.MediaItem -import dev.dimension.flare.ui.component.status.QuotedStatus -import dev.dimension.flare.ui.model.UiDMItem -import dev.dimension.flare.ui.model.UiMedia -import dev.dimension.flare.ui.model.UiUserV2 -import dev.dimension.flare.ui.model.localizedShortTime +import dev.dimension.flare.ui.component.dm.DMItem import dev.dimension.flare.ui.model.onError import dev.dimension.flare.ui.model.onSuccess import dev.dimension.flare.ui.presenter.dm.DMConversationPresenter @@ -325,166 +306,6 @@ internal fun DMConversationScreen( } } -@Composable -private fun DMItem( - item: UiDMItem, - onRetry: () -> Unit, - onUserClicked: (UiUserV2) -> Unit, - modifier: Modifier = Modifier, -) { - val uriHandler = LocalUriHandler.current - Column( - modifier = - modifier - .fillMaxWidth(), - horizontalAlignment = - if (item.isFromMe) { - Alignment.End - } else { - Alignment.Start - }, - ) { - Box( - modifier = - Modifier - .fillMaxWidth(0.75f), - contentAlignment = - if (item.isFromMe) { - Alignment.CenterEnd - } else { - Alignment.CenterStart - }, - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - if (item.showSender) { - AvatarComponent( - data = item.user.avatar, - modifier = - Modifier.clickable { - onUserClicked.invoke(item.user) - }, - ) - } - if (item.sendState == UiDMItem.SendState.Failed) { - IconButton( - onClick = onRetry, - ) { - FAIcon( - FontAwesomeIcons.Solid.CircleExclamation, - contentDescription = stringResource(id = R.string.send), - tint = MaterialTheme.colorScheme.error, - ) - } - } - when (val message = item.content) { - is UiDMItem.Message.Text -> - RichText( - text = message.text, - modifier = - Modifier - .background( - color = - if (item.isFromMe) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceDim - }, - shape = - MaterialTheme.shapes.large.let { - if (item.isFromMe) { - it.copy( - bottomEnd = CornerSize(0.dp), - ) - } else { - it.copy( - bottomStart = CornerSize(0.dp), - ) - } - }, - ).padding( - vertical = 8.dp, - horizontal = 16.dp, - ), - color = - if (item.isFromMe) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurface - }, - ) - - UiDMItem.Message.Deleted -> - Text( - text = stringResource(id = R.string.dm_deleted), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - is UiDMItem.Message.Media -> - MediaItem( - media = message.media, - modifier = - Modifier - .clip(MaterialTheme.shapes.large) - .clickable { - if (message.media is UiMedia.Image) { - uriHandler.openUri(AppDeepLink.RawImage.invoke(message.media.url)) - } - }, - ) - - is UiDMItem.Message.Status -> - QuotedStatus( - message.status, - modifier = - Modifier - .clip(MaterialTheme.shapes.large) - .background( - color = - if (item.isFromMe) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceDim - }, - shape = MaterialTheme.shapes.large, - ), - ) - } - } - } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - if (item.showSender) { - Spacer(modifier = Modifier.width(AvatarComponentDefaults.size)) - RichText( - text = item.user.name, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textStyle = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - if (item.sendState == UiDMItem.SendState.Sending) { - Text( - text = stringResource(id = R.string.dm_sending), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else if (item.sendState == null || item.sendState != UiDMItem.SendState.Failed) { - Text( - item.timestamp.shortTime.localizedShortTime, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} - @Composable private fun presenter( accountType: AccountType, diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/dm/DMListScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/dm/DMListScreen.kt index 160f95927..0f0f22ec0 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/dm/DMListScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/dm/DMListScreen.kt @@ -1,63 +1,41 @@ package dev.dimension.flare.ui.screen.dm -import androidx.compose.foundation.background -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.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.Badge import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid -import compose.icons.fontawesomeicons.solid.CircleExclamation -import compose.icons.fontawesomeicons.solid.CircleUser -import compose.icons.fontawesomeicons.solid.List import compose.icons.fontawesomeicons.solid.Message import dev.dimension.flare.R import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.common.itemsIndexed -import dev.dimension.flare.ui.component.AvatarComponent -import dev.dimension.flare.ui.component.AvatarComponentDefaults import dev.dimension.flare.ui.component.BackButton import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.FlareLargeFlexibleTopAppBar import dev.dimension.flare.ui.component.FlareScaffold -import dev.dimension.flare.ui.component.ItemPlaceHolder import dev.dimension.flare.ui.component.RefreshContainer -import dev.dimension.flare.ui.component.RichText -import dev.dimension.flare.ui.component.listCard -import dev.dimension.flare.ui.model.localizedShortTime +import dev.dimension.flare.ui.component.dm.dmList import dev.dimension.flare.ui.presenter.dm.DMListPresenter import dev.dimension.flare.ui.presenter.dm.DMListState import dev.dimension.flare.ui.presenter.invoke -import dev.dimension.flare.ui.theme.MediumAlpha import dev.dimension.flare.ui.theme.screenHorizontalPadding import kotlinx.coroutines.launch import moe.tlaster.precompose.molecule.producePresenter -import kotlin.math.min @Composable internal fun DMConversationDetailPlaceholder(modifier: Modifier = Modifier) { @@ -121,193 +99,9 @@ internal fun DMListScreen( .padding(horizontal = screenHorizontalPadding), verticalArrangement = Arrangement.spacedBy(2.dp), ) { - itemsIndexed( - state.items, - emptyContent = { - Box( - modifier = Modifier.fillParentMaxSize(), - contentAlignment = Alignment.Center, - ) { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - FAIcon( - imageVector = FontAwesomeIcons.Solid.List, - contentDescription = stringResource(id = R.string.dm_list_empty), - modifier = Modifier.size(48.dp), - ) - Text( - text = stringResource(id = R.string.dm_list_empty), - style = MaterialTheme.typography.headlineMedium, - ) - } - } - }, - loadingContent = { index, itemCount -> - ItemPlaceHolder( - modifier = - Modifier - .listCard( - index = index, - totalCount = itemCount, - ), - ) - }, - errorContent = { - Box( - modifier = Modifier.fillParentMaxSize(), - contentAlignment = Alignment.Center, - ) { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - FAIcon( - imageVector = FontAwesomeIcons.Solid.CircleExclamation, - contentDescription = stringResource(id = R.string.dm_list_error), - modifier = Modifier.size(48.dp), - ) - Text( - text = stringResource(id = R.string.dm_list_error), - style = MaterialTheme.typography.headlineMedium, - ) - Text( - text = it.message.orEmpty(), - style = MaterialTheme.typography.headlineMedium, - ) - } - } - }, - itemContent = { index, itemCount, item -> - ListItem( - headlineContent = { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (item.hasUser) { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.weight(1f), - ) { - item.users.forEach { user -> - RichText( - text = user.name, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - if (item.users.size == 1) { - Text( - text = user.handle, - style = MaterialTheme.typography.bodySmall, - modifier = - Modifier - .alpha(MediumAlpha), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - } - } - val lastMessage = item.lastMessage - if (lastMessage != null) { - Text( - text = lastMessage.timestamp.shortTime.localizedShortTime, - style = MaterialTheme.typography.bodySmall, - modifier = - Modifier - .alpha(MediumAlpha), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - }, - leadingContent = { - if (!item.hasUser) { - FAIcon( - FontAwesomeIcons.Solid.CircleUser, - contentDescription = null, - modifier = - Modifier - .size(AvatarComponentDefaults.size), - tint = MaterialTheme.colorScheme.primary, - ) - } else { - Box( - modifier = - Modifier - .size(AvatarComponentDefaults.size), - ) { - repeat( - min(item.users.size, 2), - ) { - val avatar = item.users[it].avatar - if (item.users.size == 1) { - AvatarComponent(avatar) - } else { - Box( - modifier = - Modifier - .offset( - x = (it * 12).dp, - y = (it * 12).dp, - ), - ) { - AvatarComponent( - avatar, - size = AvatarComponentDefaults.compatSize, - ) - } - } - } - if (item.users.size > 1) { - Text( - item.users.size.toString(), - modifier = - Modifier - .align(Alignment.BottomEnd) - .background( - MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp), - shape = MaterialTheme.shapes.small, - ).padding(horizontal = 4.dp), - ) - } - } - } - }, - supportingContent = { - Text( - text = item.lastMessageText, - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - trailingContent = { - if (item.unreadCount > 0) { - Badge { - Text( - text = item.unreadCount.toString(), - ) - } - } - }, - modifier = - Modifier - .listCard( - index = index, - totalCount = itemCount, - ).clickable { - onItemClicked.invoke(item.key) - }, - ) - }, + dmList( + data = state.items, + onItemClicked = onItemClicked, ) } }, diff --git a/desktopApp/src/main/composeResources/values/strings.xml b/desktopApp/src/main/composeResources/values/strings.xml index 644a3731e..22cf185b8 100644 --- a/desktopApp/src/main/composeResources/values/strings.xml +++ b/desktopApp/src/main/composeResources/values/strings.xml @@ -268,4 +268,14 @@ Feeds Login expired, please login again + + No direct messages + Failed to load direct messages + Send + Send a message + Deleted + Direct Message Room + Sending + Leave conversation + Show profile \ No newline at end of file diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt index 4b6b52399..99fc40061 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -65,7 +66,6 @@ import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.route.Route import dev.dimension.flare.ui.route.Route.AllLists import dev.dimension.flare.ui.route.Route.BlueskyFeeds -import dev.dimension.flare.ui.route.Route.DirectMessage import dev.dimension.flare.ui.route.Route.Discover import dev.dimension.flare.ui.route.Route.MeRoute import dev.dimension.flare.ui.route.Route.Notification @@ -398,7 +398,7 @@ private fun getRoute(tab: TabItem): Route = SettingsTabItem -> Route.Settings is AllListTabItem -> AllLists(tab.account) is Bluesky.FeedsTabItem -> BlueskyFeeds(tab.account) - is DirectMessageTabItem -> DirectMessage(tab.account) + is DirectMessageTabItem -> Route.DmList(tab.account) is RssTabItem -> Route.RssList is Misskey.AntennasListTabItem -> Route.RssList } @@ -506,3 +506,39 @@ internal fun RegisterTabCallback( } } } + +@Composable +internal fun RegisterTabCallback( + lazyListState: LazyListState, + onRefresh: () -> Unit, +) { + val onRefreshState by rememberUpdatedState(onRefresh) + val tabState = LocalScrollToTopRegistry.current + if (tabState != null) { + val scope = rememberCoroutineScope() + val callback: () -> Unit = + remember(lazyListState, scope) { + { + if (lazyListState.firstVisibleItemIndex == 0 && + lazyListState.firstVisibleItemScrollOffset == 0 + ) { + onRefreshState.invoke() + } else { + scope.launch { + if (lazyListState.firstVisibleItemIndex > 20) { + lazyListState.scrollToItem(0) + } else { + lazyListState.animateScrollToItem(0) + } + } + } + } + } + DisposableEffect(tabState, callback, lazyListState) { + tabState.registerCallback(callback) + onDispose { + tabState.unregisterCallback(callback) + } + } + } +} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt index 2b54e5496..d8c22b0b7 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt @@ -67,11 +67,6 @@ internal sealed interface Route { val accountType: AccountType, ) : ScreenRoute - @Serializable - data class DirectMessage( - val accountType: AccountType, - ) : ScreenRoute - @Serializable data class StatusDetail( val accountType: AccountType, @@ -197,6 +192,23 @@ internal sealed interface Route { override val url: String, ) : UrlRoute + @Serializable + data class DmList( + val accountType: AccountType, + ) : ScreenRoute + + @Serializable + data class DmConversation( + val accountType: AccountType, + val roomKey: MicroBlogKey, + ) : ScreenRoute + + @Serializable + data class DmUserConversation( + val accountType: AccountType, + val userKey: MicroBlogKey, + ) : ScreenRoute + companion object { public fun parse(url: String): Route? { val data = Url(url) diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt index 932143cd3..038330dd4 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt @@ -12,13 +12,19 @@ import dev.dimension.flare.data.model.ListTimelineTabItem import dev.dimension.flare.data.model.RssTimelineTabItem import dev.dimension.flare.data.model.TabMetaData import dev.dimension.flare.data.model.TitleType -import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.AccountType.Specific -import dev.dimension.flare.ui.presenter.compose.ComposeStatus +import dev.dimension.flare.ui.presenter.compose.ComposeStatus.Quote +import dev.dimension.flare.ui.presenter.compose.ComposeStatus.Reply +import dev.dimension.flare.ui.presenter.compose.ComposeStatus.VVOComment +import dev.dimension.flare.ui.route.Route.EditRssSource import dev.dimension.flare.ui.route.Route.Profile +import dev.dimension.flare.ui.route.Route.RssTimeline import dev.dimension.flare.ui.route.Route.Search import dev.dimension.flare.ui.route.Route.Timeline import dev.dimension.flare.ui.screen.compose.ComposeDialog +import dev.dimension.flare.ui.screen.dm.DmConversationScreen +import dev.dimension.flare.ui.screen.dm.DmListScreen +import dev.dimension.flare.ui.screen.dm.UserDMConversationScreen import dev.dimension.flare.ui.screen.feeds.FeedListScreen import dev.dimension.flare.ui.screen.home.DiscoverScreen import dev.dimension.flare.ui.screen.home.NotificationScreen @@ -183,8 +189,8 @@ internal fun RouteContent( FluentDialog(visible = true) { ComposeDialog( onBack = onBack, - status = ComposeStatus.Quote(route.statusKey), - accountType = AccountType.Specific(accountKey = route.accountKey), + status = Quote(route.statusKey), + accountType = Specific(accountKey = route.accountKey), ) } @@ -192,8 +198,8 @@ internal fun RouteContent( FluentDialog(visible = true) { ComposeDialog( onBack = onBack, - status = ComposeStatus.Reply(route.statusKey), - accountType = AccountType.Specific(accountKey = route.accountKey), + status = Reply(route.statusKey), + accountType = Specific(accountKey = route.accountKey), ) } @@ -201,8 +207,8 @@ internal fun RouteContent( FluentDialog(visible = true) { ComposeDialog( onBack = onBack, - accountType = AccountType.Specific(accountKey = route.accountKey), - status = ComposeStatus.VVOComment(route.replyTo, route.rootId), + accountType = Specific(accountKey = route.accountKey), + status = VVOComment(route.replyTo, route.rootId), ) } @@ -250,10 +256,6 @@ internal fun RouteContent( ) } - is Route.DirectMessage -> { - Text("route") - } - is Route.Discover -> { DiscoverScreen( accountType = route.accountType, @@ -276,7 +278,7 @@ internal fun RouteContent( ) } - is Route.Search -> { + is Search -> { SearchScreen( initialQuery = route.keyword, accountType = route.accountType, @@ -304,7 +306,7 @@ internal fun RouteContent( ) } - is Route.Profile -> { + is Profile -> { ProfileScreen( accountType = route.accountType, userKey = route.userKey, @@ -317,7 +319,14 @@ internal fun RouteContent( ), ) }, - toStartMessage = {}, + toStartMessage = { + navigate( + Route.DmUserConversation( + accountType = route.accountType, + userKey = it, + ), + ) + }, onFollowListClick = {}, onFansListClick = {}, ) @@ -383,7 +392,14 @@ internal fun RouteContent( ), ) }, - toStartMessage = {}, + toStartMessage = { + navigate( + Route.DmUserConversation( + accountType = route.accountType, + userKey = it, + ), + ) + }, onFollowListClick = {}, onFansListClick = {}, ) @@ -393,7 +409,7 @@ internal fun RouteContent( RssListScreen( toItem = { navigate( - Route.RssTimeline( + RssTimeline( url = it.url, title = it.title, id = it.id, @@ -402,7 +418,7 @@ internal fun RouteContent( }, onEdit = { navigate( - Route.EditRssSource( + EditRssSource( id = it.id, ), ) @@ -429,5 +445,49 @@ internal fun RouteContent( statusKey = route.statusKey, index = route.index, ) + + is Route.DmList -> + DmListScreen( + accountType = route.accountType, + onItemClicked = { + navigate( + Route.DmConversation( + accountType = route.accountType, + roomKey = it, + ), + ) + }, + ) + + is Route.DmConversation -> + DmConversationScreen( + accountType = route.accountType, + roomKey = route.roomKey, + onBack = onBack, + toProfile = { + navigate( + Profile( + accountType = route.accountType, + userKey = it, + ), + ) + }, + ) + + is Route.DmUserConversation -> { + UserDMConversationScreen( + accountType = route.accountType, + userKey = route.userKey, + onBack = onBack, + toProfile = { + navigate( + Profile( + accountType = route.accountType, + userKey = it, + ), + ) + }, + ) + } } } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DmConversationScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DmConversationScreen.kt new file mode 100644 index 000000000..8b261ce33 --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DmConversationScreen.kt @@ -0,0 +1,288 @@ +package dev.dimension.flare.ui.screen.dm + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.ArrowRightFromBracket +import compose.icons.fontawesomeicons.solid.CircleUser +import compose.icons.fontawesomeicons.solid.EllipsisVertical +import compose.icons.fontawesomeicons.solid.PaperPlane +import dev.dimension.flare.LocalWindowPadding +import dev.dimension.flare.Res +import dev.dimension.flare.common.onSuccess +import dev.dimension.flare.dm_conversation +import dev.dimension.flare.dm_leave +import dev.dimension.flare.dm_send_placeholder +import dev.dimension.flare.dm_to_profile +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.more +import dev.dimension.flare.send +import dev.dimension.flare.ui.common.items +import dev.dimension.flare.ui.common.plus +import dev.dimension.flare.ui.component.FAIcon +import dev.dimension.flare.ui.component.RichText +import dev.dimension.flare.ui.component.dm.DMItem +import dev.dimension.flare.ui.model.onError +import dev.dimension.flare.ui.model.onSuccess +import dev.dimension.flare.ui.presenter.dm.DMConversationPresenter +import dev.dimension.flare.ui.presenter.dm.DMConversationState +import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.theme.screenHorizontalPadding +import io.github.composefluent.FluentTheme +import io.github.composefluent.component.MenuFlyoutContainer +import io.github.composefluent.component.MenuFlyoutItem +import io.github.composefluent.component.Scrollbar +import io.github.composefluent.component.ScrollbarContainer +import io.github.composefluent.component.SubtleButton +import io.github.composefluent.component.Text +import io.github.composefluent.component.TextField +import moe.tlaster.precompose.molecule.producePresenter +import org.jetbrains.compose.resources.stringResource + +@Composable +fun DmConversationScreen( + accountType: AccountType, + roomKey: MicroBlogKey, + onBack: () -> Unit, + toProfile: (MicroBlogKey) -> Unit, +) { + val state by producePresenter( + key = "dm_conversation_${accountType}_$roomKey", + ) { + presenter( + accountType = accountType, + roomKey = roomKey, + ) + } + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + val listState = rememberLazyListState() + val scrollbarAdapter = rememberScrollbarAdapter(listState) + state.items.onSuccess { + if (listState.firstVisibleItemIndex == 0) { + LaunchedEffect(itemCount) { + listState.scrollToItem(0) + } + } + } + + Column { + Row( + modifier = + Modifier + .background(FluentTheme.colors.background.card.default) + .fillMaxWidth() + .padding(start = 40.dp) + .padding(LocalWindowPadding.current + PaddingValues(8.dp)), + verticalAlignment = Alignment.CenterVertically, + ) { + state.users + .onSuccess { + if (it.size == 1) { + RichText( + text = it.first().name, + maxLines = 1, + ) + } else { + Text( + text = stringResource(Res.string.dm_conversation), + ) + } + }.onError { + Text(it.message.toString()) + } + Spacer(modifier = Modifier.weight(1f)) + MenuFlyoutContainer( + flyout = { + state.users.onSuccess { + if (it.size == 1) { + MenuFlyoutItem( + text = { + Text( + text = stringResource(Res.string.dm_to_profile), + ) + }, + icon = { + FAIcon( + imageVector = FontAwesomeIcons.Solid.CircleUser, + contentDescription = stringResource(Res.string.dm_to_profile), + ) + }, + onClick = { + isFlyoutVisible = false + toProfile.invoke(it.first().key) + }, + ) + } + } + + MenuFlyoutItem( + text = { + Text( + text = stringResource(Res.string.dm_leave), + color = FluentTheme.colors.system.critical, + ) + }, + icon = { + FAIcon( + imageVector = FontAwesomeIcons.Solid.ArrowRightFromBracket, + contentDescription = stringResource(Res.string.dm_leave), + tint = FluentTheme.colors.system.critical, + ) + }, + onClick = { + isFlyoutVisible = false + state.leave() + onBack() + }, + ) + }, + ) { + SubtleButton( + onClick = { isFlyoutVisible = true }, + iconOnly = true, + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.EllipsisVertical, + contentDescription = stringResource(Res.string.more), + ) + } + } + } + ScrollbarContainer( + adapter = scrollbarAdapter, + scrollbar = { + Scrollbar(true, scrollbarAdapter, reverseLayout = true) + }, + ) { + LazyColumn( + state = listState, + reverseLayout = true, + contentPadding = PaddingValues(top = 8.dp), + modifier = + Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.Bottom), + ) { + stickyHeader { + TextField( + state = state.text, + modifier = + Modifier + .padding(8.dp) + .fillMaxWidth() + .focusRequester(focusRequester), + lineLimits = TextFieldLineLimits.SingleLine, + trailing = { + SubtleButton( + onClick = { + state.send() + }, + disabled = !state.canSend, + iconOnly = true, + ) { + FAIcon( + FontAwesomeIcons.Solid.PaperPlane, + contentDescription = stringResource(Res.string.send), + ) + } + }, + placeholder = { + Text( + text = stringResource(Res.string.dm_send_placeholder), + ) + }, + keyboardOptions = + KeyboardOptions( + imeAction = ImeAction.Send, + ), + onKeyboardAction = { + if (state.canSend) { + state.send() + } + }, + ) + } + items( + state.items, + key = { + get(it)?.id ?: it + }, + itemContent = { item -> + DMItem( + item = item, + onRetry = { + state.retry(item.key) + }, + modifier = + Modifier + .animateItem() + .padding( + horizontal = screenHorizontalPadding, + ), + onUserClicked = { + toProfile.invoke(it.key) + }, + ) + }, + ) + } + } + } +} + +@Composable +private fun presenter( + accountType: AccountType, + roomKey: MicroBlogKey, +) = run { + val text = rememberTextFieldState() + val state = + remember( + accountType, + roomKey, + ) { + DMConversationPresenter( + accountType = accountType, + roomKey = roomKey, + ) + }.invoke() + + object : DMConversationState by state { + val text = text + val canSend = text.text.isNotEmpty() + + fun send() { + send(text.text.toString()) + text.clearText() + } + } +} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DmListScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DmListScreen.kt new file mode 100644 index 000000000..5d90f7116 --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DmListScreen.kt @@ -0,0 +1,83 @@ +package dev.dimension.flare.ui.screen.dm + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.dimension.flare.LocalWindowPadding +import dev.dimension.flare.RegisterTabCallback +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.common.plus +import dev.dimension.flare.ui.component.dm.dmList +import dev.dimension.flare.ui.presenter.dm.DMListPresenter +import dev.dimension.flare.ui.presenter.dm.DMListState +import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.theme.screenHorizontalPadding +import io.github.composefluent.component.ProgressBar +import kotlinx.coroutines.launch +import moe.tlaster.precompose.molecule.producePresenter + +@Composable +internal fun DmListScreen( + accountType: AccountType, + onItemClicked: (MicroBlogKey) -> Unit, +) { + val state by producePresenter("dm_list_$accountType") { + presenter(accountType) + } + val listState = rememberLazyListState() + + RegisterTabCallback(listState, onRefresh = state::refresh) + + Box { + LazyColumn( + contentPadding = LocalWindowPadding.current + PaddingValues(top = 8.dp), + modifier = + Modifier + .padding(horizontal = screenHorizontalPadding), + verticalArrangement = Arrangement.spacedBy(2.dp), + state = listState, + ) { + dmList( + data = state.items, + onItemClicked = onItemClicked, + ) + } + if (state.isRefreshing) { + ProgressBar( + modifier = + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth(), + ) + } + } +} + +@Composable +private fun presenter(accountType: AccountType) = + run { + val scope = rememberCoroutineScope() + val state = + remember(accountType) { + DMListPresenter(accountType) + }.invoke() + object : DMListState by state { + fun refresh() { + scope.launch { + state.refreshSuspend() + } + } + } + } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/UserDMConversationScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/UserDMConversationScreen.kt new file mode 100644 index 000000000..70c192d40 --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/UserDMConversationScreen.kt @@ -0,0 +1,73 @@ +package dev.dimension.flare.ui.screen.dm + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import dev.dimension.flare.Res +import dev.dimension.flare.dm_list_error +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.MicroBlogKey +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.dm.UserDMConversationPresenter +import dev.dimension.flare.ui.presenter.invoke +import io.github.composefluent.component.ProgressRing +import io.github.composefluent.component.Text +import moe.tlaster.precompose.molecule.producePresenter +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun UserDMConversationScreen( + accountType: AccountType, + userKey: MicroBlogKey, + onBack: () -> Unit, + toProfile: (MicroBlogKey) -> Unit, +) { + val state by producePresenter(key = "UserDMConversationScreen${userKey}$accountType") { + presenter( + accountType = accountType, + userKey = userKey, + ) + } + state.roomKey + .onSuccess { + DmConversationScreen( + accountType = accountType, + roomKey = it, + onBack = onBack, + toProfile = toProfile, + ) + }.onLoading { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + ProgressRing() + } + }.onError { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text(text = stringResource(Res.string.dm_list_error)) + } + } +} + +@Composable +private fun presenter( + accountType: AccountType, + userKey: MicroBlogKey, +) = run { + remember(accountType, userKey) { + UserDMConversationPresenter( + accountType = accountType, + userKey = userKey, + ) + }.invoke() +} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedListScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedListScreen.kt index f3d79ab31..e9fc51471 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedListScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedListScreen.kt @@ -1,16 +1,22 @@ package dev.dimension.flare.ui.screen.feeds import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import compose.icons.FontAwesomeIcons @@ -18,7 +24,9 @@ import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.Plus import compose.icons.fontawesomeicons.solid.Trash import dev.dimension.flare.LocalWindowPadding +import dev.dimension.flare.RegisterTabCallback import dev.dimension.flare.Res +import dev.dimension.flare.common.isRefreshing import dev.dimension.flare.feeds_discover_feeds_title import dev.dimension.flare.feeds_my_feeds_title import dev.dimension.flare.model.AccountType @@ -31,9 +39,13 @@ import dev.dimension.flare.ui.component.status.StatusPlaceholder import dev.dimension.flare.ui.component.uiListItemComponent import dev.dimension.flare.ui.model.UiList import dev.dimension.flare.ui.presenter.home.bluesky.BlueskyFeedsPresenter +import dev.dimension.flare.ui.presenter.home.bluesky.BlueskyFeedsState import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.theme.screenHorizontalPadding +import io.github.composefluent.component.ProgressBar +import io.github.composefluent.component.ScrollbarContainer import io.github.composefluent.component.SubtleButton +import kotlinx.coroutines.launch import moe.tlaster.precompose.molecule.producePresenter import org.jetbrains.compose.resources.stringResource @@ -45,82 +57,104 @@ internal fun FeedListScreen( val state by producePresenter("FeedListScreen_$accountType") { presenter(accountType) } - Column( - modifier = - Modifier - .fillMaxSize() - .padding(horizontal = screenHorizontalPadding), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - LazyColumn( - contentPadding = - PaddingValues( - vertical = 8.dp, - ) + LocalWindowPadding.current, - modifier = - Modifier - .fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - item { - Header(stringResource(Res.string.feeds_my_feeds_title)) - } - uiListItemComponent( - state.myFeeds, - onClicked = toFeed, - ) + val listState = rememberLazyListState() + val scrollbarAdapter = rememberScrollbarAdapter(listState) + RegisterTabCallback(listState, onRefresh = state::refresh) - item { - Header(stringResource(Res.string.feeds_discover_feeds_title)) - } - items( - state.popularFeeds, - loadingContent = { - Column { - Spacer(modifier = Modifier.height(8.dp)) - StatusPlaceholder( - modifier = Modifier.padding(horizontal = screenHorizontalPadding), - ) - Spacer(modifier = Modifier.height(8.dp)) - } - }, - ) { (item, subscribed) -> - UiListItem( - item = item, + Box { + ScrollbarContainer( + adapter = scrollbarAdapter, + ) { + LazyColumn( + contentPadding = + PaddingValues( + vertical = 8.dp, + ) + LocalWindowPadding.current, + modifier = + Modifier + .padding(horizontal = screenHorizontalPadding) + .fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + item { + Header(stringResource(Res.string.feeds_my_feeds_title)) + } + uiListItemComponent( + state.myFeeds, onClicked = toFeed, - trailingContent = { - SubtleButton( - onClick = { + ) + + item { + Header(stringResource(Res.string.feeds_discover_feeds_title)) + } + items( + state.popularFeeds, + loadingContent = { + Column { + Spacer(modifier = Modifier.height(8.dp)) + StatusPlaceholder( + modifier = Modifier.padding(horizontal = screenHorizontalPadding), + ) + Spacer(modifier = Modifier.height(8.dp)) + } + }, + ) { (item, subscribed) -> + UiListItem( + item = item, + onClicked = toFeed, + trailingContent = { + SubtleButton( + onClick = { + if (subscribed) { + state.unsubscribe(item) + } else { + state.subscribe(item) + } + }, + ) { if (subscribed) { - state.unsubscribe(item) + FAIcon( + imageVector = FontAwesomeIcons.Solid.Trash, + contentDescription = null, + ) } else { - state.subscribe(item) + FAIcon( + imageVector = FontAwesomeIcons.Solid.Plus, + contentDescription = null, + ) } - }, - ) { - if (subscribed) { - FAIcon( - imageVector = FontAwesomeIcons.Solid.Trash, - contentDescription = null, - ) - } else { - FAIcon( - imageVector = FontAwesomeIcons.Solid.Plus, - contentDescription = null, - ) } - } - }, - ) + }, + ) + } } } + + if (state.myFeeds.isRefreshing || state.popularFeeds.isRefreshing) { + ProgressBar( + modifier = + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth(), + ) + } } } @Composable private fun presenter(accountType: AccountType) = run { - remember(accountType) { - BlueskyFeedsPresenter(accountType) - }.invoke() + val scope = rememberCoroutineScope() + val state = + remember(accountType) { + BlueskyFeedsPresenter(accountType) + }.invoke() + + object : BlueskyFeedsState by state { + fun refresh() { + scope.launch { + state.refreshSuspend() + } + } + } } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt index fe5b3294a..4e63308b2 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.grid.GridCells @@ -34,6 +35,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.MagnifyingGlass import compose.icons.fontawesomeicons.solid.Trash import dev.dimension.flare.LocalWindowPadding import dev.dimension.flare.RegisterTabCallback @@ -119,6 +121,13 @@ internal fun DiscoverScreen( KeyboardOptions( imeAction = ImeAction.Search, ), + trailing = { + FAIcon( + FontAwesomeIcons.Solid.MagnifyingGlass, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, ) state.searchHistories.onSuccess { history -> val searchResult by remember(history) { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/SearchScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/SearchScreen.kt index 2b6662b0c..2077ee9f1 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/SearchScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/SearchScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.grid.GridCells @@ -32,6 +33,7 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.MagnifyingGlass import compose.icons.fontawesomeicons.solid.Trash import dev.dimension.flare.LocalWindowPadding import dev.dimension.flare.RegisterTabCallback @@ -115,6 +117,13 @@ fun SearchScreen( KeyboardOptions( imeAction = ImeAction.Search, ), + trailing = { + FAIcon( + FontAwesomeIcons.Solid.MagnifyingGlass, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, ) state.searchHistories.onSuccess { history -> val searchResult by remember(history) { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/list/AllListScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/list/AllListScreen.kt index 4cf0e42ac..290a0f697 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/list/AllListScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/list/AllListScreen.kt @@ -1,22 +1,30 @@ package dev.dimension.flare.ui.screen.list import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import dev.dimension.flare.RegisterTabCallback import dev.dimension.flare.model.AccountType import dev.dimension.flare.ui.component.uiListItemComponent import dev.dimension.flare.ui.model.UiList import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.presenter.list.AllListPresenter import dev.dimension.flare.ui.theme.screenHorizontalPadding +import io.github.composefluent.component.ProgressBar +import io.github.composefluent.component.ScrollbarContainer import moe.tlaster.precompose.molecule.producePresenter @Composable @@ -28,12 +36,17 @@ internal fun AllListScreen( val state by producePresenter("AllListScreen_$accountType") { presenter(accountType) } - Column( - modifier = - Modifier - .fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { + val listState = rememberLazyListState() + val scrollbarAdapter = rememberScrollbarAdapter(listState) + RegisterTabCallback(listState, onRefresh = state::refresh) + + Box { + Column( + modifier = + Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { // Row( // horizontalArrangement = Arrangement.End, // modifier = @@ -63,20 +76,23 @@ internal fun AllListScreen( // ) // } // } - LazyColumn( - contentPadding = - PaddingValues( - vertical = 8.dp, - ), - modifier = - Modifier - .fillMaxSize() - .padding(horizontal = screenHorizontalPadding), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - uiListItemComponent( - state.items, - onClicked = toList, + ScrollbarContainer( + adapter = scrollbarAdapter, + ) { + LazyColumn( + contentPadding = + PaddingValues( + vertical = 8.dp, + ), + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = screenHorizontalPadding), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + uiListItemComponent( + state.items, + onClicked = toList, // trailingContent = { item -> // if (!item.readonly) { // SubtleButton( @@ -90,6 +106,16 @@ internal fun AllListScreen( // } // } // }, + ) + } + } + } + if (state.isRefreshing) { + ProgressBar( + modifier = + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth(), ) } } diff --git a/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/theme/PlatformColorScheme.android.kt b/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/theme/PlatformColorScheme.android.kt index 19b64e0a0..f2cd60179 100644 --- a/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/theme/PlatformColorScheme.android.kt +++ b/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/theme/PlatformColorScheme.android.kt @@ -29,4 +29,7 @@ internal actual object PlatformColorScheme { public actual val cardAlt: Color @Composable get() = MaterialTheme.colorScheme.surfaceVariant + actual val onCard: Color + @Composable + get() = MaterialTheme.colorScheme.onSurface } diff --git a/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.android.kt b/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.android.kt index 5fe05554c..8e57580ee 100644 --- a/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.android.kt +++ b/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.android.kt @@ -2,6 +2,7 @@ package dev.dimension.flare.ui.theme import android.os.Build import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.foundation.shape.CornerSize import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -14,7 +15,9 @@ import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.drawscope.clipPath import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.dp +@OptIn(ExperimentalMaterial3ExpressiveApi::class) internal actual object PlatformShapes { actual val extraSmall: Shape @Composable @@ -29,7 +32,6 @@ internal actual object PlatformShapes { @Composable get() = MaterialTheme.shapes.large - @OptIn(ExperimentalMaterial3ExpressiveApi::class) actual val topCardShape: Shape @Composable get() = @@ -38,7 +40,6 @@ internal actual object PlatformShapes { topEnd = listCardContainerShape.topEnd, ) - @OptIn(ExperimentalMaterial3ExpressiveApi::class) actual val bottomCardShape: Shape @Composable get() = @@ -55,6 +56,18 @@ internal actual object PlatformShapes { actual val listCardItemShape: CornerBasedShape @Composable get() = MaterialTheme.shapes.extraSmall + actual val dmShapeFromMe: CornerBasedShape + @Composable + get() = + MaterialTheme.shapes.largeIncreased.copy( + bottomEnd = CornerSize(0.dp), + ) + actual val dmShapeFromOther: CornerBasedShape + @Composable + get() = + MaterialTheme.shapes.largeIncreased.copy( + bottomStart = CornerSize(0.dp), + ) } public object ListCardShapes { diff --git a/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/theme/PlatformTypography.android.kt b/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/theme/PlatformTypography.android.kt index 1a6f6ea4f..9e7e03562 100644 --- a/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/theme/PlatformTypography.android.kt +++ b/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/theme/PlatformTypography.android.kt @@ -12,4 +12,7 @@ internal actual object PlatformTypography { actual val title: TextStyle @Composable get() = MaterialTheme.typography.titleMedium + actual val headline: TextStyle + @Composable + get() = MaterialTheme.typography.headlineMedium } diff --git a/shared/ui/component/src/commonMain/composeResources/values/strings.xml b/shared/ui/component/src/commonMain/composeResources/values/strings.xml index a9cbedc88..f5830d692 100644 --- a/shared/ui/component/src/commonMain/composeResources/values/strings.xml +++ b/shared/ui/component/src/commonMain/composeResources/values/strings.xml @@ -393,4 +393,15 @@ Accept Reject + + + No direct messages + Failed to load direct messages + Send + Send a message + Deleted + Direct Message Room + Sending + Leave conversation + Show profile \ No newline at end of file diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/UiListItemComponent.kt b/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/UiListItemComponent.kt index c9292cab1..a5327d2fc 100644 --- a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/UiListItemComponent.kt +++ b/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/UiListItemComponent.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListScope @@ -182,6 +183,8 @@ public fun UiListItem( text = it, modifier = Modifier + .background(PlatformTheme.colorScheme.card) + .fillMaxWidth() .padding(bottom = 8.dp) .padding(horizontal = screenHorizontalPadding), ) diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/dm/DMItem.kt b/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/dm/DMItem.kt new file mode 100644 index 000000000..ac561b687 --- /dev/null +++ b/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/dm/DMItem.kt @@ -0,0 +1,185 @@ +package dev.dimension.flare.ui.component.dm + +import androidx.compose.foundation.background +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.CircleExclamation +import dev.dimension.flare.common.AppDeepLink +import dev.dimension.flare.ui.component.AvatarComponent +import dev.dimension.flare.ui.component.AvatarComponentDefaults +import dev.dimension.flare.ui.component.FAIcon +import dev.dimension.flare.ui.component.Res +import dev.dimension.flare.ui.component.RichText +import dev.dimension.flare.ui.component.dm_deleted +import dev.dimension.flare.ui.component.dm_sending +import dev.dimension.flare.ui.component.platform.PlatformIconButton +import dev.dimension.flare.ui.component.platform.PlatformText +import dev.dimension.flare.ui.component.send +import dev.dimension.flare.ui.component.status.MediaItem +import dev.dimension.flare.ui.component.status.QuotedStatus +import dev.dimension.flare.ui.model.UiDMItem +import dev.dimension.flare.ui.model.UiMedia +import dev.dimension.flare.ui.model.UiUserV2 +import dev.dimension.flare.ui.model.localizedShortTime +import dev.dimension.flare.ui.theme.PlatformTheme +import org.jetbrains.compose.resources.stringResource + +@Composable +public fun DMItem( + item: UiDMItem, + onRetry: () -> Unit, + onUserClicked: (UiUserV2) -> Unit, + modifier: Modifier = Modifier, +) { + val uriHandler = LocalUriHandler.current + Column( + modifier = + modifier + .fillMaxWidth(), + horizontalAlignment = + if (item.isFromMe) { + Alignment.End + } else { + Alignment.Start + }, + ) { + Box( + modifier = + Modifier + .fillMaxWidth(0.75f), + contentAlignment = + if (item.isFromMe) { + Alignment.CenterEnd + } else { + Alignment.CenterStart + }, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (item.showSender) { + AvatarComponent( + data = item.user.avatar, + modifier = + Modifier.clickable { + onUserClicked.invoke(item.user) + }, + ) + } + if (item.sendState == UiDMItem.SendState.Failed) { + PlatformIconButton( + onClick = onRetry, + ) { + FAIcon( + FontAwesomeIcons.Solid.CircleExclamation, + contentDescription = stringResource(Res.string.send), + tint = PlatformTheme.colorScheme.error, + ) + } + } + when (val message = item.content) { + is UiDMItem.Message.Text -> + RichText( + text = message.text, + modifier = + Modifier + .background( + color = PlatformTheme.colorScheme.card, + shape = + if (item.isFromMe) { + PlatformTheme.shapes.dmShapeFromMe + } else { + PlatformTheme.shapes.dmShapeFromOther + }, + ).padding( + vertical = 8.dp, + horizontal = 16.dp, + ), + color = PlatformTheme.colorScheme.onCard, + ) + + UiDMItem.Message.Deleted -> + PlatformText( + text = stringResource(Res.string.dm_deleted), + style = PlatformTheme.typography.caption, + color = PlatformTheme.colorScheme.onCard, + ) + + is UiDMItem.Message.Media -> + MediaItem( + media = message.media, + modifier = + Modifier + .clip(PlatformTheme.shapes.large) + .clickable { + if (message.media is UiMedia.Image) { + uriHandler.openUri(AppDeepLink.RawImage.invoke(message.media.url)) + } + }, + ) + + is UiDMItem.Message.Status -> + QuotedStatus( + message.status, + modifier = + Modifier + .clip(PlatformTheme.shapes.large) + .background( + color = + if (item.isFromMe) { + PlatformTheme.colorScheme.primaryContainer + } else { + PlatformTheme.colorScheme.card + }, + shape = PlatformTheme.shapes.large, + ), + ) + } + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (item.showSender) { + Spacer(modifier = Modifier.width(AvatarComponentDefaults.size)) + RichText( + text = item.user.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textStyle = PlatformTheme.typography.caption, + color = PlatformTheme.colorScheme.onCard, + ) + } + if (item.sendState == UiDMItem.SendState.Sending) { + PlatformText( + text = stringResource(Res.string.dm_sending), + style = PlatformTheme.typography.caption, + color = PlatformTheme.colorScheme.onCard, + ) + } else if (item.sendState == null || item.sendState != UiDMItem.SendState.Failed) { + PlatformText( + item.timestamp.shortTime.localizedShortTime, + style = PlatformTheme.typography.caption, + color = PlatformTheme.colorScheme.onCard, + ) + } + } + } +} diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/dm/DmListItem.kt b/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/dm/DmListItem.kt new file mode 100644 index 000000000..d5a22ae81 --- /dev/null +++ b/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/dm/DmListItem.kt @@ -0,0 +1,242 @@ +package dev.dimension.flare.ui.component.dm + +import androidx.compose.foundation.background +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.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.CircleExclamation +import compose.icons.fontawesomeicons.solid.CircleUser +import compose.icons.fontawesomeicons.solid.List +import dev.dimension.flare.common.PagingState +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.common.itemsIndexed +import dev.dimension.flare.ui.component.AvatarComponent +import dev.dimension.flare.ui.component.AvatarComponentDefaults +import dev.dimension.flare.ui.component.FAIcon +import dev.dimension.flare.ui.component.ItemPlaceHolder +import dev.dimension.flare.ui.component.Res +import dev.dimension.flare.ui.component.RichText +import dev.dimension.flare.ui.component.dm_list_empty +import dev.dimension.flare.ui.component.dm_list_error +import dev.dimension.flare.ui.component.listCard +import dev.dimension.flare.ui.component.platform.PlatformListItem +import dev.dimension.flare.ui.component.platform.PlatformText +import dev.dimension.flare.ui.model.UiDMRoom +import dev.dimension.flare.ui.model.localizedShortTime +import dev.dimension.flare.ui.theme.MediumAlpha +import dev.dimension.flare.ui.theme.PlatformTheme +import org.jetbrains.compose.resources.stringResource +import kotlin.math.min + +public fun LazyListScope.dmList( + data: PagingState, + onItemClicked: (MicroBlogKey) -> Unit, +) { + itemsIndexed( + data, + emptyContent = { + Box( + modifier = Modifier.fillParentMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.List, + contentDescription = stringResource(Res.string.dm_list_empty), + modifier = Modifier.size(48.dp), + ) + PlatformText( + text = stringResource(Res.string.dm_list_empty), + style = PlatformTheme.typography.headline, + ) + } + } + }, + loadingContent = { index, itemCount -> + ItemPlaceHolder( + modifier = + Modifier + .listCard( + index = index, + totalCount = itemCount, + ), + ) + }, + errorContent = { + Box( + modifier = Modifier.fillParentMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.CircleExclamation, + contentDescription = stringResource(Res.string.dm_list_error), + modifier = Modifier.size(48.dp), + ) + PlatformText( + text = stringResource(Res.string.dm_list_error), + style = PlatformTheme.typography.headline, + ) + PlatformText( + text = it.message.orEmpty(), + style = PlatformTheme.typography.headline, + ) + } + } + }, + itemContent = { index, itemCount, item -> + PlatformListItem( + headlineContent = { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (item.hasUser) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f), + ) { + item.users.forEach { user -> + RichText( + text = user.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (item.users.size == 1) { + PlatformText( + text = user.handle, + style = PlatformTheme.typography.caption, + modifier = + Modifier + .alpha(MediumAlpha), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + val lastMessage = item.lastMessage + if (lastMessage != null) { + PlatformText( + text = lastMessage.timestamp.shortTime.localizedShortTime, + style = PlatformTheme.typography.caption, + modifier = + Modifier + .alpha(MediumAlpha), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + }, + leadingContent = { + if (!item.hasUser) { + FAIcon( + FontAwesomeIcons.Solid.CircleUser, + contentDescription = null, + modifier = + Modifier + .size(AvatarComponentDefaults.size), + tint = PlatformTheme.colorScheme.primary, + ) + } else { + Box( + modifier = + Modifier + .size(AvatarComponentDefaults.size), + ) { + repeat( + min(item.users.size, 2), + ) { + val avatar = item.users[it].avatar + if (item.users.size == 1) { + AvatarComponent(avatar) + } else { + Box( + modifier = + Modifier + .offset( + x = (it * 12).dp, + y = (it * 12).dp, + ), + ) { + AvatarComponent( + avatar, + size = AvatarComponentDefaults.compatSize, + ) + } + } + } + if (item.users.size > 1) { + PlatformText( + item.users.size.toString(), + modifier = + Modifier + .align(Alignment.BottomEnd) + .background( + PlatformTheme.colorScheme.card, + shape = PlatformTheme.shapes.small, + ).padding(horizontal = 4.dp), + ) + } + } + } + }, + supportingContent = { + PlatformText( + text = item.lastMessageText, + style = PlatformTheme.typography.caption, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + trailingContent = { + if (item.unreadCount > 0) { + PlatformText( + text = item.unreadCount.toString(), + modifier = + Modifier + .background( + PlatformTheme.colorScheme.primary, + shape = CircleShape, + ).padding(horizontal = 4.dp), + ) + } + }, + modifier = + Modifier + .listCard( + index = index, + totalCount = itemCount, + ).clickable { + onItemClicked.invoke(item.key) + }, + ) + }, + ) +} diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/theme/PlatformColorScheme.kt b/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/theme/PlatformColorScheme.kt index 54016a96b..a0e411f6a 100644 --- a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/theme/PlatformColorScheme.kt +++ b/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/theme/PlatformColorScheme.kt @@ -27,4 +27,7 @@ internal expect object PlatformColorScheme { @get:Composable val cardAlt: Color + + @get:Composable + val onCard: Color } diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.kt b/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.kt index ac3657d10..364017068 100644 --- a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.kt +++ b/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.kt @@ -27,4 +27,10 @@ internal expect object PlatformShapes { @get:Composable val listCardItemShape: CornerBasedShape + + @get:Composable + val dmShapeFromMe: CornerBasedShape + + @get:Composable + val dmShapeFromOther: CornerBasedShape } diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/theme/PlatformTypography.kt b/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/theme/PlatformTypography.kt index cb0d470b2..0125c6f58 100644 --- a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/theme/PlatformTypography.kt +++ b/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/theme/PlatformTypography.kt @@ -9,4 +9,7 @@ internal expect object PlatformTypography { @get:Composable val title: TextStyle + + @get:Composable + val headline: TextStyle } diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformListItem.jvm.kt b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformListItem.jvm.kt index 321bb8cca..2590875d2 100644 --- a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformListItem.jvm.kt +++ b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformListItem.jvm.kt @@ -1,10 +1,12 @@ package dev.dimension.flare.ui.component.platform +import androidx.compose.foundation.background import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import dev.dimension.flare.ui.component.status.ListComponent +import dev.dimension.flare.ui.theme.PlatformTheme @Composable internal actual fun PlatformListItem( @@ -18,7 +20,10 @@ internal actual fun PlatformListItem( headlineContent = { headlineContent.invoke() }, - modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp), + modifier = + modifier + .background(PlatformTheme.colorScheme.card) + .padding(horizontal = 16.dp, vertical = 8.dp), leadingContent = { leadingContent.invoke() }, diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformColorScheme.jvm.kt b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformColorScheme.jvm.kt index 124fbe782..45a0f9ae6 100644 --- a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformColorScheme.jvm.kt +++ b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformColorScheme.jvm.kt @@ -29,4 +29,7 @@ internal actual object PlatformColorScheme { public actual val cardAlt: Color @Composable get() = FluentTheme.colors.background.card.secondary + actual val onCard: Color + @Composable + get() = FluentTheme.colors.text.text.primary } diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.jvm.kt b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.jvm.kt index 94143bb74..25e716862 100644 --- a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.jvm.kt +++ b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.jvm.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.shape.CornerBasedShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.dp import io.github.composefluent.FluentTheme internal actual object PlatformShapes { @@ -43,4 +44,22 @@ internal actual object PlatformShapes { actual val listCardItemShape: CornerBasedShape @Composable get() = RoundedCornerShape(FluentTheme.cornerRadius.control) + actual val dmShapeFromMe: CornerBasedShape + @Composable + get() = + RoundedCornerShape( + topStart = FluentTheme.cornerRadius.overlay, + topEnd = FluentTheme.cornerRadius.overlay, + bottomStart = FluentTheme.cornerRadius.overlay, + bottomEnd = 0.dp, + ) + actual val dmShapeFromOther: CornerBasedShape + @Composable + get() = + RoundedCornerShape( + topStart = FluentTheme.cornerRadius.overlay, + topEnd = FluentTheme.cornerRadius.overlay, + bottomStart = 0.dp, + bottomEnd = FluentTheme.cornerRadius.overlay, + ) } diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformTypography.jvm.kt b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformTypography.jvm.kt index 2991a5c46..956a9b227 100644 --- a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformTypography.jvm.kt +++ b/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformTypography.jvm.kt @@ -12,4 +12,7 @@ internal actual object PlatformTypography { actual val title: TextStyle @Composable get() = FluentTheme.typography.subtitle + actual val headline: TextStyle + @Composable + get() = FluentTheme.typography.title } From 84509b26a965dfae56e1c67d1aba698a057b48b5 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Fri, 22 Aug 2025 16:02:26 +0900 Subject: [PATCH 18/33] add misskey antennas support --- .../main/kotlin/dev/dimension/flare/App.kt | 2 +- .../dev/dimension/flare/ui/route/Route.kt | 5 + .../dev/dimension/flare/ui/route/Router.kt | 23 +++ .../ui/screen/misskey/AntennasListScreen.kt | 189 ++++++++++++++++++ 4 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/misskey/AntennasListScreen.kt diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt index 99fc40061..e3c8e4955 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt @@ -400,7 +400,7 @@ private fun getRoute(tab: TabItem): Route = is Bluesky.FeedsTabItem -> BlueskyFeeds(tab.account) is DirectMessageTabItem -> Route.DmList(tab.account) is RssTabItem -> Route.RssList - is Misskey.AntennasListTabItem -> Route.RssList + is Misskey.AntennasListTabItem -> Route.MisskeyAntennas(tab.account) } @Composable diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt index d8c22b0b7..73454c7aa 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt @@ -209,6 +209,11 @@ internal sealed interface Route { val userKey: MicroBlogKey, ) : ScreenRoute + @Serializable + data class MisskeyAntennas( + val accountType: AccountType, + ) : ScreenRoute + companion object { public fun parse(url: String): Route? { val data = Url(url) diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt index 038330dd4..8358d360e 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt @@ -7,8 +7,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import dev.dimension.flare.common.OnDeepLink import dev.dimension.flare.data.model.Bluesky.FeedTabItem +import dev.dimension.flare.data.model.IconType import dev.dimension.flare.data.model.IconType.Material import dev.dimension.flare.data.model.ListTimelineTabItem +import dev.dimension.flare.data.model.Misskey import dev.dimension.flare.data.model.RssTimelineTabItem import dev.dimension.flare.data.model.TabMetaData import dev.dimension.flare.data.model.TitleType @@ -35,6 +37,7 @@ import dev.dimension.flare.ui.screen.home.TimelineScreen import dev.dimension.flare.ui.screen.list.AllListScreen import dev.dimension.flare.ui.screen.media.RawMediaScreen import dev.dimension.flare.ui.screen.media.StatusMediaScreen +import dev.dimension.flare.ui.screen.misskey.AntennasListScreen import dev.dimension.flare.ui.screen.rss.EditRssSourceScreen import dev.dimension.flare.ui.screen.rss.RssListScreen import dev.dimension.flare.ui.screen.serviceselect.ServiceSelectScreen @@ -489,5 +492,25 @@ internal fun RouteContent( }, ) } + + is Route.MisskeyAntennas -> + AntennasListScreen( + accountType = route.accountType, + toTimeline = { + navigate( + Timeline( + Misskey.AntennasTimelineTabItem( + account = route.accountType, + id = it.id, + metaData = + TabMetaData( + title = TitleType.Text(it.title), + icon = IconType.Material(IconType.Material.MaterialIcon.Rss), + ), + ), + ), + ) + }, + ) } } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/misskey/AntennasListScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/misskey/AntennasListScreen.kt new file mode 100644 index 000000000..8ddcc8d70 --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/misskey/AntennasListScreen.kt @@ -0,0 +1,189 @@ +package dev.dimension.flare.ui.screen.misskey + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.Thumbtack +import compose.icons.fontawesomeicons.solid.ThumbtackSlash +import dev.dimension.flare.LocalWindowPadding +import dev.dimension.flare.RegisterTabCallback +import dev.dimension.flare.Res +import dev.dimension.flare.data.model.IconType +import dev.dimension.flare.data.model.Misskey +import dev.dimension.flare.data.model.TabMetaData +import dev.dimension.flare.data.model.TitleType +import dev.dimension.flare.data.repository.SettingsRepository +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.tab_settings_add +import dev.dimension.flare.tab_settings_remove +import dev.dimension.flare.ui.common.plus +import dev.dimension.flare.ui.component.FAIcon +import dev.dimension.flare.ui.component.uiListItemComponent +import dev.dimension.flare.ui.model.UiList +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.presenter.list.AntennasListPresenter +import dev.dimension.flare.ui.theme.screenHorizontalPadding +import io.github.composefluent.component.ScrollbarContainer +import io.github.composefluent.component.SubtleButton +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import moe.tlaster.precompose.molecule.producePresenter +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject + +@Composable +internal fun AntennasListScreen( + accountType: AccountType, + toTimeline: (UiList) -> Unit, +) { + val state by producePresenter("antennas_list_$accountType") { + presenter(accountType) + } + + val listState = rememberLazyListState() + val scrollbarAdapter = rememberScrollbarAdapter(listState) + RegisterTabCallback(listState, onRefresh = state::refresh) + ScrollbarContainer( + adapter = scrollbarAdapter, + ) { + LazyColumn( + contentPadding = LocalWindowPadding.current + PaddingValues(vertical = 8.dp), + modifier = + Modifier + .padding(horizontal = screenHorizontalPadding), + verticalArrangement = Arrangement.spacedBy(2.dp), + state = listState, + ) { + uiListItemComponent( + state.data, + toTimeline, + trailingContent = { item -> + state.currentTabs.onSuccess { currentTabs -> + val isPinned = + remember( + item, + currentTabs, + ) { + currentTabs.contains(item.id) + } + SubtleButton( + onClick = { + if (isPinned) { + state.unpinList(item) + } else { + state.pinList(item) + } + }, + iconOnly = true, + ) { + AnimatedContent(isPinned) { + if (it) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.ThumbtackSlash, + contentDescription = stringResource(Res.string.tab_settings_add), + ) + } else { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Thumbtack, + contentDescription = stringResource(Res.string.tab_settings_remove), + ) + } + } + } + } + }, + ) + } + } +} + +@Composable +private fun presenter( + accountType: AccountType, + settingsRepository: SettingsRepository = koinInject(), + appScope: CoroutineScope = koinInject(), +) = run { + val tabSettings by settingsRepository.tabSettings.collectAsUiState() + val accountState = + remember(accountType) { + UserPresenter( + accountType = accountType, + userKey = null, + ) + }.invoke() + val currentTabs = + accountState.user.flatMap { user -> + tabSettings.map { + it.mainTabs + .filterIsInstance() + .map { it.id } + .toImmutableList() + } + } + val state = + remember(accountType) { + AntennasListPresenter(accountType) + }.invoke() + + object : AntennasListPresenter.State by state { + val currentTabs = currentTabs + + fun pinList(item: UiList) { + accountState.user.onSuccess { user -> + appScope.launch { + settingsRepository.updateTabSettings { + copy( + mainTabs = + mainTabs + + Misskey.AntennasTimelineTabItem( + account = AccountType.Specific(user.key), + id = item.id, + metaData = + TabMetaData( + title = TitleType.Text(item.title), + icon = IconType.Material(IconType.Material.MaterialIcon.List), + ), + ), + ) + } + } + } + } + + fun unpinList(item: UiList) { + accountState.user.onSuccess { user -> + appScope.launch { + settingsRepository.updateTabSettings { + copy( + mainTabs = + mainTabs.filter { + if (it is Misskey.AntennasTimelineTabItem) { + it.id != item.id + } else { + true + } + }, + ) + } + } + } + } + } +} From ef7402b4071cf65a5c63a9c1a94f251d4c45bbe6 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Mon, 25 Aug 2025 16:34:24 +0900 Subject: [PATCH 19/33] move jvm/android compose code to compose-ui --- app/build.gradle.kts | 7 +- app/src/main/java/dev/dimension/flare/App.kt | 3 +- .../dimension/flare/data/model/DataStore.kt | 22 - .../data/repository/SettingsRepository.kt | 46 --- .../dev/dimension/flare/di/AndroidModule.kt | 2 - .../ui/screen/bluesky/BlueskyFeedsScreen.kt | 309 +------------- .../flare/ui/screen/home/HomeScreen.kt | 6 +- .../ui/screen/home/HomeTimelineScreen.kt | 222 ++-------- .../flare/ui/screen/home/TimelineScreen.kt | 5 +- .../flare/ui/screen/list/ListScreen.kt | 194 +-------- .../ui/screen/misskey/AntennasListScreen.kt | 137 +------ .../flare/ui/screen/rss/RssDetailScreen.kt | 2 +- .../flare/ui/screen/rss/RssSourcesScreen.kt | 219 +--------- build.gradle.kts | 55 ++- .../component => compose-ui}/build.gradle.kts | 43 +- .../src/androidMain/AndroidManifest.xml | 0 .../flare/ui/common/PlatformShare.android.kt | 0 .../flare/ui/component/AudioPlayer.android.kt | 0 .../flare/ui/component/FlareDropdownMenu.kt | 0 .../dimension/flare/ui/component/Glassify.kt | 0 .../flare/ui/component/VideoPlayer.kt | 0 .../component/platform/PlaceHolder.android.kt | 0 .../platform/PlatformBigscreen.android.kt | 0 .../platform/PlatformButton.android.kt | 0 .../platform/PlatformCard.android.kt | 0 .../platform/PlatformCheckbox.android.kt | 0 .../platform/PlatformDropdown.android.kt | 0 .../PlatformFlyoutContainer.android.kt | 0 .../platform/PlatformIcon.android.kt | 0 .../platform/PlatformIndication.android.kt | 0 .../platform/PlatformListItem.android.kt | 0 .../PlatformProgressIndicator.android.kt | 0 .../platform/PlatformSlider.android.kt | 0 .../platform/PlatformText.android.kt | 0 .../platform/PlatformVideoPlayer.android.kt | 0 .../platform/PlatformWifiState.android.kt | 0 .../ui/theme/PlatformColorScheme.android.kt | 0 .../flare/ui/theme/PlatformShapes.android.kt | 0 .../ui/theme/PlatformTypography.android.kt | 0 .../dimension/flare/ui/theme/Theme.android.kt | 0 .../values-af-rZA/strings.xml | 0 .../values-ar-rSA/strings.xml | 0 .../values-ca-rES/strings.xml | 0 .../values-cs-rCZ/strings.xml | 0 .../values-da-rDK/strings.xml | 0 .../values-de-rDE/strings.xml | 0 .../values-el-rGR/strings.xml | 0 .../values-en-rUS/strings.xml | 0 .../values-es-rES/strings.xml | 0 .../values-fi-rFI/strings.xml | 0 .../values-fr-rFR/strings.xml | 0 .../values-hu-rHU/strings.xml | 0 .../values-it-rIT/strings.xml | 0 .../values-iw-rIL/strings.xml | 0 .../values-ja-rJP/strings.xml | 0 .../values-ko-rKR/strings.xml | 0 .../values-nl-rNL/strings.xml | 0 .../values-no-rNO/strings.xml | 0 .../values-pl-rPL/strings.xml | 0 .../values-pt-rBR/strings.xml | 0 .../values-pt-rPT/strings.xml | 0 .../values-ro-rRO/strings.xml | 0 .../values-ru-rRU/strings.xml | 0 .../values-sr-rSP/strings.xml | 0 .../values-sv-rSE/strings.xml | 0 .../values-tr-rTR/strings.xml | 0 .../values-uk-rUA/strings.xml | 0 .../values-vi-rVN/strings.xml | 0 .../values-zh-rCN/strings.xml | 0 .../values-zh-rTW/strings.xml | 0 .../composeResources/values/strings.xml | 23 ++ .../dev/dimension/flare/common/Event.kt | 0 .../dimension/flare/data/model/AppSettings.kt | 33 +- .../flare/data/model/AppearanceSettings.kt | 27 +- .../dimension/flare/data/model/TabSettings.kt | 41 +- .../data/repository/SettingsRepository.kt | 76 ++++ .../dev/dimension/flare/di/ComposeUiModule.kt | 11 + .../flare/ui/common/PaddingValueExt.kt | 0 .../flare/ui/common/PagingStateExt.kt | 0 .../flare/ui/common/PlatformShare.kt | 0 .../flare/ui/component/AdaptiveGrid.kt | 0 .../flare/ui/component/AnimatedText.kt | 0 .../flare/ui/component/AudioPlayer.kt | 0 .../flare/ui/component/AvatarComponent.kt | 0 .../component/BuildContentAnnotatedString.kt | 0 .../flare/ui/component/CommonProfileHeader.kt | 0 .../flare/ui/component/ComponentAppearance.kt | 0 .../dimension/flare/ui/component/FAIcon.kt | 0 .../flare/ui/component/HorizontalDivider.kt | 0 .../flare/ui/component/ListCardModifier.kt | 0 .../flare/ui/component/MatricesDisplay.kt | 6 +- .../flare/ui/component/NetworkImage.kt | 0 .../flare/ui/component/ProfileHeader.kt | 6 + .../flare/ui/component/ProfileMenu.kt | 13 +- .../dimension/flare/ui/component/RichText.kt | 0 .../flare/ui/component/UiListItemComponent.kt | 4 + .../flare/ui/component/UserFields.kt | 0 .../dimension/flare/ui/component/dm/DMItem.kt | 10 +- .../flare/ui/component/dm/DmListItem.kt | 8 +- .../ui/component/platform/PlaceHolder.kt | 0 .../component/platform/PlatformBigscreen.kt | 0 .../ui/component/platform/PlatformButton.kt | 0 .../ui/component/platform/PlatformCard.kt | 0 .../ui/component/platform/PlatformCheckbox.kt | 0 .../ui/component/platform/PlatformDropdown.kt | 0 .../platform/PlatformFlyoutContainer.kt | 0 .../ui/component/platform/PlatformIcon.kt | 0 .../component/platform/PlatformIndication.kt | 0 .../ui/component/platform/PlatformListItem.kt | 0 .../platform/PlatformProgressIndicator.kt | 0 .../ui/component/platform/PlatformSlider.kt | 0 .../ui/component/platform/PlatformText.kt | 0 .../component/platform/PlatformVideoPlayer.kt | 0 .../component/platform/PlatformWifiState.kt | 0 .../flare/ui/component/status/AdaptiveCard.kt | 0 .../component/status/CommonStatusComponent.kt | 62 +-- .../status/CommonStatusHeaderComponent.kt | 0 .../ui/component/status/FeedComponent.kt | 0 .../ui/component/status/LazyStatusItems.kt | 12 +- .../status/LazyStatusVerticalStaggeredGrid.kt | 0 .../flare/ui/component/status/QuotedStatus.kt | 2 +- .../ui/component/status/StatusActionButton.kt | 0 .../component/status/StatusMediaComponent.kt | 4 +- .../status/StatusRetweetHeaderComponent.kt | 0 .../status/StatusTranslatePresenter.kt | 0 .../ui/component/status/TranslateResult.kt | 0 .../component/status/UiTimelineComponent.kt | 384 +++++++++--------- .../dimension/flare/ui/icons/MisskeyIcon.kt | 0 .../flare/ui/model}/UiStatusExtraExt.kt | 21 +- .../flare/ui/presenter/HomeTabsPresenter.kt | 93 ++--- .../HomeTimelineWithTabsPresenter.kt | 139 +++++++ .../flare/ui/presenter/PinTabsPresenter.kt | 72 ++++ .../ui/presenter/TimelineItemPresenter.kt | 99 +++++ .../ui/screen/bluesky/BlueskyFeedWithTabs.kt | 127 ++++++ .../bluesky/BlueskyFeedsWithTabsPresenter.kt | 82 ++++ .../screen/list/AllListWithTabsPresenter.kt | 68 ++++ .../flare/ui/screen/list/UiListWithTabs.kt | 129 ++++++ .../MisskeyAntennasListWithTabsPresenter.kt | 65 +++ .../screen/misskey/MisskeyAntennasWithTabs.kt | 62 +++ .../flare/ui/screen/rss/RssListWithTabs.kt | 193 +++++++++ .../ui/screen/rss/RssListWithTabsPresenter.kt | 41 ++ .../dev/dimension/flare/ui/theme/Dimension.kt | 0 .../flare/ui/theme/PlatformColorScheme.kt | 0 .../flare/ui/theme/PlatformShapes.kt | 0 .../dimension/flare/ui/theme/PlatformTheme.kt | 0 .../flare/ui/theme/PlatformTypography.kt | 0 .../flare/ui/common/PlatformShare.jvm.kt | 0 .../flare/ui/component/AudioPlayer.jvm.kt | 0 .../ui/component/platform/PlaceHolder.jvm.kt | 0 .../platform/PlatformBigscreen.jvm.kt | 0 .../component/platform/PlatformButton.jvm.kt | 1 + .../ui/component/platform/PlatformCard.jvm.kt | 0 .../platform/PlatformCheckbox.jvm.kt | 0 .../platform/PlatformDropdown.jvm.kt | 0 .../platform/PlatformFlyoutContainer.jvm.kt | 0 .../ui/component/platform/PlatformIcon.jvm.kt | 0 .../platform/PlatformIndication.jvm.kt | 0 .../platform/PlatformListItem.jvm.kt | 0 .../platform/PlatformProgressIndicator.jvm.kt | 0 .../component/platform/PlatformSlider.jvm.kt | 0 .../ui/component/platform/PlatformText.jvm.kt | 0 .../platform/PlatformVideoPlayer.jvm.kt | 0 .../platform/PlatformWifiState.jvm.kt | 0 .../flare/ui/theme/PlatformColorScheme.jvm.kt | 0 .../flare/ui/theme/PlatformShapes.jvm.kt | 0 .../flare/ui/theme/PlatformTypography.jvm.kt | 0 .../dev/dimension/flare/ui/theme/Theme.jvm.kt | 0 desktopApp/build.gradle.kts | 4 +- .../main/kotlin/dev/dimension/flare/App.kt | 6 +- .../main/kotlin/dev/dimension/flare/Main.kt | 3 +- .../data/repository/SettingsRepository.kt | 55 --- .../dev/dimension/flare/di/DesktopModule.kt | 2 - .../dev/dimension/flare/ui/route/Route.kt | 8 + .../dev/dimension/flare/ui/route/Router.kt | 23 +- .../flare/ui/screen/feeds/FeedListScreen.kt | 85 +--- .../flare/ui/screen/feeds/TabSettingScreen.kt | 7 + .../ui/screen/home/HomeTimelineScreen.kt | 107 +++++ .../flare/ui/screen/home/TimelineScreen.kt | 114 +----- .../flare/ui/screen/list/AllListScreen.kt | 27 +- .../flare/ui/screen/list/ListScreen.kt | 16 +- .../ui/screen/misskey/AntennasListScreen.kt | 142 +------ .../flare/ui/screen/rss/RssListScreen.kt | 215 +--------- gradle/libs.versions.toml | 9 +- settings.gradle.kts | 2 +- shared/api/build.gradle.kts | 17 +- shared/build.gradle.kts | 38 +- .../ui/presenter/PresenterBase.androidJvm.kt | 2 +- .../data/io/PlatformPathProducer.android.kt | 12 + .../flare/di/PlatformModule.android.kt | 11 +- .../data/io/PlatformPathProducer.apple.kt | 26 ++ .../flare/di/PlatformModule.apple.kt | 26 +- .../flare/ui/presenter/PresenterBase.apple.kt | 2 +- .../flare/data/datastore/AppDataStore.kt | 8 +- .../flare/data/io/PlatformPathProducer.kt | 7 + .../dev/dimension/flare/ui/model/UiState.kt | 19 + .../flare/ui/presenter/PresenterBase.kt | 2 +- .../home/rss/CheckRssSourcePresenter.kt | 2 - .../flare/data/io/PlatformPathProducer.jvm.kt | 9 + .../dimension/flare/di/PlatformModule.jvm.kt | 10 +- .../flare/ui/render/UiDateTime.jvm.kt | 53 --- shared/ui/build.gradle.kts | 17 +- 201 files changed, 2059 insertions(+), 2213 deletions(-) delete mode 100644 app/src/main/java/dev/dimension/flare/data/repository/SettingsRepository.kt rename {shared/ui/component => compose-ui}/build.gradle.kts (68%) rename {shared/ui/component => compose-ui}/src/androidMain/AndroidManifest.xml (100%) rename {shared/ui/component => compose-ui}/src/androidMain/kotlin/dev/dimension/flare/ui/common/PlatformShare.android.kt (100%) rename {shared/ui/component => compose-ui}/src/androidMain/kotlin/dev/dimension/flare/ui/component/AudioPlayer.android.kt (100%) rename {shared/ui/component => compose-ui}/src/androidMain/kotlin/dev/dimension/flare/ui/component/FlareDropdownMenu.kt (100%) rename {shared/ui/component => compose-ui}/src/androidMain/kotlin/dev/dimension/flare/ui/component/Glassify.kt (100%) rename {shared/ui/component => compose-ui}/src/androidMain/kotlin/dev/dimension/flare/ui/component/VideoPlayer.kt (100%) rename {shared/ui/component => compose-ui}/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlaceHolder.android.kt (100%) rename {shared/ui/component => compose-ui}/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformBigscreen.android.kt (100%) rename {shared/ui/component => compose-ui}/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.android.kt (100%) rename {shared/ui/component => compose-ui}/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCard.android.kt (100%) rename {shared/ui/component => compose-ui}/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCheckbox.android.kt (100%) rename {shared/ui/component => compose-ui}/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformDropdown.android.kt (100%) rename {shared/ui/component => compose-ui}/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformFlyoutContainer.android.kt (100%) rename {shared/ui/component => compose-ui}/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIcon.android.kt (100%) rename {shared/ui/component => compose-ui}/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIndication.android.kt (100%) rename {shared/ui/component => compose-ui}/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformListItem.android.kt (100%) rename {shared/ui/component => compose-ui}/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformProgressIndicator.android.kt (100%) rename {shared/ui/component => compose-ui}/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformSlider.android.kt (100%) rename {shared/ui/component => compose-ui}/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformText.android.kt (100%) rename {shared/ui/component => compose-ui}/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformVideoPlayer.android.kt (100%) rename {shared/ui/component => compose-ui}/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformWifiState.android.kt (100%) rename {shared/ui/component => compose-ui}/src/androidMain/kotlin/dev/dimension/flare/ui/theme/PlatformColorScheme.android.kt (100%) rename {shared/ui/component => compose-ui}/src/androidMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.android.kt (100%) rename {shared/ui/component => compose-ui}/src/androidMain/kotlin/dev/dimension/flare/ui/theme/PlatformTypography.android.kt (100%) rename {shared/ui/component => compose-ui}/src/androidMain/kotlin/dev/dimension/flare/ui/theme/Theme.android.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/composeResources/values-af-rZA/strings.xml (100%) rename {shared/ui/component => compose-ui}/src/commonMain/composeResources/values-ar-rSA/strings.xml (100%) rename {shared/ui/component => compose-ui}/src/commonMain/composeResources/values-ca-rES/strings.xml (100%) rename {shared/ui/component => compose-ui}/src/commonMain/composeResources/values-cs-rCZ/strings.xml (100%) rename {shared/ui/component => compose-ui}/src/commonMain/composeResources/values-da-rDK/strings.xml (100%) rename {shared/ui/component => compose-ui}/src/commonMain/composeResources/values-de-rDE/strings.xml (100%) rename {shared/ui/component => compose-ui}/src/commonMain/composeResources/values-el-rGR/strings.xml (100%) rename {shared/ui/component => compose-ui}/src/commonMain/composeResources/values-en-rUS/strings.xml (100%) rename {shared/ui/component => compose-ui}/src/commonMain/composeResources/values-es-rES/strings.xml (100%) rename {shared/ui/component => compose-ui}/src/commonMain/composeResources/values-fi-rFI/strings.xml (100%) rename {shared/ui/component => compose-ui}/src/commonMain/composeResources/values-fr-rFR/strings.xml (100%) rename {shared/ui/component => compose-ui}/src/commonMain/composeResources/values-hu-rHU/strings.xml (100%) rename {shared/ui/component => compose-ui}/src/commonMain/composeResources/values-it-rIT/strings.xml (100%) rename {shared/ui/component => compose-ui}/src/commonMain/composeResources/values-iw-rIL/strings.xml (100%) rename {shared/ui/component => compose-ui}/src/commonMain/composeResources/values-ja-rJP/strings.xml (100%) rename {shared/ui/component => compose-ui}/src/commonMain/composeResources/values-ko-rKR/strings.xml (100%) rename {shared/ui/component => compose-ui}/src/commonMain/composeResources/values-nl-rNL/strings.xml (100%) rename {shared/ui/component => compose-ui}/src/commonMain/composeResources/values-no-rNO/strings.xml (100%) rename {shared/ui/component => compose-ui}/src/commonMain/composeResources/values-pl-rPL/strings.xml (100%) rename {shared/ui/component => compose-ui}/src/commonMain/composeResources/values-pt-rBR/strings.xml (100%) rename {shared/ui/component => compose-ui}/src/commonMain/composeResources/values-pt-rPT/strings.xml (100%) rename {shared/ui/component => compose-ui}/src/commonMain/composeResources/values-ro-rRO/strings.xml (100%) rename {shared/ui/component => compose-ui}/src/commonMain/composeResources/values-ru-rRU/strings.xml (100%) rename {shared/ui/component => compose-ui}/src/commonMain/composeResources/values-sr-rSP/strings.xml (100%) rename {shared/ui/component => compose-ui}/src/commonMain/composeResources/values-sv-rSE/strings.xml (100%) rename {shared/ui/component => compose-ui}/src/commonMain/composeResources/values-tr-rTR/strings.xml (100%) rename {shared/ui/component => compose-ui}/src/commonMain/composeResources/values-uk-rUA/strings.xml (100%) rename {shared/ui/component => compose-ui}/src/commonMain/composeResources/values-vi-rVN/strings.xml (100%) rename {shared/ui/component => compose-ui}/src/commonMain/composeResources/values-zh-rCN/strings.xml (100%) rename {shared/ui/component => compose-ui}/src/commonMain/composeResources/values-zh-rTW/strings.xml (100%) rename {shared/ui/component => compose-ui}/src/commonMain/composeResources/values/strings.xml (95%) rename {shared/src/androidJvmMain => compose-ui/src/commonMain}/kotlin/dev/dimension/flare/common/Event.kt (100%) rename {shared/src/androidJvmMain => compose-ui/src/commonMain}/kotlin/dev/dimension/flare/data/model/AppSettings.kt (64%) rename {shared/src/androidJvmMain => compose-ui/src/commonMain}/kotlin/dev/dimension/flare/data/model/AppearanceSettings.kt (77%) rename {shared/src/androidJvmMain => compose-ui/src/commonMain}/kotlin/dev/dimension/flare/data/model/TabSettings.kt (98%) create mode 100644 compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/repository/SettingsRepository.kt create mode 100644 compose-ui/src/commonMain/kotlin/dev/dimension/flare/di/ComposeUiModule.kt rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/common/PaddingValueExt.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/common/PagingStateExt.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/common/PlatformShare.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/AdaptiveGrid.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/AnimatedText.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/AudioPlayer.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/AvatarComponent.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/BuildContentAnnotatedString.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/CommonProfileHeader.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/ComponentAppearance.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/FAIcon.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/HorizontalDivider.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/ListCardModifier.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/MatricesDisplay.kt (92%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/NetworkImage.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/ProfileHeader.kt (97%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/ProfileMenu.kt (95%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/RichText.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/UiListItemComponent.kt (97%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/UserFields.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/dm/DMItem.kt (96%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/dm/DmListItem.kt (97%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlaceHolder.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformBigscreen.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCard.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCheckbox.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformDropdown.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformFlyoutContainer.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIcon.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIndication.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformListItem.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformProgressIndicator.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformSlider.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformText.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformVideoPlayer.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformWifiState.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/AdaptiveCard.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt (96%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusHeaderComponent.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/FeedComponent.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/LazyStatusItems.kt (97%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/LazyStatusVerticalStaggeredGrid.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/QuotedStatus.kt (98%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusActionButton.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusMediaComponent.kt (99%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusRetweetHeaderComponent.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusTranslatePresenter.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/TranslateResult.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/UiTimelineComponent.kt (72%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/icons/MisskeyIcon.kt (100%) rename {shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui => compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/model}/UiStatusExtraExt.kt (77%) rename {shared/src/androidJvmMain => compose-ui/src/commonMain}/kotlin/dev/dimension/flare/ui/presenter/HomeTabsPresenter.kt (67%) create mode 100644 compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTimelineWithTabsPresenter.kt create mode 100644 compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/PinTabsPresenter.kt create mode 100644 compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/TimelineItemPresenter.kt create mode 100644 compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/bluesky/BlueskyFeedWithTabs.kt create mode 100644 compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/bluesky/BlueskyFeedsWithTabsPresenter.kt create mode 100644 compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/list/AllListWithTabsPresenter.kt create mode 100644 compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/list/UiListWithTabs.kt create mode 100644 compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/misskey/MisskeyAntennasListWithTabsPresenter.kt create mode 100644 compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/misskey/MisskeyAntennasWithTabs.kt create mode 100644 compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/rss/RssListWithTabs.kt create mode 100644 compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/rss/RssListWithTabsPresenter.kt rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/theme/Dimension.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/theme/PlatformColorScheme.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/theme/PlatformTheme.kt (100%) rename {shared/ui/component => compose-ui}/src/commonMain/kotlin/dev/dimension/flare/ui/theme/PlatformTypography.kt (100%) rename {shared/ui/component => compose-ui}/src/jvmMain/kotlin/dev/dimension/flare/ui/common/PlatformShare.jvm.kt (100%) rename {shared/ui/component => compose-ui}/src/jvmMain/kotlin/dev/dimension/flare/ui/component/AudioPlayer.jvm.kt (100%) rename {shared/ui/component => compose-ui}/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlaceHolder.jvm.kt (100%) rename {shared/ui/component => compose-ui}/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformBigscreen.jvm.kt (100%) rename {shared/ui/component => compose-ui}/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.jvm.kt (98%) rename {shared/ui/component => compose-ui}/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCard.jvm.kt (100%) rename {shared/ui/component => compose-ui}/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCheckbox.jvm.kt (100%) rename {shared/ui/component => compose-ui}/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformDropdown.jvm.kt (100%) rename {shared/ui/component => compose-ui}/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformFlyoutContainer.jvm.kt (100%) rename {shared/ui/component => compose-ui}/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIcon.jvm.kt (100%) rename {shared/ui/component => compose-ui}/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIndication.jvm.kt (100%) rename {shared/ui/component => compose-ui}/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformListItem.jvm.kt (100%) rename {shared/ui/component => compose-ui}/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformProgressIndicator.jvm.kt (100%) rename {shared/ui/component => compose-ui}/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformSlider.jvm.kt (100%) rename {shared/ui/component => compose-ui}/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformText.jvm.kt (100%) rename {shared/ui/component => compose-ui}/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformVideoPlayer.jvm.kt (100%) rename {shared/ui/component => compose-ui}/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformWifiState.jvm.kt (100%) rename {shared/ui/component => compose-ui}/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformColorScheme.jvm.kt (100%) rename {shared/ui/component => compose-ui}/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.jvm.kt (100%) rename {shared/ui/component => compose-ui}/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformTypography.jvm.kt (100%) rename {shared/ui/component => compose-ui}/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/Theme.jvm.kt (100%) delete mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/data/repository/SettingsRepository.kt create mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/TabSettingScreen.kt create mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt create mode 100644 shared/src/androidMain/kotlin/dev/dimension/flare/data/io/PlatformPathProducer.android.kt create mode 100644 shared/src/appleMain/kotlin/dev/dimension/flare/data/io/PlatformPathProducer.apple.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/io/PlatformPathProducer.kt create mode 100644 shared/src/jvmMain/kotlin/dev/dimension/flare/data/io/PlatformPathProducer.jvm.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9699da656..e1de8a368 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,7 +1,8 @@ + import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsPlugin import com.google.gms.googleservices.GoogleServicesPlugin -import org.jetbrains.kotlin.gradle.dsl.JvmTarget import java.util.Properties +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.android.application) @@ -21,8 +22,6 @@ if (project.file("google-services.json").exists()) { apply() } -kotlin.compilerOptions.jvmTarget.set(JvmTarget.fromTarget(libs.versions.java.get())) - android { namespace = "dev.dimension.flare" compileSdk = libs.versions.compileSdk.get().toInt() @@ -136,7 +135,7 @@ dependencies { implementation(libs.kotlinx.coroutines.play.services) implementation(projects.shared) implementation(projects.shared.ui) - implementation(projects.shared.ui.component) + implementation(projects.composeUi) implementation(libs.androidx.splash) implementation(libs.materialKolor) implementation(libs.colorpicker.compose) diff --git a/app/src/main/java/dev/dimension/flare/App.kt b/app/src/main/java/dev/dimension/flare/App.kt index 57b5c5ec6..a806a8247 100644 --- a/app/src/main/java/dev/dimension/flare/App.kt +++ b/app/src/main/java/dev/dimension/flare/App.kt @@ -15,6 +15,7 @@ import dev.dimension.flare.common.AnimatedPngDecoder import dev.dimension.flare.common.AnimatedWebPDecoder import dev.dimension.flare.di.KoinHelper import dev.dimension.flare.di.androidModule +import dev.dimension.flare.di.composeUiModule import io.ktor.client.HttpClient import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin @@ -26,7 +27,7 @@ class App : super.onCreate() startKoin { androidContext(this@App) - modules(KoinHelper.modules() + androidModule) + modules(KoinHelper.modules() + androidModule + composeUiModule) } } diff --git a/app/src/main/java/dev/dimension/flare/data/model/DataStore.kt b/app/src/main/java/dev/dimension/flare/data/model/DataStore.kt index 16bc95f48..d0bc55e1c 100644 --- a/app/src/main/java/dev/dimension/flare/data/model/DataStore.kt +++ b/app/src/main/java/dev/dimension/flare/data/model/DataStore.kt @@ -1,10 +1,6 @@ package dev.dimension.flare.data.model -import android.content.Context import androidx.compose.ui.graphics.vector.ImageVector -import androidx.datastore.core.DataStore -import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler -import androidx.datastore.dataStore import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Brands import compose.icons.fontawesomeicons.Solid @@ -27,15 +23,6 @@ import compose.icons.fontawesomeicons.solid.Users import dev.dimension.flare.R import dev.dimension.flare.ui.icons.Misskey -internal val Context.appearanceSettings: DataStore by dataStore( - fileName = "appearance_settings.pb", - serializer = AccountPreferencesSerializer, -) -internal val Context.appSettings: DataStore by dataStore( - fileName = "app_settings.pb", - serializer = AppSettingsSerializer, -) - internal val TitleType.Localized.resId: Int get() = when (key) { @@ -79,12 +66,3 @@ internal fun IconType.Material.MaterialIcon.toIcon(): ImageVector = IconType.Material.MaterialIcon.Messages -> FontAwesomeIcons.Solid.Message IconType.Material.MaterialIcon.Rss -> FontAwesomeIcons.Solid.SquareRss } - -internal val Context.tabSettings: DataStore by dataStore( - fileName = "tab_settings.pb", - serializer = TabSettingsSerializer, - corruptionHandler = - ReplaceFileCorruptionHandler { - TabSettingsSerializer.defaultValue - }, -) diff --git a/app/src/main/java/dev/dimension/flare/data/repository/SettingsRepository.kt b/app/src/main/java/dev/dimension/flare/data/repository/SettingsRepository.kt deleted file mode 100644 index 09616d808..000000000 --- a/app/src/main/java/dev/dimension/flare/data/repository/SettingsRepository.kt +++ /dev/null @@ -1,46 +0,0 @@ -package dev.dimension.flare.data.repository - -import android.content.Context -import dev.dimension.flare.data.model.AppSettings -import dev.dimension.flare.data.model.AppearanceSettings -import dev.dimension.flare.data.model.TabSettings -import dev.dimension.flare.data.model.appSettings -import dev.dimension.flare.data.model.appearanceSettings -import dev.dimension.flare.data.model.tabSettings - -internal class SettingsRepository( - private val context: Context, -) { - private val appearanceSettingsStore by lazy { - context.appearanceSettings - } - val appearanceSettings by lazy { - appearanceSettingsStore.data - } - private val appSettingsStore by lazy { - context.appSettings - } - val appSettings by lazy { - appSettingsStore.data - } - - suspend fun updateAppearanceSettings(block: AppearanceSettings.() -> AppearanceSettings) { - appearanceSettingsStore.updateData(block) - } - - private val tabSettingsStore by lazy { - context.tabSettings - } - - val tabSettings by lazy { - tabSettingsStore.data - } - - suspend fun updateTabSettings(block: TabSettings.() -> TabSettings) { - tabSettingsStore.updateData(block) - } - - suspend fun updateAppSettings(block: AppSettings.() -> AppSettings) { - appSettingsStore.updateData(block) - } -} 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 23cd56d4c..82193a12b 100644 --- a/app/src/main/java/dev/dimension/flare/di/AndroidModule.kt +++ b/app/src/main/java/dev/dimension/flare/di/AndroidModule.kt @@ -5,7 +5,6 @@ import dev.dimension.flare.common.ComposeInAppNotification import dev.dimension.flare.common.InAppNotification import dev.dimension.flare.common.PodcastManager import dev.dimension.flare.common.VideoDownloadHelper -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 @@ -14,7 +13,6 @@ import org.koin.dsl.module @UnstableApi val androidModule = module { - singleOf(::SettingsRepository) singleOf(::VideoPlayerPool) singleOf(::ComposeInAppNotification) binds arrayOf(InAppNotification::class, ComposeInAppNotification::class) singleOf(::VideoDownloadHelper) diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/bluesky/BlueskyFeedsScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/bluesky/BlueskyFeedsScreen.kt index 34b9d44f5..417c00c98 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/bluesky/BlueskyFeedsScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/bluesky/BlueskyFeedsScreen.kt @@ -1,6 +1,5 @@ package dev.dimension.flare.ui.screen.bluesky -import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -8,17 +7,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable 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.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -27,43 +22,18 @@ 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.Plus import compose.icons.fontawesomeicons.solid.SquareRss -import compose.icons.fontawesomeicons.solid.Thumbtack -import compose.icons.fontawesomeicons.solid.ThumbtackSlash -import compose.icons.fontawesomeicons.solid.Trash import dev.dimension.flare.R -import dev.dimension.flare.data.model.Bluesky -import dev.dimension.flare.data.model.IconType -import dev.dimension.flare.data.model.TabMetaData -import dev.dimension.flare.data.model.TitleType -import dev.dimension.flare.data.repository.SettingsRepository import dev.dimension.flare.model.AccountType -import dev.dimension.flare.ui.common.itemsIndexed import dev.dimension.flare.ui.component.BackButton import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.FlareLargeFlexibleTopAppBar import dev.dimension.flare.ui.component.FlareScaffold import dev.dimension.flare.ui.component.RefreshContainer -import dev.dimension.flare.ui.component.UiListItem -import dev.dimension.flare.ui.component.listCard -import dev.dimension.flare.ui.component.status.StatusPlaceholder -import dev.dimension.flare.ui.component.uiListItemComponent import dev.dimension.flare.ui.model.UiList -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.home.bluesky.BlueskyFeedsPresenter -import dev.dimension.flare.ui.presenter.home.bluesky.BlueskyFeedsState import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.theme.screenHorizontalPadding -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch import moe.tlaster.precompose.molecule.producePresenter -import org.koin.compose.koinInject @Composable internal fun BlueskyFeedDetailPlaceholder(modifier: Modifier = Modifier) { @@ -138,43 +108,10 @@ internal fun BlueskyFeedsScreen( ), ) } - uiListItemComponent( - state.myFeeds, - onClicked = toFeed, - trailingContent = { item -> - state.currentTabs.onSuccess { currentTabs -> - val isPinned = - remember( - item, - currentTabs, - ) { - currentTabs.contains(item.id) - } - IconButton( - onClick = { - if (isPinned) { - state.unpinFeed(item) - } else { - state.pinFeed(item) - } - }, - ) { - AnimatedContent(isPinned) { - if (it) { - FAIcon( - imageVector = FontAwesomeIcons.Solid.ThumbtackSlash, - contentDescription = stringResource(id = R.string.tab_settings_add), - ) - } else { - FAIcon( - imageVector = FontAwesomeIcons.Solid.Thumbtack, - contentDescription = stringResource(id = R.string.tab_settings_remove), - ) - } - } - } - } - }, + + myBlueskyFeedWithTabs( + state = state, + toFeed = toFeed, ) item { @@ -188,153 +125,10 @@ internal fun BlueskyFeedsScreen( ), ) } - itemsIndexed( - state.popularFeeds, - loadingCount = 5, - loadingContent = { index, itemCount -> - StatusPlaceholder( - modifier = - Modifier - .listCard( - index = index, - totalCount = itemCount, - ), - ) - }, - ) { index, itemCount, (item, subscribed) -> - UiListItem( - onClicked = { - toFeed.invoke(item) - }, - item = item, - trailingContent = { - IconButton( - onClick = { - if (subscribed) { - state.unsubscribe(item) - state.unpinFeed(item) - } else { - state.subscribe(item) - state.pinFeed(item) - } - }, - ) { - if (subscribed) { - FAIcon( - imageVector = FontAwesomeIcons.Solid.Trash, - contentDescription = null, - ) - } else { - FAIcon( - imageVector = FontAwesomeIcons.Solid.Plus, - contentDescription = null, - ) - } - } - }, - modifier = - Modifier - .listCard( - index = index, - totalCount = itemCount, - ), - ) -// Column( -// modifier = -// Modifier -// .clickable { -// toFeed.invoke(item) -// }, -// ) { -// Spacer(modifier = Modifier.height(8.dp)) -// ListComponent( -// modifier = -// Modifier -// .padding( -// horizontal = screenHorizontalPadding, -// ), -// headlineContent = { -// Text(text = item.title) -// }, -// leadingContent = { -// if (item.avatar != null) { -// NetworkImage( -// model = item.avatar, -// contentDescription = item.title, -// modifier = -// Modifier -// .size(AvatarComponentDefaults.size) -// .clip(MaterialTheme.shapes.medium), -// ) -// } else { -// FAIcon( -// imageVector = FontAwesomeIcons.Solid.Rss, -// contentDescription = null, -// modifier = -// Modifier -// .size(AvatarComponentDefaults.size) -// .background( -// color = MaterialTheme.colorScheme.primaryContainer, -// shape = MaterialTheme.shapes.medium, -// ).padding(8.dp), -// tint = MaterialTheme.colorScheme.onPrimaryContainer, -// ) -// } -// }, -// supportingContent = { -// Text( -// text = -// stringResource( -// R.string.feeds_discover_feeds_created_by, -// item.creator?.handle ?: "Unknown", -// ), -// style = MaterialTheme.typography.bodySmall, -// modifier = -// Modifier -// .alpha(MediumAlpha), -// ) -// }, -// trailingContent = { -// IconButton( -// onClick = { -// if (subscribed) { -// state.unsubscribe(item) -// state.unpinFeed(item) -// } else { -// state.subscribe(item) -// state.pinFeed(item) -// } -// }, -// ) { -// if (subscribed) { -// FAIcon( -// imageVector = FontAwesomeIcons.Solid.Trash, -// contentDescription = null, -// ) -// } else { -// FAIcon( -// imageVector = FontAwesomeIcons.Solid.Plus, -// contentDescription = null, -// ) -// } -// } -// }, -// ) -// item.description?.takeIf { it.isNotEmpty() }?.let { -// Text( -// text = it, -// modifier = -// Modifier -// .padding( -// horizontal = screenHorizontalPadding, -// ), -// ) -// } -// -// Spacer(modifier = Modifier.height(8.dp)) -// HorizontalDivider() -// } - } + popularBlueskyFeedWithTabs( + state = state, + toFeed = toFeed, + ) } }, ) @@ -342,88 +136,7 @@ internal fun BlueskyFeedsScreen( } @Composable -private fun presenter( - accountType: AccountType, - settingsRepository: SettingsRepository = koinInject(), - appScope: CoroutineScope = koinInject(), -) = run { - val tabSettings by settingsRepository.tabSettings.collectAsUiState() - val accountState = - remember(accountType) { - UserPresenter( - accountType = accountType, - userKey = null, - ) - }.invoke() - val currentTabs = - accountState.user.flatMap { user -> - tabSettings.map { - it.mainTabs - .filterIsInstance() - .map { it.uri } - .toImmutableList() - } - } - val scope = rememberCoroutineScope() - var isRefreshing by remember { mutableStateOf(false) } - val state = - remember(accountType) { - BlueskyFeedsPresenter(accountType = accountType) - }.invoke() - - object : BlueskyFeedsState by state { - val isRefreshing: Boolean - get() = isRefreshing - - fun refresh() { - isRefreshing = true - scope.launch { - state.refreshSuspend() - isRefreshing = false - } - } - - val currentTabs = currentTabs - - fun pinFeed(item: UiList) { - accountState.user.onSuccess { user -> - appScope.launch { - settingsRepository.updateTabSettings { - copy( - mainTabs = - mainTabs + - Bluesky.FeedTabItem( - account = AccountType.Specific(user.key), - uri = item.id, - metaData = - TabMetaData( - title = TitleType.Text(item.title), - icon = IconType.Material(IconType.Material.MaterialIcon.Feeds), - ), - ), - ) - } - } - } - } - - fun unpinFeed(item: UiList) { - accountState.user.onSuccess { user -> - appScope.launch { - settingsRepository.updateTabSettings { - copy( - mainTabs = - mainTabs.filter { - if (it is Bluesky.FeedTabItem) { - it.uri != item.id - } else { - true - } - }, - ) - } - } - } - } +private fun presenter(accountType: AccountType) = + run { + remember(accountType) { BlueskyFeedsWithTabsPresenter(accountType) }.invoke() } -} 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 10cb6a5a5..da7dde1f8 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 @@ -72,7 +72,6 @@ import dev.dimension.flare.data.model.RssTabItem import dev.dimension.flare.data.model.SettingsTabItem import dev.dimension.flare.data.model.TabItem import dev.dimension.flare.data.model.TimelineTabItem -import dev.dimension.flare.data.repository.SettingsRepository import dev.dimension.flare.model.AccountType import dev.dimension.flare.ui.component.AvatarComponent import dev.dimension.flare.ui.component.FAIcon @@ -102,7 +101,6 @@ import dev.dimension.flare.ui.screen.splash.SplashScreen import dev.dimension.flare.ui.theme.MediumAlpha import kotlinx.coroutines.launch import moe.tlaster.precompose.molecule.producePresenter -import org.koin.compose.koinInject @OptIn( ExperimentalMaterial3AdaptiveNavigationSuiteApi::class, @@ -554,7 +552,7 @@ private fun getDirection( } @Composable -private fun presenter(settingsRepository: SettingsRepository = koinInject()) = +private fun presenter() = run { val navigationState = remember { @@ -562,7 +560,7 @@ private fun presenter(settingsRepository: SettingsRepository = koinInject()) = } val tabs = remember { - HomeTabsPresenter(settingsRepository.tabSettings) + HomeTabsPresenter() }.invoke() val scrollToTopRegistry = remember { 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 23560480d..96023784b 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 @@ -14,8 +14,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -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.foundation.shape.CircleShape @@ -31,16 +29,10 @@ 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.key -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.draw.clip @@ -55,13 +47,7 @@ import compose.icons.fontawesomeicons.solid.Plus import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState 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.MixedTimelineTabItem -import dev.dimension.flare.data.model.TimelineTabItem -import dev.dimension.flare.data.repository.SettingsRepository import dev.dimension.flare.model.AccountType import dev.dimension.flare.ui.component.AvatarComponent import dev.dimension.flare.ui.component.FAIcon @@ -75,28 +61,17 @@ import dev.dimension.flare.ui.component.platform.isBigScreen import dev.dimension.flare.ui.component.status.AdaptiveCard import dev.dimension.flare.ui.component.status.LazyStatusVerticalStaggeredGrid import dev.dimension.flare.ui.component.status.status -import dev.dimension.flare.ui.model.UiRssSource -import dev.dimension.flare.ui.model.UiTimeline -import dev.dimension.flare.ui.model.collectAsUiState 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.HomeTimelineWithTabsPresenter +import dev.dimension.flare.ui.presenter.TimelineItemPresenter import dev.dimension.flare.ui.presenter.invoke -import dev.dimension.flare.ui.presenter.settings.AccountEventPresenter import dev.dimension.flare.ui.screen.settings.TabIcon import dev.dimension.flare.ui.screen.settings.TabTitle import dev.dimension.flare.ui.theme.screenHorizontalPadding -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch import moe.tlaster.precompose.molecule.producePresenter -import org.koin.compose.koinInject @OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) @Composable @@ -127,6 +102,7 @@ internal fun HomeTimelineScreen( lazyListState = lazyListState, onRefresh = { currentTab.refreshSync() + state.changeLogState.dismissChangeLog() }, ) } @@ -254,6 +230,7 @@ internal fun HomeTimelineScreen( state = tabs[index], contentPadding = contentPadding, modifier = Modifier.fillMaxWidth(), + changeLogState = state.changeLogState, ) } } @@ -263,15 +240,19 @@ internal fun HomeTimelineScreen( @Composable internal fun TimelineItemContent( - state: TimelineItemState, + state: TimelineItemPresenter.State, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(0.dp), + changeLogState: ChangeLogState? = null, ) { val hazeState = rememberHazeState() val scope = rememberCoroutineScope() RefreshContainer( modifier = modifier, - onRefresh = state::refreshSync, + onRefresh = { + state.refreshSync() + changeLogState?.dismissChangeLog() + }, isRefreshing = state.isRefreshing, indicatorPadding = contentPadding, content = { @@ -283,8 +264,8 @@ internal fun TimelineItemContent( .fillMaxSize() .hazeSource(hazeState), ) { - state.shouldShowChangeLog.onSuccess { - state.changeLog?.let { changelog -> + changeLogState?.shouldShowChangeLog?.onSuccess { + changeLogState.changeLog?.let { changelog -> if (it) { item { Column { @@ -309,7 +290,7 @@ internal fun TimelineItemContent( Text(changelog) Button( onClick = { - state.dismissChangeLog() + changeLogState.dismissChangeLog() }, ) { Text( @@ -372,176 +353,19 @@ internal fun TimelineItemContent( } @Composable -private fun timelinePresenter( - accountType: AccountType, - settingsRepository: SettingsRepository = koinInject(), -) = run { - val accountState = - remember(accountType) { - UserPresenter( - accountType = accountType, - userKey = null, - ) - }.invoke() - - val accountEvent = - remember { - AccountEventPresenter() - }.invoke() - - LaunchedEffect(accountEvent.onAdded) { - accountEvent.onAdded.collect { account -> - val tab = - HomeTimelineTabItem( - accountKey = account.accountKey, - icon = UiRssSource.favIconUrl(account.accountKey.host), - title = - account.accountKey.host - .substringBeforeLast('.') - .substringAfter('.'), - ) - settingsRepository.updateTabSettings { - copy( - mainTabs = - (mainTabs + tab).distinctBy { - it.key - }, - ) - } - } - } +private fun timelinePresenter(accountType: AccountType) = + run { + val state = remember(accountType) { HomeTimelineWithTabsPresenter(accountType) }.invoke() - LaunchedEffect(accountEvent.onRemoved) { - accountEvent.onRemoved.collect { accountKey -> - settingsRepository.updateTabSettings { - copy( - mainTabs = mainTabs.filterNot { it.account == AccountType.Specific(accountKey) }, - ) + val pagerState = + state.tabState.map { + rememberPagerState { it.size } } - } - } - - val tabs by remember { - settingsRepository.tabSettings - .map { settings -> - if (accountType == AccountType.Guest) { - listOf( - HomeTimelineTabItem(AccountType.Guest), - ) - } else { - ( - listOfNotNull( - if (settings.enableMixedTimeline && settings.mainTabs.size > 1) { - MixedTimelineTabItem( - subTimelineTabItem = settings.mainTabs, - ) - } else { - null - }, - ) + settings.mainTabs - ).ifEmpty { - listOf( - HomeTimelineTabItem( - accountType = AccountType.Active, - ), - ) - } - } - }.map { - it.toImmutableList() - } - }.collectAsUiState() - val tabState = - tabs.map { - it - .map { - // use key inorder to force update when the list is changed - key(it.key) { - timelineItemPresenter(it) - } - }.toImmutableList() - } - val pagerState = - tabState.map { - rememberPagerState { it.size } - } - - object : UserState by accountState { - val pagerState = pagerState - val tabState = tabState - } -} - -@Composable -internal fun timelineItemPresenter(timelineTabItem: TimelineTabItem): TimelineItemState { - val changeLogState = changeLogPresenter() - val state = - remember(timelineTabItem.key) { - timelineTabItem.createPresenter() - }.invoke() - val badge = - remember(timelineTabItem.account) { - NotificationBadgePresenter(timelineTabItem.account) - }.invoke() - val scope = rememberCoroutineScope() - 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, ChangeLogState by changeLogState { - 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 - } + val changeLogState = changeLogPresenter() - override fun refreshSync() { - scope.launch { - state.refresh() - } - badge.refresh() - changeLogState.dismissChangeLog() + object : HomeTimelineWithTabsPresenter.State by state { + val pagerState = pagerState + val changeLogState = changeLogState } } -} - -@Immutable -internal interface TimelineItemState : ChangeLogState { - 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/TimelineScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/TimelineScreen.kt index 760bbf99d..a7a5c6d7f 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 @@ -17,6 +17,7 @@ import dev.dimension.flare.ui.component.BackButton import dev.dimension.flare.ui.component.FlareLargeFlexibleTopAppBar import dev.dimension.flare.ui.component.FlareScaffold import dev.dimension.flare.ui.model.onError +import dev.dimension.flare.ui.presenter.TimelineItemPresenter import dev.dimension.flare.ui.presenter.home.UserPresenter import dev.dimension.flare.ui.presenter.home.UserState import dev.dimension.flare.ui.presenter.invoke @@ -75,7 +76,7 @@ internal fun TimelineScreen( @Composable private fun timelinePresenter(tabItem: TimelineTabItem) = run { - val state = timelineItemPresenter(tabItem) + val state = remember(tabItem.key) { TimelineItemPresenter(tabItem) }.invoke() val accountState = remember(tabItem.account) { UserPresenter( @@ -83,6 +84,6 @@ private fun timelinePresenter(tabItem: TimelineTabItem) = userKey = null, ) }.invoke() - object : UserState by accountState, TimelineItemState by state { + object : UserState by accountState, TimelineItemPresenter.State by state { } } diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/list/ListScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/list/ListScreen.kt index b0299998f..235fce70a 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/list/ListScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/list/ListScreen.kt @@ -1,23 +1,18 @@ package dev.dimension.flare.ui.screen.list -import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable 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.input.nestedscroll.nestedScroll @@ -25,42 +20,19 @@ 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.EllipsisVertical import compose.icons.fontawesomeicons.solid.List -import compose.icons.fontawesomeicons.solid.Pen import compose.icons.fontawesomeicons.solid.Plus -import compose.icons.fontawesomeicons.solid.Thumbtack -import compose.icons.fontawesomeicons.solid.ThumbtackSlash -import compose.icons.fontawesomeicons.solid.Trash import dev.dimension.flare.R -import dev.dimension.flare.data.model.IconType -import dev.dimension.flare.data.model.ListTimelineTabItem -import dev.dimension.flare.data.model.TabMetaData -import dev.dimension.flare.data.model.TitleType -import dev.dimension.flare.data.repository.SettingsRepository import dev.dimension.flare.model.AccountType import dev.dimension.flare.ui.component.BackButton import dev.dimension.flare.ui.component.FAIcon -import dev.dimension.flare.ui.component.FlareDropdownMenu import dev.dimension.flare.ui.component.FlareLargeFlexibleTopAppBar import dev.dimension.flare.ui.component.FlareScaffold import dev.dimension.flare.ui.component.RefreshContainer -import dev.dimension.flare.ui.component.uiListItemComponent import dev.dimension.flare.ui.model.UiList -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.presenter.list.AllListPresenter -import dev.dimension.flare.ui.presenter.list.AllListState import dev.dimension.flare.ui.theme.screenHorizontalPadding -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch import moe.tlaster.precompose.molecule.producePresenter -import org.koin.compose.koinInject @Composable internal fun ListDetailPlaceholder(modifier: Modifier = Modifier) { @@ -139,95 +111,11 @@ internal fun ListScreen( .padding(horizontal = screenHorizontalPadding), verticalArrangement = Arrangement.spacedBy(2.dp), ) { - uiListItemComponent( - state.items, - toList, - trailingContent = { item -> - state.currentTabs.onSuccess { currentTabs -> - val isPinned = - remember( - item, - currentTabs, - ) { - currentTabs.contains(item.id) - } - IconButton( - onClick = { - if (isPinned) { - state.unpinList(item) - } else { - state.pinList(item) - } - }, - ) { - AnimatedContent(isPinned) { - if (it) { - FAIcon( - imageVector = FontAwesomeIcons.Solid.ThumbtackSlash, - contentDescription = stringResource(id = R.string.tab_settings_add), - ) - } else { - FAIcon( - imageVector = FontAwesomeIcons.Solid.Thumbtack, - contentDescription = stringResource(id = R.string.tab_settings_remove), - ) - } - } - } - } - if (!item.readonly) { - var showDropdown by remember { - mutableStateOf(false) - } - IconButton(onClick = { showDropdown = true }) { - FAIcon( - imageVector = FontAwesomeIcons.Solid.EllipsisVertical, - contentDescription = stringResource(id = R.string.more), - ) - FlareDropdownMenu( - expanded = showDropdown, - onDismissRequest = { showDropdown = false }, - ) { - DropdownMenuItem( - text = { - Text( - text = stringResource(id = R.string.list_edit), - ) - }, - onClick = { - editList(item) - showDropdown = false - }, - leadingIcon = { - FAIcon( - imageVector = FontAwesomeIcons.Solid.Pen, - contentDescription = stringResource(id = R.string.list_edit), - ) - }, - ) - DropdownMenuItem( - text = { - Text( - text = stringResource(id = R.string.list_delete), - color = MaterialTheme.colorScheme.error, - ) - }, - onClick = { - deleteList(item) - showDropdown = false - }, - leadingIcon = { - FAIcon( - imageVector = FontAwesomeIcons.Solid.Trash, - contentDescription = stringResource(id = R.string.list_delete), - tint = MaterialTheme.colorScheme.error, - ) - }, - ) - } - } - } - }, + uiListWithTabs( + state = state, + toList = toList, + editList = editList, + deleteList = deleteList, ) } }, @@ -236,75 +124,9 @@ internal fun ListScreen( } @Composable -private fun presenter( - accountType: AccountType, - settingsRepository: SettingsRepository = koinInject(), - appScope: CoroutineScope = koinInject(), -) = run { - val tabSettings by settingsRepository.tabSettings.collectAsUiState() - val accountState = +private fun presenter(accountType: AccountType) = + run { remember(accountType) { - UserPresenter( - accountType = accountType, - userKey = null, - ) + AllListWithTabsPresenter(accountType = accountType) }.invoke() - val currentTabs = - accountState.user.flatMap { user -> - tabSettings.map { - it.mainTabs - .filterIsInstance() - .map { it.listId } - .toImmutableList() - } - } - val state = - remember(accountType) { - AllListPresenter(accountType) - }.invoke() - - object : AllListState by state { - val currentTabs = currentTabs - - fun pinList(item: UiList) { - accountState.user.onSuccess { user -> - appScope.launch { - settingsRepository.updateTabSettings { - copy( - mainTabs = - mainTabs + - ListTimelineTabItem( - account = AccountType.Specific(user.key), - listId = item.id, - metaData = - TabMetaData( - title = TitleType.Text(item.title), - icon = IconType.Material(IconType.Material.MaterialIcon.List), - ), - ), - ) - } - } - } - } - - fun unpinList(item: UiList) { - accountState.user.onSuccess { user -> - appScope.launch { - settingsRepository.updateTabSettings { - copy( - mainTabs = - mainTabs.filter { - if (it is ListTimelineTabItem) { - it.listId != item.id - } else { - true - } - }, - ) - } - } - } - } } -} diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/misskey/AntennasListScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/misskey/AntennasListScreen.kt index b8692dab3..9c14deaca 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/misskey/AntennasListScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/misskey/AntennasListScreen.kt @@ -1,12 +1,10 @@ package dev.dimension.flare.ui.screen.misskey -import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable @@ -16,38 +14,17 @@ 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 compose.icons.FontAwesomeIcons -import compose.icons.fontawesomeicons.Solid -import compose.icons.fontawesomeicons.solid.Thumbtack -import compose.icons.fontawesomeicons.solid.ThumbtackSlash import dev.dimension.flare.R import dev.dimension.flare.common.isRefreshing -import dev.dimension.flare.data.model.IconType -import dev.dimension.flare.data.model.Misskey -import dev.dimension.flare.data.model.TabMetaData -import dev.dimension.flare.data.model.TitleType -import dev.dimension.flare.data.repository.SettingsRepository import dev.dimension.flare.model.AccountType import dev.dimension.flare.ui.component.BackButton -import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.FlareLargeFlexibleTopAppBar import dev.dimension.flare.ui.component.FlareScaffold import dev.dimension.flare.ui.component.RefreshContainer -import dev.dimension.flare.ui.component.uiListItemComponent import dev.dimension.flare.ui.model.UiList -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.presenter.list.AntennasListPresenter import dev.dimension.flare.ui.theme.screenHorizontalPadding -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch import moe.tlaster.precompose.molecule.producePresenter -import org.koin.compose.koinInject @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -90,43 +67,9 @@ internal fun AntennasListScreen( .padding(horizontal = screenHorizontalPadding), verticalArrangement = Arrangement.spacedBy(2.dp), ) { - uiListItemComponent( - state.data, - toTimeline, - trailingContent = { item -> - state.currentTabs.onSuccess { currentTabs -> - val isPinned = - remember( - item, - currentTabs, - ) { - currentTabs.contains(item.id) - } - IconButton( - onClick = { - if (isPinned) { - state.unpinList(item) - } else { - state.pinList(item) - } - }, - ) { - AnimatedContent(isPinned) { - if (it) { - FAIcon( - imageVector = FontAwesomeIcons.Solid.ThumbtackSlash, - contentDescription = stringResource(id = R.string.tab_settings_add), - ) - } else { - FAIcon( - imageVector = FontAwesomeIcons.Solid.Thumbtack, - contentDescription = stringResource(id = R.string.tab_settings_remove), - ) - } - } - } - } - }, + misskeyAntennasWithTabs( + state = state, + onClick = toTimeline, ) } }, @@ -135,75 +78,7 @@ internal fun AntennasListScreen( } @Composable -private fun presenter( - accountType: AccountType, - settingsRepository: SettingsRepository = koinInject(), - appScope: CoroutineScope = koinInject(), -) = run { - val tabSettings by settingsRepository.tabSettings.collectAsUiState() - val accountState = - remember(accountType) { - UserPresenter( - accountType = accountType, - userKey = null, - ) - }.invoke() - val currentTabs = - accountState.user.flatMap { user -> - tabSettings.map { - it.mainTabs - .filterIsInstance() - .map { it.id } - .toImmutableList() - } - } - val state = - remember(accountType) { - AntennasListPresenter(accountType) - }.invoke() - - object : AntennasListPresenter.State by state { - val currentTabs = currentTabs - - fun pinList(item: UiList) { - accountState.user.onSuccess { user -> - appScope.launch { - settingsRepository.updateTabSettings { - copy( - mainTabs = - mainTabs + - Misskey.AntennasTimelineTabItem( - account = AccountType.Specific(user.key), - id = item.id, - metaData = - TabMetaData( - title = TitleType.Text(item.title), - icon = IconType.Material(IconType.Material.MaterialIcon.List), - ), - ), - ) - } - } - } - } - - fun unpinList(item: UiList) { - accountState.user.onSuccess { user -> - appScope.launch { - settingsRepository.updateTabSettings { - copy( - mainTabs = - mainTabs.filter { - if (it is Misskey.AntennasTimelineTabItem) { - it.id != item.id - } else { - true - } - }, - ) - } - } - } - } +private fun presenter(accountType: AccountType) = + run { + remember(accountType) { MisskeyAntennasListWithTabsPresenter(accountType) }.invoke() } -} diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/rss/RssDetailScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/rss/RssDetailScreen.kt index 3ad197657..0a9b0684e 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/rss/RssDetailScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/rss/RssDetailScreen.kt @@ -158,7 +158,7 @@ internal fun RssDetailScreen( ) { it.publishDateTime?.let { Text( - text = it.value.localizedFullTime, + text = it.localizedFullTime, style = MaterialTheme.typography.bodySmall, ) } diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/rss/RssSourcesScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/rss/RssSourcesScreen.kt index 7d68ef937..254ce4f52 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/rss/RssSourcesScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/rss/RssSourcesScreen.kt @@ -1,62 +1,31 @@ package dev.dimension.flare.ui.screen.rss -import androidx.compose.animation.AnimatedContent -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable 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.input.nestedscroll.nestedScroll 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.EllipsisVertical -import compose.icons.fontawesomeicons.solid.File -import compose.icons.fontawesomeicons.solid.Pen import compose.icons.fontawesomeicons.solid.Plus -import compose.icons.fontawesomeicons.solid.Thumbtack -import compose.icons.fontawesomeicons.solid.ThumbtackSlash -import compose.icons.fontawesomeicons.solid.Trash import dev.dimension.flare.R -import dev.dimension.flare.data.model.RssTimelineTabItem -import dev.dimension.flare.data.repository.SettingsRepository -import dev.dimension.flare.ui.common.itemsIndexed import dev.dimension.flare.ui.component.BackButton import dev.dimension.flare.ui.component.FAIcon -import dev.dimension.flare.ui.component.FlareDropdownMenu import dev.dimension.flare.ui.component.FlareLargeFlexibleTopAppBar import dev.dimension.flare.ui.component.FlareScaffold -import dev.dimension.flare.ui.component.NetworkImage -import dev.dimension.flare.ui.component.listCard import dev.dimension.flare.ui.model.UiRssSource -import dev.dimension.flare.ui.model.collectAsUiState -import dev.dimension.flare.ui.model.map -import dev.dimension.flare.ui.model.onSuccess -import dev.dimension.flare.ui.presenter.home.rss.RssSourcesPresenter import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.theme.screenHorizontalPadding -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch import moe.tlaster.precompose.molecule.producePresenter -import org.koin.compose.koinInject @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -103,189 +72,17 @@ internal fun RssSourcesScreen( verticalArrangement = Arrangement.spacedBy(2.dp), contentPadding = contentPadding, ) { - itemsIndexed( - state.sources, - emptyContent = { - Column( - modifier = - Modifier - .fillParentMaxSize(), - verticalArrangement = - Arrangement.spacedBy( - 8.dp, - Alignment.CenterVertically, - ), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - FAIcon( - FontAwesomeIcons.Solid.File, - contentDescription = stringResource(R.string.empty_rss_sources), - modifier = Modifier.size(48.dp), - ) - Text( - text = stringResource(R.string.empty_rss_sources), - style = MaterialTheme.typography.headlineMedium, - ) - } - }, - ) { index, itemCount, it -> - ListItem( - modifier = - Modifier - .listCard( - index = index, - totalCount = itemCount, - ).clickable { - onClicked.invoke(it) - }, - headlineContent = { - it.title?.let { - Text(text = it) - } - }, - supportingContent = { - Text(it.url) - }, - leadingContent = { - NetworkImage( - model = it.favIcon, - contentDescription = it.title, - modifier = Modifier.size(24.dp), - ) - }, - trailingContent = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - state.currentTabs.onSuccess { currentTabs -> - val isPinned = - remember( - it, - currentTabs, - ) { - currentTabs.contains(it.url) - } - IconButton( - onClick = { - if (isPinned) { - state.unpinSource(it) - } else { - state.pinSource(it) - } - }, - ) { - AnimatedContent(isPinned) { - if (it) { - FAIcon( - imageVector = FontAwesomeIcons.Solid.ThumbtackSlash, - contentDescription = stringResource(id = R.string.tab_settings_add), - ) - } else { - FAIcon( - imageVector = FontAwesomeIcons.Solid.Thumbtack, - contentDescription = stringResource(id = R.string.tab_settings_remove), - ) - } - } - } - } - - var showDropdown by remember { - mutableStateOf(false) - } - IconButton(onClick = { showDropdown = true }) { - FAIcon( - imageVector = FontAwesomeIcons.Solid.EllipsisVertical, - contentDescription = stringResource(id = R.string.more), - ) - FlareDropdownMenu( - expanded = showDropdown, - onDismissRequest = { showDropdown = false }, - ) { - DropdownMenuItem( - text = { - Text( - text = stringResource(id = R.string.edit_rss_source), - ) - }, - onClick = { - onEdit.invoke(it.id) - showDropdown = false - }, - leadingIcon = { - FAIcon( - imageVector = FontAwesomeIcons.Solid.Pen, - contentDescription = stringResource(id = R.string.edit_rss_source), - ) - }, - ) - DropdownMenuItem( - text = { - Text( - text = stringResource(id = R.string.delete_rss_source), - color = MaterialTheme.colorScheme.error, - ) - }, - onClick = { - state.delete(it.id) - showDropdown = false - }, - leadingIcon = { - FAIcon( - imageVector = FontAwesomeIcons.Solid.Trash, - contentDescription = stringResource(id = R.string.delete_rss_source), - tint = MaterialTheme.colorScheme.error, - ) - }, - ) - } - } - } - }, - ) - } + rssListWithTabs( + state = state, + onClicked = onClicked, + onEdit = onEdit, + ) } } } @Composable -private fun presenter( - settingsRepository: SettingsRepository = koinInject(), - appScope: CoroutineScope = koinInject(), -) = run { - val state = remember { RssSourcesPresenter() }.invoke() - val tabSettings by settingsRepository.tabSettings.collectAsUiState() - val currentTabs = - tabSettings.map { - it.mainTabs - .filterIsInstance() - .map { it.feedUrl } - .toImmutableList() - } - object : RssSourcesPresenter.State by state { - val currentTabs = currentTabs - - fun pinSource(source: UiRssSource) { - appScope.launch { - settingsRepository.updateTabSettings { - copy( - mainTabs = - mainTabs + RssTimelineTabItem(source), - ) - } - } - } - - fun unpinSource(source: UiRssSource) { - appScope.launch { - settingsRepository.updateTabSettings { - copy( - mainTabs = - mainTabs.filterNot { it is RssTimelineTabItem && it.feedUrl == source.url }, - ) - } - } - } +private fun presenter() = + run { + remember { RssListWithTabsPresenter() }.invoke() } -} diff --git a/build.gradle.kts b/build.gradle.kts index 0ee9ee759..f781d341c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,7 @@ +import com.android.build.api.dsl.LibraryExtension import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension plugins { alias(libs.plugins.android.application) apply false @@ -17,23 +20,45 @@ plugins { alias(libs.plugins.composeMultiplatform) apply false } -allprojects { - tasks.withType { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_21) +subprojects { + val commonOptIn = listOf( + "kotlin.time.ExperimentalTime", + ) + + val freeArgs = listOf( + "-Xexpect-actual-classes", + "-Xconsistent-data-class-copy-visibility", + "-Xmulti-dollar-interpolation", + ) + + plugins.withId("org.jetbrains.kotlin.multiplatform") { + extensions.configure { + compilerOptions { + allWarningsAsErrors.set(true) + freeCompilerArgs.addAll(freeArgs) + optIn.addAll(commonOptIn) + } + jvmToolchain(libs.versions.java.get().toInt()) } } - tasks.withType>().configureEach { - compilerOptions { - allWarningsAsErrors.set(true) - freeCompilerArgs.set( - listOf( - "-Xexpect-actual-classes", - "-Xconsistent-data-class-copy-visibility", - "-Xmulti-dollar-interpolation", - "-opt-in=kotlin.time.ExperimentalTime", - ) - ) + plugins.withId("org.jetbrains.kotlin.android") { + extensions.configure { + compilerOptions { + allWarningsAsErrors.set(true) + freeCompilerArgs.addAll(freeArgs) + optIn.addAll(commonOptIn) + jvmTarget.set(JvmTarget.fromTarget(libs.versions.java.get())) + } + jvmToolchain(libs.versions.java.get().toInt()) + } + } + + plugins.withId("com.android.library") { + extensions.configure { + compileSdk = libs.versions.compileSdk.get().toInt() + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + } } } } diff --git a/shared/ui/component/build.gradle.kts b/compose-ui/build.gradle.kts similarity index 68% rename from shared/ui/component/build.gradle.kts rename to compose-ui/build.gradle.kts index c924f6a13..b69906c50 100644 --- a/shared/ui/component/build.gradle.kts +++ b/compose-ui/build.gradle.kts @@ -3,21 +3,38 @@ import org.jetbrains.compose.compose plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kotlin.serialization) alias(libs.plugins.ktlint) alias(libs.plugins.compose.compiler) alias(libs.plugins.composeMultiplatform) } +android { + namespace = "dev.dimension.flare.compose.ui" + compileOptions { + isCoreLibraryDesugaringEnabled = true + } +} + kotlin { jvmToolchain(libs.versions.java.get().toInt()) explicitApi() - applyDefaultHierarchyTemplate() - androidLibrary { - compileSdk = libs.versions.compileSdk.get().toInt() - namespace = "dev.dimension.flare.shared.ui.component" - minSdk = libs.versions.minSdk.get().toInt() - experimentalProperties["android.experimental.kmp.enableAndroidResources"] = true + applyDefaultHierarchyTemplate { + common { + group("androidJvm") { + withAndroidTarget() + withJvm() + } + } } + androidTarget() +// androidLibrary { +// compileSdk = libs.versions.compileSdk.get().toInt() +// namespace = "dev.dimension.flare.compose.ui" +// minSdk = libs.versions.minSdk.get().toInt() +// experimentalProperties["android.experimental.kmp.enableAndroidResources"] = true +// enableCoreLibraryDesugaring = true +// } jvm() sourceSets { @@ -39,6 +56,11 @@ kotlin { implementation(libs.kotlinx.immutable) implementation(libs.precompose.molecule) implementation(libs.kotlinx.datetime) + implementation(libs.datastore) + implementation(libs.kotlinx.serialization.protobuf) + implementation(project.dependencies.platform(libs.koin.bom)) + implementation(libs.koin.core) + implementation(libs.koin.compose) } } val commonTest by getting { @@ -46,7 +68,9 @@ kotlin { implementation(kotlin("test")) } } + val androidJvmMain by getting val androidMain by getting { +// dependsOn(androidJvmMain) dependencies { implementation(libs.compose.placeholder.material3) implementation(libs.material3.adaptive) @@ -56,7 +80,6 @@ kotlin { implementation(libs.bundles.koin) implementation(libs.haze) implementation(libs.haze.materials) - } } val jvmMain by getting { @@ -72,6 +95,10 @@ kotlin { } } +dependencies { + coreLibraryDesugaring(libs.desugar.jdk.libs) +} + ktlint { version.set(libs.versions.ktlint) filter { @@ -81,5 +108,5 @@ ktlint { compose.resources { - packageOfResClass = "dev.dimension.flare.ui.component" + packageOfResClass = "dev.dimension.flare.compose.ui" } diff --git a/shared/ui/component/src/androidMain/AndroidManifest.xml b/compose-ui/src/androidMain/AndroidManifest.xml similarity index 100% rename from shared/ui/component/src/androidMain/AndroidManifest.xml rename to compose-ui/src/androidMain/AndroidManifest.xml diff --git a/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/common/PlatformShare.android.kt b/compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/common/PlatformShare.android.kt similarity index 100% rename from shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/common/PlatformShare.android.kt rename to compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/common/PlatformShare.android.kt diff --git a/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/AudioPlayer.android.kt b/compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/AudioPlayer.android.kt similarity index 100% rename from shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/AudioPlayer.android.kt rename to compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/AudioPlayer.android.kt diff --git a/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/FlareDropdownMenu.kt b/compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/FlareDropdownMenu.kt similarity index 100% rename from shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/FlareDropdownMenu.kt rename to compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/FlareDropdownMenu.kt diff --git a/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/Glassify.kt b/compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/Glassify.kt similarity index 100% rename from shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/Glassify.kt rename to compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/Glassify.kt diff --git a/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/VideoPlayer.kt b/compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/VideoPlayer.kt similarity index 100% rename from shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/VideoPlayer.kt rename to compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/VideoPlayer.kt diff --git a/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlaceHolder.android.kt b/compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlaceHolder.android.kt similarity index 100% rename from shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlaceHolder.android.kt rename to compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlaceHolder.android.kt diff --git a/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformBigscreen.android.kt b/compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformBigscreen.android.kt similarity index 100% rename from shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformBigscreen.android.kt rename to compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformBigscreen.android.kt diff --git a/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.android.kt b/compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.android.kt similarity index 100% rename from shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.android.kt rename to compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.android.kt diff --git a/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCard.android.kt b/compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCard.android.kt similarity index 100% rename from shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCard.android.kt rename to compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCard.android.kt diff --git a/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCheckbox.android.kt b/compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCheckbox.android.kt similarity index 100% rename from shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCheckbox.android.kt rename to compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCheckbox.android.kt diff --git a/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformDropdown.android.kt b/compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformDropdown.android.kt similarity index 100% rename from shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformDropdown.android.kt rename to compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformDropdown.android.kt diff --git a/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformFlyoutContainer.android.kt b/compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformFlyoutContainer.android.kt similarity index 100% rename from shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformFlyoutContainer.android.kt rename to compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformFlyoutContainer.android.kt diff --git a/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIcon.android.kt b/compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIcon.android.kt similarity index 100% rename from shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIcon.android.kt rename to compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIcon.android.kt diff --git a/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIndication.android.kt b/compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIndication.android.kt similarity index 100% rename from shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIndication.android.kt rename to compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIndication.android.kt diff --git a/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformListItem.android.kt b/compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformListItem.android.kt similarity index 100% rename from shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformListItem.android.kt rename to compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformListItem.android.kt diff --git a/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformProgressIndicator.android.kt b/compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformProgressIndicator.android.kt similarity index 100% rename from shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformProgressIndicator.android.kt rename to compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformProgressIndicator.android.kt diff --git a/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformSlider.android.kt b/compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformSlider.android.kt similarity index 100% rename from shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformSlider.android.kt rename to compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformSlider.android.kt diff --git a/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformText.android.kt b/compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformText.android.kt similarity index 100% rename from shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformText.android.kt rename to compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformText.android.kt diff --git a/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformVideoPlayer.android.kt b/compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformVideoPlayer.android.kt similarity index 100% rename from shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformVideoPlayer.android.kt rename to compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformVideoPlayer.android.kt diff --git a/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformWifiState.android.kt b/compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformWifiState.android.kt similarity index 100% rename from shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformWifiState.android.kt rename to compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformWifiState.android.kt diff --git a/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/theme/PlatformColorScheme.android.kt b/compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/theme/PlatformColorScheme.android.kt similarity index 100% rename from shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/theme/PlatformColorScheme.android.kt rename to compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/theme/PlatformColorScheme.android.kt diff --git a/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.android.kt b/compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.android.kt similarity index 100% rename from shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.android.kt rename to compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.android.kt diff --git a/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/theme/PlatformTypography.android.kt b/compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/theme/PlatformTypography.android.kt similarity index 100% rename from shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/theme/PlatformTypography.android.kt rename to compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/theme/PlatformTypography.android.kt diff --git a/shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/theme/Theme.android.kt b/compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/theme/Theme.android.kt similarity index 100% rename from shared/ui/component/src/androidMain/kotlin/dev/dimension/flare/ui/theme/Theme.android.kt rename to compose-ui/src/androidMain/kotlin/dev/dimension/flare/ui/theme/Theme.android.kt diff --git a/shared/ui/component/src/commonMain/composeResources/values-af-rZA/strings.xml b/compose-ui/src/commonMain/composeResources/values-af-rZA/strings.xml similarity index 100% rename from shared/ui/component/src/commonMain/composeResources/values-af-rZA/strings.xml rename to compose-ui/src/commonMain/composeResources/values-af-rZA/strings.xml diff --git a/shared/ui/component/src/commonMain/composeResources/values-ar-rSA/strings.xml b/compose-ui/src/commonMain/composeResources/values-ar-rSA/strings.xml similarity index 100% rename from shared/ui/component/src/commonMain/composeResources/values-ar-rSA/strings.xml rename to compose-ui/src/commonMain/composeResources/values-ar-rSA/strings.xml diff --git a/shared/ui/component/src/commonMain/composeResources/values-ca-rES/strings.xml b/compose-ui/src/commonMain/composeResources/values-ca-rES/strings.xml similarity index 100% rename from shared/ui/component/src/commonMain/composeResources/values-ca-rES/strings.xml rename to compose-ui/src/commonMain/composeResources/values-ca-rES/strings.xml diff --git a/shared/ui/component/src/commonMain/composeResources/values-cs-rCZ/strings.xml b/compose-ui/src/commonMain/composeResources/values-cs-rCZ/strings.xml similarity index 100% rename from shared/ui/component/src/commonMain/composeResources/values-cs-rCZ/strings.xml rename to compose-ui/src/commonMain/composeResources/values-cs-rCZ/strings.xml diff --git a/shared/ui/component/src/commonMain/composeResources/values-da-rDK/strings.xml b/compose-ui/src/commonMain/composeResources/values-da-rDK/strings.xml similarity index 100% rename from shared/ui/component/src/commonMain/composeResources/values-da-rDK/strings.xml rename to compose-ui/src/commonMain/composeResources/values-da-rDK/strings.xml diff --git a/shared/ui/component/src/commonMain/composeResources/values-de-rDE/strings.xml b/compose-ui/src/commonMain/composeResources/values-de-rDE/strings.xml similarity index 100% rename from shared/ui/component/src/commonMain/composeResources/values-de-rDE/strings.xml rename to compose-ui/src/commonMain/composeResources/values-de-rDE/strings.xml diff --git a/shared/ui/component/src/commonMain/composeResources/values-el-rGR/strings.xml b/compose-ui/src/commonMain/composeResources/values-el-rGR/strings.xml similarity index 100% rename from shared/ui/component/src/commonMain/composeResources/values-el-rGR/strings.xml rename to compose-ui/src/commonMain/composeResources/values-el-rGR/strings.xml diff --git a/shared/ui/component/src/commonMain/composeResources/values-en-rUS/strings.xml b/compose-ui/src/commonMain/composeResources/values-en-rUS/strings.xml similarity index 100% rename from shared/ui/component/src/commonMain/composeResources/values-en-rUS/strings.xml rename to compose-ui/src/commonMain/composeResources/values-en-rUS/strings.xml diff --git a/shared/ui/component/src/commonMain/composeResources/values-es-rES/strings.xml b/compose-ui/src/commonMain/composeResources/values-es-rES/strings.xml similarity index 100% rename from shared/ui/component/src/commonMain/composeResources/values-es-rES/strings.xml rename to compose-ui/src/commonMain/composeResources/values-es-rES/strings.xml diff --git a/shared/ui/component/src/commonMain/composeResources/values-fi-rFI/strings.xml b/compose-ui/src/commonMain/composeResources/values-fi-rFI/strings.xml similarity index 100% rename from shared/ui/component/src/commonMain/composeResources/values-fi-rFI/strings.xml rename to compose-ui/src/commonMain/composeResources/values-fi-rFI/strings.xml diff --git a/shared/ui/component/src/commonMain/composeResources/values-fr-rFR/strings.xml b/compose-ui/src/commonMain/composeResources/values-fr-rFR/strings.xml similarity index 100% rename from shared/ui/component/src/commonMain/composeResources/values-fr-rFR/strings.xml rename to compose-ui/src/commonMain/composeResources/values-fr-rFR/strings.xml diff --git a/shared/ui/component/src/commonMain/composeResources/values-hu-rHU/strings.xml b/compose-ui/src/commonMain/composeResources/values-hu-rHU/strings.xml similarity index 100% rename from shared/ui/component/src/commonMain/composeResources/values-hu-rHU/strings.xml rename to compose-ui/src/commonMain/composeResources/values-hu-rHU/strings.xml diff --git a/shared/ui/component/src/commonMain/composeResources/values-it-rIT/strings.xml b/compose-ui/src/commonMain/composeResources/values-it-rIT/strings.xml similarity index 100% rename from shared/ui/component/src/commonMain/composeResources/values-it-rIT/strings.xml rename to compose-ui/src/commonMain/composeResources/values-it-rIT/strings.xml diff --git a/shared/ui/component/src/commonMain/composeResources/values-iw-rIL/strings.xml b/compose-ui/src/commonMain/composeResources/values-iw-rIL/strings.xml similarity index 100% rename from shared/ui/component/src/commonMain/composeResources/values-iw-rIL/strings.xml rename to compose-ui/src/commonMain/composeResources/values-iw-rIL/strings.xml diff --git a/shared/ui/component/src/commonMain/composeResources/values-ja-rJP/strings.xml b/compose-ui/src/commonMain/composeResources/values-ja-rJP/strings.xml similarity index 100% rename from shared/ui/component/src/commonMain/composeResources/values-ja-rJP/strings.xml rename to compose-ui/src/commonMain/composeResources/values-ja-rJP/strings.xml diff --git a/shared/ui/component/src/commonMain/composeResources/values-ko-rKR/strings.xml b/compose-ui/src/commonMain/composeResources/values-ko-rKR/strings.xml similarity index 100% rename from shared/ui/component/src/commonMain/composeResources/values-ko-rKR/strings.xml rename to compose-ui/src/commonMain/composeResources/values-ko-rKR/strings.xml diff --git a/shared/ui/component/src/commonMain/composeResources/values-nl-rNL/strings.xml b/compose-ui/src/commonMain/composeResources/values-nl-rNL/strings.xml similarity index 100% rename from shared/ui/component/src/commonMain/composeResources/values-nl-rNL/strings.xml rename to compose-ui/src/commonMain/composeResources/values-nl-rNL/strings.xml diff --git a/shared/ui/component/src/commonMain/composeResources/values-no-rNO/strings.xml b/compose-ui/src/commonMain/composeResources/values-no-rNO/strings.xml similarity index 100% rename from shared/ui/component/src/commonMain/composeResources/values-no-rNO/strings.xml rename to compose-ui/src/commonMain/composeResources/values-no-rNO/strings.xml diff --git a/shared/ui/component/src/commonMain/composeResources/values-pl-rPL/strings.xml b/compose-ui/src/commonMain/composeResources/values-pl-rPL/strings.xml similarity index 100% rename from shared/ui/component/src/commonMain/composeResources/values-pl-rPL/strings.xml rename to compose-ui/src/commonMain/composeResources/values-pl-rPL/strings.xml diff --git a/shared/ui/component/src/commonMain/composeResources/values-pt-rBR/strings.xml b/compose-ui/src/commonMain/composeResources/values-pt-rBR/strings.xml similarity index 100% rename from shared/ui/component/src/commonMain/composeResources/values-pt-rBR/strings.xml rename to compose-ui/src/commonMain/composeResources/values-pt-rBR/strings.xml diff --git a/shared/ui/component/src/commonMain/composeResources/values-pt-rPT/strings.xml b/compose-ui/src/commonMain/composeResources/values-pt-rPT/strings.xml similarity index 100% rename from shared/ui/component/src/commonMain/composeResources/values-pt-rPT/strings.xml rename to compose-ui/src/commonMain/composeResources/values-pt-rPT/strings.xml diff --git a/shared/ui/component/src/commonMain/composeResources/values-ro-rRO/strings.xml b/compose-ui/src/commonMain/composeResources/values-ro-rRO/strings.xml similarity index 100% rename from shared/ui/component/src/commonMain/composeResources/values-ro-rRO/strings.xml rename to compose-ui/src/commonMain/composeResources/values-ro-rRO/strings.xml diff --git a/shared/ui/component/src/commonMain/composeResources/values-ru-rRU/strings.xml b/compose-ui/src/commonMain/composeResources/values-ru-rRU/strings.xml similarity index 100% rename from shared/ui/component/src/commonMain/composeResources/values-ru-rRU/strings.xml rename to compose-ui/src/commonMain/composeResources/values-ru-rRU/strings.xml diff --git a/shared/ui/component/src/commonMain/composeResources/values-sr-rSP/strings.xml b/compose-ui/src/commonMain/composeResources/values-sr-rSP/strings.xml similarity index 100% rename from shared/ui/component/src/commonMain/composeResources/values-sr-rSP/strings.xml rename to compose-ui/src/commonMain/composeResources/values-sr-rSP/strings.xml diff --git a/shared/ui/component/src/commonMain/composeResources/values-sv-rSE/strings.xml b/compose-ui/src/commonMain/composeResources/values-sv-rSE/strings.xml similarity index 100% rename from shared/ui/component/src/commonMain/composeResources/values-sv-rSE/strings.xml rename to compose-ui/src/commonMain/composeResources/values-sv-rSE/strings.xml diff --git a/shared/ui/component/src/commonMain/composeResources/values-tr-rTR/strings.xml b/compose-ui/src/commonMain/composeResources/values-tr-rTR/strings.xml similarity index 100% rename from shared/ui/component/src/commonMain/composeResources/values-tr-rTR/strings.xml rename to compose-ui/src/commonMain/composeResources/values-tr-rTR/strings.xml diff --git a/shared/ui/component/src/commonMain/composeResources/values-uk-rUA/strings.xml b/compose-ui/src/commonMain/composeResources/values-uk-rUA/strings.xml similarity index 100% rename from shared/ui/component/src/commonMain/composeResources/values-uk-rUA/strings.xml rename to compose-ui/src/commonMain/composeResources/values-uk-rUA/strings.xml diff --git a/shared/ui/component/src/commonMain/composeResources/values-vi-rVN/strings.xml b/compose-ui/src/commonMain/composeResources/values-vi-rVN/strings.xml similarity index 100% rename from shared/ui/component/src/commonMain/composeResources/values-vi-rVN/strings.xml rename to compose-ui/src/commonMain/composeResources/values-vi-rVN/strings.xml diff --git a/shared/ui/component/src/commonMain/composeResources/values-zh-rCN/strings.xml b/compose-ui/src/commonMain/composeResources/values-zh-rCN/strings.xml similarity index 100% rename from shared/ui/component/src/commonMain/composeResources/values-zh-rCN/strings.xml rename to compose-ui/src/commonMain/composeResources/values-zh-rCN/strings.xml diff --git a/shared/ui/component/src/commonMain/composeResources/values-zh-rTW/strings.xml b/compose-ui/src/commonMain/composeResources/values-zh-rTW/strings.xml similarity index 100% rename from shared/ui/component/src/commonMain/composeResources/values-zh-rTW/strings.xml rename to compose-ui/src/commonMain/composeResources/values-zh-rTW/strings.xml diff --git a/shared/ui/component/src/commonMain/composeResources/values/strings.xml b/compose-ui/src/commonMain/composeResources/values/strings.xml similarity index 95% rename from shared/ui/component/src/commonMain/composeResources/values/strings.xml rename to compose-ui/src/commonMain/composeResources/values/strings.xml index f5830d692..b493ad12f 100644 --- a/shared/ui/component/src/commonMain/composeResources/values/strings.xml +++ b/compose-ui/src/commonMain/composeResources/values/strings.xml @@ -404,4 +404,27 @@ Sending Leave conversation Show profile + + Default will display side panel depends on the account platform. + Add + Drag + Edit + Remove + Default + List + Feeds + + Add list + Edit list + Delete list + + Rss Sources + Add Rss Source + Edit Rss Source + Delete Rss Source + + Title + Url + Rss + No rss sources \ No newline at end of file diff --git a/shared/src/androidJvmMain/kotlin/dev/dimension/flare/common/Event.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/common/Event.kt similarity index 100% rename from shared/src/androidJvmMain/kotlin/dev/dimension/flare/common/Event.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/common/Event.kt diff --git a/shared/src/androidJvmMain/kotlin/dev/dimension/flare/data/model/AppSettings.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/model/AppSettings.kt similarity index 64% rename from shared/src/androidJvmMain/kotlin/dev/dimension/flare/data/model/AppSettings.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/model/AppSettings.kt index 70bedb6bd..0ba3e4858 100644 --- a/shared/src/androidJvmMain/kotlin/dev/dimension/flare/data/model/AppSettings.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/model/AppSettings.kt @@ -1,15 +1,16 @@ package dev.dimension.flare.data.model -import androidx.datastore.core.Serializer +import androidx.datastore.core.okio.OkioSerializer import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.withContext import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromByteArray import kotlinx.serialization.encodeToByteArray import kotlinx.serialization.protobuf.ProtoBuf -import java.io.InputStream -import java.io.OutputStream +import okio.BufferedSink +import okio.BufferedSource @Serializable public data class AppSettings( @@ -24,20 +25,24 @@ public data class AppSettings( } @OptIn(ExperimentalSerializationApi::class) -public object AppSettingsSerializer : Serializer { - override suspend fun readFrom(input: InputStream): AppSettings = ProtoBuf.decodeFromByteArray(input.readBytes()) - - override suspend fun writeTo( - t: AppSettings, - output: OutputStream, - ): Unit = - withContext(Dispatchers.IO) { - output.write(ProtoBuf.encodeToByteArray(t)) - } - +public object AppSettingsSerializer : OkioSerializer { override val defaultValue: AppSettings get() = AppSettings( version = "", ) + + override suspend fun readFrom(source: BufferedSource): AppSettings = + withContext(Dispatchers.IO) { + ProtoBuf.decodeFromByteArray(source.readByteArray()) + } + + override suspend fun writeTo( + t: AppSettings, + sink: BufferedSink, + ) { + withContext(Dispatchers.IO) { + sink.write(ProtoBuf.encodeToByteArray(t)) + } + } } diff --git a/shared/src/androidJvmMain/kotlin/dev/dimension/flare/data/model/AppearanceSettings.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/model/AppearanceSettings.kt similarity index 77% rename from shared/src/androidJvmMain/kotlin/dev/dimension/flare/data/model/AppearanceSettings.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/model/AppearanceSettings.kt index 21de91f10..04eb9aff5 100644 --- a/shared/src/androidJvmMain/kotlin/dev/dimension/flare/data/model/AppearanceSettings.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/model/AppearanceSettings.kt @@ -3,16 +3,17 @@ package dev.dimension.flare.data.model import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color -import androidx.datastore.core.Serializer +import androidx.datastore.core.okio.OkioSerializer import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.withContext import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromByteArray import kotlinx.serialization.encodeToByteArray import kotlinx.serialization.protobuf.ProtoBuf -import java.io.InputStream -import java.io.OutputStream +import okio.BufferedSink +import okio.BufferedSource public val LocalAppearanceSettings: ProvidableCompositionLocal = staticCompositionLocalOf { AppearanceSettings() } @@ -54,17 +55,21 @@ public enum class VideoAutoplay { } @OptIn(ExperimentalSerializationApi::class) -public object AccountPreferencesSerializer : Serializer { - override suspend fun readFrom(input: InputStream): AppearanceSettings = ProtoBuf.decodeFromByteArray(input.readBytes()) +public object AccountPreferencesSerializer : OkioSerializer { + override val defaultValue: AppearanceSettings + get() = AppearanceSettings() + + override suspend fun readFrom(source: BufferedSource): AppearanceSettings = + withContext(Dispatchers.IO) { + ProtoBuf.decodeFromByteArray(source.readByteArray()) + } override suspend fun writeTo( t: AppearanceSettings, - output: OutputStream, - ): Unit = + sink: BufferedSink, + ) { withContext(Dispatchers.IO) { - output.write(ProtoBuf.encodeToByteArray(t)) + sink.write(ProtoBuf.encodeToByteArray(t)) } - - override val defaultValue: AppearanceSettings - get() = AppearanceSettings() + } } diff --git a/shared/src/androidJvmMain/kotlin/dev/dimension/flare/data/model/TabSettings.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/model/TabSettings.kt similarity index 98% rename from shared/src/androidJvmMain/kotlin/dev/dimension/flare/data/model/TabSettings.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/model/TabSettings.kt index 38c9647f3..89866d5d0 100644 --- a/shared/src/androidJvmMain/kotlin/dev/dimension/flare/data/model/TabSettings.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/model/TabSettings.kt @@ -1,10 +1,9 @@ package dev.dimension.flare.data.model -import androidx.datastore.core.Serializer +import androidx.datastore.core.okio.OkioSerializer import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType -import dev.dimension.flare.ui.model.UiAccount import dev.dimension.flare.ui.model.UiRssSource import dev.dimension.flare.ui.model.UiUserV2 import dev.dimension.flare.ui.presenter.home.HomeTimelinePresenter @@ -15,6 +14,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.withContext import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable @@ -22,8 +22,8 @@ import kotlinx.serialization.SerializationException import kotlinx.serialization.decodeFromByteArray import kotlinx.serialization.encodeToByteArray import kotlinx.serialization.protobuf.ProtoBuf -import java.io.InputStream -import java.io.OutputStream +import okio.BufferedSink +import okio.BufferedSource @Serializable public data class TabSettings( @@ -241,7 +241,7 @@ public sealed interface TimelineTabItem : TabItem { PlatformType.VVo -> vvo(user.key) } - public fun defaultSecondary(user: UiAccount): ImmutableList { + public fun defaultSecondary(user: UiUserV2): ImmutableList { val result = listOf( RssTabItem( @@ -254,11 +254,11 @@ public sealed interface TimelineTabItem : TabItem { ), ) + when (user.platformType) { - PlatformType.Mastodon -> defaultMastodonSecondaryItems(user.accountKey) - PlatformType.Misskey -> defaultMisskeySecondaryItems(user.accountKey) - PlatformType.Bluesky -> defaultBlueskySecondaryItems(user.accountKey) - PlatformType.xQt -> defaultXqtSecondaryItems(user.accountKey) - PlatformType.VVo -> defaultVVOSecondaryItems(user.accountKey) + PlatformType.Mastodon -> defaultMastodonSecondaryItems(user.key) + PlatformType.Misskey -> defaultMisskeySecondaryItems(user.key) + PlatformType.Bluesky -> defaultBlueskySecondaryItems(user.key) + PlatformType.xQt -> defaultXqtSecondaryItems(user.key) + PlatformType.VVo -> defaultVVOSecondaryItems(user.key) } return result.toImmutableList() } @@ -997,22 +997,25 @@ public data class RssTabItem( } @OptIn(ExperimentalSerializationApi::class) -public object TabSettingsSerializer : Serializer { - override suspend fun readFrom(input: InputStream): TabSettings = +public object TabSettingsSerializer : OkioSerializer { + override val defaultValue: TabSettings + get() = TabSettings() + + override suspend fun readFrom(source: BufferedSource): TabSettings = try { - ProtoBuf.decodeFromByteArray(input.readBytes()) + withContext(Dispatchers.IO) { + ProtoBuf.decodeFromByteArray(source.readByteArray()) + } } catch (e: SerializationException) { throw androidx.datastore.core.CorruptionException("Cannot read proto.", e) } override suspend fun writeTo( t: TabSettings, - output: OutputStream, - ): Unit = + sink: BufferedSink, + ) { withContext(Dispatchers.IO) { - output.write(ProtoBuf.encodeToByteArray(t)) + sink.write(ProtoBuf.encodeToByteArray(t)) } - - override val defaultValue: TabSettings - get() = TabSettings() + } } diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/repository/SettingsRepository.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/repository/SettingsRepository.kt new file mode 100644 index 000000000..08307ce5f --- /dev/null +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/repository/SettingsRepository.kt @@ -0,0 +1,76 @@ +package dev.dimension.flare.data.repository + +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.core.okio.OkioStorage +import dev.dimension.flare.data.io.PlatformPathProducer +import dev.dimension.flare.data.model.AccountPreferencesSerializer +import dev.dimension.flare.data.model.AppSettings +import dev.dimension.flare.data.model.AppSettingsSerializer +import dev.dimension.flare.data.model.AppearanceSettings +import dev.dimension.flare.data.model.TabSettings +import dev.dimension.flare.data.model.TabSettingsSerializer +import kotlinx.coroutines.flow.Flow +import okio.FileSystem +import okio.SYSTEM + +public class SettingsRepository internal constructor( + private val pathProducer: PlatformPathProducer, +) { + private val appearanceSettingsStore by lazy { + createDataStore( + name = "appearance_settings.pb", + serializer = AccountPreferencesSerializer, + ) + } + public val appearanceSettings: Flow by lazy { + appearanceSettingsStore.data + } + private val appSettingsStore by lazy { + createDataStore( + name = "app_settings.pb", + serializer = AppSettingsSerializer, + ) + } + public val appSettings: Flow by lazy { + appSettingsStore.data + } + + public suspend fun updateAppearanceSettings(block: AppearanceSettings.() -> AppearanceSettings) { + appearanceSettingsStore.updateData(block) + } + + private val tabSettingsStore by lazy { + createDataStore( + name = "tab_settings.pb", + serializer = TabSettingsSerializer, + ) + } + + public val tabSettings: Flow by lazy { + tabSettingsStore.data + } + + public suspend fun updateTabSettings(block: TabSettings.() -> TabSettings) { + tabSettingsStore.updateData(block) + } + + public suspend fun updateAppSettings(block: AppSettings.() -> AppSettings) { + appSettingsStore.updateData(block) + } + + private inline fun createDataStore( + name: String, + serializer: androidx.datastore.core.okio.OkioSerializer, + ): DataStore = + DataStoreFactory.create( + storage = + OkioStorage( + fileSystem = FileSystem.SYSTEM, + serializer = serializer, + producePath = { + pathProducer.dataStoreFile(name) + }, + ), + ) +} diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/di/ComposeUiModule.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/di/ComposeUiModule.kt new file mode 100644 index 000000000..5773de0a0 --- /dev/null +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/di/ComposeUiModule.kt @@ -0,0 +1,11 @@ +package dev.dimension.flare.di + +import dev.dimension.flare.data.repository.SettingsRepository +import org.koin.core.module.Module +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module + +public val composeUiModule: Module = + module { + singleOf(::SettingsRepository) + } diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/common/PaddingValueExt.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/common/PaddingValueExt.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/common/PaddingValueExt.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/common/PaddingValueExt.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/common/PagingStateExt.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/common/PagingStateExt.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/common/PagingStateExt.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/common/PagingStateExt.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/common/PlatformShare.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/common/PlatformShare.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/common/PlatformShare.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/common/PlatformShare.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/AdaptiveGrid.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/AdaptiveGrid.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/AdaptiveGrid.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/AdaptiveGrid.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/AnimatedText.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/AnimatedText.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/AnimatedText.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/AnimatedText.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/AudioPlayer.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/AudioPlayer.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/AudioPlayer.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/AudioPlayer.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/AvatarComponent.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/AvatarComponent.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/AvatarComponent.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/AvatarComponent.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/BuildContentAnnotatedString.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/BuildContentAnnotatedString.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/BuildContentAnnotatedString.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/BuildContentAnnotatedString.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/CommonProfileHeader.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/CommonProfileHeader.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/CommonProfileHeader.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/CommonProfileHeader.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/ComponentAppearance.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/ComponentAppearance.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/ComponentAppearance.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/ComponentAppearance.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/FAIcon.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/FAIcon.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/FAIcon.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/FAIcon.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/HorizontalDivider.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/HorizontalDivider.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/HorizontalDivider.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/HorizontalDivider.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/ListCardModifier.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/ListCardModifier.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/ListCardModifier.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/ListCardModifier.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/MatricesDisplay.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/MatricesDisplay.kt similarity index 92% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/MatricesDisplay.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/MatricesDisplay.kt index e19e7fbde..f812084de 100644 --- a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/MatricesDisplay.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/MatricesDisplay.kt @@ -12,13 +12,15 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import dev.dimension.flare.compose.ui.Res +import dev.dimension.flare.compose.ui.profile_header_fans_count +import dev.dimension.flare.compose.ui.profile_header_following_count +import dev.dimension.flare.compose.ui.profile_misskey_header_status_count import dev.dimension.flare.ui.component.platform.PlatformText import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.theme.PlatformTheme import kotlinx.collections.immutable.persistentMapOf import org.jetbrains.compose.resources.stringResource -import kotlin.collections.component1 -import kotlin.collections.component2 @OptIn(ExperimentalLayoutApi::class) @Composable diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/NetworkImage.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/NetworkImage.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/NetworkImage.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/NetworkImage.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/ProfileHeader.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/ProfileHeader.kt similarity index 97% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/ProfileHeader.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/ProfileHeader.kt index db32ac799..0a9559222 100644 --- a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/ProfileHeader.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/ProfileHeader.kt @@ -32,6 +32,12 @@ import compose.icons.fontawesomeicons.solid.LocationDot import compose.icons.fontawesomeicons.solid.Lock import compose.icons.fontawesomeicons.solid.Robot import dev.dimension.flare.common.AppDeepLink +import dev.dimension.flare.compose.ui.Res +import dev.dimension.flare.compose.ui.profile_header_button_blocked +import dev.dimension.flare.compose.ui.profile_header_button_follow +import dev.dimension.flare.compose.ui.profile_header_button_following +import dev.dimension.flare.compose.ui.profile_header_button_is_fans +import dev.dimension.flare.compose.ui.profile_header_button_requested import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.component.platform.PlatformFilledTonalButton import dev.dimension.flare.ui.component.platform.PlatformText diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/ProfileMenu.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/ProfileMenu.kt similarity index 95% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/ProfileMenu.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/ProfileMenu.kt index 145e06e41..a78867325 100644 --- a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/ProfileMenu.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/ProfileMenu.kt @@ -9,6 +9,17 @@ import compose.icons.fontawesomeicons.solid.List import compose.icons.fontawesomeicons.solid.Message import compose.icons.fontawesomeicons.solid.UserSlash import compose.icons.fontawesomeicons.solid.VolumeXmark +import dev.dimension.flare.compose.ui.Res +import dev.dimension.flare.compose.ui.more +import dev.dimension.flare.compose.ui.profile_search_user_using_account +import dev.dimension.flare.compose.ui.profile_search_user_using_account_compat +import dev.dimension.flare.compose.ui.user_block +import dev.dimension.flare.compose.ui.user_follow_edit_list +import dev.dimension.flare.compose.ui.user_mute +import dev.dimension.flare.compose.ui.user_report +import dev.dimension.flare.compose.ui.user_send_message +import dev.dimension.flare.compose.ui.user_unblock +import dev.dimension.flare.compose.ui.user_unmute import dev.dimension.flare.data.datasource.microblog.ProfileAction import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType @@ -23,8 +34,6 @@ import dev.dimension.flare.ui.presenter.profile.ProfileState import dev.dimension.flare.ui.theme.PlatformTheme import kotlinx.collections.immutable.ImmutableList import org.jetbrains.compose.resources.stringResource -import kotlin.collections.component1 -import kotlin.collections.component2 @Composable public fun ProfileMenu( diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/RichText.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/RichText.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/RichText.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/RichText.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/UiListItemComponent.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/UiListItemComponent.kt similarity index 97% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/UiListItemComponent.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/UiListItemComponent.kt index a5327d2fc..8e1f21d17 100644 --- a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/UiListItemComponent.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/UiListItemComponent.kt @@ -23,6 +23,10 @@ import compose.icons.fontawesomeicons.solid.CircleExclamation import compose.icons.fontawesomeicons.solid.List import compose.icons.fontawesomeicons.solid.Rss import dev.dimension.flare.common.PagingState +import dev.dimension.flare.compose.ui.Res +import dev.dimension.flare.compose.ui.feeds_discover_feeds_created_by +import dev.dimension.flare.compose.ui.list_empty +import dev.dimension.flare.compose.ui.list_error import dev.dimension.flare.ui.common.itemsIndexed import dev.dimension.flare.ui.component.platform.PlatformListItem import dev.dimension.flare.ui.component.platform.PlatformText diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/UserFields.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/UserFields.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/UserFields.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/UserFields.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/dm/DMItem.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/dm/DMItem.kt similarity index 96% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/dm/DMItem.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/dm/DMItem.kt index ac561b687..4a672e806 100644 --- a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/dm/DMItem.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/dm/DMItem.kt @@ -21,16 +21,16 @@ import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.CircleExclamation import dev.dimension.flare.common.AppDeepLink +import dev.dimension.flare.compose.ui.Res +import dev.dimension.flare.compose.ui.dm_deleted +import dev.dimension.flare.compose.ui.dm_sending +import dev.dimension.flare.compose.ui.send import dev.dimension.flare.ui.component.AvatarComponent import dev.dimension.flare.ui.component.AvatarComponentDefaults import dev.dimension.flare.ui.component.FAIcon -import dev.dimension.flare.ui.component.Res import dev.dimension.flare.ui.component.RichText -import dev.dimension.flare.ui.component.dm_deleted -import dev.dimension.flare.ui.component.dm_sending import dev.dimension.flare.ui.component.platform.PlatformIconButton import dev.dimension.flare.ui.component.platform.PlatformText -import dev.dimension.flare.ui.component.send import dev.dimension.flare.ui.component.status.MediaItem import dev.dimension.flare.ui.component.status.QuotedStatus import dev.dimension.flare.ui.model.UiDMItem @@ -175,7 +175,7 @@ public fun DMItem( ) } else if (item.sendState == null || item.sendState != UiDMItem.SendState.Failed) { PlatformText( - item.timestamp.shortTime.localizedShortTime, + item.timestamp.localizedShortTime, style = PlatformTheme.typography.caption, color = PlatformTheme.colorScheme.onCard, ) diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/dm/DmListItem.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/dm/DmListItem.kt similarity index 97% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/dm/DmListItem.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/dm/DmListItem.kt index d5a22ae81..de95c0867 100644 --- a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/dm/DmListItem.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/dm/DmListItem.kt @@ -23,16 +23,16 @@ import compose.icons.fontawesomeicons.solid.CircleExclamation import compose.icons.fontawesomeicons.solid.CircleUser import compose.icons.fontawesomeicons.solid.List import dev.dimension.flare.common.PagingState +import dev.dimension.flare.compose.ui.Res +import dev.dimension.flare.compose.ui.dm_list_empty +import dev.dimension.flare.compose.ui.dm_list_error import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.common.itemsIndexed import dev.dimension.flare.ui.component.AvatarComponent import dev.dimension.flare.ui.component.AvatarComponentDefaults import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.ItemPlaceHolder -import dev.dimension.flare.ui.component.Res import dev.dimension.flare.ui.component.RichText -import dev.dimension.flare.ui.component.dm_list_empty -import dev.dimension.flare.ui.component.dm_list_error import dev.dimension.flare.ui.component.listCard import dev.dimension.flare.ui.component.platform.PlatformListItem import dev.dimension.flare.ui.component.platform.PlatformText @@ -143,7 +143,7 @@ public fun LazyListScope.dmList( val lastMessage = item.lastMessage if (lastMessage != null) { PlatformText( - text = lastMessage.timestamp.shortTime.localizedShortTime, + text = lastMessage.timestamp.localizedShortTime, style = PlatformTheme.typography.caption, modifier = Modifier diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlaceHolder.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlaceHolder.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlaceHolder.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlaceHolder.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformBigscreen.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformBigscreen.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformBigscreen.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformBigscreen.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCard.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCard.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCard.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCard.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCheckbox.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCheckbox.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCheckbox.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCheckbox.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformDropdown.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformDropdown.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformDropdown.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformDropdown.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformFlyoutContainer.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformFlyoutContainer.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformFlyoutContainer.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformFlyoutContainer.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIcon.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIcon.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIcon.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIcon.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIndication.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIndication.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIndication.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIndication.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformListItem.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformListItem.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformListItem.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformListItem.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformProgressIndicator.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformProgressIndicator.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformProgressIndicator.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformProgressIndicator.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformSlider.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformSlider.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformSlider.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformSlider.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformText.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformText.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformText.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformText.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformVideoPlayer.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformVideoPlayer.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformVideoPlayer.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformVideoPlayer.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformWifiState.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformWifiState.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformWifiState.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformWifiState.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/AdaptiveCard.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/AdaptiveCard.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/AdaptiveCard.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/AdaptiveCard.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt similarity index 96% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt index 64e403e9b..554ef31c1 100644 --- a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt @@ -69,6 +69,34 @@ import compose.icons.fontawesomeicons.solid.Reply import compose.icons.fontawesomeicons.solid.Retweet import compose.icons.fontawesomeicons.solid.ShareNodes import compose.icons.fontawesomeicons.solid.Trash +import dev.dimension.flare.compose.ui.Res +import dev.dimension.flare.compose.ui.bookmark_add +import dev.dimension.flare.compose.ui.bookmark_remove +import dev.dimension.flare.compose.ui.delete +import dev.dimension.flare.compose.ui.like +import dev.dimension.flare.compose.ui.mastodon_item_show_less +import dev.dimension.flare.compose.ui.mastodon_item_show_more +import dev.dimension.flare.compose.ui.mastodon_visibility_direct +import dev.dimension.flare.compose.ui.mastodon_visibility_private +import dev.dimension.flare.compose.ui.mastodon_visibility_public +import dev.dimension.flare.compose.ui.mastodon_visibility_unlisted +import dev.dimension.flare.compose.ui.more +import dev.dimension.flare.compose.ui.poll_expired +import dev.dimension.flare.compose.ui.poll_expired_at +import dev.dimension.flare.compose.ui.quote +import dev.dimension.flare.compose.ui.reaction_add +import dev.dimension.flare.compose.ui.reaction_remove +import dev.dimension.flare.compose.ui.reply +import dev.dimension.flare.compose.ui.reply_to +import dev.dimension.flare.compose.ui.report +import dev.dimension.flare.compose.ui.retweet +import dev.dimension.flare.compose.ui.retweet_remove +import dev.dimension.flare.compose.ui.share +import dev.dimension.flare.compose.ui.show_media +import dev.dimension.flare.compose.ui.status_detail_tldr +import dev.dimension.flare.compose.ui.status_detail_translate +import dev.dimension.flare.compose.ui.unlike +import dev.dimension.flare.compose.ui.vote import dev.dimension.flare.data.datasource.microblog.StatusAction import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.common.PlatformShare @@ -80,19 +108,7 @@ import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.FlareDividerDefaults import dev.dimension.flare.ui.component.HorizontalDivider import dev.dimension.flare.ui.component.LocalComponentAppearance -import dev.dimension.flare.ui.component.Res import dev.dimension.flare.ui.component.RichText -import dev.dimension.flare.ui.component.bookmark_add -import dev.dimension.flare.ui.component.bookmark_remove -import dev.dimension.flare.ui.component.delete -import dev.dimension.flare.ui.component.like -import dev.dimension.flare.ui.component.mastodon_item_show_less -import dev.dimension.flare.ui.component.mastodon_item_show_more -import dev.dimension.flare.ui.component.mastodon_visibility_direct -import dev.dimension.flare.ui.component.mastodon_visibility_private -import dev.dimension.flare.ui.component.mastodon_visibility_public -import dev.dimension.flare.ui.component.mastodon_visibility_unlisted -import dev.dimension.flare.ui.component.more import dev.dimension.flare.ui.component.platform.PlatformCard import dev.dimension.flare.ui.component.platform.PlatformCheckbox import dev.dimension.flare.ui.component.platform.PlatformDropdownMenuItem @@ -103,22 +119,6 @@ import dev.dimension.flare.ui.component.platform.PlatformText import dev.dimension.flare.ui.component.platform.PlatformTextButton import dev.dimension.flare.ui.component.platform.PlatformTextStyle import dev.dimension.flare.ui.component.platform.placeholder -import dev.dimension.flare.ui.component.poll_expired -import dev.dimension.flare.ui.component.poll_expired_at -import dev.dimension.flare.ui.component.quote -import dev.dimension.flare.ui.component.reaction_add -import dev.dimension.flare.ui.component.reaction_remove -import dev.dimension.flare.ui.component.reply -import dev.dimension.flare.ui.component.reply_to -import dev.dimension.flare.ui.component.report -import dev.dimension.flare.ui.component.retweet -import dev.dimension.flare.ui.component.retweet_remove -import dev.dimension.flare.ui.component.share -import dev.dimension.flare.ui.component.show_media -import dev.dimension.flare.ui.component.status_detail_tldr -import dev.dimension.flare.ui.component.status_detail_translate -import dev.dimension.flare.ui.component.unlike -import dev.dimension.flare.ui.component.vote import dev.dimension.flare.ui.model.ClickContext import dev.dimension.flare.ui.model.Digit import dev.dimension.flare.ui.model.UiCard @@ -200,7 +200,7 @@ public fun CommonStatusComponent( } if (!isDetail) { PlatformText( - text = item.createdAt.shortTime.localizedShortTime, + text = item.createdAt.localizedShortTime, style = PlatformTheme.typography.caption, color = PlatformTheme.colorScheme.caption, ) @@ -297,7 +297,7 @@ public fun CommonStatusComponent( if (isDetail) { Spacer(modifier = Modifier.height(8.dp)) PlatformText( - text = item.createdAt.value.localizedFullTime, + text = item.createdAt.localizedFullTime, style = PlatformTheme.typography.caption, color = PlatformTheme.colorScheme.caption, ) @@ -1057,7 +1057,7 @@ private fun StatusPollComponent( text = stringResource( resource = Res.string.poll_expired_at, - poll.expiredAt.value.localizedFullTime, + poll.expiredAt.localizedFullTime, ), modifier = Modifier diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusHeaderComponent.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusHeaderComponent.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusHeaderComponent.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusHeaderComponent.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/FeedComponent.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/FeedComponent.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/FeedComponent.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/FeedComponent.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/LazyStatusItems.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/LazyStatusItems.kt similarity index 97% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/LazyStatusItems.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/LazyStatusItems.kt index 0d568c96c..1bf0e757a 100644 --- a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/LazyStatusItems.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/LazyStatusItems.kt @@ -34,17 +34,17 @@ import dev.dimension.flare.common.onEndOfList import dev.dimension.flare.common.onError import dev.dimension.flare.common.onLoading import dev.dimension.flare.common.onSuccess +import dev.dimension.flare.compose.ui.Res +import dev.dimension.flare.compose.ui.login_expired +import dev.dimension.flare.compose.ui.login_expired_message +import dev.dimension.flare.compose.ui.status_empty +import dev.dimension.flare.compose.ui.status_loadmore_end +import dev.dimension.flare.compose.ui.status_loadmore_error import dev.dimension.flare.data.repository.LoginExpiredException import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.component.FAIcon -import dev.dimension.flare.ui.component.Res -import dev.dimension.flare.ui.component.login_expired -import dev.dimension.flare.ui.component.login_expired_message import dev.dimension.flare.ui.component.platform.PlatformText import dev.dimension.flare.ui.component.platform.placeholder -import dev.dimension.flare.ui.component.status_empty -import dev.dimension.flare.ui.component.status_loadmore_end -import dev.dimension.flare.ui.component.status_loadmore_error import dev.dimension.flare.ui.model.UiTimeline import dev.dimension.flare.ui.theme.MediumAlpha import dev.dimension.flare.ui.theme.PlatformTheme diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/LazyStatusVerticalStaggeredGrid.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/LazyStatusVerticalStaggeredGrid.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/LazyStatusVerticalStaggeredGrid.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/LazyStatusVerticalStaggeredGrid.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/QuotedStatus.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/QuotedStatus.kt similarity index 98% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/QuotedStatus.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/QuotedStatus.kt index 411d103a5..7df77f20a 100644 --- a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/QuotedStatus.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/QuotedStatus.kt @@ -58,7 +58,7 @@ public fun QuotedStatus( data.user?.let { UserCompat(it) { PlatformText( - text = data.createdAt.shortTime.localizedShortTime, + text = data.createdAt.localizedShortTime, style = PlatformTheme.typography.caption, modifier = Modifier diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusActionButton.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusActionButton.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusActionButton.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusActionButton.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusMediaComponent.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusMediaComponent.kt similarity index 99% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusMediaComponent.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusMediaComponent.kt index fee8e8dbe..1f6a32e56 100644 --- a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusMediaComponent.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusMediaComponent.kt @@ -32,20 +32,20 @@ import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.CirclePlay import compose.icons.fontawesomeicons.solid.EyeSlash import dev.dimension.flare.common.AppDeepLink +import dev.dimension.flare.compose.ui.Res +import dev.dimension.flare.compose.ui.status_sensitive_media import dev.dimension.flare.ui.component.AdaptiveGrid import dev.dimension.flare.ui.component.AudioPlayer import dev.dimension.flare.ui.component.ComponentAppearance import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.LocalComponentAppearance import dev.dimension.flare.ui.component.NetworkImage -import dev.dimension.flare.ui.component.Res import dev.dimension.flare.ui.component.platform.PlatformCircularProgressIndicator import dev.dimension.flare.ui.component.platform.PlatformFlyoutContainer import dev.dimension.flare.ui.component.platform.PlatformIconButton import dev.dimension.flare.ui.component.platform.PlatformText import dev.dimension.flare.ui.component.platform.PlatformVideoPlayer import dev.dimension.flare.ui.component.platform.rememberPlatformWifiState -import dev.dimension.flare.ui.component.status_sensitive_media import dev.dimension.flare.ui.humanizer.humanize import dev.dimension.flare.ui.model.UiMedia import dev.dimension.flare.ui.theme.PlatformTheme diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusRetweetHeaderComponent.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusRetweetHeaderComponent.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusRetweetHeaderComponent.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusRetweetHeaderComponent.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusTranslatePresenter.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusTranslatePresenter.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusTranslatePresenter.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/StatusTranslatePresenter.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/TranslateResult.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/TranslateResult.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/TranslateResult.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/TranslateResult.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/UiTimelineComponent.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/UiTimelineComponent.kt similarity index 72% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/UiTimelineComponent.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/UiTimelineComponent.kt index a853b833a..565c603bb 100644 --- a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/UiTimelineComponent.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/UiTimelineComponent.kt @@ -36,208 +36,208 @@ import compose.icons.fontawesomeicons.solid.SquarePollHorizontal import compose.icons.fontawesomeicons.solid.Thumbtack import compose.icons.fontawesomeicons.solid.UserPlus import compose.icons.fontawesomeicons.solid.Xmark +import dev.dimension.flare.compose.ui.Res +import dev.dimension.flare.compose.ui.bluesky_notification_item_favourited_your_status +import dev.dimension.flare.compose.ui.bluesky_notification_item_followed_you +import dev.dimension.flare.compose.ui.bluesky_notification_item_mentioned_you +import dev.dimension.flare.compose.ui.bluesky_notification_item_pin +import dev.dimension.flare.compose.ui.bluesky_notification_item_quoted_your_status +import dev.dimension.flare.compose.ui.bluesky_notification_item_reblogged_your_status +import dev.dimension.flare.compose.ui.bluesky_notification_item_replied_to_you +import dev.dimension.flare.compose.ui.bluesky_notification_item_starterpack_joined +import dev.dimension.flare.compose.ui.bluesky_notification_item_unKnown +import dev.dimension.flare.compose.ui.mastodon_item_pinned +import dev.dimension.flare.compose.ui.mastodon_notification_item_favourited_your_status +import dev.dimension.flare.compose.ui.mastodon_notification_item_followed_you +import dev.dimension.flare.compose.ui.mastodon_notification_item_mentioned_you +import dev.dimension.flare.compose.ui.mastodon_notification_item_poll_ended +import dev.dimension.flare.compose.ui.mastodon_notification_item_posted_status +import dev.dimension.flare.compose.ui.mastodon_notification_item_reblogged_your_status +import dev.dimension.flare.compose.ui.mastodon_notification_item_requested_follow +import dev.dimension.flare.compose.ui.mastodon_notification_item_updated_status +import dev.dimension.flare.compose.ui.misskey_achievement_brain_diver_description +import dev.dimension.flare.compose.ui.misskey_achievement_brain_diver_title +import dev.dimension.flare.compose.ui.misskey_achievement_bubble_game_double_exploding_head_description +import dev.dimension.flare.compose.ui.misskey_achievement_bubble_game_double_exploding_head_title +import dev.dimension.flare.compose.ui.misskey_achievement_bubble_game_exploding_head_description +import dev.dimension.flare.compose.ui.misskey_achievement_bubble_game_exploding_head_title +import dev.dimension.flare.compose.ui.misskey_achievement_clicked_click_here_description +import dev.dimension.flare.compose.ui.misskey_achievement_clicked_click_here_title +import dev.dimension.flare.compose.ui.misskey_achievement_client30min_description +import dev.dimension.flare.compose.ui.misskey_achievement_client30min_title +import dev.dimension.flare.compose.ui.misskey_achievement_client60min_description +import dev.dimension.flare.compose.ui.misskey_achievement_client60min_title +import dev.dimension.flare.compose.ui.misskey_achievement_collect_achievements30_description +import dev.dimension.flare.compose.ui.misskey_achievement_collect_achievements30_title +import dev.dimension.flare.compose.ui.misskey_achievement_cookie_clicked_description +import dev.dimension.flare.compose.ui.misskey_achievement_cookie_clicked_title +import dev.dimension.flare.compose.ui.misskey_achievement_drive_folder_circular_reference_description +import dev.dimension.flare.compose.ui.misskey_achievement_drive_folder_circular_reference_title +import dev.dimension.flare.compose.ui.misskey_achievement_followers1000_description +import dev.dimension.flare.compose.ui.misskey_achievement_followers1000_title +import dev.dimension.flare.compose.ui.misskey_achievement_followers100_description +import dev.dimension.flare.compose.ui.misskey_achievement_followers100_title +import dev.dimension.flare.compose.ui.misskey_achievement_followers10_description +import dev.dimension.flare.compose.ui.misskey_achievement_followers10_title +import dev.dimension.flare.compose.ui.misskey_achievement_followers1_description +import dev.dimension.flare.compose.ui.misskey_achievement_followers1_title +import dev.dimension.flare.compose.ui.misskey_achievement_followers300_description +import dev.dimension.flare.compose.ui.misskey_achievement_followers300_title +import dev.dimension.flare.compose.ui.misskey_achievement_followers500_description +import dev.dimension.flare.compose.ui.misskey_achievement_followers500_title +import dev.dimension.flare.compose.ui.misskey_achievement_followers50_description +import dev.dimension.flare.compose.ui.misskey_achievement_followers50_title +import dev.dimension.flare.compose.ui.misskey_achievement_following100_description +import dev.dimension.flare.compose.ui.misskey_achievement_following100_title +import dev.dimension.flare.compose.ui.misskey_achievement_following10_description +import dev.dimension.flare.compose.ui.misskey_achievement_following10_title +import dev.dimension.flare.compose.ui.misskey_achievement_following1_description +import dev.dimension.flare.compose.ui.misskey_achievement_following1_title +import dev.dimension.flare.compose.ui.misskey_achievement_following300_description +import dev.dimension.flare.compose.ui.misskey_achievement_following300_title +import dev.dimension.flare.compose.ui.misskey_achievement_following50_description +import dev.dimension.flare.compose.ui.misskey_achievement_following50_title +import dev.dimension.flare.compose.ui.misskey_achievement_found_treasure_description +import dev.dimension.flare.compose.ui.misskey_achievement_found_treasure_title +import dev.dimension.flare.compose.ui.misskey_achievement_htl20npm_description +import dev.dimension.flare.compose.ui.misskey_achievement_htl20npm_title +import dev.dimension.flare.compose.ui.misskey_achievement_i_love_misskey_description +import dev.dimension.flare.compose.ui.misskey_achievement_i_love_misskey_title +import dev.dimension.flare.compose.ui.misskey_achievement_just_plain_lucky_description +import dev.dimension.flare.compose.ui.misskey_achievement_just_plain_lucky_title +import dev.dimension.flare.compose.ui.misskey_achievement_logged_in_on_birthday_description +import dev.dimension.flare.compose.ui.misskey_achievement_logged_in_on_birthday_title +import dev.dimension.flare.compose.ui.misskey_achievement_logged_in_on_new_years_day_description +import dev.dimension.flare.compose.ui.misskey_achievement_logged_in_on_new_years_day_title +import dev.dimension.flare.compose.ui.misskey_achievement_login1000_description +import dev.dimension.flare.compose.ui.misskey_achievement_login1000_title +import dev.dimension.flare.compose.ui.misskey_achievement_login100_description +import dev.dimension.flare.compose.ui.misskey_achievement_login100_title +import dev.dimension.flare.compose.ui.misskey_achievement_login15_description +import dev.dimension.flare.compose.ui.misskey_achievement_login15_title +import dev.dimension.flare.compose.ui.misskey_achievement_login200_description +import dev.dimension.flare.compose.ui.misskey_achievement_login200_title +import dev.dimension.flare.compose.ui.misskey_achievement_login300_description +import dev.dimension.flare.compose.ui.misskey_achievement_login300_title +import dev.dimension.flare.compose.ui.misskey_achievement_login30_description +import dev.dimension.flare.compose.ui.misskey_achievement_login30_title +import dev.dimension.flare.compose.ui.misskey_achievement_login3_description +import dev.dimension.flare.compose.ui.misskey_achievement_login3_title +import dev.dimension.flare.compose.ui.misskey_achievement_login400_description +import dev.dimension.flare.compose.ui.misskey_achievement_login400_title +import dev.dimension.flare.compose.ui.misskey_achievement_login500_description +import dev.dimension.flare.compose.ui.misskey_achievement_login500_title +import dev.dimension.flare.compose.ui.misskey_achievement_login600_description +import dev.dimension.flare.compose.ui.misskey_achievement_login600_title +import dev.dimension.flare.compose.ui.misskey_achievement_login60_description +import dev.dimension.flare.compose.ui.misskey_achievement_login60_title +import dev.dimension.flare.compose.ui.misskey_achievement_login700_description +import dev.dimension.flare.compose.ui.misskey_achievement_login700_title +import dev.dimension.flare.compose.ui.misskey_achievement_login7_description +import dev.dimension.flare.compose.ui.misskey_achievement_login7_title +import dev.dimension.flare.compose.ui.misskey_achievement_login800_description +import dev.dimension.flare.compose.ui.misskey_achievement_login800_title +import dev.dimension.flare.compose.ui.misskey_achievement_login900_description +import dev.dimension.flare.compose.ui.misskey_achievement_login900_title +import dev.dimension.flare.compose.ui.misskey_achievement_marked_as_cat_description +import dev.dimension.flare.compose.ui.misskey_achievement_marked_as_cat_title +import dev.dimension.flare.compose.ui.misskey_achievement_my_note_favorited1_description +import dev.dimension.flare.compose.ui.misskey_achievement_my_note_favorited1_title +import dev.dimension.flare.compose.ui.misskey_achievement_note_clipped1_description +import dev.dimension.flare.compose.ui.misskey_achievement_note_clipped1_title +import dev.dimension.flare.compose.ui.misskey_achievement_note_deleted_within1min_description +import dev.dimension.flare.compose.ui.misskey_achievement_note_deleted_within1min_title +import dev.dimension.flare.compose.ui.misskey_achievement_note_favorited1_description +import dev.dimension.flare.compose.ui.misskey_achievement_note_favorited1_title +import dev.dimension.flare.compose.ui.misskey_achievement_notes100000_description +import dev.dimension.flare.compose.ui.misskey_achievement_notes100000_title +import dev.dimension.flare.compose.ui.misskey_achievement_notes10000_description +import dev.dimension.flare.compose.ui.misskey_achievement_notes10000_title +import dev.dimension.flare.compose.ui.misskey_achievement_notes1000_description +import dev.dimension.flare.compose.ui.misskey_achievement_notes1000_title +import dev.dimension.flare.compose.ui.misskey_achievement_notes100_description +import dev.dimension.flare.compose.ui.misskey_achievement_notes100_title +import dev.dimension.flare.compose.ui.misskey_achievement_notes10_description +import dev.dimension.flare.compose.ui.misskey_achievement_notes10_title +import dev.dimension.flare.compose.ui.misskey_achievement_notes1_description +import dev.dimension.flare.compose.ui.misskey_achievement_notes1_title +import dev.dimension.flare.compose.ui.misskey_achievement_notes20000_description +import dev.dimension.flare.compose.ui.misskey_achievement_notes20000_title +import dev.dimension.flare.compose.ui.misskey_achievement_notes30000_description +import dev.dimension.flare.compose.ui.misskey_achievement_notes30000_title +import dev.dimension.flare.compose.ui.misskey_achievement_notes40000_description +import dev.dimension.flare.compose.ui.misskey_achievement_notes40000_title +import dev.dimension.flare.compose.ui.misskey_achievement_notes50000_description +import dev.dimension.flare.compose.ui.misskey_achievement_notes50000_title +import dev.dimension.flare.compose.ui.misskey_achievement_notes5000_description +import dev.dimension.flare.compose.ui.misskey_achievement_notes5000_title +import dev.dimension.flare.compose.ui.misskey_achievement_notes500_description +import dev.dimension.flare.compose.ui.misskey_achievement_notes500_title +import dev.dimension.flare.compose.ui.misskey_achievement_notes60000_description +import dev.dimension.flare.compose.ui.misskey_achievement_notes60000_title +import dev.dimension.flare.compose.ui.misskey_achievement_notes70000_description +import dev.dimension.flare.compose.ui.misskey_achievement_notes70000_title +import dev.dimension.flare.compose.ui.misskey_achievement_notes80000_description +import dev.dimension.flare.compose.ui.misskey_achievement_notes80000_title +import dev.dimension.flare.compose.ui.misskey_achievement_notes90000_description +import dev.dimension.flare.compose.ui.misskey_achievement_notes90000_title +import dev.dimension.flare.compose.ui.misskey_achievement_open3windows_description +import dev.dimension.flare.compose.ui.misskey_achievement_open3windows_title +import dev.dimension.flare.compose.ui.misskey_achievement_output_hello_world_on_scratchpad_description +import dev.dimension.flare.compose.ui.misskey_achievement_output_hello_world_on_scratchpad_title +import dev.dimension.flare.compose.ui.misskey_achievement_passed_since_account_created1_description +import dev.dimension.flare.compose.ui.misskey_achievement_passed_since_account_created1_title +import dev.dimension.flare.compose.ui.misskey_achievement_passed_since_account_created2_description +import dev.dimension.flare.compose.ui.misskey_achievement_passed_since_account_created2_title +import dev.dimension.flare.compose.ui.misskey_achievement_passed_since_account_created3_description +import dev.dimension.flare.compose.ui.misskey_achievement_passed_since_account_created3_title +import dev.dimension.flare.compose.ui.misskey_achievement_posted_at_0min0sec_description +import dev.dimension.flare.compose.ui.misskey_achievement_posted_at_0min0sec_title +import dev.dimension.flare.compose.ui.misskey_achievement_posted_at_late_night_description +import dev.dimension.flare.compose.ui.misskey_achievement_posted_at_late_night_title +import dev.dimension.flare.compose.ui.misskey_achievement_profile_filled_description +import dev.dimension.flare.compose.ui.misskey_achievement_profile_filled_title +import dev.dimension.flare.compose.ui.misskey_achievement_react_without_read_description +import dev.dimension.flare.compose.ui.misskey_achievement_react_without_read_title +import dev.dimension.flare.compose.ui.misskey_achievement_self_quote_description +import dev.dimension.flare.compose.ui.misskey_achievement_self_quote_title +import dev.dimension.flare.compose.ui.misskey_achievement_set_name_to_syuilo_description +import dev.dimension.flare.compose.ui.misskey_achievement_set_name_to_syuilo_title +import dev.dimension.flare.compose.ui.misskey_achievement_smash_test_notification_button_description +import dev.dimension.flare.compose.ui.misskey_achievement_smash_test_notification_button_title +import dev.dimension.flare.compose.ui.misskey_achievement_tutorial_completed_description +import dev.dimension.flare.compose.ui.misskey_achievement_tutorial_completed_title +import dev.dimension.flare.compose.ui.misskey_achievement_view_achievements3min_description +import dev.dimension.flare.compose.ui.misskey_achievement_view_achievements3min_title +import dev.dimension.flare.compose.ui.misskey_achievement_view_instance_chart_description +import dev.dimension.flare.compose.ui.misskey_achievement_view_instance_chart_title +import dev.dimension.flare.compose.ui.misskey_notification_item_achievement_earned +import dev.dimension.flare.compose.ui.misskey_notification_item_app +import dev.dimension.flare.compose.ui.misskey_notification_item_follow_request_accepted +import dev.dimension.flare.compose.ui.misskey_notification_item_followed_you +import dev.dimension.flare.compose.ui.misskey_notification_item_mentioned_you +import dev.dimension.flare.compose.ui.misskey_notification_item_poll_ended +import dev.dimension.flare.compose.ui.misskey_notification_item_quoted_your_status +import dev.dimension.flare.compose.ui.misskey_notification_item_reacted_to_your_status +import dev.dimension.flare.compose.ui.misskey_notification_item_replied_to_you +import dev.dimension.flare.compose.ui.misskey_notification_item_reposted_your_status +import dev.dimension.flare.compose.ui.misskey_notification_item_requested_follow +import dev.dimension.flare.compose.ui.misskey_notification_unknwon +import dev.dimension.flare.compose.ui.notification_item_accept_follow_request +import dev.dimension.flare.compose.ui.notification_item_reject_follow_request +import dev.dimension.flare.compose.ui.vvo_notification_like +import dev.dimension.flare.compose.ui.xqt_item_mention_status +import dev.dimension.flare.compose.ui.xqt_item_reblogged_status import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.component.AvatarComponentDefaults import dev.dimension.flare.ui.component.FAIcon -import dev.dimension.flare.ui.component.Res import dev.dimension.flare.ui.component.VerticalDivider -import dev.dimension.flare.ui.component.bluesky_notification_item_favourited_your_status -import dev.dimension.flare.ui.component.bluesky_notification_item_followed_you -import dev.dimension.flare.ui.component.bluesky_notification_item_mentioned_you -import dev.dimension.flare.ui.component.bluesky_notification_item_pin -import dev.dimension.flare.ui.component.bluesky_notification_item_quoted_your_status -import dev.dimension.flare.ui.component.bluesky_notification_item_reblogged_your_status -import dev.dimension.flare.ui.component.bluesky_notification_item_replied_to_you -import dev.dimension.flare.ui.component.bluesky_notification_item_starterpack_joined -import dev.dimension.flare.ui.component.bluesky_notification_item_unKnown -import dev.dimension.flare.ui.component.mastodon_item_pinned -import dev.dimension.flare.ui.component.mastodon_notification_item_favourited_your_status -import dev.dimension.flare.ui.component.mastodon_notification_item_followed_you -import dev.dimension.flare.ui.component.mastodon_notification_item_mentioned_you -import dev.dimension.flare.ui.component.mastodon_notification_item_poll_ended -import dev.dimension.flare.ui.component.mastodon_notification_item_posted_status -import dev.dimension.flare.ui.component.mastodon_notification_item_reblogged_your_status -import dev.dimension.flare.ui.component.mastodon_notification_item_requested_follow -import dev.dimension.flare.ui.component.mastodon_notification_item_updated_status -import dev.dimension.flare.ui.component.misskey_achievement_brain_diver_description -import dev.dimension.flare.ui.component.misskey_achievement_brain_diver_title -import dev.dimension.flare.ui.component.misskey_achievement_bubble_game_double_exploding_head_description -import dev.dimension.flare.ui.component.misskey_achievement_bubble_game_double_exploding_head_title -import dev.dimension.flare.ui.component.misskey_achievement_bubble_game_exploding_head_description -import dev.dimension.flare.ui.component.misskey_achievement_bubble_game_exploding_head_title -import dev.dimension.flare.ui.component.misskey_achievement_clicked_click_here_description -import dev.dimension.flare.ui.component.misskey_achievement_clicked_click_here_title -import dev.dimension.flare.ui.component.misskey_achievement_client30min_description -import dev.dimension.flare.ui.component.misskey_achievement_client30min_title -import dev.dimension.flare.ui.component.misskey_achievement_client60min_description -import dev.dimension.flare.ui.component.misskey_achievement_client60min_title -import dev.dimension.flare.ui.component.misskey_achievement_collect_achievements30_description -import dev.dimension.flare.ui.component.misskey_achievement_collect_achievements30_title -import dev.dimension.flare.ui.component.misskey_achievement_cookie_clicked_description -import dev.dimension.flare.ui.component.misskey_achievement_cookie_clicked_title -import dev.dimension.flare.ui.component.misskey_achievement_drive_folder_circular_reference_description -import dev.dimension.flare.ui.component.misskey_achievement_drive_folder_circular_reference_title -import dev.dimension.flare.ui.component.misskey_achievement_followers1000_description -import dev.dimension.flare.ui.component.misskey_achievement_followers1000_title -import dev.dimension.flare.ui.component.misskey_achievement_followers100_description -import dev.dimension.flare.ui.component.misskey_achievement_followers100_title -import dev.dimension.flare.ui.component.misskey_achievement_followers10_description -import dev.dimension.flare.ui.component.misskey_achievement_followers10_title -import dev.dimension.flare.ui.component.misskey_achievement_followers1_description -import dev.dimension.flare.ui.component.misskey_achievement_followers1_title -import dev.dimension.flare.ui.component.misskey_achievement_followers300_description -import dev.dimension.flare.ui.component.misskey_achievement_followers300_title -import dev.dimension.flare.ui.component.misskey_achievement_followers500_description -import dev.dimension.flare.ui.component.misskey_achievement_followers500_title -import dev.dimension.flare.ui.component.misskey_achievement_followers50_description -import dev.dimension.flare.ui.component.misskey_achievement_followers50_title -import dev.dimension.flare.ui.component.misskey_achievement_following100_description -import dev.dimension.flare.ui.component.misskey_achievement_following100_title -import dev.dimension.flare.ui.component.misskey_achievement_following10_description -import dev.dimension.flare.ui.component.misskey_achievement_following10_title -import dev.dimension.flare.ui.component.misskey_achievement_following1_description -import dev.dimension.flare.ui.component.misskey_achievement_following1_title -import dev.dimension.flare.ui.component.misskey_achievement_following300_description -import dev.dimension.flare.ui.component.misskey_achievement_following300_title -import dev.dimension.flare.ui.component.misskey_achievement_following50_description -import dev.dimension.flare.ui.component.misskey_achievement_following50_title -import dev.dimension.flare.ui.component.misskey_achievement_found_treasure_description -import dev.dimension.flare.ui.component.misskey_achievement_found_treasure_title -import dev.dimension.flare.ui.component.misskey_achievement_htl20npm_description -import dev.dimension.flare.ui.component.misskey_achievement_htl20npm_title -import dev.dimension.flare.ui.component.misskey_achievement_i_love_misskey_description -import dev.dimension.flare.ui.component.misskey_achievement_i_love_misskey_title -import dev.dimension.flare.ui.component.misskey_achievement_just_plain_lucky_description -import dev.dimension.flare.ui.component.misskey_achievement_just_plain_lucky_title -import dev.dimension.flare.ui.component.misskey_achievement_logged_in_on_birthday_description -import dev.dimension.flare.ui.component.misskey_achievement_logged_in_on_birthday_title -import dev.dimension.flare.ui.component.misskey_achievement_logged_in_on_new_years_day_description -import dev.dimension.flare.ui.component.misskey_achievement_logged_in_on_new_years_day_title -import dev.dimension.flare.ui.component.misskey_achievement_login1000_description -import dev.dimension.flare.ui.component.misskey_achievement_login1000_title -import dev.dimension.flare.ui.component.misskey_achievement_login100_description -import dev.dimension.flare.ui.component.misskey_achievement_login100_title -import dev.dimension.flare.ui.component.misskey_achievement_login15_description -import dev.dimension.flare.ui.component.misskey_achievement_login15_title -import dev.dimension.flare.ui.component.misskey_achievement_login200_description -import dev.dimension.flare.ui.component.misskey_achievement_login200_title -import dev.dimension.flare.ui.component.misskey_achievement_login300_description -import dev.dimension.flare.ui.component.misskey_achievement_login300_title -import dev.dimension.flare.ui.component.misskey_achievement_login30_description -import dev.dimension.flare.ui.component.misskey_achievement_login30_title -import dev.dimension.flare.ui.component.misskey_achievement_login3_description -import dev.dimension.flare.ui.component.misskey_achievement_login3_title -import dev.dimension.flare.ui.component.misskey_achievement_login400_description -import dev.dimension.flare.ui.component.misskey_achievement_login400_title -import dev.dimension.flare.ui.component.misskey_achievement_login500_description -import dev.dimension.flare.ui.component.misskey_achievement_login500_title -import dev.dimension.flare.ui.component.misskey_achievement_login600_description -import dev.dimension.flare.ui.component.misskey_achievement_login600_title -import dev.dimension.flare.ui.component.misskey_achievement_login60_description -import dev.dimension.flare.ui.component.misskey_achievement_login60_title -import dev.dimension.flare.ui.component.misskey_achievement_login700_description -import dev.dimension.flare.ui.component.misskey_achievement_login700_title -import dev.dimension.flare.ui.component.misskey_achievement_login7_description -import dev.dimension.flare.ui.component.misskey_achievement_login7_title -import dev.dimension.flare.ui.component.misskey_achievement_login800_description -import dev.dimension.flare.ui.component.misskey_achievement_login800_title -import dev.dimension.flare.ui.component.misskey_achievement_login900_description -import dev.dimension.flare.ui.component.misskey_achievement_login900_title -import dev.dimension.flare.ui.component.misskey_achievement_marked_as_cat_description -import dev.dimension.flare.ui.component.misskey_achievement_marked_as_cat_title -import dev.dimension.flare.ui.component.misskey_achievement_my_note_favorited1_description -import dev.dimension.flare.ui.component.misskey_achievement_my_note_favorited1_title -import dev.dimension.flare.ui.component.misskey_achievement_note_clipped1_description -import dev.dimension.flare.ui.component.misskey_achievement_note_clipped1_title -import dev.dimension.flare.ui.component.misskey_achievement_note_deleted_within1min_description -import dev.dimension.flare.ui.component.misskey_achievement_note_deleted_within1min_title -import dev.dimension.flare.ui.component.misskey_achievement_note_favorited1_description -import dev.dimension.flare.ui.component.misskey_achievement_note_favorited1_title -import dev.dimension.flare.ui.component.misskey_achievement_notes100000_description -import dev.dimension.flare.ui.component.misskey_achievement_notes100000_title -import dev.dimension.flare.ui.component.misskey_achievement_notes10000_description -import dev.dimension.flare.ui.component.misskey_achievement_notes10000_title -import dev.dimension.flare.ui.component.misskey_achievement_notes1000_description -import dev.dimension.flare.ui.component.misskey_achievement_notes1000_title -import dev.dimension.flare.ui.component.misskey_achievement_notes100_description -import dev.dimension.flare.ui.component.misskey_achievement_notes100_title -import dev.dimension.flare.ui.component.misskey_achievement_notes10_description -import dev.dimension.flare.ui.component.misskey_achievement_notes10_title -import dev.dimension.flare.ui.component.misskey_achievement_notes1_description -import dev.dimension.flare.ui.component.misskey_achievement_notes1_title -import dev.dimension.flare.ui.component.misskey_achievement_notes20000_description -import dev.dimension.flare.ui.component.misskey_achievement_notes20000_title -import dev.dimension.flare.ui.component.misskey_achievement_notes30000_description -import dev.dimension.flare.ui.component.misskey_achievement_notes30000_title -import dev.dimension.flare.ui.component.misskey_achievement_notes40000_description -import dev.dimension.flare.ui.component.misskey_achievement_notes40000_title -import dev.dimension.flare.ui.component.misskey_achievement_notes50000_description -import dev.dimension.flare.ui.component.misskey_achievement_notes50000_title -import dev.dimension.flare.ui.component.misskey_achievement_notes5000_description -import dev.dimension.flare.ui.component.misskey_achievement_notes5000_title -import dev.dimension.flare.ui.component.misskey_achievement_notes500_description -import dev.dimension.flare.ui.component.misskey_achievement_notes500_title -import dev.dimension.flare.ui.component.misskey_achievement_notes60000_description -import dev.dimension.flare.ui.component.misskey_achievement_notes60000_title -import dev.dimension.flare.ui.component.misskey_achievement_notes70000_description -import dev.dimension.flare.ui.component.misskey_achievement_notes70000_title -import dev.dimension.flare.ui.component.misskey_achievement_notes80000_description -import dev.dimension.flare.ui.component.misskey_achievement_notes80000_title -import dev.dimension.flare.ui.component.misskey_achievement_notes90000_description -import dev.dimension.flare.ui.component.misskey_achievement_notes90000_title -import dev.dimension.flare.ui.component.misskey_achievement_open3windows_description -import dev.dimension.flare.ui.component.misskey_achievement_open3windows_title -import dev.dimension.flare.ui.component.misskey_achievement_output_hello_world_on_scratchpad_description -import dev.dimension.flare.ui.component.misskey_achievement_output_hello_world_on_scratchpad_title -import dev.dimension.flare.ui.component.misskey_achievement_passed_since_account_created1_description -import dev.dimension.flare.ui.component.misskey_achievement_passed_since_account_created1_title -import dev.dimension.flare.ui.component.misskey_achievement_passed_since_account_created2_description -import dev.dimension.flare.ui.component.misskey_achievement_passed_since_account_created2_title -import dev.dimension.flare.ui.component.misskey_achievement_passed_since_account_created3_description -import dev.dimension.flare.ui.component.misskey_achievement_passed_since_account_created3_title -import dev.dimension.flare.ui.component.misskey_achievement_posted_at_0min0sec_description -import dev.dimension.flare.ui.component.misskey_achievement_posted_at_0min0sec_title -import dev.dimension.flare.ui.component.misskey_achievement_posted_at_late_night_description -import dev.dimension.flare.ui.component.misskey_achievement_posted_at_late_night_title -import dev.dimension.flare.ui.component.misskey_achievement_profile_filled_description -import dev.dimension.flare.ui.component.misskey_achievement_profile_filled_title -import dev.dimension.flare.ui.component.misskey_achievement_react_without_read_description -import dev.dimension.flare.ui.component.misskey_achievement_react_without_read_title -import dev.dimension.flare.ui.component.misskey_achievement_self_quote_description -import dev.dimension.flare.ui.component.misskey_achievement_self_quote_title -import dev.dimension.flare.ui.component.misskey_achievement_set_name_to_syuilo_description -import dev.dimension.flare.ui.component.misskey_achievement_set_name_to_syuilo_title -import dev.dimension.flare.ui.component.misskey_achievement_smash_test_notification_button_description -import dev.dimension.flare.ui.component.misskey_achievement_smash_test_notification_button_title -import dev.dimension.flare.ui.component.misskey_achievement_tutorial_completed_description -import dev.dimension.flare.ui.component.misskey_achievement_tutorial_completed_title -import dev.dimension.flare.ui.component.misskey_achievement_view_achievements3min_description -import dev.dimension.flare.ui.component.misskey_achievement_view_achievements3min_title -import dev.dimension.flare.ui.component.misskey_achievement_view_instance_chart_description -import dev.dimension.flare.ui.component.misskey_achievement_view_instance_chart_title -import dev.dimension.flare.ui.component.misskey_notification_item_achievement_earned -import dev.dimension.flare.ui.component.misskey_notification_item_app -import dev.dimension.flare.ui.component.misskey_notification_item_follow_request_accepted -import dev.dimension.flare.ui.component.misskey_notification_item_followed_you -import dev.dimension.flare.ui.component.misskey_notification_item_mentioned_you -import dev.dimension.flare.ui.component.misskey_notification_item_poll_ended -import dev.dimension.flare.ui.component.misskey_notification_item_quoted_your_status -import dev.dimension.flare.ui.component.misskey_notification_item_reacted_to_your_status -import dev.dimension.flare.ui.component.misskey_notification_item_replied_to_you -import dev.dimension.flare.ui.component.misskey_notification_item_reposted_your_status -import dev.dimension.flare.ui.component.misskey_notification_item_requested_follow -import dev.dimension.flare.ui.component.misskey_notification_unknwon -import dev.dimension.flare.ui.component.notification_item_accept_follow_request -import dev.dimension.flare.ui.component.notification_item_reject_follow_request import dev.dimension.flare.ui.component.platform.PlatformButton import dev.dimension.flare.ui.component.platform.PlatformCard import dev.dimension.flare.ui.component.platform.PlatformFilledTonalButton import dev.dimension.flare.ui.component.platform.PlatformText import dev.dimension.flare.ui.component.platform.PlatformTextStyle import dev.dimension.flare.ui.component.platform.isBigScreen -import dev.dimension.flare.ui.component.vvo_notification_like -import dev.dimension.flare.ui.component.xqt_item_mention_status -import dev.dimension.flare.ui.component.xqt_item_reblogged_status import dev.dimension.flare.ui.model.ClickContext import dev.dimension.flare.ui.model.UiTimeline import dev.dimension.flare.ui.model.mapper.MisskeyAchievement diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/icons/MisskeyIcon.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/icons/MisskeyIcon.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/icons/MisskeyIcon.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/icons/MisskeyIcon.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/UiStatusExtraExt.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiStatusExtraExt.kt similarity index 77% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/UiStatusExtraExt.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiStatusExtraExt.kt index c236d52f8..1d7a43566 100644 --- a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/UiStatusExtraExt.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiStatusExtraExt.kt @@ -2,11 +2,12 @@ package dev.dimension.flare.ui.model import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import dev.dimension.flare.ui.component.Res -import dev.dimension.flare.ui.component.date_format_full -import dev.dimension.flare.ui.component.date_format_month_day -import dev.dimension.flare.ui.component.date_format_year_month_day +import dev.dimension.flare.compose.ui.Res +import dev.dimension.flare.compose.ui.date_format_full +import dev.dimension.flare.compose.ui.date_format_month_day +import dev.dimension.flare.compose.ui.date_format_year_month_day import dev.dimension.flare.ui.render.LocalizedShortTime +import dev.dimension.flare.ui.render.UiDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toJavaLocalDateTime import kotlinx.datetime.toLocalDateTime @@ -14,7 +15,15 @@ import org.jetbrains.compose.resources.stringResource import java.time.format.DateTimeFormatter import kotlin.time.Instant -public val LocalizedShortTime.localizedShortTime: String +@get:Composable +public val UiDateTime.localizedShortTime: String + get() = shortTime.localizedShortTime + +@get:Composable +public val UiDateTime.localizedFullTime: String + get() = value.localizedFullTime + +private val LocalizedShortTime.localizedShortTime: String @Composable get() = when (val type = this) { @@ -41,7 +50,7 @@ public val LocalizedShortTime.localizedShortTime: String } } -public val Instant.localizedFullTime: String +private val Instant.localizedFullTime: String @Composable get() { val format = stringResource(resource = Res.string.date_format_full) diff --git a/shared/src/androidJvmMain/kotlin/dev/dimension/flare/ui/presenter/HomeTabsPresenter.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTabsPresenter.kt similarity index 67% rename from shared/src/androidJvmMain/kotlin/dev/dimension/flare/ui/presenter/HomeTabsPresenter.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTabsPresenter.kt index 197a25253..b2ccca38f 100644 --- a/shared/src/androidJvmMain/kotlin/dev/dimension/flare/ui/presenter/HomeTabsPresenter.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTabsPresenter.kt @@ -2,34 +2,31 @@ package dev.dimension.flare.ui.presenter import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import dev.dimension.flare.data.model.DirectMessageTabItem import dev.dimension.flare.data.model.NotificationTabItem import dev.dimension.flare.data.model.ProfileTabItem import dev.dimension.flare.data.model.TabItem -import dev.dimension.flare.data.model.TabSettings import dev.dimension.flare.data.model.TimelineTabItem -import dev.dimension.flare.data.repository.AccountRepository +import dev.dimension.flare.data.repository.SettingsRepository 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.flattenUiState +import dev.dimension.flare.ui.model.collectAsUiState import dev.dimension.flare.ui.model.map +import dev.dimension.flare.ui.model.zipState import dev.dimension.flare.ui.presenter.HomeTabsPresenter.State.HomeTabState.HomeTabItem +import dev.dimension.flare.ui.presenter.home.ActiveAccountPresenter import dev.dimension.flare.ui.presenter.home.DirectMessageBadgePresenter import dev.dimension.flare.ui.presenter.home.NotificationBadgePresenter import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChangedBy import org.koin.core.component.KoinComponent import org.koin.core.component.inject -public class HomeTabsPresenter( - private val tabSettings: Flow, -) : PresenterBase(), +public class HomeTabsPresenter : + PresenterBase(), KoinComponent { public interface State { public val tabs: UiState @@ -56,17 +53,17 @@ public class HomeTabsPresenter( } } - private val accountRepository by inject() - private val tabsFlow by lazy { - accountRepository.activeAccount - .distinctUntilChangedBy { - when (it) { - is UiState.Success -> it.data.accountKey - is UiState.Error -> it.throwable - is UiState.Loading -> null - } - }.combine(tabSettings) { user, tabSettings -> - user.flatMap( + private val settingsRepository by inject() + + @Composable + override fun body(): State { + val activeAccount = remember { ActiveAccountPresenter() }.invoke() + val tabSettings by settingsRepository.tabSettings.collectAsUiState() + val tabs = + remember(tabSettings, activeAccount.user) { + zipState( + tabSettings, + activeAccount.user, onError = { UiState.Success( State.HomeTabState( @@ -81,40 +78,32 @@ public class HomeTabsPresenter( ), ) }, - ) { user -> + ) { settings, user -> val secondary = - tabSettings.secondaryItems ?: TimelineTabItem.defaultSecondary(user) - UiState.Success( - State.HomeTabState( - primary = - TimelineTabItem.default - .map { - HomeTabItem(it) - }.toImmutableList(), - secondary = - secondary - .map { - HomeTabItem(it) - }.toImmutableList(), - extraProfileRoute = - HomeTabItem( - tabItem = - ProfileTabItem( - accountKey = user.accountKey, - userKey = user.accountKey, - ), - ), - secondaryIconOnly = tabSettings.secondaryItems == null, - ), + settings.secondaryItems ?: TimelineTabItem.defaultSecondary(user) + State.HomeTabState( + primary = + TimelineTabItem.default + .map { + HomeTabItem(it) + }.toImmutableList(), + secondary = + secondary + .map { + HomeTabItem(it) + }.toImmutableList(), + extraProfileRoute = + HomeTabItem( + tabItem = + ProfileTabItem( + accountKey = user.key, + userKey = user.key, + ), + ), + secondaryIconOnly = settings.secondaryItems == null, ) } - } - } - - @Composable - override fun body(): State { - val tabs = - tabsFlow.flattenUiState().value.map { + }.map { it.copy( primary = it.primary diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTimelineWithTabsPresenter.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTimelineWithTabsPresenter.kt new file mode 100644 index 000000000..6af7a3738 --- /dev/null +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTimelineWithTabsPresenter.kt @@ -0,0 +1,139 @@ +package dev.dimension.flare.ui.presenter + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import dev.dimension.flare.data.model.HomeTimelineTabItem +import dev.dimension.flare.data.model.MixedTimelineTabItem +import dev.dimension.flare.data.repository.SettingsRepository +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.model.vvo +import dev.dimension.flare.ui.model.UiRssSource +import dev.dimension.flare.ui.model.UiState +import dev.dimension.flare.ui.model.collectAsUiState +import dev.dimension.flare.ui.model.map +import dev.dimension.flare.ui.presenter.home.UserPresenter +import dev.dimension.flare.ui.presenter.home.UserState +import dev.dimension.flare.ui.presenter.settings.AccountEventPresenter +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.map +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +public class HomeTimelineWithTabsPresenter( + private val accountType: AccountType, +) : PresenterBase(), + KoinComponent { + private val settingsRepository by inject() + + public interface State : UserState { + public val tabState: UiState> + } + + @Composable + override fun body(): State { + val accountState = + remember(accountType) { + UserPresenter( + accountType = accountType, + userKey = null, + ) + }.invoke() + + val accountEvent = + remember { + AccountEventPresenter() + }.invoke() + + LaunchedEffect(accountEvent.onAdded) { + accountEvent.onAdded.collect { account -> + val tab = + HomeTimelineTabItem( + accountKey = account.accountKey, + icon = UiRssSource.favIconUrl(account.accountKey.host), + title = + when (account.platformType) { + PlatformType.Mastodon -> "Mastodon" + PlatformType.Misskey -> "Misskey" + PlatformType.Bluesky -> "Bluesky" + PlatformType.xQt -> "X" + PlatformType.VVo -> vvo + }, + ) + settingsRepository.updateTabSettings { + if (mainTabs.any { it.key == tab.key }) { + copy() + } else { + copy( + mainTabs = + (mainTabs + tab).distinctBy { + it.key + }, + ) + } + } + } + } + + LaunchedEffect(accountEvent.onRemoved) { + accountEvent.onRemoved.collect { accountKey -> + settingsRepository.updateTabSettings { + copy( + mainTabs = mainTabs.filterNot { it.account == AccountType.Specific(accountKey) }, + ) + } + } + } + + val tabs by remember { + settingsRepository.tabSettings + .map { settings -> + if (accountType == AccountType.Guest) { + listOf( + HomeTimelineTabItem(AccountType.Guest), + ) + } else { + ( + listOfNotNull( + if (settings.enableMixedTimeline && settings.mainTabs.size > 1) { + MixedTimelineTabItem( + subTimelineTabItem = settings.mainTabs, + ) + } else { + null + }, + ) + settings.mainTabs + ).ifEmpty { + listOf( + HomeTimelineTabItem( + accountType = AccountType.Active, + ), + ) + } + } + }.map { + it.toImmutableList() + } + }.collectAsUiState() + val tabState = + tabs.map { + it + .map { + // use key inorder to force update when the list is changed + key(it.key) { + TimelineItemPresenter(it).invoke() + } + }.toImmutableList() + } + + return remember(accountState, tabState) { + object : State, UserState by accountState { + override val tabState: UiState> = tabState + } + } + } +} diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/PinTabsPresenter.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/PinTabsPresenter.kt new file mode 100644 index 000000000..6d24d6bd2 --- /dev/null +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/PinTabsPresenter.kt @@ -0,0 +1,72 @@ +package dev.dimension.flare.ui.presenter + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import dev.dimension.flare.data.model.TimelineTabItem +import dev.dimension.flare.data.repository.SettingsRepository +import dev.dimension.flare.ui.model.UiState +import dev.dimension.flare.ui.model.collectAsUiState +import dev.dimension.flare.ui.model.map +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +public abstract class PinTabsPresenter : + PresenterBase>(), + KoinComponent { + private val settingsRepository by inject() + private val appScope: CoroutineScope by inject() + + public interface State { + public val currentTabs: UiState> + + public fun pinTab(item: T) + + public fun unpinTab(item: T) + } + + @Composable + override fun body(): State { + val tabSettings by settingsRepository.tabSettings.collectAsUiState() + val currentTabs = + tabSettings.map { + it.mainTabs + .filterPinned() + .toImmutableList() + } + + return object : State { + override val currentTabs = currentTabs + + override fun pinTab(item: T) { + appScope.launch { + settingsRepository.updateTabSettings { + copy( + mainTabs = + mainTabs + getTimelineTabItem(item), + ) + } + } + } + + override fun unpinTab(item: T) { + appScope.launch { + settingsRepository.updateTabSettings { + copy( + mainTabs = mainTabs.filter(item), + ) + } + } + } + } + } + + protected abstract fun List.filterPinned(): List + + protected abstract fun getTimelineTabItem(item: T): TimelineTabItem + + protected abstract fun List.filter(item: T): List +} diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/TimelineItemPresenter.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/TimelineItemPresenter.kt new file mode 100644 index 000000000..989876422 --- /dev/null +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/TimelineItemPresenter.kt @@ -0,0 +1,99 @@ +package dev.dimension.flare.ui.presenter + +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +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 dev.dimension.flare.common.PagingState +import dev.dimension.flare.common.isRefreshing +import dev.dimension.flare.common.onSuccess +import dev.dimension.flare.data.model.TimelineTabItem +import dev.dimension.flare.ui.model.UiTimeline +import dev.dimension.flare.ui.presenter.home.NotificationBadgePresenter +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch + +public class TimelineItemPresenter( + private val timelineTabItem: TimelineTabItem, +) : PresenterBase() { + public interface State { + public val listState: PagingState + public val showNewToots: Boolean + public val isRefreshing: Boolean + public val lazyListState: LazyStaggeredGridState + public val timelineTabItem: TimelineTabItem + + public fun onNewTootsShown() + + public fun refreshSync() + } + + @Composable + override fun body(): State { + val state = + remember(timelineTabItem.key) { + timelineTabItem.createPresenter() + }.invoke() + val badge = + remember(timelineTabItem) { + NotificationBadgePresenter(timelineTabItem.account) + }.invoke() + val scope = rememberCoroutineScope() + 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 && + lazyListState.firstVisibleItemScrollOffset == 0 + } + } + LaunchedEffect(isAtTheTop, showNewToots) { + if (isAtTheTop) { + showNewToots = false + } + } + return object : State { + override val listState = state.listState + override val showNewToots = showNewToots + override val isRefreshing = state.listState.isRefreshing + override val lazyListState = lazyListState + override val timelineTabItem = this@TimelineItemPresenter.timelineTabItem + + override fun onNewTootsShown() { + showNewToots = false + } + + override fun refreshSync() { + scope.launch { + state.refresh() + } + badge.refresh() + } + } + } +} diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/bluesky/BlueskyFeedWithTabs.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/bluesky/BlueskyFeedWithTabs.kt new file mode 100644 index 000000000..d07495028 --- /dev/null +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/bluesky/BlueskyFeedWithTabs.kt @@ -0,0 +1,127 @@ +package dev.dimension.flare.ui.screen.bluesky + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.Plus +import compose.icons.fontawesomeicons.solid.Thumbtack +import compose.icons.fontawesomeicons.solid.ThumbtackSlash +import compose.icons.fontawesomeicons.solid.Trash +import dev.dimension.flare.compose.ui.Res +import dev.dimension.flare.compose.ui.tab_settings_add +import dev.dimension.flare.compose.ui.tab_settings_remove +import dev.dimension.flare.ui.common.itemsIndexed +import dev.dimension.flare.ui.component.FAIcon +import dev.dimension.flare.ui.component.UiListItem +import dev.dimension.flare.ui.component.listCard +import dev.dimension.flare.ui.component.platform.PlatformIconButton +import dev.dimension.flare.ui.component.status.StatusPlaceholder +import dev.dimension.flare.ui.component.uiListItemComponent +import dev.dimension.flare.ui.model.UiList +import dev.dimension.flare.ui.model.onSuccess +import org.jetbrains.compose.resources.stringResource + +public fun LazyListScope.myBlueskyFeedWithTabs( + state: BlueskyFeedsWithTabsPresenter.State, + toFeed: (UiList) -> Unit, +) { + uiListItemComponent( + state.myFeeds, + onClicked = toFeed, + trailingContent = { item -> + state.currentTabs.onSuccess { currentTabs -> + val isPinned = + remember( + item, + currentTabs, + ) { + currentTabs.contains(item.id) + } + PlatformIconButton( + onClick = { + if (isPinned) { + state.unpinTab(item) + } else { + state.pinTab(item) + } + }, + ) { + AnimatedContent(isPinned) { + if (it) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.ThumbtackSlash, + contentDescription = stringResource(Res.string.tab_settings_add), + ) + } else { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Thumbtack, + contentDescription = stringResource(Res.string.tab_settings_remove), + ) + } + } + } + } + }, + ) +} + +public fun LazyListScope.popularBlueskyFeedWithTabs( + state: BlueskyFeedsWithTabsPresenter.State, + toFeed: (UiList) -> Unit, +) { + itemsIndexed( + state.popularFeeds, + loadingCount = 5, + loadingContent = { index, itemCount -> + StatusPlaceholder( + modifier = + Modifier + .listCard( + index = index, + totalCount = itemCount, + ), + ) + }, + ) { index, itemCount, (item, subscribed) -> + UiListItem( + onClicked = { + toFeed.invoke(item) + }, + item = item, + trailingContent = { + PlatformIconButton( + onClick = { + if (subscribed) { + state.unsubscribe(item) + state.unpinTab(item) + } else { + state.subscribe(item) + state.pinTab(item) + } + }, + ) { + if (subscribed) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Trash, + contentDescription = null, + ) + } else { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Plus, + contentDescription = null, + ) + } + } + }, + modifier = + Modifier + .listCard( + index = index, + totalCount = itemCount, + ), + ) + } +} diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/bluesky/BlueskyFeedsWithTabsPresenter.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/bluesky/BlueskyFeedsWithTabsPresenter.kt new file mode 100644 index 000000000..8dd8795a4 --- /dev/null +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/bluesky/BlueskyFeedsWithTabsPresenter.kt @@ -0,0 +1,82 @@ +package dev.dimension.flare.ui.screen.bluesky + +import androidx.compose.runtime.Composable +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 dev.dimension.flare.data.model.Bluesky +import dev.dimension.flare.data.model.IconType +import dev.dimension.flare.data.model.TabMetaData +import dev.dimension.flare.data.model.TimelineTabItem +import dev.dimension.flare.data.model.TitleType +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.ui.model.UiList +import dev.dimension.flare.ui.presenter.PinTabsPresenter +import dev.dimension.flare.ui.presenter.PresenterBase +import dev.dimension.flare.ui.presenter.home.bluesky.BlueskyFeedsPresenter +import dev.dimension.flare.ui.presenter.home.bluesky.BlueskyFeedsState +import dev.dimension.flare.ui.presenter.invoke +import kotlinx.coroutines.launch + +public class BlueskyFeedsWithTabsPresenter( + private val accountType: AccountType, +) : PresenterBase() { + private val pinTabsPresenter by lazy { + object : PinTabsPresenter() { + override fun List.filterPinned(): List = filterIsInstance().map { it.uri } + + override fun getTimelineTabItem(item: UiList): TimelineTabItem = + Bluesky.FeedTabItem( + account = accountType, + uri = item.id, + metaData = + TabMetaData( + title = TitleType.Text(item.title), + icon = IconType.Material(IconType.Material.MaterialIcon.Feeds), + ), + ) + + override fun List.filter(item: UiList): List = + filter { + if (it is Bluesky.FeedTabItem) { + it.uri != item.id + } else { + true + } + } + } + } + + @Composable + override fun body(): State { + val scope = rememberCoroutineScope() + var isRefreshing by remember { mutableStateOf(false) } + val state = + remember(accountType) { + BlueskyFeedsPresenter(accountType = accountType) + }.invoke() + val tabState = pinTabsPresenter.invoke() + return object : State, BlueskyFeedsState by state, PinTabsPresenter.State by tabState { + override val isRefreshing: Boolean + get() = isRefreshing + + override fun refresh() { + isRefreshing = true + scope.launch { + state.refreshSuspend() + isRefreshing = false + } + } + } + } + + public interface State : + BlueskyFeedsState, + PinTabsPresenter.State { + public val isRefreshing: Boolean + + public fun refresh() + } +} diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/list/AllListWithTabsPresenter.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/list/AllListWithTabsPresenter.kt new file mode 100644 index 000000000..82a718849 --- /dev/null +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/list/AllListWithTabsPresenter.kt @@ -0,0 +1,68 @@ +package dev.dimension.flare.ui.screen.list + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import dev.dimension.flare.data.model.IconType +import dev.dimension.flare.data.model.ListTimelineTabItem +import dev.dimension.flare.data.model.TabMetaData +import dev.dimension.flare.data.model.TimelineTabItem +import dev.dimension.flare.data.model.TitleType +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.ui.model.UiList +import dev.dimension.flare.ui.presenter.PinTabsPresenter +import dev.dimension.flare.ui.presenter.PresenterBase +import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.presenter.list.AllListPresenter +import dev.dimension.flare.ui.presenter.list.AllListState + +public class AllListWithTabsPresenter( + private val accountType: AccountType, +) : PresenterBase() { + private val pinTabsPresenter by lazy { + object : PinTabsPresenter() { + override fun List.filterPinned(): List = + filterIsInstance() + .map { it.listId } + + override fun getTimelineTabItem(item: UiList): TimelineTabItem = + ListTimelineTabItem( + account = accountType, + listId = item.id, + metaData = + TabMetaData( + title = TitleType.Text(item.title), + icon = IconType.Material(IconType.Material.MaterialIcon.List), + ), + ) + + override fun List.filter(item: UiList): List = + filter { + if (it is ListTimelineTabItem) { + it.listId != item.id + } else { + true + } + } + } + } + + @Composable + override fun body(): State { + val state = + remember(accountType) { + AllListPresenter(accountType) + }.invoke() + + val pinState = pinTabsPresenter.invoke() + + return object : + State, + AllListState by state, + PinTabsPresenter.State by pinState { + } + } + + public interface State : + AllListState, + PinTabsPresenter.State +} diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/list/UiListWithTabs.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/list/UiListWithTabs.kt new file mode 100644 index 000000000..36446ea0c --- /dev/null +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/list/UiListWithTabs.kt @@ -0,0 +1,129 @@ +package dev.dimension.flare.ui.screen.list + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.EllipsisVertical +import compose.icons.fontawesomeicons.solid.Pen +import compose.icons.fontawesomeicons.solid.Thumbtack +import compose.icons.fontawesomeicons.solid.ThumbtackSlash +import compose.icons.fontawesomeicons.solid.Trash +import dev.dimension.flare.compose.ui.Res +import dev.dimension.flare.compose.ui.list_delete +import dev.dimension.flare.compose.ui.list_edit +import dev.dimension.flare.compose.ui.more +import dev.dimension.flare.compose.ui.tab_settings_add +import dev.dimension.flare.compose.ui.tab_settings_remove +import dev.dimension.flare.ui.component.FAIcon +import dev.dimension.flare.ui.component.platform.PlatformDropdownMenu +import dev.dimension.flare.ui.component.platform.PlatformDropdownMenuItem +import dev.dimension.flare.ui.component.platform.PlatformIconButton +import dev.dimension.flare.ui.component.platform.PlatformText +import dev.dimension.flare.ui.component.uiListItemComponent +import dev.dimension.flare.ui.model.UiList +import dev.dimension.flare.ui.model.onSuccess +import dev.dimension.flare.ui.theme.PlatformTheme +import org.jetbrains.compose.resources.stringResource + +public fun LazyListScope.uiListWithTabs( + state: AllListWithTabsPresenter.State, + toList: (UiList) -> Unit, + editList: (UiList) -> Unit, + deleteList: (UiList) -> Unit, +) { + uiListItemComponent( + state.items, + toList, + trailingContent = { item -> + state.currentTabs.onSuccess { currentTabs -> + val isPinned = + remember( + item, + currentTabs, + ) { + currentTabs.contains(item.id) + } + PlatformIconButton( + onClick = { + if (isPinned) { + state.unpinTab(item) + } else { + state.pinTab(item) + } + }, + ) { + AnimatedContent(isPinned) { + if (it) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.ThumbtackSlash, + contentDescription = stringResource(Res.string.tab_settings_add), + ) + } else { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Thumbtack, + contentDescription = stringResource(Res.string.tab_settings_remove), + ) + } + } + } + } + if (!item.readonly) { + var showDropdown by remember { + mutableStateOf(false) + } + PlatformIconButton(onClick = { showDropdown = true }) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.EllipsisVertical, + contentDescription = stringResource(Res.string.more), + ) + PlatformDropdownMenu( + expanded = showDropdown, + onDismissRequest = { showDropdown = false }, + ) { + PlatformDropdownMenuItem( + text = { + PlatformText( + text = stringResource(Res.string.list_edit), + ) + }, + onClick = { + editList(item) + showDropdown = false + }, + leadingIcon = { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Pen, + contentDescription = stringResource(Res.string.list_edit), + ) + }, + ) + PlatformDropdownMenuItem( + text = { + PlatformText( + text = stringResource(Res.string.list_delete), + color = PlatformTheme.colorScheme.error, + ) + }, + onClick = { + deleteList(item) + showDropdown = false + }, + leadingIcon = { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Trash, + contentDescription = stringResource(Res.string.list_delete), + tint = PlatformTheme.colorScheme.error, + ) + }, + ) + } + } + } + }, + ) +} diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/misskey/MisskeyAntennasListWithTabsPresenter.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/misskey/MisskeyAntennasListWithTabsPresenter.kt new file mode 100644 index 000000000..95eab1806 --- /dev/null +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/misskey/MisskeyAntennasListWithTabsPresenter.kt @@ -0,0 +1,65 @@ +package dev.dimension.flare.ui.screen.misskey + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import dev.dimension.flare.data.model.IconType +import dev.dimension.flare.data.model.Misskey +import dev.dimension.flare.data.model.TabMetaData +import dev.dimension.flare.data.model.TimelineTabItem +import dev.dimension.flare.data.model.TitleType +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.ui.model.UiList +import dev.dimension.flare.ui.presenter.PinTabsPresenter +import dev.dimension.flare.ui.presenter.PresenterBase +import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.presenter.list.AntennasListPresenter + +public class MisskeyAntennasListWithTabsPresenter( + private val accountType: AccountType, +) : PresenterBase() { + private val pinTabsPresenter by lazy { + object : PinTabsPresenter() { + override fun List.filterPinned(): List = + filterIsInstance() + .map { it.id } + + override fun getTimelineTabItem(item: UiList): TimelineTabItem = + Misskey.AntennasTimelineTabItem( + account = accountType, + id = item.id, + metaData = + TabMetaData( + title = TitleType.Text(item.title), + icon = IconType.Material(IconType.Material.MaterialIcon.List), + ), + ) + + override fun List.filter(item: UiList): List = + filter { + if (it is Misskey.AntennasTimelineTabItem) { + it.id != item.id + } else { + true + } + } + } + } + + @Composable + override fun body(): State { + val state = + remember(accountType) { + AntennasListPresenter(accountType) + }.invoke() + + val pinTabsState = pinTabsPresenter.invoke() + return object : + State, + AntennasListPresenter.State by state, + PinTabsPresenter.State by pinTabsState {} + } + + public interface State : + PinTabsPresenter.State, + AntennasListPresenter.State +} diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/misskey/MisskeyAntennasWithTabs.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/misskey/MisskeyAntennasWithTabs.kt new file mode 100644 index 000000000..c8d85b127 --- /dev/null +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/misskey/MisskeyAntennasWithTabs.kt @@ -0,0 +1,62 @@ +package dev.dimension.flare.ui.screen.misskey + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.remember +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.Thumbtack +import compose.icons.fontawesomeicons.solid.ThumbtackSlash +import dev.dimension.flare.compose.ui.Res +import dev.dimension.flare.compose.ui.tab_settings_add +import dev.dimension.flare.compose.ui.tab_settings_remove +import dev.dimension.flare.ui.component.FAIcon +import dev.dimension.flare.ui.component.platform.PlatformIconButton +import dev.dimension.flare.ui.component.uiListItemComponent +import dev.dimension.flare.ui.model.UiList +import dev.dimension.flare.ui.model.onSuccess +import org.jetbrains.compose.resources.stringResource + +public fun LazyListScope.misskeyAntennasWithTabs( + state: MisskeyAntennasListWithTabsPresenter.State, + onClick: (UiList) -> Unit, +) { + uiListItemComponent( + state.data, + onClick, + trailingContent = { item -> + state.currentTabs.onSuccess { currentTabs -> + val isPinned = + remember( + item, + currentTabs, + ) { + currentTabs.contains(item.id) + } + PlatformIconButton( + onClick = { + if (isPinned) { + state.unpinTab(item) + } else { + state.pinTab(item) + } + }, + ) { + AnimatedContent(isPinned) { + if (it) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.ThumbtackSlash, + contentDescription = stringResource(Res.string.tab_settings_add), + ) + } else { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Thumbtack, + contentDescription = stringResource(Res.string.tab_settings_remove), + ) + } + } + } + } + }, + ) +} diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/rss/RssListWithTabs.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/rss/RssListWithTabs.kt new file mode 100644 index 000000000..dbecbe1f3 --- /dev/null +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/rss/RssListWithTabs.kt @@ -0,0 +1,193 @@ +package dev.dimension.flare.ui.screen.rss + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +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.unit.dp +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.EllipsisVertical +import compose.icons.fontawesomeicons.solid.File +import compose.icons.fontawesomeicons.solid.Pen +import compose.icons.fontawesomeicons.solid.Thumbtack +import compose.icons.fontawesomeicons.solid.ThumbtackSlash +import compose.icons.fontawesomeicons.solid.Trash +import dev.dimension.flare.compose.ui.Res +import dev.dimension.flare.compose.ui.delete_rss_source +import dev.dimension.flare.compose.ui.edit_rss_source +import dev.dimension.flare.compose.ui.empty_rss_sources +import dev.dimension.flare.compose.ui.more +import dev.dimension.flare.compose.ui.tab_settings_add +import dev.dimension.flare.compose.ui.tab_settings_remove +import dev.dimension.flare.ui.common.itemsIndexed +import dev.dimension.flare.ui.component.FAIcon +import dev.dimension.flare.ui.component.NetworkImage +import dev.dimension.flare.ui.component.listCard +import dev.dimension.flare.ui.component.platform.PlatformDropdownMenu +import dev.dimension.flare.ui.component.platform.PlatformDropdownMenuItem +import dev.dimension.flare.ui.component.platform.PlatformIconButton +import dev.dimension.flare.ui.component.platform.PlatformListItem +import dev.dimension.flare.ui.component.platform.PlatformText +import dev.dimension.flare.ui.model.UiRssSource +import dev.dimension.flare.ui.model.onSuccess +import dev.dimension.flare.ui.theme.PlatformTheme +import org.jetbrains.compose.resources.stringResource + +public fun LazyListScope.rssListWithTabs( + state: RssListWithTabsPresenter.State, + onClicked: (item: UiRssSource) -> Unit, + onEdit: (id: Int) -> Unit, +) { + itemsIndexed( + state.sources, + emptyContent = { + Column( + modifier = + Modifier + .fillParentMaxSize(), + verticalArrangement = + Arrangement.spacedBy( + 8.dp, + Alignment.CenterVertically, + ), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + FAIcon( + FontAwesomeIcons.Solid.File, + contentDescription = stringResource(Res.string.empty_rss_sources), + modifier = Modifier.size(48.dp), + ) + PlatformText( + text = stringResource(Res.string.empty_rss_sources), + style = PlatformTheme.typography.headline, + ) + } + }, + ) { index, itemCount, it -> + PlatformListItem( + modifier = + Modifier + .listCard( + index = index, + totalCount = itemCount, + ).clickable { + onClicked.invoke(it) + }, + headlineContent = { + it.title?.let { + PlatformText(text = it) + } + }, + supportingContent = { + PlatformText(it.url) + }, + leadingContent = { + NetworkImage( + model = it.favIcon, + contentDescription = it.title, + modifier = Modifier.size(24.dp), + ) + }, + trailingContent = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + state.currentTabs.onSuccess { currentTabs -> + val isPinned = + remember( + it, + currentTabs, + ) { + currentTabs.contains(it.url) + } + PlatformIconButton( + onClick = { + if (isPinned) { + state.unpinTab(it) + } else { + state.pinTab(it) + } + }, + ) { + AnimatedContent(isPinned) { + if (it) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.ThumbtackSlash, + contentDescription = stringResource(Res.string.tab_settings_add), + ) + } else { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Thumbtack, + contentDescription = stringResource(Res.string.tab_settings_remove), + ) + } + } + } + } + + var showDropdown by remember { + mutableStateOf(false) + } + PlatformIconButton(onClick = { showDropdown = true }) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.EllipsisVertical, + contentDescription = stringResource(Res.string.more), + ) + PlatformDropdownMenu( + expanded = showDropdown, + onDismissRequest = { showDropdown = false }, + ) { + PlatformDropdownMenuItem( + text = { + PlatformText( + text = stringResource(Res.string.edit_rss_source), + ) + }, + onClick = { + onEdit.invoke(it.id) + showDropdown = false + }, + leadingIcon = { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Pen, + contentDescription = stringResource(Res.string.edit_rss_source), + ) + }, + ) + PlatformDropdownMenuItem( + text = { + PlatformText( + text = stringResource(Res.string.delete_rss_source), + color = PlatformTheme.colorScheme.error, + ) + }, + onClick = { + state.delete(it.id) + showDropdown = false + }, + leadingIcon = { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Trash, + contentDescription = stringResource(Res.string.delete_rss_source), + tint = PlatformTheme.colorScheme.error, + ) + }, + ) + } + } + } + }, + ) + } +} diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/rss/RssListWithTabsPresenter.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/rss/RssListWithTabsPresenter.kt new file mode 100644 index 000000000..03f8faea6 --- /dev/null +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/rss/RssListWithTabsPresenter.kt @@ -0,0 +1,41 @@ +package dev.dimension.flare.ui.screen.rss + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import dev.dimension.flare.data.model.RssTimelineTabItem +import dev.dimension.flare.data.model.TimelineTabItem +import dev.dimension.flare.ui.model.UiRssSource +import dev.dimension.flare.ui.presenter.PinTabsPresenter +import dev.dimension.flare.ui.presenter.PresenterBase +import dev.dimension.flare.ui.presenter.home.rss.RssSourcesPresenter +import dev.dimension.flare.ui.presenter.invoke + +public class RssListWithTabsPresenter : PresenterBase() { + private val pinTabsPresenter by lazy { + object : PinTabsPresenter() { + override fun List.filterPinned(): List = + filterIsInstance() + .map { it.feedUrl } + + override fun getTimelineTabItem(item: UiRssSource): TimelineTabItem = RssTimelineTabItem(item) + + override fun List.filter(item: UiRssSource): List = + filterNot { it is RssTimelineTabItem && it.feedUrl == item.url } + } + } + + @Composable + override fun body(): State { + val state = remember { RssSourcesPresenter() }.invoke() + val pinState = pinTabsPresenter.invoke() + return object : + State, + RssSourcesPresenter.State by state, + PinTabsPresenter.State by pinState { + } + } + + public interface State : + RssSourcesPresenter.State, + PinTabsPresenter.State +} diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/theme/Dimension.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/theme/Dimension.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/theme/Dimension.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/theme/Dimension.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/theme/PlatformColorScheme.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/theme/PlatformColorScheme.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/theme/PlatformColorScheme.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/theme/PlatformColorScheme.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/theme/PlatformTheme.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/theme/PlatformTheme.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/theme/PlatformTheme.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/theme/PlatformTheme.kt diff --git a/shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/theme/PlatformTypography.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/theme/PlatformTypography.kt similarity index 100% rename from shared/ui/component/src/commonMain/kotlin/dev/dimension/flare/ui/theme/PlatformTypography.kt rename to compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/theme/PlatformTypography.kt diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/common/PlatformShare.jvm.kt b/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/common/PlatformShare.jvm.kt similarity index 100% rename from shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/common/PlatformShare.jvm.kt rename to compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/common/PlatformShare.jvm.kt diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/AudioPlayer.jvm.kt b/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/AudioPlayer.jvm.kt similarity index 100% rename from shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/AudioPlayer.jvm.kt rename to compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/AudioPlayer.jvm.kt diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlaceHolder.jvm.kt b/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlaceHolder.jvm.kt similarity index 100% rename from shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlaceHolder.jvm.kt rename to compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlaceHolder.jvm.kt diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformBigscreen.jvm.kt b/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformBigscreen.jvm.kt similarity index 100% rename from shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformBigscreen.jvm.kt rename to compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformBigscreen.jvm.kt diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.jvm.kt b/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.jvm.kt similarity index 98% rename from shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.jvm.kt rename to compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.jvm.kt index bdd5e960f..0e55942c5 100644 --- a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.jvm.kt +++ b/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformButton.jvm.kt @@ -56,5 +56,6 @@ internal actual fun PlatformIconButton( onClick = onClick, modifier = modifier, content = { content.invoke() }, + iconOnly = true, ) } diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCard.jvm.kt b/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCard.jvm.kt similarity index 100% rename from shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCard.jvm.kt rename to compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCard.jvm.kt diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCheckbox.jvm.kt b/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCheckbox.jvm.kt similarity index 100% rename from shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCheckbox.jvm.kt rename to compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformCheckbox.jvm.kt diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformDropdown.jvm.kt b/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformDropdown.jvm.kt similarity index 100% rename from shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformDropdown.jvm.kt rename to compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformDropdown.jvm.kt diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformFlyoutContainer.jvm.kt b/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformFlyoutContainer.jvm.kt similarity index 100% rename from shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformFlyoutContainer.jvm.kt rename to compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformFlyoutContainer.jvm.kt diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIcon.jvm.kt b/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIcon.jvm.kt similarity index 100% rename from shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIcon.jvm.kt rename to compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIcon.jvm.kt diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIndication.jvm.kt b/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIndication.jvm.kt similarity index 100% rename from shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIndication.jvm.kt rename to compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformIndication.jvm.kt diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformListItem.jvm.kt b/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformListItem.jvm.kt similarity index 100% rename from shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformListItem.jvm.kt rename to compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformListItem.jvm.kt diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformProgressIndicator.jvm.kt b/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformProgressIndicator.jvm.kt similarity index 100% rename from shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformProgressIndicator.jvm.kt rename to compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformProgressIndicator.jvm.kt diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformSlider.jvm.kt b/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformSlider.jvm.kt similarity index 100% rename from shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformSlider.jvm.kt rename to compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformSlider.jvm.kt diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformText.jvm.kt b/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformText.jvm.kt similarity index 100% rename from shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformText.jvm.kt rename to compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformText.jvm.kt diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformVideoPlayer.jvm.kt b/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformVideoPlayer.jvm.kt similarity index 100% rename from shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformVideoPlayer.jvm.kt rename to compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformVideoPlayer.jvm.kt diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformWifiState.jvm.kt b/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformWifiState.jvm.kt similarity index 100% rename from shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformWifiState.jvm.kt rename to compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformWifiState.jvm.kt diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformColorScheme.jvm.kt b/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformColorScheme.jvm.kt similarity index 100% rename from shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformColorScheme.jvm.kt rename to compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformColorScheme.jvm.kt diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.jvm.kt b/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.jvm.kt similarity index 100% rename from shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.jvm.kt rename to compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformShapes.jvm.kt diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformTypography.jvm.kt b/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformTypography.jvm.kt similarity index 100% rename from shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformTypography.jvm.kt rename to compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/PlatformTypography.jvm.kt diff --git a/shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/Theme.jvm.kt b/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/Theme.jvm.kt similarity index 100% rename from shared/ui/component/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/Theme.jvm.kt rename to compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/theme/Theme.jvm.kt diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 16af70e6a..d108c19aa 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -11,7 +11,7 @@ plugins { dependencies { implementation(projects.shared) - implementation(projects.shared.ui.component) + implementation(projects.composeUi) implementation(compose.runtime) implementation(compose.foundation) @@ -36,6 +36,8 @@ dependencies { implementation(libs.datastore) implementation(libs.filekit.dialogs.compose) implementation(libs.filekit.coil) + implementation(libs.bouncycastle.bcprov) + implementation(libs.bouncycastle.bcpkix) } compose.desktop { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt index e3c8e4955..677946efb 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt @@ -43,13 +43,13 @@ import dev.dimension.flare.data.model.AllListTabItem import dev.dimension.flare.data.model.Bluesky import dev.dimension.flare.data.model.DirectMessageTabItem import dev.dimension.flare.data.model.DiscoverTabItem +import dev.dimension.flare.data.model.HomeTimelineTabItem import dev.dimension.flare.data.model.Misskey import dev.dimension.flare.data.model.NotificationTabItem import dev.dimension.flare.data.model.ProfileTabItem import dev.dimension.flare.data.model.RssTabItem import dev.dimension.flare.data.model.SettingsTabItem import dev.dimension.flare.data.model.TabItem -import dev.dimension.flare.data.model.TabSettings import dev.dimension.flare.data.model.TimelineTabItem import dev.dimension.flare.model.AccountType import dev.dimension.flare.ui.component.AvatarComponent @@ -83,7 +83,6 @@ import io.github.composefluent.component.NavigationDefaults import io.github.composefluent.component.SubtleButton import io.github.composefluent.component.Text import io.ktor.http.Url -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import moe.tlaster.precompose.molecule.producePresenter import org.apache.commons.lang3.SystemUtils @@ -393,6 +392,7 @@ private fun getRoute(tab: TabItem): Route = when (tab) { is DiscoverTabItem -> Discover(tab.account) is ProfileTabItem -> MeRoute(tab.account) + is HomeTimelineTabItem -> Route.Home(tab.account) is TimelineTabItem -> Timeline(tab) is NotificationTabItem -> Notification(tab.account) SettingsTabItem -> Route.Settings @@ -407,7 +407,7 @@ private fun getRoute(tab: TabItem): Route = private fun presenter() = run { val accountState = remember { ActiveAccountPresenter() }.invoke() - val tabState = remember { HomeTabsPresenter(flowOf(TabSettings())) }.invoke() + val tabState = remember { HomeTabsPresenter() }.invoke() val scrollToTopRegistry = remember { ScrollToTopRegistry() diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt index 7f7f8c68b..ab396291e 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt @@ -18,6 +18,7 @@ import coil3.request.crossfade import dev.dimension.flare.common.DeeplinkHandler import dev.dimension.flare.common.SandboxHelper import dev.dimension.flare.di.KoinHelper +import dev.dimension.flare.di.composeUiModule import dev.dimension.flare.di.desktopModule import dev.dimension.flare.ui.route.FloatingWindowState import dev.dimension.flare.ui.route.Route @@ -34,7 +35,7 @@ import java.awt.Desktop fun main(args: Array) { SandboxHelper.configureSQLiteDriver() startKoin { - modules(desktopModule + KoinHelper.modules()) + modules(desktopModule + KoinHelper.modules() + composeUiModule) } if (SystemUtils.IS_OS_MAC_OSX) { Desktop.getDesktop().setOpenURIHandler { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/data/repository/SettingsRepository.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/data/repository/SettingsRepository.kt deleted file mode 100644 index 465952ad8..000000000 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/data/repository/SettingsRepository.kt +++ /dev/null @@ -1,55 +0,0 @@ -package dev.dimension.flare.data.repository - -import dev.dimension.flare.common.FileSystemUtilsExt -import dev.dimension.flare.data.model.AccountPreferencesSerializer -import dev.dimension.flare.data.model.AppSettings -import dev.dimension.flare.data.model.AppSettingsSerializer -import dev.dimension.flare.data.model.AppearanceSettings -import dev.dimension.flare.data.model.TabSettings -import dev.dimension.flare.data.model.TabSettingsSerializer -import java.io.File -import kotlin.getValue - -internal class SettingsRepository { - private val appearanceSettingsStore by lazy { - androidx.datastore.core.DataStoreFactory.create( - serializer = AccountPreferencesSerializer, - produceFile = { File(FileSystemUtilsExt.flareDirectory(), "appearance.pb") }, - ) - } - val appearanceSettings by lazy { - appearanceSettingsStore.data - } - private val appSettingsStore by lazy { - androidx.datastore.core.DataStoreFactory.create( - serializer = AppSettingsSerializer, - produceFile = { File(FileSystemUtilsExt.flareDirectory(), "app_settings.pb") }, - ) - } - val appSettings by lazy { - appSettingsStore.data - } - - suspend fun updateAppearanceSettings(block: AppearanceSettings.() -> AppearanceSettings) { - appearanceSettingsStore.updateData(block) - } - - private val tabSettingsStore by lazy { - androidx.datastore.core.DataStoreFactory.create( - serializer = TabSettingsSerializer, - produceFile = { File(FileSystemUtilsExt.flareDirectory(), "tab_settings.pb") }, - ) - } - - val tabSettings by lazy { - tabSettingsStore.data - } - - suspend fun updateTabSettings(block: TabSettings.() -> TabSettings) { - tabSettingsStore.updateData(block) - } - - suspend fun updateAppSettings(block: AppSettings.() -> AppSettings) { - appSettingsStore.updateData(block) - } -} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/di/DesktopModule.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/di/DesktopModule.kt index a58177938..a57efbd92 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/di/DesktopModule.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/di/DesktopModule.kt @@ -1,7 +1,6 @@ package dev.dimension.flare.di import dev.dimension.flare.common.InAppNotification -import dev.dimension.flare.data.repository.SettingsRepository import dev.dimension.flare.ui.component.ComposeInAppNotification import dev.dimension.flare.ui.component.platform.VideoPlayerPool import org.koin.core.module.dsl.singleOf @@ -12,5 +11,4 @@ val desktopModule = module { single { ComposeInAppNotification() } binds arrayOf(InAppNotification::class) singleOf(::VideoPlayerPool) - singleOf(::SettingsRepository) } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt index 73454c7aa..074e86528 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt @@ -23,6 +23,11 @@ internal sealed interface Route { val tabItem: TimelineTabItem, ) : ScreenRoute + @Serializable + data class Home( + val accountType: AccountType, + ) : ScreenRoute + @Serializable data class Discover( val accountType: AccountType, @@ -214,6 +219,9 @@ internal sealed interface Route { val accountType: AccountType, ) : ScreenRoute + @Serializable + data object TabSetting : ScreenRoute + companion object { public fun parse(url: String): Route? { val data = Url(url) diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt index 8358d360e..0371e5196 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt @@ -28,7 +28,9 @@ import dev.dimension.flare.ui.screen.dm.DmConversationScreen import dev.dimension.flare.ui.screen.dm.DmListScreen import dev.dimension.flare.ui.screen.dm.UserDMConversationScreen import dev.dimension.flare.ui.screen.feeds.FeedListScreen +import dev.dimension.flare.ui.screen.feeds.TabSettingScreen import dev.dimension.flare.ui.screen.home.DiscoverScreen +import dev.dimension.flare.ui.screen.home.HomeTimelineScreen import dev.dimension.flare.ui.screen.home.NotificationScreen import dev.dimension.flare.ui.screen.home.ProfileScreen import dev.dimension.flare.ui.screen.home.ProfileWithUserNameAndHostDeeplinkRoute @@ -235,6 +237,10 @@ internal fun RouteContent( ), ) }, + editList = { + }, + deleteList = { + }, ) } @@ -359,6 +365,21 @@ internal fun RouteContent( ) } + is Route.Home -> { + HomeTimelineScreen( + route.accountType, + onAddTab = { + navigate( + Route.TabSetting, + ) + }, + ) + } + + Route.TabSetting -> { + TabSettingScreen() + } + is Route.StatusDetail -> { StatusScreen( statusKey = route.statusKey, @@ -422,7 +443,7 @@ internal fun RouteContent( onEdit = { navigate( EditRssSource( - id = it.id, + id = it, ), ) }, diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedListScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedListScreen.kt index e9fc51471..154d4ade2 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedListScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/FeedListScreen.kt @@ -2,12 +2,9 @@ package dev.dimension.flare.ui.screen.feeds import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState @@ -15,14 +12,9 @@ import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import compose.icons.FontAwesomeIcons -import compose.icons.fontawesomeicons.Solid -import compose.icons.fontawesomeicons.solid.Plus -import compose.icons.fontawesomeicons.solid.Trash import dev.dimension.flare.LocalWindowPadding import dev.dimension.flare.RegisterTabCallback import dev.dimension.flare.Res @@ -30,22 +22,16 @@ import dev.dimension.flare.common.isRefreshing import dev.dimension.flare.feeds_discover_feeds_title import dev.dimension.flare.feeds_my_feeds_title import dev.dimension.flare.model.AccountType -import dev.dimension.flare.ui.common.items import dev.dimension.flare.ui.common.plus -import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.Header -import dev.dimension.flare.ui.component.UiListItem -import dev.dimension.flare.ui.component.status.StatusPlaceholder -import dev.dimension.flare.ui.component.uiListItemComponent import dev.dimension.flare.ui.model.UiList -import dev.dimension.flare.ui.presenter.home.bluesky.BlueskyFeedsPresenter -import dev.dimension.flare.ui.presenter.home.bluesky.BlueskyFeedsState import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.screen.bluesky.BlueskyFeedsWithTabsPresenter +import dev.dimension.flare.ui.screen.bluesky.myBlueskyFeedWithTabs +import dev.dimension.flare.ui.screen.bluesky.popularBlueskyFeedWithTabs import dev.dimension.flare.ui.theme.screenHorizontalPadding import io.github.composefluent.component.ProgressBar import io.github.composefluent.component.ScrollbarContainer -import io.github.composefluent.component.SubtleButton -import kotlinx.coroutines.launch import moe.tlaster.precompose.molecule.producePresenter import org.jetbrains.compose.resources.stringResource @@ -79,54 +65,17 @@ internal fun FeedListScreen( item { Header(stringResource(Res.string.feeds_my_feeds_title)) } - uiListItemComponent( - state.myFeeds, - onClicked = toFeed, + myBlueskyFeedWithTabs( + state = state, + toFeed = toFeed, ) - item { Header(stringResource(Res.string.feeds_discover_feeds_title)) } - items( - state.popularFeeds, - loadingContent = { - Column { - Spacer(modifier = Modifier.height(8.dp)) - StatusPlaceholder( - modifier = Modifier.padding(horizontal = screenHorizontalPadding), - ) - Spacer(modifier = Modifier.height(8.dp)) - } - }, - ) { (item, subscribed) -> - UiListItem( - item = item, - onClicked = toFeed, - trailingContent = { - SubtleButton( - onClick = { - if (subscribed) { - state.unsubscribe(item) - } else { - state.subscribe(item) - } - }, - ) { - if (subscribed) { - FAIcon( - imageVector = FontAwesomeIcons.Solid.Trash, - contentDescription = null, - ) - } else { - FAIcon( - imageVector = FontAwesomeIcons.Solid.Plus, - contentDescription = null, - ) - } - } - }, - ) - } + popularBlueskyFeedWithTabs( + state = state, + toFeed = toFeed, + ) } } @@ -144,17 +93,5 @@ internal fun FeedListScreen( @Composable private fun presenter(accountType: AccountType) = run { - val scope = rememberCoroutineScope() - val state = - remember(accountType) { - BlueskyFeedsPresenter(accountType) - }.invoke() - - object : BlueskyFeedsState by state { - fun refresh() { - scope.launch { - state.refreshSuspend() - } - } - } + remember { BlueskyFeedsWithTabsPresenter(accountType) }.invoke() } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/TabSettingScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/TabSettingScreen.kt new file mode 100644 index 000000000..53956d976 --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/TabSettingScreen.kt @@ -0,0 +1,7 @@ +package dev.dimension.flare.ui.screen.feeds + +import androidx.compose.runtime.Composable + +@Composable +internal fun TabSettingScreen() { +} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt new file mode 100644 index 000000000..3f4d8d42e --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt @@ -0,0 +1,107 @@ +package dev.dimension.flare.ui.screen.home + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.Plus +import dev.dimension.flare.RegisterTabCallback +import dev.dimension.flare.data.repository.SettingsRepository +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.ui.component.FAIcon +import dev.dimension.flare.ui.component.TabIcon +import dev.dimension.flare.ui.component.TabTitle +import dev.dimension.flare.ui.model.map +import dev.dimension.flare.ui.model.onSuccess +import dev.dimension.flare.ui.presenter.HomeTimelineWithTabsPresenter +import dev.dimension.flare.ui.presenter.invoke +import io.github.composefluent.component.LiteFilter +import io.github.composefluent.component.PillButton +import moe.tlaster.precompose.molecule.producePresenter +import org.koin.compose.koinInject + +@Composable +internal fun HomeTimelineScreen( + accountType: AccountType, + onAddTab: () -> Unit, +) { + val state by producePresenter(key = "home_timeline_$accountType") { + presenter(accountType) + } + + state.tabState.onSuccess { tabState -> + state.selectedTab.onSuccess { currentTab -> + val lazyListState = currentTab.lazyListState + RegisterTabCallback( + lazyListState = lazyListState, + onRefresh = { + currentTab.refreshSync() + }, + ) + + TimelineScreen( + tabItem = currentTab.timelineTabItem, + header = { + LiteFilter { + tabState.forEachIndexed { index, tab -> + PillButton( + selected = tab.timelineTabItem.key == currentTab.timelineTabItem.key, + onSelectedChanged = { + state.setSelectedIndex(index) + }, + ) { + TabIcon( + tabItem = tab.timelineTabItem, + ) + TabTitle( + title = tab.timelineTabItem.metaData.title, + ) + } + } + PillButton( + selected = false, + onSelectedChanged = { + onAddTab.invoke() + }, + ) { + FAIcon( + FontAwesomeIcons.Solid.Plus, + contentDescription = null, + ) + } + } + }, + ) + } + } +} + +@Composable +private fun presenter( + accountType: AccountType, + settingsRepository: SettingsRepository = koinInject(), +) = run { + val state = remember(accountType) { HomeTimelineWithTabsPresenter(accountType) }.invoke() + var selectedIndex by remember(state.tabState) { + mutableStateOf(0) + } + + val selectedTab = + remember( + state.tabState, + selectedIndex, + ) { + state.tabState.map { it.elementAt(selectedIndex) } + } + + object : HomeTimelineWithTabsPresenter.State by state { + val selectedTab = selectedTab + + fun setSelectedIndex(index: Int) { + selectedIndex = index + } + } +} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt index f282a2195..4f0f40e75 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt @@ -13,18 +13,11 @@ 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.lazy.staggeredgrid.StaggeredGridItemSpan 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.unit.dp @@ -34,7 +27,6 @@ import compose.icons.fontawesomeicons.solid.AnglesUp import dev.dimension.flare.LocalWindowPadding import dev.dimension.flare.RegisterTabCallback import dev.dimension.flare.Res -import dev.dimension.flare.common.PagingState import dev.dimension.flare.common.isRefreshing import dev.dimension.flare.common.onSuccess import dev.dimension.flare.data.model.TimelineTabItem @@ -43,23 +35,20 @@ import dev.dimension.flare.ui.common.plus import dev.dimension.flare.ui.component.FAIcon 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.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.TimelineItemPresenter import dev.dimension.flare.ui.presenter.invoke import io.github.composefluent.component.AccentButton import io.github.composefluent.component.ProgressBar import io.github.composefluent.component.Text -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch import moe.tlaster.precompose.molecule.producePresenter import org.jetbrains.compose.resources.stringResource @Composable -internal fun TimelineScreen(tabItem: TimelineTabItem) { +internal fun TimelineScreen( + tabItem: TimelineTabItem, + header: @Composable (() -> Unit)? = null, +) { val scope = rememberCoroutineScope() val state by producePresenter( "timeline_$tabItem", @@ -79,6 +68,13 @@ internal fun TimelineScreen(tabItem: TimelineTabItem) { ) + LocalWindowPadding.current, state = state.lazyListState, ) { + if (header != null) { + item( + span = StaggeredGridItemSpan.FullLine, + ) { + header.invoke() + } + } status(state.listState) } if (state.listState.isRefreshing) { @@ -124,87 +120,7 @@ internal fun TimelineScreen(tabItem: TimelineTabItem) { @Composable private fun presenter(tabItem: TimelineTabItem) = run { - val state = timelineItemPresenter(tabItem) - val accountState = - remember(tabItem.account) { - UserPresenter( - accountType = tabItem.account, - userKey = null, - ) - }.invoke() - object : UserState by accountState, TimelineItemState by state { - } - } - -@Composable -internal fun timelineItemPresenter(timelineTabItem: TimelineTabItem): TimelineItemState { - val state = - remember(timelineTabItem.key) { - timelineTabItem.createPresenter() + remember(tabItem.key) { + TimelineItemPresenter(tabItem) }.invoke() - val badge = - remember(timelineTabItem) { - NotificationBadgePresenter(timelineTabItem.account) - }.invoke() - val scope = rememberCoroutineScope() - 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 && - lazyListState.firstVisibleItemScrollOffset == 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() - } - badge.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/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/list/AllListScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/list/AllListScreen.kt index 290a0f697..808ebf299 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/list/AllListScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/list/AllListScreen.kt @@ -18,10 +18,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import dev.dimension.flare.RegisterTabCallback import dev.dimension.flare.model.AccountType -import dev.dimension.flare.ui.component.uiListItemComponent import dev.dimension.flare.ui.model.UiList import dev.dimension.flare.ui.presenter.invoke -import dev.dimension.flare.ui.presenter.list.AllListPresenter import dev.dimension.flare.ui.theme.screenHorizontalPadding import io.github.composefluent.component.ProgressBar import io.github.composefluent.component.ScrollbarContainer @@ -32,6 +30,8 @@ internal fun AllListScreen( accountType: AccountType, onAddList: () -> Unit, toList: (UiList) -> Unit, + editList: (UiList) -> Unit, + deleteList: (UiList) -> Unit, ) { val state by producePresenter("AllListScreen_$accountType") { presenter(accountType) @@ -90,22 +90,11 @@ internal fun AllListScreen( .padding(horizontal = screenHorizontalPadding), verticalArrangement = Arrangement.spacedBy(2.dp), ) { - uiListItemComponent( - state.items, - onClicked = toList, -// trailingContent = { item -> -// if (!item.readonly) { -// SubtleButton( -// onClick = { -// }, -// ) { -// FAIcon( -// FontAwesomeIcons.Solid.EllipsisVertical, -// contentDescription = stringResource(Res.string.more), -// ) -// } -// } -// }, + uiListWithTabs( + state = state, + toList = toList, + editList = editList, + deleteList = deleteList, ) } } @@ -125,6 +114,6 @@ internal fun AllListScreen( private fun presenter(accountType: AccountType) = run { remember(accountType) { - AllListPresenter(accountType) + AllListWithTabsPresenter(accountType) }.invoke() } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/list/ListScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/list/ListScreen.kt index 4a329c5ea..9323bf73c 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/list/ListScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/list/ListScreen.kt @@ -29,14 +29,14 @@ internal fun ListScreen(accountType: AccountType) { MasterDetailViewState.Detail }, master = { - AllListScreen( - accountType = accountType, - onAddList = { - }, - toList = { - state.setSelectedList(it) - }, - ) +// AllListScreen( +// accountType = accountType, +// onAddList = { +// }, +// toList = { +// state.setSelectedList(it) +// }, +// ) }, detail = { state.selectedList?.let { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/misskey/AntennasListScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/misskey/AntennasListScreen.kt index 8ddcc8d70..38998f836 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/misskey/AntennasListScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/misskey/AntennasListScreen.kt @@ -1,6 +1,5 @@ package dev.dimension.flare.ui.screen.misskey -import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding @@ -12,41 +11,15 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import compose.icons.FontAwesomeIcons -import compose.icons.fontawesomeicons.Solid -import compose.icons.fontawesomeicons.solid.Thumbtack -import compose.icons.fontawesomeicons.solid.ThumbtackSlash import dev.dimension.flare.LocalWindowPadding import dev.dimension.flare.RegisterTabCallback -import dev.dimension.flare.Res -import dev.dimension.flare.data.model.IconType -import dev.dimension.flare.data.model.Misskey -import dev.dimension.flare.data.model.TabMetaData -import dev.dimension.flare.data.model.TitleType -import dev.dimension.flare.data.repository.SettingsRepository import dev.dimension.flare.model.AccountType -import dev.dimension.flare.tab_settings_add -import dev.dimension.flare.tab_settings_remove import dev.dimension.flare.ui.common.plus -import dev.dimension.flare.ui.component.FAIcon -import dev.dimension.flare.ui.component.uiListItemComponent import dev.dimension.flare.ui.model.UiList -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.presenter.list.AntennasListPresenter import dev.dimension.flare.ui.theme.screenHorizontalPadding import io.github.composefluent.component.ScrollbarContainer -import io.github.composefluent.component.SubtleButton -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch import moe.tlaster.precompose.molecule.producePresenter -import org.jetbrains.compose.resources.stringResource -import org.koin.compose.koinInject @Composable internal fun AntennasListScreen( @@ -71,119 +44,16 @@ internal fun AntennasListScreen( verticalArrangement = Arrangement.spacedBy(2.dp), state = listState, ) { - uiListItemComponent( - state.data, - toTimeline, - trailingContent = { item -> - state.currentTabs.onSuccess { currentTabs -> - val isPinned = - remember( - item, - currentTabs, - ) { - currentTabs.contains(item.id) - } - SubtleButton( - onClick = { - if (isPinned) { - state.unpinList(item) - } else { - state.pinList(item) - } - }, - iconOnly = true, - ) { - AnimatedContent(isPinned) { - if (it) { - FAIcon( - imageVector = FontAwesomeIcons.Solid.ThumbtackSlash, - contentDescription = stringResource(Res.string.tab_settings_add), - ) - } else { - FAIcon( - imageVector = FontAwesomeIcons.Solid.Thumbtack, - contentDescription = stringResource(Res.string.tab_settings_remove), - ) - } - } - } - } - }, + misskeyAntennasWithTabs( + state = state, + onClick = toTimeline, ) } } } @Composable -private fun presenter( - accountType: AccountType, - settingsRepository: SettingsRepository = koinInject(), - appScope: CoroutineScope = koinInject(), -) = run { - val tabSettings by settingsRepository.tabSettings.collectAsUiState() - val accountState = - remember(accountType) { - UserPresenter( - accountType = accountType, - userKey = null, - ) - }.invoke() - val currentTabs = - accountState.user.flatMap { user -> - tabSettings.map { - it.mainTabs - .filterIsInstance() - .map { it.id } - .toImmutableList() - } - } - val state = - remember(accountType) { - AntennasListPresenter(accountType) - }.invoke() - - object : AntennasListPresenter.State by state { - val currentTabs = currentTabs - - fun pinList(item: UiList) { - accountState.user.onSuccess { user -> - appScope.launch { - settingsRepository.updateTabSettings { - copy( - mainTabs = - mainTabs + - Misskey.AntennasTimelineTabItem( - account = AccountType.Specific(user.key), - id = item.id, - metaData = - TabMetaData( - title = TitleType.Text(item.title), - icon = IconType.Material(IconType.Material.MaterialIcon.List), - ), - ), - ) - } - } - } - } - - fun unpinList(item: UiList) { - accountState.user.onSuccess { user -> - appScope.launch { - settingsRepository.updateTabSettings { - copy( - mainTabs = - mainTabs.filter { - if (it is Misskey.AntennasTimelineTabItem) { - it.id != item.id - } else { - true - } - }, - ) - } - } - } - } +private fun presenter(accountType: AccountType) = + run { + remember(accountType) { MisskeyAntennasListWithTabsPresenter(accountType) }.invoke() } -} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/rss/RssListScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/rss/RssListScreen.kt index afb9b44a8..e024ddd4b 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/rss/RssListScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/rss/RssListScreen.kt @@ -1,15 +1,11 @@ package dev.dimension.flare.ui.screen.rss -import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -19,53 +15,24 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid -import compose.icons.fontawesomeicons.solid.EllipsisVertical -import compose.icons.fontawesomeicons.solid.File -import compose.icons.fontawesomeicons.solid.Pen import compose.icons.fontawesomeicons.solid.Plus -import compose.icons.fontawesomeicons.solid.Thumbtack -import compose.icons.fontawesomeicons.solid.ThumbtackSlash -import compose.icons.fontawesomeicons.solid.Trash import dev.dimension.flare.LocalWindowPadding import dev.dimension.flare.Res import dev.dimension.flare.add_rss_source -import dev.dimension.flare.data.model.RssTimelineTabItem -import dev.dimension.flare.data.repository.SettingsRepository -import dev.dimension.flare.delete_rss_source -import dev.dimension.flare.edit_rss_source -import dev.dimension.flare.empty_rss_sources -import dev.dimension.flare.more -import dev.dimension.flare.tab_settings_add -import dev.dimension.flare.tab_settings_remove -import dev.dimension.flare.ui.common.itemsIndexed import dev.dimension.flare.ui.common.plus import dev.dimension.flare.ui.component.FAIcon -import dev.dimension.flare.ui.component.NetworkImage import dev.dimension.flare.ui.model.UiRssSource -import dev.dimension.flare.ui.model.collectAsUiState -import dev.dimension.flare.ui.model.map -import dev.dimension.flare.ui.model.onSuccess -import dev.dimension.flare.ui.presenter.home.rss.RssSourcesPresenter import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.theme.screenHorizontalPadding -import io.github.composefluent.FluentTheme import io.github.composefluent.component.AccentButton -import io.github.composefluent.component.CardExpanderItem -import io.github.composefluent.component.MenuFlyoutContainer -import io.github.composefluent.component.MenuFlyoutItem -import io.github.composefluent.component.SubtleButton import io.github.composefluent.component.Text -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch import moe.tlaster.precompose.molecule.producePresenter import org.jetbrains.compose.resources.stringResource -import org.koin.compose.koinInject @Composable internal fun RssListScreen( toItem: (UiRssSource) -> Unit, - onEdit: (UiRssSource) -> Unit, + onEdit: (Int) -> Unit, onAdd: () -> Unit, modifier: Modifier = Modifier, ) { @@ -101,181 +68,17 @@ internal fun RssListScreen( item { Spacer(modifier = Modifier.height(6.dp)) } - itemsIndexed( - state.sources, - emptyContent = { - Column( - modifier = - Modifier - .fillParentMaxSize(), - verticalArrangement = - Arrangement.spacedBy( - 8.dp, - Alignment.CenterVertically, - ), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - FAIcon( - FontAwesomeIcons.Solid.File, - contentDescription = stringResource(Res.string.empty_rss_sources), - modifier = Modifier.size(48.dp), - ) - Text( - text = stringResource(Res.string.empty_rss_sources), - style = FluentTheme.typography.subtitle, - ) - } - }, - ) { index, itemCount, it -> - CardExpanderItem( - onClick = { - toItem.invoke(it) - }, - heading = { - it.title?.let { - Text(text = it) - } - }, - caption = { - Text(it.url) - }, - icon = { - NetworkImage( - model = it.favIcon, - contentDescription = it.title, - modifier = Modifier.size(24.dp), - ) - }, - trailing = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - state.currentTabs.onSuccess { currentTabs -> - val isPinned = - remember( - it, - currentTabs, - ) { - currentTabs.contains(it.url) - } - SubtleButton( - iconOnly = true, - onClick = { - if (isPinned) { - state.unpinSource(it) - } else { - state.pinSource(it) - } - }, - ) { - AnimatedContent(isPinned) { - if (it) { - FAIcon( - imageVector = FontAwesomeIcons.Solid.ThumbtackSlash, - contentDescription = stringResource(Res.string.tab_settings_add), - ) - } else { - FAIcon( - imageVector = FontAwesomeIcons.Solid.Thumbtack, - contentDescription = stringResource(Res.string.tab_settings_remove), - ) - } - } - } - } - MenuFlyoutContainer( - flyout = { - MenuFlyoutItem( - text = { - Text( - text = stringResource(Res.string.edit_rss_source), - ) - }, - onClick = { - onEdit.invoke(it) - isFlyoutVisible = false - }, - icon = { - FAIcon( - imageVector = FontAwesomeIcons.Solid.Pen, - contentDescription = stringResource(Res.string.edit_rss_source), - ) - }, - ) - MenuFlyoutItem( - text = { - Text( - text = stringResource(Res.string.delete_rss_source), - color = FluentTheme.colors.system.critical, - ) - }, - onClick = { - state.delete(it.id) - isFlyoutVisible = false - }, - icon = { - FAIcon( - imageVector = FontAwesomeIcons.Solid.Trash, - contentDescription = stringResource(Res.string.delete_rss_source), - tint = FluentTheme.colors.system.critical, - ) - }, - ) - }, - ) { - SubtleButton(onClick = { isFlyoutVisible = true }) { - FAIcon( - imageVector = FontAwesomeIcons.Solid.EllipsisVertical, - contentDescription = stringResource(Res.string.more), - ) - } - } - } - }, - ) - } + rssListWithTabs( + state = state, + onClicked = toItem, + onEdit = onEdit, + ) } } @Composable -private fun presenter( - settingsRepository: SettingsRepository = koinInject(), - appScope: CoroutineScope = koinInject(), -) = run { - val state = remember { RssSourcesPresenter() }.invoke() - val tabSettings by settingsRepository.tabSettings.collectAsUiState() - val currentTabs = - tabSettings.map { - it.mainTabs - .filterIsInstance() - .map { it.feedUrl } - .toImmutableList() - } - object : RssSourcesPresenter.State by state { - val currentTabs = currentTabs - - fun pinSource(source: UiRssSource) { - appScope.launch { - settingsRepository.updateTabSettings { - copy( - mainTabs = - mainTabs + RssTimelineTabItem(source), - ) - } - } - } - - fun unpinSource(source: UiRssSource) { - appScope.launch { - settingsRepository.updateTabSettings { - copy( - mainTabs = - mainTabs.filterNot { it is RssTimelineTabItem && it.feedUrl == source.url }, - ) - } - } - } +private fun presenter() = + run { + remember { RssListWithTabsPresenter() }.invoke() } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 08c45dc71..428bed55a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,7 +8,7 @@ haze = "1.6.10" lifecycleViewmodelComposeVersion = "2.9.2" minSdk = "23" java = "21" -agp = "8.12.0" +agp = "8.12.1" kotlin = "2.2.0" core-ktx = "1.17.0" junit = "4.13.2" @@ -55,6 +55,7 @@ compose-multiplatform = "1.8.2" logback = "1.5.18" navigation3 = "1.0.0-alpha07" zoomable = "0.16.0" +bouncycastle = "1.81" [libraries] androidx-collection = { module = "androidx.collection:collection", version.ref = "collection" } @@ -210,6 +211,9 @@ androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", vers androidx-material3-adaptive-navigation3 = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation3", version = "1.0.0-SNAPSHOT" } androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version = "2.10.0-alpha02" } +bouncycastle-bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" } +bouncycastle-bcpkix = { module = "org.bouncycastle:bcpkix-jdk18on", version.ref = "bouncycastle" } + ### Server ### ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" } ktor-resources = { module = "io.ktor:ktor-resources", version.ref = "ktor" } @@ -222,7 +226,6 @@ ktor-server-config-yaml = { module = "io.ktor:ktor-server-config-yaml", version. logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } ktor-server-test-host = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" } - [bundles] compose = ["ui", "ui-util", "ui-graphics", "ui-tooling", "ui-tooling-preview", "material3", "material3WindowSizeClass", "material3-adaptive-navigation-suite", "material3-adaptive", "material3-adaptive-navigation", "material3-adaptive-layout"] kotlinx = ["kotlinx-datetime", "kotlinx-immutable", "kotlinx-serialization-json", "kotlinx-coroutines-core"] @@ -241,7 +244,7 @@ ktor-server = ["ktor-server-core", "ktor-server-resources", "ktor-server-content [plugins] android-application = { id = "com.android.application", version.ref = "agp" } -android-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 3af4625a1..39f51adf5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -20,7 +20,7 @@ rootProject.name = "Flare" include(":app") include(":shared") include(":shared:ui") -include(":shared:ui:component") +include(":compose-ui") include(":desktopApp") include(":server") include(":shared:api") diff --git a/shared/api/build.gradle.kts b/shared/api/build.gradle.kts index 5ae1edef4..c3f63d925 100644 --- a/shared/api/build.gradle.kts +++ b/shared/api/build.gradle.kts @@ -8,11 +8,12 @@ kotlin { jvmToolchain(libs.versions.java.get().toInt()) explicitApi() applyDefaultHierarchyTemplate() - androidLibrary { - compileSdk = libs.versions.compileSdk.get().toInt() - namespace = "dev.dimension.flare.shared.api" - minSdk = libs.versions.minSdk.get().toInt() - } +// androidLibrary { +// compileSdk = libs.versions.compileSdk.get().toInt() +// namespace = "dev.dimension.flare.shared.api" +// minSdk = libs.versions.minSdk.get().toInt() +// } + androidTarget() jvm() macosX64() macosArm64() @@ -33,4 +34,8 @@ kotlin { } } } -} \ No newline at end of file +} + +android { + namespace = "dev.dimension.flare.shared.api" +} diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index d4f0b6b4f..8bba95bed 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -14,17 +14,33 @@ plugins { alias(libs.plugins.room) } +android { + namespace = "dev.dimension.flare.shared" +} + kotlin { + applyDefaultHierarchyTemplate { + common { + group("apple") { + withMacos() + withIos() + } + group("androidJvm") { + withAndroidTarget() + withJvm() + } + } + } jvmToolchain(libs.versions.java.get().toInt()) explicitApi() - applyDefaultHierarchyTemplate() - androidLibrary { - compileSdk = libs.versions.compileSdk.get().toInt() - namespace = "dev.dimension.flare.shared" - minSdk = libs.versions.minSdk.get().toInt() - } +// androidLibrary { +// compileSdk = libs.versions.compileSdk.get().toInt() +// namespace = "dev.dimension.flare.shared" +// minSdk = libs.versions.minSdk.get().toInt() +// } + androidTarget() jvm() listOf( @@ -90,27 +106,21 @@ kotlin { implementation(kotlin("test")) } } - val androidJvmMain by creating { - dependsOn(commonMain) + val androidJvmMain by getting { dependencies { implementation(compose.foundation) implementation(libs.ktor.client.okhttp) } } val androidMain by getting { - dependsOn(androidJvmMain) dependencies { - implementation(project.dependencies.platform(libs.compose.bom)) implementation(libs.core.ktx) implementation(libs.koin.android) } } val jvmMain by getting { - dependsOn(androidJvmMain) dependencies { implementation(libs.commons.lang3) - // TODO: workaround for https://issuetracker.google.com/issues/396148592 - implementation("androidx.sqlite:sqlite-jvm:2.5.2") } } val appleMain by getting { @@ -163,6 +173,4 @@ afterEvaluate { } } } - - } diff --git a/shared/src/androidJvmMain/kotlin/dev/dimension/flare/ui/presenter/PresenterBase.androidJvm.kt b/shared/src/androidJvmMain/kotlin/dev/dimension/flare/ui/presenter/PresenterBase.androidJvm.kt index 6d8b2dd6a..6a60da070 100644 --- a/shared/src/androidJvmMain/kotlin/dev/dimension/flare/ui/presenter/PresenterBase.androidJvm.kt +++ b/shared/src/androidJvmMain/kotlin/dev/dimension/flare/ui/presenter/PresenterBase.androidJvm.kt @@ -12,7 +12,7 @@ public actual abstract class PresenterBase { } @Composable - internal actual abstract fun body(): Model + public actual abstract fun body(): Model } @Composable diff --git a/shared/src/androidMain/kotlin/dev/dimension/flare/data/io/PlatformPathProducer.android.kt b/shared/src/androidMain/kotlin/dev/dimension/flare/data/io/PlatformPathProducer.android.kt new file mode 100644 index 000000000..9ff6ea15e --- /dev/null +++ b/shared/src/androidMain/kotlin/dev/dimension/flare/data/io/PlatformPathProducer.android.kt @@ -0,0 +1,12 @@ +package dev.dimension.flare.data.io + +import android.content.Context +import androidx.datastore.dataStoreFile +import okio.Path +import okio.Path.Companion.toOkioPath + +public actual class PlatformPathProducer( + private val context: Context, +) { + public actual fun dataStoreFile(fileName: String): Path = context.dataStoreFile(fileName).toOkioPath() +} diff --git a/shared/src/androidMain/kotlin/dev/dimension/flare/di/PlatformModule.android.kt b/shared/src/androidMain/kotlin/dev/dimension/flare/di/PlatformModule.android.kt index 139195807..3580f49df 100644 --- a/shared/src/androidMain/kotlin/dev/dimension/flare/di/PlatformModule.android.kt +++ b/shared/src/androidMain/kotlin/dev/dimension/flare/di/PlatformModule.android.kt @@ -1,24 +1,19 @@ package dev.dimension.flare.di -import androidx.datastore.dataStoreFile import dev.dimension.flare.data.database.DriverFactory import dev.dimension.flare.data.datastore.AppDataStore +import dev.dimension.flare.data.io.PlatformPathProducer import dev.dimension.flare.data.network.rss.NativeWebScraper -import org.koin.android.ext.koin.androidContext import org.koin.core.module.Module import org.koin.core.module.dsl.singleOf import org.koin.dsl.module internal actual val platformModule: Module = module { - single { - val context = androidContext() - AppDataStore { - context.dataStoreFile(it).absolutePath - } - } + singleOf(::AppDataStore) singleOf(::DriverFactory) singleOf(::NativeWebScraper) + singleOf(::PlatformPathProducer) } public object KoinHelper { diff --git a/shared/src/appleMain/kotlin/dev/dimension/flare/data/io/PlatformPathProducer.apple.kt b/shared/src/appleMain/kotlin/dev/dimension/flare/data/io/PlatformPathProducer.apple.kt new file mode 100644 index 000000000..0e3192b20 --- /dev/null +++ b/shared/src/appleMain/kotlin/dev/dimension/flare/data/io/PlatformPathProducer.apple.kt @@ -0,0 +1,26 @@ +package dev.dimension.flare.data.io + +import kotlinx.cinterop.ExperimentalForeignApi +import okio.Path +import okio.Path.Companion.toPath +import platform.Foundation.NSDocumentDirectory +import platform.Foundation.NSFileManager +import platform.Foundation.NSURL +import platform.Foundation.NSUserDomainMask + +public actual class PlatformPathProducer { + public actual fun dataStoreFile(fileName: String): Path = "${fileDirectory()}/$fileName".toPath() + + @OptIn(ExperimentalForeignApi::class) + private fun fileDirectory(): String { + val documentDirectory: NSURL? = + NSFileManager.defaultManager.URLForDirectory( + directory = NSDocumentDirectory, + inDomain = NSUserDomainMask, + appropriateForURL = null, + create = false, + error = null, + ) + return requireNotNull(documentDirectory).path!! + } +} diff --git a/shared/src/appleMain/kotlin/dev/dimension/flare/di/PlatformModule.apple.kt b/shared/src/appleMain/kotlin/dev/dimension/flare/di/PlatformModule.apple.kt index 6fb0e828c..ad1201008 100644 --- a/shared/src/appleMain/kotlin/dev/dimension/flare/di/PlatformModule.apple.kt +++ b/shared/src/appleMain/kotlin/dev/dimension/flare/di/PlatformModule.apple.kt @@ -2,36 +2,16 @@ package dev.dimension.flare.di import dev.dimension.flare.data.database.DriverFactory import dev.dimension.flare.data.datastore.AppDataStore +import dev.dimension.flare.data.io.PlatformPathProducer import dev.dimension.flare.data.network.rss.NativeWebScraper -import kotlinx.cinterop.ExperimentalForeignApi import org.koin.core.module.Module import org.koin.core.module.dsl.singleOf import org.koin.dsl.module -import platform.Foundation.NSDocumentDirectory -import platform.Foundation.NSFileManager -import platform.Foundation.NSURL -import platform.Foundation.NSUserDomainMask internal actual val platformModule: Module = module { - single { - AppDataStore { fileName -> - "${fileDirectory()}/$fileName" - } - } + singleOf(::AppDataStore) singleOf(::DriverFactory) + singleOf(::PlatformPathProducer) singleOf(::NativeWebScraper) } - -@OptIn(ExperimentalForeignApi::class) -private fun fileDirectory(): String { - val documentDirectory: NSURL? = - NSFileManager.defaultManager.URLForDirectory( - directory = NSDocumentDirectory, - inDomain = NSUserDomainMask, - appropriateForURL = null, - create = false, - error = null, - ) - return requireNotNull(documentDirectory).path!! -} diff --git a/shared/src/appleMain/kotlin/dev/dimension/flare/ui/presenter/PresenterBase.apple.kt b/shared/src/appleMain/kotlin/dev/dimension/flare/ui/presenter/PresenterBase.apple.kt index fc5b9189b..afafef9ba 100644 --- a/shared/src/appleMain/kotlin/dev/dimension/flare/ui/presenter/PresenterBase.apple.kt +++ b/shared/src/appleMain/kotlin/dev/dimension/flare/ui/presenter/PresenterBase.apple.kt @@ -26,5 +26,5 @@ public actual abstract class PresenterBase : AutoCloseable { @OptIn(ExperimentalObjCRefinement::class) @HiddenFromObjC @Composable - internal actual abstract fun body(): Model + public actual abstract fun body(): Model } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/AppDataStore.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/AppDataStore.kt index f5b7f03bb..1411e5824 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/AppDataStore.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/AppDataStore.kt @@ -7,12 +7,12 @@ import dev.dimension.flare.data.datastore.model.FlareConfig import dev.dimension.flare.data.datastore.model.FlareConfigSerializer import dev.dimension.flare.data.datastore.model.GuestData import dev.dimension.flare.data.datastore.model.GuestDataSerializer +import dev.dimension.flare.data.io.PlatformPathProducer import okio.FileSystem -import okio.Path.Companion.toPath import okio.SYSTEM internal class AppDataStore( - private val producePath: (fileName: String) -> String, + private val platformPathProducer: PlatformPathProducer, ) { val guestDataStore: DataStore by lazy { DataStoreFactory.create( @@ -21,7 +21,7 @@ internal class AppDataStore( fileSystem = FileSystem.SYSTEM, serializer = GuestDataSerializer, producePath = { - producePath.invoke("guest_data.pb").toPath() + platformPathProducer.dataStoreFile("guest_data.pb") }, ), ) @@ -34,7 +34,7 @@ internal class AppDataStore( fileSystem = FileSystem.SYSTEM, serializer = FlareConfigSerializer, producePath = { - producePath.invoke("flare_config.pb").toPath() + platformPathProducer.dataStoreFile("flare_config.pb") }, ), ) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/io/PlatformPathProducer.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/io/PlatformPathProducer.kt new file mode 100644 index 000000000..d9e92b773 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/io/PlatformPathProducer.kt @@ -0,0 +1,7 @@ +package dev.dimension.flare.data.io + +import okio.Path + +public expect class PlatformPathProducer { + public fun dataStoreFile(fileName: String): Path +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiState.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiState.kt index f17db4c6e..278c0a7b1 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiState.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiState.kt @@ -65,6 +65,25 @@ public inline fun UiState.flatMap( is UiState.Loading -> UiState.Loading() } +public inline fun zipState( + a: UiState, + b: UiState, + onError: (Throwable) -> UiState = { UiState.Error(it) }, + transform: (T1, T2) -> R, +): UiState = + when { + a is UiState.Loading || b is UiState.Loading -> UiState.Loading() + a is UiState.Error -> onError(a.throwable) + b is UiState.Error -> onError(b.throwable) + a is UiState.Success && b is UiState.Success -> + try { + UiState.Success(transform(a.data, b.data)) + } catch (e: Throwable) { + UiState.Error(e) + } + else -> UiState.Error(IllegalStateException("Unreachable")) + } + public fun List>.merge(requireAllSuccess: Boolean = true): UiState> { val success = filterIsInstance>().map { it.data } val error = filterIsInstance>().map { it.throwable } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/PresenterBase.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/PresenterBase.kt index b17d57c3b..8ee4fba8e 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/PresenterBase.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/PresenterBase.kt @@ -7,5 +7,5 @@ public expect abstract class PresenterBase() { public val models: StateFlow @Composable - internal abstract fun body(): Model + public abstract fun body(): Model } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/rss/CheckRssSourcePresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/rss/CheckRssSourcePresenter.kt index d9788f296..f032fdf5b 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/rss/CheckRssSourcePresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/rss/CheckRssSourcePresenter.kt @@ -2,12 +2,10 @@ package dev.dimension.flare.ui.presenter.home.rss import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue import dev.dimension.flare.data.network.rss.RssService import dev.dimension.flare.ui.model.UiRssSource import dev.dimension.flare.ui.model.UiState import dev.dimension.flare.ui.model.flattenUiState -import dev.dimension.flare.ui.model.map import dev.dimension.flare.ui.model.mapper.title import dev.dimension.flare.ui.presenter.PresenterBase import dev.dimension.flare.ui.presenter.home.rss.CheckRssSourcePresenter.State.RssState diff --git a/shared/src/jvmMain/kotlin/dev/dimension/flare/data/io/PlatformPathProducer.jvm.kt b/shared/src/jvmMain/kotlin/dev/dimension/flare/data/io/PlatformPathProducer.jvm.kt new file mode 100644 index 000000000..8d69a8fab --- /dev/null +++ b/shared/src/jvmMain/kotlin/dev/dimension/flare/data/io/PlatformPathProducer.jvm.kt @@ -0,0 +1,9 @@ +package dev.dimension.flare.data.io + +import dev.dimension.flare.common.FileSystemUtilsExt +import okio.Path +import okio.Path.Companion.toOkioPath + +public actual class PlatformPathProducer { + public actual fun dataStoreFile(fileName: String): Path = FileSystemUtilsExt.flareDirectory().toOkioPath().resolve(fileName) +} diff --git a/shared/src/jvmMain/kotlin/dev/dimension/flare/di/PlatformModule.jvm.kt b/shared/src/jvmMain/kotlin/dev/dimension/flare/di/PlatformModule.jvm.kt index 72b3e8ae5..61d146cd2 100644 --- a/shared/src/jvmMain/kotlin/dev/dimension/flare/di/PlatformModule.jvm.kt +++ b/shared/src/jvmMain/kotlin/dev/dimension/flare/di/PlatformModule.jvm.kt @@ -1,21 +1,17 @@ package dev.dimension.flare.di -import dev.dimension.flare.common.FileSystemUtilsExt import dev.dimension.flare.data.database.DriverFactory import dev.dimension.flare.data.datastore.AppDataStore +import dev.dimension.flare.data.io.PlatformPathProducer import org.koin.core.module.Module import org.koin.core.module.dsl.singleOf import org.koin.dsl.module -import java.io.File internal actual val platformModule: Module = module { - single { - AppDataStore { - File(FileSystemUtilsExt.flareDirectory(), it).absolutePath - } - } + singleOf(::AppDataStore) singleOf(::DriverFactory) + singleOf(::PlatformPathProducer) } public object KoinHelper { diff --git a/shared/src/jvmMain/kotlin/dev/dimension/flare/ui/render/UiDateTime.jvm.kt b/shared/src/jvmMain/kotlin/dev/dimension/flare/ui/render/UiDateTime.jvm.kt index 6493268ff..e46290bed 100644 --- a/shared/src/jvmMain/kotlin/dev/dimension/flare/ui/render/UiDateTime.jvm.kt +++ b/shared/src/jvmMain/kotlin/dev/dimension/flare/ui/render/UiDateTime.jvm.kt @@ -38,56 +38,3 @@ internal actual fun Instant.shortTime(): LocalizedShortTime { } } } - -// public actual data class UiDateTime( -// val value: Instant, -// ) { -// public val shortTime: LocalizedShortTime by lazy { -// val compareTo = Clock.System.now() -// val timeZone = TimeZone.currentSystemDefault() -// val time = value.toLocalDateTime(timeZone) -// val diff = compareTo - value -// when { -// // dd MMM yy -// compareTo.toLocalDateTime(timeZone).year != time.year -> { -// LocalizedShortTime.YearMonthDay(time.toJavaLocalDateTime()) -// } -// // dd MMM -// diff.inWholeDays >= 7 -> { -// LocalizedShortTime.MonthDay(time.toJavaLocalDateTime()) -// } -// // xx day(s) -// diff.inWholeDays >= 1 -> { -// LocalizedShortTime.String(diff.inWholeDays.toString() + " d") -// } -// // xx hr(s) -// diff.inWholeHours >= 1 -> { -// LocalizedShortTime.String(diff.inWholeHours.toString() + " h") -// } -// // xx sec(s) -// diff.inWholeMinutes < 1 -> { -// LocalizedShortTime.String(diff.inWholeSeconds.toString() + " s") -// } -// // xx min(s) -// else -> { -// LocalizedShortTime.String(diff.inWholeMinutes.toString() + " m") -// } -// } -// } -// } -// -// internal actual fun Instant.toUi(): UiDateTime = UiDateTime(this) - -// public sealed interface LocalizedShortTime { -// public data class String( -// val value: kotlin.String, -// ) : LocalizedShortTime -// -// public data class YearMonthDay( -// val localDateTime: LocalDateTime, -// ) : LocalizedShortTime -// -// public data class MonthDay( -// val localDateTime: LocalDateTime, -// ) : LocalizedShortTime -// } diff --git a/shared/ui/build.gradle.kts b/shared/ui/build.gradle.kts index aec5ab085..2803c48e0 100644 --- a/shared/ui/build.gradle.kts +++ b/shared/ui/build.gradle.kts @@ -7,17 +7,22 @@ plugins { alias(libs.plugins.composeMultiplatform) } +android { + namespace = "dev.dimension.flare.shared.ui" +} + kotlin { jvmToolchain(libs.versions.java.get().toInt()) explicitApi() applyDefaultHierarchyTemplate() - androidLibrary { - compileSdk = libs.versions.compileSdk.get().toInt() - namespace = "dev.dimension.flare.shared.ui" - minSdk = libs.versions.minSdk.get().toInt() - experimentalProperties["android.experimental.kmp.enableAndroidResources"] = true - } +// androidLibrary { +// compileSdk = libs.versions.compileSdk.get().toInt() +// namespace = "dev.dimension.flare.shared.ui" +// minSdk = libs.versions.minSdk.get().toInt() +// experimentalProperties["android.experimental.kmp.enableAndroidResources"] = true +// } + androidTarget() listOf( iosX64(), From de53d4b9e8e745c3cbf532ad9f475fdf43011b75 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Tue, 26 Aug 2025 14:13:49 +0900 Subject: [PATCH 20/33] add tab settings --- .../flare/ui/screen/settings/EditTabDialog.kt | 4 - .../ui/screen/settings/TabAddBottomSheet.kt | 1 + .../ui/screen/settings/TabCustomizeScreen.kt | 56 -- .../dev/dimension/flare/ui/model/UiListExt.kt | 62 +++ .../bluesky/BlueskyFeedsWithTabsPresenter.kt | 5 +- .../screen/list/AllListWithTabsPresenter.kt | 5 +- desktopApp/build.gradle.kts | 1 + .../main/composeResources/values/strings.xml | 8 + .../dimension/flare/ui/component/TabIcon.kt | 23 +- .../dev/dimension/flare/ui/route/Router.kt | 10 +- .../ui/screen/dm/DmConversationScreen.kt | 2 +- .../flare/ui/screen/dm/DmListScreen.kt | 3 +- .../flare/ui/screen/feeds/TabSettingScreen.kt | 7 - .../flare/ui/screen/home/AddTabDialog.kt | 477 ++++++++++++++++++ .../flare/ui/screen/home/DiscoverScreen.kt | 3 +- .../flare/ui/screen/home/EditTabDialog.kt | 252 +++++++++ .../ui/screen/home/HomeTimelineScreen.kt | 9 +- .../ui/screen/home/NotificationScreen.kt | 7 +- .../flare/ui/screen/home/SearchScreen.kt | 3 +- .../flare/ui/screen/home/TabSettingScreen.kt | 343 +++++++++++++ .../flare/ui/screen/home/TimelineScreen.kt | 10 +- .../flare/ui/screen/list/AllListScreen.kt | 7 +- .../ui/screen/misskey/AntennasListScreen.kt | 4 +- .../flare/ui/screen/rss/RssListScreen.kt | 4 +- .../flare/ui/screen/status/StatusScreen.kt | 6 +- .../ui/screen/status/VVOCommentScreen.kt | 7 +- .../flare/ui/screen/status/VVOStatusScreen.kt | 10 +- .../dimension/flare/ui/theme/FlareTheme.kt | 6 +- 28 files changed, 1206 insertions(+), 129 deletions(-) create mode 100644 compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiListExt.kt delete mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/TabSettingScreen.kt create mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/AddTabDialog.kt create mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/EditTabDialog.kt create mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TabSettingScreen.kt diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/settings/EditTabDialog.kt b/app/src/main/java/dev/dimension/flare/ui/screen/settings/EditTabDialog.kt index c95664939..98d79012c 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/settings/EditTabDialog.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/settings/EditTabDialog.kt @@ -30,13 +30,11 @@ import dev.dimension.flare.data.model.RssTimelineTabItem import dev.dimension.flare.data.model.TabItem import dev.dimension.flare.data.model.TitleType import dev.dimension.flare.data.model.resId -import dev.dimension.flare.data.repository.SettingsRepository import dev.dimension.flare.model.AccountType import dev.dimension.flare.ui.component.FlareDropdownMenu import dev.dimension.flare.ui.model.UiRssSource import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toPersistentList -import kotlinx.coroutines.CoroutineScope import moe.tlaster.precompose.molecule.producePresenter import org.koin.compose.koinInject @@ -159,8 +157,6 @@ internal fun EditTabDialog( private fun presenter( tabItem: TabItem, context: Context = koinInject(), - repository: SettingsRepository = koinInject(), - appScope: CoroutineScope = koinInject(), ) = run { val text = rememberTextFieldState() var icon: IconType by remember { diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/settings/TabAddBottomSheet.kt b/app/src/main/java/dev/dimension/flare/ui/screen/settings/TabAddBottomSheet.kt index 68478cef5..decacdf88 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/settings/TabAddBottomSheet.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/settings/TabAddBottomSheet.kt @@ -63,6 +63,7 @@ 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.onSuccess +import dev.dimension.flare.ui.model.toTabItem import dev.dimension.flare.ui.presenter.home.rss.RssSourcesPresenter import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.presenter.list.PinnableTimelineTabPresenter 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 2ef225afc..cc2555ebc 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,20 +58,14 @@ import compose.icons.fontawesomeicons.solid.Plus import compose.icons.fontawesomeicons.solid.TableList import compose.icons.fontawesomeicons.solid.Trash import dev.dimension.flare.R -import dev.dimension.flare.data.model.Bluesky.FeedTabItem import dev.dimension.flare.data.model.IconType import dev.dimension.flare.data.model.IconType.Mixed -import dev.dimension.flare.data.model.ListTimelineTabItem -import dev.dimension.flare.data.model.Misskey import dev.dimension.flare.data.model.TabItem -import dev.dimension.flare.data.model.TabMetaData import dev.dimension.flare.data.model.TitleType import dev.dimension.flare.data.model.resId import dev.dimension.flare.data.model.toIcon import dev.dimension.flare.data.repository.SettingsRepository import dev.dimension.flare.model.AccountType -import dev.dimension.flare.model.AccountType.Specific -import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.component.AvatarComponent import dev.dimension.flare.ui.component.BackButton import dev.dimension.flare.ui.component.FAIcon @@ -79,7 +73,6 @@ import dev.dimension.flare.ui.component.FlareLargeFlexibleTopAppBar import dev.dimension.flare.ui.component.FlareScaffold import dev.dimension.flare.ui.component.NetworkImage import dev.dimension.flare.ui.component.listCard -import dev.dimension.flare.ui.model.UiList import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.collectAsUiState import dev.dimension.flare.ui.model.onLoading @@ -241,55 +234,6 @@ internal fun TabCustomizeScreen( } } -internal fun UiList.toTabItem(accountKey: MicroBlogKey) = - when (type) { - UiList.Type.Feed -> { - FeedTabItem( - account = Specific(accountKey), - uri = id, - metaData = - TabMetaData( - title = TitleType.Text(title), - icon = - Mixed( - icon = IconType.Material.MaterialIcon.List, - userKey = accountKey, - ), - ), - ) - } - - UiList.Type.List -> - ListTimelineTabItem( - account = AccountType.Specific(accountKey), - listId = id, - metaData = - TabMetaData( - title = TitleType.Text(title), - icon = - IconType.Mixed( - icon = IconType.Material.MaterialIcon.List, - userKey = accountKey, - ), - ), - ) - - UiList.Type.Antenna -> - Misskey.AntennasTimelineTabItem( - account = AccountType.Specific(accountKey), - id = id, - metaData = - TabMetaData( - title = TitleType.Text(title), - icon = - IconType.Mixed( - icon = IconType.Material.MaterialIcon.Rss, - userKey = accountKey, - ), - ), - ) - } - @Composable internal fun ListTabItem( data: TabItem, diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiListExt.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiListExt.kt new file mode 100644 index 000000000..9be39378a --- /dev/null +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiListExt.kt @@ -0,0 +1,62 @@ +package dev.dimension.flare.ui.model + +import dev.dimension.flare.data.model.Bluesky.FeedTabItem +import dev.dimension.flare.data.model.IconType +import dev.dimension.flare.data.model.IconType.Mixed +import dev.dimension.flare.data.model.ListTimelineTabItem +import dev.dimension.flare.data.model.Misskey +import dev.dimension.flare.data.model.TabItem +import dev.dimension.flare.data.model.TabMetaData +import dev.dimension.flare.data.model.TitleType +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.AccountType.Specific +import dev.dimension.flare.model.MicroBlogKey + +public fun UiList.toTabItem(accountKey: MicroBlogKey): TabItem = + when (type) { + UiList.Type.Feed -> { + FeedTabItem( + account = Specific(accountKey), + uri = id, + metaData = + TabMetaData( + title = TitleType.Text(title), + icon = + Mixed( + icon = IconType.Material.MaterialIcon.List, + userKey = accountKey, + ), + ), + ) + } + + UiList.Type.List -> + ListTimelineTabItem( + account = AccountType.Specific(accountKey), + listId = id, + metaData = + TabMetaData( + title = TitleType.Text(title), + icon = + IconType.Mixed( + icon = IconType.Material.MaterialIcon.List, + userKey = accountKey, + ), + ), + ) + + UiList.Type.Antenna -> + Misskey.AntennasTimelineTabItem( + account = AccountType.Specific(accountKey), + id = id, + metaData = + TabMetaData( + title = TitleType.Text(title), + icon = + IconType.Mixed( + icon = IconType.Material.MaterialIcon.Rss, + userKey = accountKey, + ), + ), + ) + } diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/bluesky/BlueskyFeedsWithTabsPresenter.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/bluesky/BlueskyFeedsWithTabsPresenter.kt index 8dd8795a4..59f53a1b1 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/bluesky/BlueskyFeedsWithTabsPresenter.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/bluesky/BlueskyFeedsWithTabsPresenter.kt @@ -34,7 +34,10 @@ public class BlueskyFeedsWithTabsPresenter( metaData = TabMetaData( title = TitleType.Text(item.title), - icon = IconType.Material(IconType.Material.MaterialIcon.Feeds), + icon = + item.avatar?.let { + IconType.Url(it) + } ?: IconType.Material(IconType.Material.MaterialIcon.Feeds), ), ) diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/list/AllListWithTabsPresenter.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/list/AllListWithTabsPresenter.kt index 82a718849..e9b2006d6 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/list/AllListWithTabsPresenter.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/list/AllListWithTabsPresenter.kt @@ -31,7 +31,10 @@ public class AllListWithTabsPresenter( metaData = TabMetaData( title = TitleType.Text(item.title), - icon = IconType.Material(IconType.Material.MaterialIcon.List), + icon = + item.avatar?.let { + IconType.Url(it) + } ?: IconType.Material(IconType.Material.MaterialIcon.List), ), ) diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index d108c19aa..78571a25f 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { implementation(libs.datastore) implementation(libs.filekit.dialogs.compose) implementation(libs.filekit.coil) + implementation(libs.reorderable) implementation(libs.bouncycastle.bcprov) implementation(libs.bouncycastle.bcpkix) } diff --git a/desktopApp/src/main/composeResources/values/strings.xml b/desktopApp/src/main/composeResources/values/strings.xml index 22cf185b8..490aa0100 100644 --- a/desktopApp/src/main/composeResources/values/strings.xml +++ b/desktopApp/src/main/composeResources/values/strings.xml @@ -278,4 +278,12 @@ Sending Leave conversation Show profile + Add mixed timeline tab + Mixed timeline will mix all tabs timeline result in one tab + + Edit tab + Name + Tab name + Icon + With avatar \ No newline at end of file diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/TabIcon.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/TabIcon.kt index d3d1a2ab1..224308b36 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/TabIcon.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/TabIcon.kt @@ -19,6 +19,7 @@ import dev.dimension.flare.data.model.TabItem import dev.dimension.flare.data.model.TitleType import dev.dimension.flare.data.model.res import dev.dimension.flare.data.model.toIcon +import dev.dimension.flare.model.AccountType import dev.dimension.flare.ui.component.platform.placeholder import dev.dimension.flare.ui.model.onLoading import dev.dimension.flare.ui.model.onSuccess @@ -60,9 +61,25 @@ fun TabIcon( iconOnly: Boolean = false, color: Color = LocalContentColor.current, ) { - val accountType = tabItem.account - val icon = tabItem.metaData.icon - val title = tabItem.metaData.title + TabIcon( + accountType = tabItem.account, + icon = tabItem.metaData.icon, + title = tabItem.metaData.title, + modifier = modifier, + iconOnly = iconOnly, + color = color, + ) +} + +@Composable +fun TabIcon( + accountType: AccountType, + icon: IconType, + title: TitleType, + modifier: Modifier = Modifier, + iconOnly: Boolean = false, + color: Color = LocalContentColor.current, +) { when (icon) { is IconType.Avatar -> { val userState by producePresenter(key = "$accountType:${icon.userKey}") { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt index 0371e5196..15e6435bf 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt @@ -28,13 +28,13 @@ import dev.dimension.flare.ui.screen.dm.DmConversationScreen import dev.dimension.flare.ui.screen.dm.DmListScreen import dev.dimension.flare.ui.screen.dm.UserDMConversationScreen import dev.dimension.flare.ui.screen.feeds.FeedListScreen -import dev.dimension.flare.ui.screen.feeds.TabSettingScreen import dev.dimension.flare.ui.screen.home.DiscoverScreen import dev.dimension.flare.ui.screen.home.HomeTimelineScreen import dev.dimension.flare.ui.screen.home.NotificationScreen import dev.dimension.flare.ui.screen.home.ProfileScreen import dev.dimension.flare.ui.screen.home.ProfileWithUserNameAndHostDeeplinkRoute import dev.dimension.flare.ui.screen.home.SearchScreen +import dev.dimension.flare.ui.screen.home.TabSettingScreen import dev.dimension.flare.ui.screen.home.TimelineScreen import dev.dimension.flare.ui.screen.list.AllListScreen import dev.dimension.flare.ui.screen.media.RawMediaScreen @@ -377,7 +377,13 @@ internal fun RouteContent( } Route.TabSetting -> { - TabSettingScreen() + TabSettingScreen( + toAddRssSource = { + navigate( + Route.CreateRssSource, + ) + }, + ) } is Route.StatusDetail -> { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DmConversationScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DmConversationScreen.kt index 8b261ce33..3020c7314 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DmConversationScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DmConversationScreen.kt @@ -101,7 +101,7 @@ fun DmConversationScreen( .background(FluentTheme.colors.background.card.default) .fillMaxWidth() .padding(start = 40.dp) - .padding(LocalWindowPadding.current + PaddingValues(8.dp)), + .padding(LocalWindowPadding.current), verticalAlignment = Alignment.CenterVertically, ) { state.users diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DmListScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DmListScreen.kt index 5d90f7116..31ac5d729 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DmListScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DmListScreen.kt @@ -2,7 +2,6 @@ package dev.dimension.flare.ui.screen.dm import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn @@ -42,7 +41,7 @@ internal fun DmListScreen( Box { LazyColumn( - contentPadding = LocalWindowPadding.current + PaddingValues(top = 8.dp), + contentPadding = LocalWindowPadding.current, modifier = Modifier .padding(horizontal = screenHorizontalPadding), diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/TabSettingScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/TabSettingScreen.kt deleted file mode 100644 index 53956d976..000000000 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/feeds/TabSettingScreen.kt +++ /dev/null @@ -1,7 +0,0 @@ -package dev.dimension.flare.ui.screen.feeds - -import androidx.compose.runtime.Composable - -@Composable -internal fun TabSettingScreen() { -} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/AddTabDialog.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/AddTabDialog.kt new file mode 100644 index 000000000..068fa0301 --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/AddTabDialog.kt @@ -0,0 +1,477 @@ +package dev.dimension.flare.ui.screen.home + +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.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.CircleMinus +import compose.icons.fontawesomeicons.solid.CirclePlus +import compose.icons.fontawesomeicons.solid.Plus +import dev.dimension.flare.Res +import dev.dimension.flare.add_rss_source +import dev.dimension.flare.antenna_title +import dev.dimension.flare.common.PagingState +import dev.dimension.flare.common.isEmpty +import dev.dimension.flare.data.model.RssTimelineTabItem +import dev.dimension.flare.data.model.TabItem +import dev.dimension.flare.data.model.TimelineTabItem +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ok +import dev.dimension.flare.rss_title +import dev.dimension.flare.tab_settings_add +import dev.dimension.flare.tab_settings_default +import dev.dimension.flare.tab_settings_feed +import dev.dimension.flare.tab_settings_list +import dev.dimension.flare.tab_settings_remove +import dev.dimension.flare.ui.common.itemsIndexed +import dev.dimension.flare.ui.component.AvatarComponent +import dev.dimension.flare.ui.component.FAIcon +import dev.dimension.flare.ui.component.RichText +import dev.dimension.flare.ui.component.TabIcon +import dev.dimension.flare.ui.component.TabTitle +import dev.dimension.flare.ui.component.listCard +import dev.dimension.flare.ui.model.UiProfile +import dev.dimension.flare.ui.model.UiRssSource +import dev.dimension.flare.ui.model.UiState +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.onSuccess +import dev.dimension.flare.ui.model.toTabItem +import dev.dimension.flare.ui.presenter.home.rss.RssSourcesPresenter +import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.presenter.list.PinnableTimelineTabPresenter +import dev.dimension.flare.ui.presenter.settings.AccountsPresenter +import dev.dimension.flare.ui.theme.MediumAlpha +import io.github.composefluent.FluentTheme +import io.github.composefluent.component.AccentButton +import io.github.composefluent.component.CardExpanderItem +import io.github.composefluent.component.ContentDialog +import io.github.composefluent.component.LiteFilter +import io.github.composefluent.component.PillButton +import io.github.composefluent.component.Text +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun AddTabDialog( + visible: Boolean, + onDismiss: () -> Unit, + tabs: ImmutableList, + allTabs: AllTabsState, + onAddTab: (TabItem) -> Unit, + onDeleteTab: (String) -> Unit, + toAddRssSource: () -> Unit, +) { + ContentDialog( + visible = visible, + title = stringResource(Res.string.tab_settings_add), + primaryButtonText = stringResource(Res.string.ok), + onButtonClick = { + onDismiss.invoke() + }, + content = { + @Composable + fun TabItem( + tabItem: TabItem, + modifier: Modifier = Modifier, + ) { + ListTabItem( + data = tabItem, + isAdded = tabs.any { tab -> tabItem.key == tab.key }, + modifier = + modifier.clickable { + if (tabs.any { tab -> tabItem.key == tab.key }) { + onDeleteTab(tabItem.key) + } else { + onAddTab(tabItem) + } + }, + ) + } + Column( + modifier = Modifier.heightIn(max = 480.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + allTabs.accountTabs.onSuccess { tabs -> + val pagerState = + rememberPagerState { + tabs.size + 2 + } + val scope = rememberCoroutineScope() + LiteFilter { + PillButton( + selected = pagerState.currentPage == 0, + onSelectedChanged = { + if (it) { + scope.launch { + pagerState.animateScrollToPage(0) + } + } + }, + content = { + Text(text = stringResource(Res.string.tab_settings_default)) + }, + ) + PillButton( + selected = pagerState.currentPage == 1, + onSelectedChanged = { + if (it) { + scope.launch { + pagerState.animateScrollToPage(1) + } + } + }, + content = { + Text(text = stringResource(Res.string.rss_title)) + }, + ) + tabs.forEachIndexed { index, tabState -> + PillButton( + modifier = Modifier.clip(CircleShape), + selected = pagerState.currentPage == index + 2, + onSelectedChanged = { + scope.launch { + pagerState.animateScrollToPage(index + 2) + } + }, + content = { + tabState.onSuccess { tab -> + AvatarComponent( + tab.profile.avatar, + size = 24.dp, + ) + RichText( + text = tab.profile.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = tab.profile.handle, + style = FluentTheme.typography.caption, + modifier = + Modifier + .alpha(MediumAlpha), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, + ) + } + } + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxHeight(), + verticalAlignment = Alignment.Top, + ) { + if (it == 0) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + itemsIndexed(allTabs.defaultTabs) { index, it -> + TabItem( + it, + modifier = + Modifier + .listCard( + index = index, + totalCount = allTabs.defaultTabs.size, + ), + ) + } + } + } else if (it == 1) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + itemsIndexed( + allTabs.rssTabs, + emptyContent = { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth(), + ) { + AccentButton( + onClick = toAddRssSource, + ) { + FAIcon( + FontAwesomeIcons.Solid.Plus, + contentDescription = stringResource(Res.string.add_rss_source), + ) + Text(stringResource(Res.string.add_rss_source)) + } + } + }, + ) { index, itemCount, it -> + TabItem( + remember(it) { + RssTimelineTabItem(it) + }, + modifier = + Modifier + .listCard( + index = index, + totalCount = itemCount, + ), + ) + } + if (!allTabs.rssTabs.isEmpty) { + item { + Spacer(modifier = Modifier.height(12.dp)) + } + item { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth(), + ) { + AccentButton( + onClick = toAddRssSource, + ) { + FAIcon( + FontAwesomeIcons.Solid.Plus, + contentDescription = stringResource(Res.string.add_rss_source), + ) + Text(stringResource(Res.string.add_rss_source)) + } + } + } + } + } + } else { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + val tabState = tabs[it - 2] + tabState.onSuccess { tab -> + var selectedIndex by remember { mutableStateOf(0) } + if (tab.extraTabs.any()) { + val items = + listOf( + stringResource(Res.string.tab_settings_default), + ) + + tab.extraTabs + .map { + when (it) { + is PinnableTimelineTabPresenter.State.Tab.Feed -> + Res.string.tab_settings_feed + + is PinnableTimelineTabPresenter.State.Tab.List -> + Res.string.tab_settings_list + + is PinnableTimelineTabPresenter.State.Tab.Antenna -> + Res.string.antenna_title + } + }.map { stringResource(it) } + LiteFilter( + modifier = Modifier.fillMaxWidth(), + ) { + items.forEachIndexed { index, text -> + PillButton( + selected = selectedIndex == index, + onSelectedChanged = { selectedIndex = index }, + ) { + Text(text = text) + } + } + } + } + when (selectedIndex) { + 0 -> { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + itemsIndexed(tab.tabs) { index, it -> + TabItem( + it, + modifier = + Modifier + .listCard( + index = index, + totalCount = tab.tabs.size, + ), + ) + } + } + } + + else -> { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + val data = + tab.extraTabs.elementAtOrNull(selectedIndex - 1)?.data + if (data != null) { + itemsIndexed(data) { index, totalCount, item -> + TabItem( + remember(item) { + item.toTabItem( + accountKey = tab.profile.key, + ) + }, + modifier = + Modifier + .listCard( + index = index, + totalCount = totalCount, + ), + ) + } + } + } + } + } + } + } + } + } + } + } + }, + ) +} + +@Composable +internal fun ListTabItem( + data: TabItem, + isAdded: Boolean, + modifier: Modifier = Modifier, +) { + CardExpanderItem( + heading = { + TabTitle(data.metaData.title) + }, + icon = { + TabIcon(data) + }, + modifier = modifier, + trailing = { + if (isAdded) { + FAIcon( + FontAwesomeIcons.Solid.CircleMinus, + contentDescription = stringResource(Res.string.tab_settings_remove), + ) + } else { + FAIcon( + FontAwesomeIcons.Solid.CirclePlus, + contentDescription = stringResource(Res.string.tab_settings_add), + ) + } + }, + ) +} + +@Immutable +internal interface AllTabsState { + val defaultTabs: ImmutableList + val rssTabs: PagingState + val accountTabs: UiState>> +} + +@Composable +internal fun allTabsPresenter(filterIsTimeline: Boolean = false): AllTabsState = + run { + val accountState = remember { AccountsPresenter() }.invoke() + val accountTabs = + accountState.accounts.map { + it + .toImmutableList() + .filter { + it.second.isSuccess + }.map { (_, userState) -> + userState.flatMap { user -> + val tabs = + remember(user.key) { + ( + TimelineTabItem.defaultPrimary(user) + + TimelineTabItem.secondaryFor( + user, + ) + ).let { + if (filterIsTimeline) { + it.filterIsInstance() + } else { + it + } + } + } + userState + .flatMap { user -> + listTabPresenter(accountKey = user.key).tabs.map { + it.toImmutableList() + } + }.map { extraTabs -> + AccountTabs( + profile = user, + tabs = tabs.toImmutableList(), + extraTabs = extraTabs, + ) + } + } + }.toImmutableList() + } + + val rssTabs = + remember { + RssSourcesPresenter() + }.invoke() + + object : AllTabsState { + override val defaultTabs = + TimelineTabItem.mainSidePanel + .let { + if (filterIsTimeline) { + it.filterIsInstance() + } else { + it + } + }.toImmutableList() + override val accountTabs: UiState>> = accountTabs + override val rssTabs = rssTabs.sources + } + } + +@Composable +private fun listTabPresenter(accountKey: MicroBlogKey) = + run { + remember(accountKey) { + PinnableTimelineTabPresenter(accountType = AccountType.Specific(accountKey)) + }.invoke() + } + +@Immutable +internal data class AccountTabs( + val profile: UiProfile, + val tabs: ImmutableList, + val extraTabs: ImmutableList, +) diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt index 4e63308b2..e11c65c8b 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -94,7 +93,7 @@ internal fun DiscoverScreen( LazyStatusVerticalStaggeredGrid( modifier = Modifier.fillMaxSize(), state = lazyListState, - contentPadding = PaddingValues(vertical = 8.dp) + LocalWindowPadding.current, + contentPadding = LocalWindowPadding.current, ) { if (true) { item( diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/EditTabDialog.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/EditTabDialog.kt new file mode 100644 index 000000000..7c9aaada4 --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/EditTabDialog.kt @@ -0,0 +1,252 @@ +package dev.dimension.flare.ui.screen.home + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.dimension.flare.Res +import dev.dimension.flare.cancel +import dev.dimension.flare.data.model.IconType +import dev.dimension.flare.data.model.RssTimelineTabItem +import dev.dimension.flare.data.model.TabItem +import dev.dimension.flare.data.model.TitleType +import dev.dimension.flare.data.model.res +import dev.dimension.flare.edit_tab_icon +import dev.dimension.flare.edit_tab_name +import dev.dimension.flare.edit_tab_name_placeholder +import dev.dimension.flare.edit_tab_title +import dev.dimension.flare.edit_tab_with_avatar +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.ok +import dev.dimension.flare.ui.component.TabIcon +import dev.dimension.flare.ui.model.UiRssSource +import io.github.composefluent.component.CheckBox +import io.github.composefluent.component.ContentDialog +import io.github.composefluent.component.ContentDialogButton +import io.github.composefluent.component.FlyoutContainer +import io.github.composefluent.component.FlyoutPlacement +import io.github.composefluent.component.SubtleButton +import io.github.composefluent.component.Text +import io.github.composefluent.component.TextField +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toPersistentList +import moe.tlaster.precompose.molecule.producePresenter +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun EditTabDialog( + visible: Boolean, + tabItem: TabItem, + onDismissRequest: () -> Unit, + onConfirm: (TabItem) -> Unit, +) { + val state by producePresenter(key = "EditTabSheet_$tabItem") { + presenter(tabItem = tabItem) + } + ContentDialog( + visible = visible, + title = stringResource(Res.string.edit_tab_title), + primaryButtonText = stringResource(Res.string.ok), + closeButtonText = stringResource(Res.string.cancel), + onButtonClick = { + when (it) { + ContentDialogButton.Primary -> + tabItem.metaData + .copy( + title = TitleType.Text(state.text.text.toString()), + icon = state.icon, + ).let { + if (state.canConfirm) { + onConfirm(tabItem.update(metaData = it)) + } + } + + ContentDialogButton.Secondary -> Unit + ContentDialogButton.Close -> onDismissRequest() + } + }, + content = { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(text = stringResource(Res.string.edit_tab_icon)) + if (tabItem.account is AccountType.Specific) { + Row( + modifier = + Modifier + .clickable { + state.setWithAvatar(!state.withAvatar) + }, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + CheckBox( + checked = state.withAvatar, + onCheckStateChange = state::setWithAvatar, + ) + Text(text = stringResource(Res.string.edit_tab_with_avatar)) + } + } + } + + FlyoutContainer( + flyout = { + LazyHorizontalGrid( + rows = GridCells.FixedSize(32.dp), + modifier = Modifier.heightIn(max = 120.dp), + ) { + items(state.availableIcons) { icon -> + SubtleButton( + onClick = { + state.setIcon(icon) + }, + iconOnly = true, + modifier = Modifier.padding(4.dp), + ) { + TabIcon( + accountType = tabItem.account, + icon = icon, + title = tabItem.metaData.title, + ) + } + } + } + }, + placement = FlyoutPlacement.BottomAlignedEnd, + ) { + SubtleButton( + onClick = { + isFlyoutVisible = true + }, + iconOnly = true, + ) { + TabIcon( + accountType = tabItem.account, + icon = state.icon, + title = tabItem.metaData.title, + ) + } + } + } + TextField( + state = state.text, + modifier = Modifier.fillMaxWidth(), + header = { + Text(text = stringResource(Res.string.edit_tab_name)) + }, + placeholder = { + Text(text = stringResource(Res.string.edit_tab_name_placeholder)) + }, + ) + } + }, + ) +} + +@Composable +private fun presenter(tabItem: TabItem) = + run { + val text = rememberTextFieldState() + var icon: IconType by remember { + mutableStateOf(tabItem.metaData.icon) + } + var withAvatar by remember { + mutableStateOf(tabItem.metaData.icon is IconType.Mixed) + } + LaunchedEffect(Unit) { + val value = + when (val title = tabItem.metaData.title) { + is TitleType.Localized -> getString(title.res) + is TitleType.Text -> title.content + } + text.edit { + append(value) + } + } + object { + val withAvatar = withAvatar + val availableIcons: ImmutableList = + kotlin + .run { + when (val account = tabItem.account) { + is AccountType.Specific -> + listOf( + IconType.Avatar(account.accountKey), + IconType.Url( + UiRssSource.favIconUrl(account.accountKey.host), + ), + ) + + else -> emptyList() + } + + IconType.Material.MaterialIcon.entries.map { + IconType.Material(it) + } + + if (tabItem is RssTimelineTabItem) { + listOfNotNull( + IconType.Url(UiRssSource.favIconUrl(tabItem.feedUrl)), + ) + } else { + emptyList() + } + }.let { + it.toPersistentList() + } + val text = text + val icon = icon + val canConfirm = text.text.isNotEmpty() + + fun setWithAvatar(value: Boolean) { + withAvatar = value + setIcon(icon) + } + + fun setIcon(value: IconType) { + val account = tabItem.account + icon = + if (withAvatar && account is AccountType.Specific) { + when (value) { + is IconType.Avatar -> value + is IconType.Material -> + IconType.Mixed(value.icon, account.accountKey) + + is IconType.Mixed -> + IconType.Mixed(value.icon, account.accountKey) + + is IconType.Url -> value + } + } else { + when (value) { + is IconType.Avatar -> value + is IconType.Material -> value + is IconType.Mixed -> IconType.Material(value.icon) + is IconType.Url -> value + } + } + } + } + } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt index 3f4d8d42e..a9c2e9ed7 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt @@ -1,6 +1,7 @@ package dev.dimension.flare.ui.screen.home import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -85,10 +86,16 @@ private fun presenter( settingsRepository: SettingsRepository = koinInject(), ) = run { val state = remember(accountType) { HomeTimelineWithTabsPresenter(accountType) }.invoke() - var selectedIndex by remember(state.tabState) { + var selectedIndex by remember { mutableStateOf(0) } + state.tabState.onSuccess { + LaunchedEffect(it.size) { + selectedIndex = 0 + } + } + val selectedTab = remember( state.tabState, diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/NotificationScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/NotificationScreen.kt index 0aaa13568..c438fc2a1 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/NotificationScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/NotificationScreen.kt @@ -1,7 +1,6 @@ package dev.dimension.flare.ui.screen.home import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan @@ -12,7 +11,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import dev.dimension.flare.LocalWindowPadding import dev.dimension.flare.RegisterTabCallback import dev.dimension.flare.common.isRefreshing @@ -48,10 +46,7 @@ internal fun NotificationScreen(accountType: AccountType) { .fillMaxSize(), ) { LazyStatusVerticalStaggeredGrid( - contentPadding = - PaddingValues( - vertical = 8.dp, - ) + LocalWindowPadding.current, + contentPadding = LocalWindowPadding.current, state = listState, ) { state.state.allTypes.onSuccess { types -> diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/SearchScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/SearchScreen.kt index 2077ee9f1..cb3ce7d29 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/SearchScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/SearchScreen.kt @@ -2,7 +2,6 @@ package dev.dimension.flare.ui.screen.home import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -91,7 +90,7 @@ fun SearchScreen( LazyStatusVerticalStaggeredGrid( modifier = Modifier.fillMaxSize(), state = lazyListState, - contentPadding = PaddingValues(vertical = 8.dp) + LocalWindowPadding.current, + contentPadding = LocalWindowPadding.current, ) { item( span = StaggeredGridItemSpan.FullLine, diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TabSettingScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TabSettingScreen.kt new file mode 100644 index 000000000..9a79011ac --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TabSettingScreen.kt @@ -0,0 +1,343 @@ +package dev.dimension.flare.ui.screen.home + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.Bars +import compose.icons.fontawesomeicons.solid.Pen +import compose.icons.fontawesomeicons.solid.Plus +import compose.icons.fontawesomeicons.solid.Rss +import compose.icons.fontawesomeicons.solid.Trash +import dev.dimension.flare.LocalWindowPadding +import dev.dimension.flare.Res +import dev.dimension.flare.data.model.TabItem +import dev.dimension.flare.data.model.TimelineTabItem +import dev.dimension.flare.data.repository.SettingsRepository +import dev.dimension.flare.tab_settings_add +import dev.dimension.flare.tab_settings_drag +import dev.dimension.flare.tab_settings_edit +import dev.dimension.flare.tab_settings_mixed_timeline +import dev.dimension.flare.tab_settings_mixed_timeline_desc +import dev.dimension.flare.tab_settings_remove +import dev.dimension.flare.ui.common.plus +import dev.dimension.flare.ui.component.FAIcon +import dev.dimension.flare.ui.component.TabIcon +import dev.dimension.flare.ui.component.TabTitle +import dev.dimension.flare.ui.model.collectAsUiState +import dev.dimension.flare.ui.model.map +import dev.dimension.flare.ui.model.onSuccess +import dev.dimension.flare.ui.theme.screenHorizontalPadding +import io.github.composefluent.FluentTheme +import io.github.composefluent.component.AccentButton +import io.github.composefluent.component.CardExpanderItem +import io.github.composefluent.component.SubtleButton +import io.github.composefluent.component.Switcher +import io.github.composefluent.component.Text +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import moe.tlaster.precompose.molecule.producePresenter +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState + +@Composable +internal fun TabSettingScreen(toAddRssSource: () -> Unit) { + val state by producePresenter { + presenter() + } + + DisposableEffect(Unit) { + onDispose { + state.commit() + } + } + + val lazyListState = rememberLazyListState() + val reorderableLazyColumnState = + rememberReorderableLazyListState(lazyListState) { from, to -> + state.moveTab(from.key, to.key) + } + LazyColumn( + state = lazyListState, + contentPadding = LocalWindowPadding.current + PaddingValues(vertical = 24.dp), + modifier = + Modifier + .padding(horizontal = screenHorizontalPadding), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + state.enableMixedTimeline.onSuccess { enabled -> + if (state.currentTabs.size > 1) { + item("header") { + CardExpanderItem( + heading = { + Text(stringResource(Res.string.tab_settings_mixed_timeline)) + }, + trailing = { + Switcher( + checked = enabled, + onCheckStateChange = { + state.setEnableMixedTimeline(it) + }, + ) + }, + icon = { + FAIcon( + FontAwesomeIcons.Solid.Rss, + contentDescription = null, + ) + }, + caption = { + Text(stringResource(Res.string.tab_settings_mixed_timeline_desc)) + }, + modifier = + Modifier + .animateItem(), + ) + } + } + } + item { + Spacer(modifier = Modifier.height(16.dp)) + } + item { + Box( + contentAlignment = Alignment.CenterEnd, + modifier = Modifier.fillParentMaxWidth(), + ) { + AccentButton( + onClick = { + state.setAddTab(true) + }, + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Plus, + contentDescription = stringResource(Res.string.tab_settings_add), + ) + Text( + text = stringResource(Res.string.tab_settings_add), + ) + } + } + } + item { + Spacer(modifier = Modifier.height(8.dp)) + } + itemsIndexed(state.currentTabs, key = { _, item -> item.key }) { index, item -> + ReorderableItem(reorderableLazyColumnState, key = item.key) { isDragging -> + CardExpanderItem( + heading = { + TabTitle(item.metaData.title) + }, + icon = { + TabIcon(item) + }, + trailing = { + Row { + SubtleButton( + onClick = { + state.setEditTab(item) + }, + iconOnly = true, + ) { + FAIcon( + FontAwesomeIcons.Solid.Pen, + contentDescription = + stringResource( + Res.string.tab_settings_edit, + ), + ) + } + SubtleButton( + onClick = { + state.deleteTab(item) + }, + iconOnly = true, + ) { + FAIcon( + FontAwesomeIcons.Solid.Trash, + contentDescription = + stringResource( + Res.string.tab_settings_remove, + ), + tint = FluentTheme.colors.system.critical, + ) + } + SubtleButton( + modifier = + Modifier.draggableHandle(), + onClick = {}, + iconOnly = true, + ) { + FAIcon( + FontAwesomeIcons.Solid.Bars, + contentDescription = + stringResource( + Res.string.tab_settings_drag, + ), + ) + } + } + }, + ) + } + } + } + + AddTabDialog( + visible = state.showAddTab, + onDismiss = { + state.setAddTab(false) + }, + tabs = state.currentTabs.toImmutableList(), + allTabs = state.allTabsState, + onAddTab = { tabItem -> + if (tabItem is TimelineTabItem) { + state.addTab(tabItem) + } + }, + onDeleteTab = { key -> + state.deleteTab(key) + }, + toAddRssSource = toAddRssSource, + ) + + state.selectedEditTab?.let { + EditTabDialog( + tabItem = it, + onDismissRequest = { + state.setEditTab(null) + }, + onConfirm = { + state.setEditTab(null) + if (it is TimelineTabItem) { + state.updateTab(it) + } + }, + visible = true, + ) + } +} + +@Composable +private fun presenter( + settingsRepository: SettingsRepository = koinInject(), + appScope: CoroutineScope = koinInject(), +) = run { + val scope = rememberCoroutineScope() + var selectedEditTab by remember { mutableStateOf(null) } + val allTabsState = allTabsPresenter(filterIsTimeline = true) + val tabSettings by settingsRepository.tabSettings.collectAsUiState() + val cacheTabs = + remember { + mutableStateListOf() + } + val currentTabs = + remember(tabSettings) { + tabSettings.map { + it.mainTabs + .toImmutableList() + } + } + currentTabs + .onSuccess { + LaunchedEffect(it.size) { + cacheTabs.clear() + cacheTabs.addAll(it) + } + } + val enableMixedTimeline by remember { + settingsRepository.tabSettings.map { it.enableMixedTimeline } + }.collectAsUiState() + var showAddTab by remember { mutableStateOf(false) } + object { + val currentTabs = cacheTabs + + val allTabsState = allTabsState + val canSwipeToDelete = cacheTabs.size > 1 + val showAddTab = showAddTab + val selectedEditTab = selectedEditTab + val enableMixedTimeline = enableMixedTimeline + + fun setEnableMixedTimeline(enable: Boolean) { + scope.launch { + settingsRepository.updateTabSettings { + copy(enableMixedTimeline = enable) + } + } + } + + fun setEditTab(tab: TimelineTabItem?) { + selectedEditTab = tab + } + + fun updateTab(tab: TimelineTabItem) { + val index = cacheTabs.indexOfFirst { it.key == tab.key } + cacheTabs[index] = tab + } + + 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() { + appScope.launch { + settingsRepository.updateTabSettings { + copy( + mainTabs = cacheTabs, + ) + } + } + } + + fun deleteTab(tab: TimelineTabItem) { + if (cacheTabs.size <= 1) { + return + } + cacheTabs.removeIf { it.key == tab.key } + } + + fun deleteTab(key: String) { + if (cacheTabs.size <= 1) { + return + } + cacheTabs.removeIf { it.key == key } + } + + fun addTab(tab: TimelineTabItem) { + cacheTabs.add(tab) + } + + fun setAddTab(value: Boolean) { + showAddTab = value + } + } +} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt index 4f0f40e75..69012ae55 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt @@ -6,7 +6,6 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -31,7 +30,6 @@ import dev.dimension.flare.common.isRefreshing import dev.dimension.flare.common.onSuccess import dev.dimension.flare.data.model.TimelineTabItem import dev.dimension.flare.home_timeline_new_toots -import dev.dimension.flare.ui.common.plus import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.status.LazyStatusVerticalStaggeredGrid import dev.dimension.flare.ui.component.status.status @@ -62,10 +60,7 @@ internal fun TimelineScreen( .fillMaxSize(), ) { LazyStatusVerticalStaggeredGrid( - contentPadding = - PaddingValues( - vertical = 8.dp, - ) + LocalWindowPadding.current, + contentPadding = LocalWindowPadding.current, state = state.lazyListState, ) { if (header != null) { @@ -93,8 +88,7 @@ internal fun TimelineScreen( modifier = Modifier .align(Alignment.TopCenter) - .padding(LocalWindowPadding.current) - .padding(top = 8.dp), + .padding(LocalWindowPadding.current), ) { AccentButton( onClick = { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/list/AllListScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/list/AllListScreen.kt index 808ebf299..c34eefb51 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/list/AllListScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/list/AllListScreen.kt @@ -3,7 +3,6 @@ package dev.dimension.flare.ui.screen.list import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -16,6 +15,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import dev.dimension.flare.LocalWindowPadding import dev.dimension.flare.RegisterTabCallback import dev.dimension.flare.model.AccountType import dev.dimension.flare.ui.model.UiList @@ -80,10 +80,7 @@ internal fun AllListScreen( adapter = scrollbarAdapter, ) { LazyColumn( - contentPadding = - PaddingValues( - vertical = 8.dp, - ), + contentPadding = LocalWindowPadding.current, modifier = Modifier .fillMaxSize() diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/misskey/AntennasListScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/misskey/AntennasListScreen.kt index 38998f836..906c45129 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/misskey/AntennasListScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/misskey/AntennasListScreen.kt @@ -1,7 +1,6 @@ package dev.dimension.flare.ui.screen.misskey import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState @@ -14,7 +13,6 @@ import androidx.compose.ui.unit.dp import dev.dimension.flare.LocalWindowPadding import dev.dimension.flare.RegisterTabCallback import dev.dimension.flare.model.AccountType -import dev.dimension.flare.ui.common.plus import dev.dimension.flare.ui.model.UiList import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.theme.screenHorizontalPadding @@ -37,7 +35,7 @@ internal fun AntennasListScreen( adapter = scrollbarAdapter, ) { LazyColumn( - contentPadding = LocalWindowPadding.current + PaddingValues(vertical = 8.dp), + contentPadding = LocalWindowPadding.current, modifier = Modifier .padding(horizontal = screenHorizontalPadding), diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/rss/RssListScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/rss/RssListScreen.kt index e024ddd4b..4c6389982 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/rss/RssListScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/rss/RssListScreen.kt @@ -2,7 +2,6 @@ package dev.dimension.flare.ui.screen.rss import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -19,7 +18,6 @@ import compose.icons.fontawesomeicons.solid.Plus import dev.dimension.flare.LocalWindowPadding import dev.dimension.flare.Res import dev.dimension.flare.add_rss_source -import dev.dimension.flare.ui.common.plus import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.model.UiRssSource import dev.dimension.flare.ui.presenter.invoke @@ -43,7 +41,7 @@ internal fun RssListScreen( modifier .padding(horizontal = screenHorizontalPadding), verticalArrangement = Arrangement.spacedBy(2.dp), - contentPadding = LocalWindowPadding.current + PaddingValues(top = 8.dp), + contentPadding = LocalWindowPadding.current, ) { item { Box( diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/StatusScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/StatusScreen.kt index d4f06d954..38ac8a670 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/StatusScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/StatusScreen.kt @@ -1,7 +1,6 @@ package dev.dimension.flare.ui.screen.status import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.widthIn @@ -49,10 +48,7 @@ internal fun StatusScreen( LazyStatusVerticalStaggeredGrid( modifier = Modifier.widthIn(max = 480.dp), columns = StaggeredGridCells.Fixed(1), - contentPadding = - PaddingValues( - vertical = 8.dp, - ) + LocalWindowPadding.current, + contentPadding = LocalWindowPadding.current, state = listState, ) { status( diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOCommentScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOCommentScreen.kt index 2812b0065..59e3c58a3 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOCommentScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOCommentScreen.kt @@ -1,7 +1,6 @@ package dev.dimension.flare.ui.screen.status import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells @@ -14,7 +13,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import dev.dimension.flare.LocalWindowPadding import dev.dimension.flare.RegisterTabCallback import dev.dimension.flare.common.onSuccess @@ -53,10 +51,7 @@ internal fun VVOCommentScreen( ) { LazyStatusVerticalStaggeredGrid( columns = StaggeredGridCells.Fixed(1), - contentPadding = - PaddingValues( - vertical = 8.dp, - ) + LocalWindowPadding.current, + contentPadding = LocalWindowPadding.current, state = listState, ) { item { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOStatusScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOStatusScreen.kt index 249d72e0e..e6a32e940 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOStatusScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/VVOStatusScreen.kt @@ -80,10 +80,7 @@ internal fun VVOStatusScreen( .padding(LocalWindowPadding.current), ) LazyStatusVerticalStaggeredGrid( - contentPadding = - PaddingValues( - vertical = 8.dp, - ) + LocalWindowPadding.current, + contentPadding = LocalWindowPadding.current, ) { reactionContent( comment = state.comment, @@ -95,10 +92,7 @@ internal fun VVOStatusScreen( } } else { LazyStatusVerticalStaggeredGrid( - contentPadding = - PaddingValues( - vertical = 8.dp, - ) + LocalWindowPadding.current, + contentPadding = LocalWindowPadding.current, ) { item { StatusContent(statusState = state.status, detailStatusKey = statusKey) diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt index b3b3ab84a..7e7801d06 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt @@ -85,12 +85,12 @@ internal fun FrameWindowScope.FlareTheme( if (SystemUtils.IS_OS_MAC) { PaddingValues( start = 0.dp, - top = 24.dp, + top = 24.dp + 8.dp, end = 0.dp, - bottom = 0.dp, + bottom = 8.dp, ) } else { - PaddingValues(0.dp) + PaddingValues(vertical = 8.dp) }, LocalComposeWindow provides window, ) { From 6644190ed90ba5b296929c25342449f8d47ba2d0 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Tue, 26 Aug 2025 15:05:08 +0900 Subject: [PATCH 21/33] disable tab setting when guest --- .../ui/screen/home/HomeTimelineScreen.kt | 20 +++--- .../ui/screen/home/HomeTimelineScreen.kt | 70 +++++++++---------- 2 files changed, 45 insertions(+), 45 deletions(-) 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 96023784b..45d0d69f3 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 @@ -173,15 +173,17 @@ internal fun HomeTimelineScreen( ) } } - IconButton( - onClick = { - toTabSettings.invoke() - }, - ) { - FAIcon( - imageVector = FontAwesomeIcons.Solid.Plus, - contentDescription = null, - ) + if (accountType !is AccountType.Guest) { + IconButton( + onClick = { + toTabSettings.invoke() + }, + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Plus, + contentDescription = null, + ) + } } } } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt index a9c2e9ed7..861680e97 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt @@ -10,7 +10,6 @@ import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.Plus import dev.dimension.flare.RegisterTabCallback -import dev.dimension.flare.data.repository.SettingsRepository import dev.dimension.flare.model.AccountType import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.TabIcon @@ -22,7 +21,6 @@ import dev.dimension.flare.ui.presenter.invoke import io.github.composefluent.component.LiteFilter import io.github.composefluent.component.PillButton import moe.tlaster.precompose.molecule.producePresenter -import org.koin.compose.koinInject @Composable internal fun HomeTimelineScreen( @@ -62,16 +60,18 @@ internal fun HomeTimelineScreen( ) } } - PillButton( - selected = false, - onSelectedChanged = { - onAddTab.invoke() - }, - ) { - FAIcon( - FontAwesomeIcons.Solid.Plus, - contentDescription = null, - ) + if (accountType !is AccountType.Guest) { + PillButton( + selected = false, + onSelectedChanged = { + onAddTab.invoke() + }, + ) { + FAIcon( + FontAwesomeIcons.Solid.Plus, + contentDescription = null, + ) + } } } }, @@ -81,34 +81,32 @@ internal fun HomeTimelineScreen( } @Composable -private fun presenter( - accountType: AccountType, - settingsRepository: SettingsRepository = koinInject(), -) = run { - val state = remember(accountType) { HomeTimelineWithTabsPresenter(accountType) }.invoke() - var selectedIndex by remember { - mutableStateOf(0) - } - - state.tabState.onSuccess { - LaunchedEffect(it.size) { - selectedIndex = 0 +private fun presenter(accountType: AccountType) = + run { + val state = remember(accountType) { HomeTimelineWithTabsPresenter(accountType) }.invoke() + var selectedIndex by remember { + mutableStateOf(0) } - } - val selectedTab = - remember( - state.tabState, - selectedIndex, - ) { - state.tabState.map { it.elementAt(selectedIndex) } + state.tabState.onSuccess { + LaunchedEffect(it.size) { + selectedIndex = 0 + } } - object : HomeTimelineWithTabsPresenter.State by state { - val selectedTab = selectedTab + val selectedTab = + remember( + state.tabState, + selectedIndex, + ) { + state.tabState.map { it.elementAt(selectedIndex) } + } + + object : HomeTimelineWithTabsPresenter.State by state { + val selectedTab = selectedTab - fun setSelectedIndex(index: Int) { - selectedIndex = index + fun setSelectedIndex(index: Int) { + selectedIndex = index + } } } -} From 64e4888f71e134b63ee275d549c559042c4d5024 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Tue, 26 Aug 2025 21:29:26 +0900 Subject: [PATCH 22/33] update macos build --- desktopApp/build.gradle.kts | 14 ++++--- desktopApp/install-native-libs.gradle.kts | 17 ++++++++- .../main/kotlin/dev/dimension/flare/Main.kt | 4 +- .../dimension/flare/common/SandboxHelper.kt | 2 +- .../flare/ui/screen/compose/ComposeDialog.kt | 38 +++++++++++-------- .../serviceselect/ServiceSelectScreen.kt | 4 +- .../dimension/flare/ui/theme/FlareTheme.kt | 4 +- gradle/libs.versions.toml | 3 -- .../datasource/bluesky/BlueskyDataSource.kt | 15 ++++---- 9 files changed, 60 insertions(+), 41 deletions(-) diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 78571a25f..de325e389 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -1,5 +1,5 @@ -import java.util.Properties import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import java.util.Properties plugins { alias(libs.plugins.kotlin.jvm) @@ -34,11 +34,10 @@ dependencies { implementation(libs.commons.lang3) implementation(libs.zoomable) implementation(libs.datastore) - implementation(libs.filekit.dialogs.compose) - implementation(libs.filekit.coil) implementation(libs.reorderable) - implementation(libs.bouncycastle.bcprov) - implementation(libs.bouncycastle.bcpkix) + implementation("io.github.kdroidfilter:platformtools.darkmodedetector:0.5.0") +// implementation(libs.bouncycastle.bcprov) +// implementation(libs.bouncycastle.bcpkix) } compose.desktop { @@ -52,7 +51,7 @@ compose.desktop { macOS { val file = project.file("signing.properties") val hasSigningProps = file.exists() - packageBuildVersion = System.getenv("BUILD_NUMBER") ?: "12" + packageBuildVersion = System.getenv("BUILD_NUMBER") ?: "15" bundleID = "dev.dimension.flare" minimumSystemVersion = "12.0" appStore = hasSigningProps @@ -132,12 +131,15 @@ val macExtraPlistKeys: String ITSAppUsesNonExemptEncryption + LSMultipleInstancesProhibited + """ extra["sqliteVersion"] = libs.versions.sqlite.get() extra["sqliteOsArch"] = "osx_arm64" extra["composeMediaPlayerVersion"] = libs.versions.composemediaplayer.get() +extra["jnaVersion"] = "5.17.0" extra["nativeDestDir"] = "resources/macos-arm64" apply(from = File(projectDir, "install-native-libs.gradle.kts")) diff --git a/desktopApp/install-native-libs.gradle.kts b/desktopApp/install-native-libs.gradle.kts index 3f5530057..e00416913 100644 --- a/desktopApp/install-native-libs.gradle.kts +++ b/desktopApp/install-native-libs.gradle.kts @@ -118,9 +118,24 @@ val cmpTask = destFile = nativeDestDir.resolve("libNativeVideoPlayer.dylib"), ) +val jnaVersion = (findProperty("jnaVersion") as String?) ?: "5.17.0" +val jnaJarName = "jna-$jnaVersion.jar" +val jnaJarUrl = "https://repo1.maven.org/maven2/net/java/dev/jna/jna/$jnaVersion/$jnaJarName" +val jnaEntry = "com/sun/jna/darwin-aarch64/libjnidispatch.jnilib" + +val jnaTask = + registerExtractFromJarTask( + taskName = "installJnaNative_$jnaVersion", + jarUrl = jnaJarUrl, + cacheSubDir = "jna/$jnaVersion", + jarFileName = jnaJarName, + entryPathInJar = jnaEntry, + destFile = nativeDestDir.resolve("libjnidispatch.dylib"), + ) + val installNativeLibs = tasks.register("installNativeLibs") { group = "setup" description = "Install sqliteJni + NativeVideoPlayer dylibs to $nativeDestDirPath" - dependsOn(sqliteTask, cmpTask) + dependsOn(sqliteTask, cmpTask, jnaTask) } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt index ab396291e..2aedaa2ce 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt @@ -25,7 +25,6 @@ import dev.dimension.flare.ui.route.Route import dev.dimension.flare.ui.route.WindowRouter import dev.dimension.flare.ui.theme.FlareTheme import dev.dimension.flare.ui.theme.ProvideThemeSettings -import io.github.vinceglb.filekit.FileKit import org.apache.commons.lang3.SystemUtils import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @@ -33,7 +32,7 @@ import org.koin.core.context.startKoin import java.awt.Desktop fun main(args: Array) { - SandboxHelper.configureSQLiteDriver() + SandboxHelper.configureSandboxArgs() startKoin { modules(desktopModule + KoinHelper.modules() + composeUiModule) } @@ -43,7 +42,6 @@ fun main(args: Array) { } } application { - FileKit.init(appId = "dev.dimension.flare") setSingletonImageLoaderFactory { context -> ImageLoader .Builder(context) diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/common/SandboxHelper.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/common/SandboxHelper.kt index c15793904..b90a0b890 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/common/SandboxHelper.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/common/SandboxHelper.kt @@ -1,7 +1,7 @@ package dev.dimension.flare.common object SandboxHelper { - fun configureSQLiteDriver() { + fun configureSandboxArgs() { val isSandboxed = System.getenv("APP_SANDBOX_CONTAINER_ID") != null if (isSandboxed) { val resourcesPath = System.getProperty("compose.application.resources.dir") diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt index 74404ec97..ef4fddfc1 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt @@ -102,6 +102,7 @@ import dev.dimension.flare.ui.model.takeSuccessOr import dev.dimension.flare.ui.presenter.compose.ComposePresenter import dev.dimension.flare.ui.presenter.compose.ComposeStatus import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.theme.LocalComposeWindow import dev.dimension.flare.ui.theme.screenHorizontalPadding import io.github.composefluent.FluentTheme import io.github.composefluent.LocalTextStyle @@ -119,8 +120,6 @@ import io.github.composefluent.component.Text import io.github.composefluent.component.TextField import io.github.composefluent.component.TextFieldDefaults import io.github.composefluent.surface.Card -import io.github.vinceglb.filekit.dialogs.FileKitType -import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher import kotlinx.collections.immutable.toImmutableList import moe.tlaster.precompose.molecule.producePresenter import org.jetbrains.compose.resources.StringResource @@ -132,6 +131,10 @@ import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes import kotlin.uuid.ExperimentalUuidApi +private val imageExtensions = setOf("png", "jpg", "jpeg", "gif", "bmp") +private val videoExtensions = setOf("mp4", "mov", "avi", "mkv", "webm") +private val pickerFileExtensions = imageExtensions + videoExtensions + @OptIn(ExperimentalComposeUiApi::class, ExperimentalUuidApi::class) @Composable fun ComposeDialog( @@ -147,17 +150,7 @@ fun ComposeDialog( initialText = initialText, ) } - val launcher = - rememberFilePickerLauncher( - FileKitType.ImageAndVideo, - ) { file -> - if (file != null) { - state.mediaState.onSuccess { - it.addMedia(listOf(file.file)) - } - } - } - + val composeWindow = LocalComposeWindow.current val focusRequester = remember { FocusRequester() } val contentWarningFocusRequester = remember { FocusRequester() } state.contentWarningState @@ -573,11 +566,24 @@ fun ComposeDialog( Row( verticalAlignment = Alignment.CenterVertically, ) { - state.mediaState.onSuccess { - if (it.enabled) { + state.mediaState.onSuccess { mediaState -> + if (mediaState.enabled) { SubtleButton( onClick = { - launcher.launch() + java.awt + .FileDialog(composeWindow) + .apply { + filenameFilter = + java.io.FilenameFilter { _, name -> + pickerFileExtensions.any { + name.endsWith(".$it", ignoreCase = true) + } + } + isVisible = true + }.files + .takeIf { files -> files.isNotEmpty() } + ?.map { file -> File(file.toURI()) } + ?.let { uris -> mediaState.addMedia(uris) } }, disabled = !state.canMedia, iconOnly = true, diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt index 5be5269ec..17bd16f4e 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt @@ -249,7 +249,7 @@ internal fun ServiceSelectScreen( SegmentedButton( checked = !useOAuth, onCheckedChanged = { - useOAuth = true + useOAuth = false }, position = SegmentedItemPosition.Start, ) { @@ -258,7 +258,7 @@ internal fun ServiceSelectScreen( SegmentedButton( checked = useOAuth, onCheckedChanged = { - useOAuth = false + useOAuth = true }, position = SegmentedItemPosition.End, ) { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt index 7e7801d06..2a9a6df0d 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.interaction.FocusInteraction import androidx.compose.foundation.interaction.HoverInteraction import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.interaction.PressInteraction -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -41,6 +40,7 @@ import io.github.composefluent.ExperimentalFluentApi import io.github.composefluent.FluentTheme import io.github.composefluent.darkColors import io.github.composefluent.lightColors +import io.github.kdroidfilter.platformtools.darkmodedetector.isSystemInDarkMode import kotlinx.coroutines.launch import org.apache.commons.lang3.SystemUtils import org.koin.compose.koinInject @@ -181,7 +181,7 @@ private class FluentIndication( @Composable private fun isDarkTheme(): Boolean = LocalAppearanceSettings.current.theme == Theme.DARK || - (LocalAppearanceSettings.current.theme == Theme.SYSTEM && isSystemInDarkTheme()) + (LocalAppearanceSettings.current.theme == Theme.SYSTEM && isSystemInDarkMode()) @Composable internal fun ProvideThemeSettings(content: @Composable () -> Unit) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 39041c0a0..7be7f7b43 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,6 @@ clikt = "5.0.3" collection = "1.5.0" compileSdk = "36" composemediaplayer = "0.8.1" -filekitDialogsCompose = "0.10.0" haze = "1.6.10" lifecycleViewmodelComposeVersion = "2.9.2" minSdk = "23" @@ -64,8 +63,6 @@ bluesky-oauth = { module = "moe.tlaster.ozone:oauth", version.ref = "bluesky" } clikt = { module = "com.github.ajalt.clikt:clikt", version.ref = "clikt" } composemediaplayer = { module = "io.github.kdroidfilter:composemediaplayer", version.ref = "composemediaplayer" } core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } -filekit-dialogs-compose = { module = "io.github.vinceglb:filekit-dialogs-compose", version.ref = "filekitDialogsCompose" } -filekit-coil = { module = "io.github.vinceglb:filekit-coil", version.ref = "filekitDialogsCompose" } haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" } haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } junit = { group = "junit", name = "junit", version.ref = "junit" } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BlueskyDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BlueskyDataSource.kt index 8a8ce9313..399e19cd0 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BlueskyDataSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BlueskyDataSource.kt @@ -427,13 +427,14 @@ internal class BlueskyDataSource( ) }, ) - service.createRecord( - CreateRecordRequest( - repo = Did(did = data.account.accountKey.id), - collection = Nsid("app.bsky.feed.post"), - record = post.bskyJson(), - ), - ) + service + .createRecord( + CreateRecordRequest( + repo = Did(did = data.account.accountKey.id), + collection = Nsid("app.bsky.feed.post"), + record = post.bskyJson(), + ), + ).requireResponse() } suspend fun report( From 44ad5e50b6c563d0ab0b69eef3353d151bbc734e Mon Sep 17 00:00:00 2001 From: Tlaster Date: Wed, 27 Aug 2025 12:11:05 +0900 Subject: [PATCH 23/33] fix mixed timeline only load one page --- .../microblog/MixedRemoteMediator.kt | 103 +++++++++++++++--- 1 file changed, 89 insertions(+), 14 deletions(-) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediator.kt index ea306916c..099b5f55a 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediator.kt @@ -4,13 +4,14 @@ import SnowflakeIdGenerator import androidx.paging.ExperimentalPagingApi import dev.dimension.flare.common.BaseTimelineRemoteMediator import dev.dimension.flare.data.database.cache.CacheDatabase +import dev.dimension.flare.data.database.cache.connect import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlin.uuid.Uuid internal class MixedRemoteMediator( - database: CacheDatabase, + private val database: CacheDatabase, private val mediators: List, ) : BaseTimelineRemoteMediator(database = database) { override val pagingKey = @@ -36,21 +37,23 @@ internal class MixedRemoteMediator( } val response = currentMediators - .map { + .mapNotNull { + getSubRequest(request, it) + }.map { subRequest -> async { - it to - runCatching { it.timeline(pageSize, request) } - .onFailure { it.printStackTrace() } - .getOrElse { - // TODO: Handle errors for each mediator - Result( - endOfPaginationReached = true, - ) - } + runCatching { + subRequest.load(pageSize) + }.onFailure { + it.printStackTrace() + }.getOrElse { + Result(endOfPaginationReached = true) + }.let { + SubResponse(subRequest.mediator, it) + } } }.awaitAll() - val timelineResult = response.flatMap { it.second.data } + val timelineResult = response.flatMap { it.result.data } val mixedTimelineResult = timelineResult @@ -68,19 +71,91 @@ internal class MixedRemoteMediator( ) } + database.connect { + response.forEach { + saveSubResponse(request, it) + } + } + currentMediators = response.mapNotNull { - if (it.second.endOfPaginationReached) { + if (it.result.endOfPaginationReached) { null } else { - it.first + it.mediator } } Result( endOfPaginationReached = currentMediators.isEmpty(), data = mixedTimelineResult + timelineResult, + nextKey = if (currentMediators.isEmpty()) null else "mixed_next_key", + previousKey = null, ) } } + + private suspend fun getSubRequest( + request: Request, + mediator: BaseTimelineRemoteMediator, + ): SubRequest? = + when (request) { + is Request.Append -> { + database + .pagingTimelineDao() + .getPagingKey(mediator.pagingKey) + ?.nextKey + ?.let(Request::Append) + } + + is Request.Prepend -> + database + .pagingTimelineDao() + .getPagingKey(mediator.pagingKey) + ?.prevKey + ?.let(Request::Prepend) + + is Request.Refresh -> Request.Refresh + }?.let { + SubRequest(mediator, it) + } + + private suspend fun saveSubResponse( + request: Request, + subResponse: SubResponse, + ) { + val (mediator, result) = subResponse + if (request is Request.Prepend && result.previousKey != null) { + database.pagingTimelineDao().updatePagingKeyPrevKey( + pagingKey = mediator.pagingKey, + prevKey = result.previousKey, + ) + } else if (request is Request.Append && result.nextKey != null) { + database.pagingTimelineDao().updatePagingKeyNextKey( + pagingKey = mediator.pagingKey, + nextKey = result.nextKey, + ) + } else if (request is Request.Refresh) { + database.pagingTimelineDao().deletePagingKey(mediator.pagingKey) + database.pagingTimelineDao().insertPagingKey( + dev.dimension.flare.data.database.cache.model.DbPagingKey( + pagingKey = mediator.pagingKey, + nextKey = result.nextKey, + prevKey = result.previousKey, + ), + ) + } + } + + private data class SubRequest( + val mediator: BaseTimelineRemoteMediator, + val request: Request, + ) { + suspend fun load(pageSize: Int) = mediator.timeline(pageSize, request) + } + + private data class SubResponse( + val mediator: BaseTimelineRemoteMediator, + val result: Result, + ) } From 45ff79d08a9445ab0710cfab8896b75b71e7d11b Mon Sep 17 00:00:00 2001 From: Tlaster Date: Wed, 27 Aug 2025 16:52:30 +0900 Subject: [PATCH 24/33] update home toolbar --- desktopApp/build.gradle.kts | 16 +- desktopApp/proguard-rules.pro | 4 +- .../main/composeResources/values/strings.xml | 2 + .../flare/ui/component/FloatingToolbar.kt | 214 +++++++++++++ .../ui/screen/home/HomeTimelineScreen.kt | 92 +++++- .../flare/ui/screen/home/TimelineScreen.kt | 18 +- .../ui/screen/settings/SettingsScreen.kt | 283 +++++++++++------- gradle/libs.versions.toml | 2 + .../dimension/flare/ui/model/UiRssSource.kt | 7 +- 9 files changed, 503 insertions(+), 135 deletions(-) create mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/FloatingToolbar.kt diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index de325e389..84d5efe1c 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -35,7 +35,9 @@ dependencies { implementation(libs.zoomable) implementation(libs.datastore) implementation(libs.reorderable) - implementation("io.github.kdroidfilter:platformtools.darkmodedetector:0.5.0") + implementation(libs.platformtools.darkmodedetector) + implementation(libs.haze) + implementation(libs.haze.materials) // implementation(libs.bouncycastle.bcprov) // implementation(libs.bouncycastle.bcpkix) } @@ -51,7 +53,7 @@ compose.desktop { macOS { val file = project.file("signing.properties") val hasSigningProps = file.exists() - packageBuildVersion = System.getenv("BUILD_NUMBER") ?: "15" + packageBuildVersion = System.getenv("BUILD_NUMBER") ?: "17" bundleID = "dev.dimension.flare" minimumSystemVersion = "12.0" appStore = hasSigningProps @@ -92,11 +94,11 @@ compose.desktop { buildTypes { release { proguard { - this.isEnabled.set(false) -// version.set("7.7.0") -// this.configurationFiles.from( -// file("proguard-rules.pro") -// ) +// this.isEnabled.set(false) + version.set("7.7.0") + this.configurationFiles.from( + file("proguard-rules.pro") + ) } } } diff --git a/desktopApp/proguard-rules.pro b/desktopApp/proguard-rules.pro index 7ac8848e2..5908db5a7 100644 --- a/desktopApp/proguard-rules.pro +++ b/desktopApp/proguard-rules.pro @@ -131,4 +131,6 @@ -dontwarn java.lang.ClassValue # An annotation used for build tooling, won't be directly accessed. --dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement \ No newline at end of file +-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement +-keep class io.github.kdroidfilter.** { *; } +-keep class de.jangassen.jfa.** { *; } \ No newline at end of file diff --git a/desktopApp/src/main/composeResources/values/strings.xml b/desktopApp/src/main/composeResources/values/strings.xml index 490aa0100..3886703f4 100644 --- a/desktopApp/src/main/composeResources/values/strings.xml +++ b/desktopApp/src/main/composeResources/values/strings.xml @@ -286,4 +286,6 @@ Tab name Icon With avatar + + Edit \ No newline at end of file diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/FloatingToolbar.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/FloatingToolbar.kt new file mode 100644 index 000000000..aa60dd587 --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/FloatingToolbar.kt @@ -0,0 +1,214 @@ +package dev.dimension.flare.ui.component + +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScrollModifierNode +import androidx.compose.ui.node.CompositionLocalConsumerModifierNode +import androidx.compose.ui.node.DelegatableNode +import androidx.compose.ui.node.DelegatingNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.requireDensity +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +private val ScrollDistanceThreshold: Dp = 40.dp + +/** + * This [Modifier] tracks vertical scroll events on the scrolling container that a floating + * toolbar appears above. It then calls [onExpand] and [onCollapse] to adjust the toolbar's + * state based on the scroll direction and distance. + * + * Essentially, it expands the toolbar when you scroll down past a certain threshold and + * collapses it when you scroll back up. You can customize the expand and collapse thresholds + * through the [expandScrollDistanceThreshold] and [collapseScrollDistanceThreshold]. + * + * @param expanded the current expanded state of the floating toolbar + * @param onExpand callback to be invoked when the toolbar should expand + * @param onCollapse callback to be invoked when the toolbar should collapse + * @param expandScrollDistanceThreshold the scroll distance (in dp) required to trigger an + * [onExpand] + * @param collapseScrollDistanceThreshold the scroll distance (in dp) required to trigger an + * [onCollapse] + * @param reverseLayout indicates that the scrollable content has a reversed scrolling direction + */ +fun Modifier.floatingToolbarVerticalNestedScroll( + expanded: Boolean, + onExpand: () -> Unit, + onCollapse: () -> Unit, + expandScrollDistanceThreshold: Dp = ScrollDistanceThreshold, + collapseScrollDistanceThreshold: Dp = ScrollDistanceThreshold, + reverseLayout: Boolean = false, +): Modifier = + this then + VerticalNestedScrollExpansionElement( + expanded = expanded, + onExpand = onExpand, + onCollapse = onCollapse, + reverseLayout = reverseLayout, + expandScrollThreshold = expandScrollDistanceThreshold, + collapseScrollThreshold = collapseScrollDistanceThreshold, + ) + +internal class VerticalNestedScrollExpansionElement( + val expanded: Boolean, + val onExpand: () -> Unit, + val onCollapse: () -> Unit, + val reverseLayout: Boolean, + val expandScrollThreshold: Dp, + val collapseScrollThreshold: Dp, +) : ModifierNodeElement() { + override fun create() = + VerticalNestedScrollExpansionNode( + expanded = expanded, + onExpand = onExpand, + onCollapse = onCollapse, + reverseLayout = reverseLayout, + expandScrollThreshold = expandScrollThreshold, + collapseScrollThreshold = collapseScrollThreshold, + ) + + override fun update(node: VerticalNestedScrollExpansionNode) { + node.updateNode( + expanded, + onExpand, + onCollapse, + reverseLayout, + expandScrollThreshold, + collapseScrollThreshold, + ) + } + + override fun InspectorInfo.inspectableProperties() { + name = "floatingToolbarVerticalNestedScroll" + properties["expanded"] = expanded + properties["expandScrollThreshold"] = expandScrollThreshold + properties["collapseScrollThreshold"] = collapseScrollThreshold + properties["reverseLayout"] = reverseLayout + properties["onExpand"] = onExpand + properties["onCollapse"] = onCollapse + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is VerticalNestedScrollExpansionElement) return false + + if (expanded != other.expanded) return false + if (reverseLayout != other.reverseLayout) return false + if (onExpand !== other.onExpand) return false + if (onCollapse !== other.onCollapse) return false + if (expandScrollThreshold != other.expandScrollThreshold) return false + if (collapseScrollThreshold != other.collapseScrollThreshold) return false + + return true + } + + override fun hashCode(): Int { + var result = expanded.hashCode() + result = 31 * result + reverseLayout.hashCode() + result = 31 * result + onExpand.hashCode() + result = 31 * result + onCollapse.hashCode() + result = 31 * result + expandScrollThreshold.hashCode() + result = 31 * result + collapseScrollThreshold.hashCode() + return result + } +} + +internal class VerticalNestedScrollExpansionNode( + var expanded: Boolean, + var onExpand: () -> Unit, + var onCollapse: () -> Unit, + var reverseLayout: Boolean, + var expandScrollThreshold: Dp, + var collapseScrollThreshold: Dp, +) : DelegatingNode(), + CompositionLocalConsumerModifierNode, + NestedScrollConnection { + private var expandScrollThresholdPx = 0f + private var collapseScrollThresholdPx = 0f + private var contentOffset = 0f + private var threshold = 0f + + // In reverse layouts, scrolling direction is flipped. We will use this factor to flip some + // of the values we read on the onPostScroll to ensure consistent behavior regardless of + // scroll direction. + private var reverseLayoutFactor = if (reverseLayout) -1 else 1 + + override val shouldAutoInvalidate: Boolean + get() = false + + private var nestedScrollNode: DelegatableNode = + nestedScrollModifierNode(connection = this, dispatcher = null) + + override fun onAttach() { + delegate(nestedScrollNode) + with(nestedScrollNode.requireDensity()) { + expandScrollThresholdPx = expandScrollThreshold.toPx() + collapseScrollThresholdPx = collapseScrollThreshold.toPx() + } + updateThreshold() + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + val scrollDelta = consumed.y * reverseLayoutFactor + contentOffset += scrollDelta + + if (scrollDelta < 0 && contentOffset <= threshold) { + threshold = contentOffset + expandScrollThresholdPx + onCollapse() + } else if (scrollDelta > 0 && contentOffset >= threshold) { + threshold = contentOffset - collapseScrollThresholdPx + onExpand() + } + return Offset.Zero + } + + fun updateNode( + expanded: Boolean, + onExpand: () -> Unit, + onCollapse: () -> Unit, + reverseLayout: Boolean, + expandScrollThreshold: Dp, + collapseScrollThreshold: Dp, + ) { + if ( + this.expandScrollThreshold != expandScrollThreshold || + this.collapseScrollThreshold != collapseScrollThreshold + ) { + this.expandScrollThreshold = expandScrollThreshold + this.collapseScrollThreshold = collapseScrollThreshold + with(nestedScrollNode.requireDensity()) { + expandScrollThresholdPx = expandScrollThreshold.toPx() + collapseScrollThresholdPx = collapseScrollThreshold.toPx() + } + updateThreshold() + } + if (this.reverseLayout != reverseLayout) { + this.reverseLayout = reverseLayout + reverseLayoutFactor = if (this.reverseLayout) -1 else 1 + } + + this.onExpand = onExpand + this.onCollapse = onCollapse + + if (this.expanded != expanded) { + this.expanded = expanded + updateThreshold() + } + } + + private fun updateThreshold() { + threshold = + if (expanded) { + contentOffset - collapseScrollThresholdPx + } else { + contentOffset + expandScrollThresholdPx + } + } +} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt index 861680e97..dbdcbeefb 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt @@ -1,32 +1,55 @@ package dev.dimension.flare.ui.screen.home +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect 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.graphics.luminance +import androidx.compose.ui.unit.dp import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.Plus +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import dev.chrisbanes.haze.materials.FluentMaterials +import dev.chrisbanes.haze.rememberHazeState +import dev.dimension.flare.LocalWindowPadding import dev.dimension.flare.RegisterTabCallback import dev.dimension.flare.model.AccountType import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.TabIcon import dev.dimension.flare.ui.component.TabTitle +import dev.dimension.flare.ui.component.floatingToolbarVerticalNestedScroll import dev.dimension.flare.ui.model.map import dev.dimension.flare.ui.model.onSuccess import dev.dimension.flare.ui.presenter.HomeTimelineWithTabsPresenter import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.theme.screenHorizontalPadding +import io.github.composefluent.FluentTheme import io.github.composefluent.component.LiteFilter import io.github.composefluent.component.PillButton +import io.github.composefluent.component.ProgressBar import moe.tlaster.precompose.molecule.producePresenter +@OptIn(ExperimentalHazeMaterialsApi::class) @Composable internal fun HomeTimelineScreen( accountType: AccountType, onAddTab: () -> Unit, ) { + val hazeState = rememberHazeState() val state by producePresenter(key = "home_timeline_$accountType") { presenter(accountType) } @@ -40,11 +63,46 @@ internal fun HomeTimelineScreen( currentTab.refreshSync() }, ) - - TimelineScreen( - tabItem = currentTab.timelineTabItem, - header = { - LiteFilter { + Box { + TimelineScreen( + tabItem = currentTab.timelineTabItem, + modifier = + Modifier + .hazeSource(hazeState) + .floatingToolbarVerticalNestedScroll( + expanded = state.isTopBarExpanded, + onExpand = { + state.setTopBarExpanded(true) + }, + onCollapse = { + state.setTopBarExpanded(false) + }, + ), + contentPadding = PaddingValues(top = 48.dp), + ) + AnimatedVisibility( + visible = state.isTopBarExpanded, + modifier = + Modifier + .fillMaxWidth(), + enter = slideInVertically { -it }, + exit = slideOutVertically { -it }, + ) { + LiteFilter( + modifier = + Modifier + .hazeEffect( + state = hazeState, + style = + FluentMaterials.mica( + FluentTheme.colors.background.mica.base + .luminance() < 0.5f, + ), + ) +// .background(FluentTheme.colors.background.solid.base) + .padding(LocalWindowPadding.current) + .padding(horizontal = screenHorizontalPadding), + ) { tabState.forEachIndexed { index, tab -> PillButton( selected = tab.timelineTabItem.key == currentTab.timelineTabItem.key, @@ -74,8 +132,20 @@ internal fun HomeTimelineScreen( } } } - }, - ) + } + AnimatedVisibility( + currentTab.isRefreshing, + enter = slideInVertically { -it }, + exit = slideOutVertically { -it }, + ) { + ProgressBar( + modifier = + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth(), + ) + } + } } } } @@ -83,6 +153,7 @@ internal fun HomeTimelineScreen( @Composable private fun presenter(accountType: AccountType) = run { + var isTopBarExpanded by remember { mutableStateOf(true) } val state = remember(accountType) { HomeTimelineWithTabsPresenter(accountType) }.invoke() var selectedIndex by remember { mutableStateOf(0) @@ -108,5 +179,12 @@ private fun presenter(accountType: AccountType) = fun setSelectedIndex(index: Int) { selectedIndex = index } + + val isTopBarExpanded: Boolean + get() = isTopBarExpanded + + fun setTopBarExpanded(expanded: Boolean) { + isTopBarExpanded = expanded + } } } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt index 69012ae55..b6192aec9 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -30,6 +31,7 @@ import dev.dimension.flare.common.isRefreshing import dev.dimension.flare.common.onSuccess import dev.dimension.flare.data.model.TimelineTabItem import dev.dimension.flare.home_timeline_new_toots +import dev.dimension.flare.ui.common.plus import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.status.LazyStatusVerticalStaggeredGrid import dev.dimension.flare.ui.component.status.status @@ -45,6 +47,8 @@ import org.jetbrains.compose.resources.stringResource @Composable internal fun TimelineScreen( tabItem: TimelineTabItem, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), header: @Composable (() -> Unit)? = null, ) { val scope = rememberCoroutineScope() @@ -56,11 +60,11 @@ internal fun TimelineScreen( RegisterTabCallback(state.lazyListState, onRefresh = state::refreshSync) Box( modifier = - Modifier + modifier .fillMaxSize(), ) { LazyStatusVerticalStaggeredGrid( - contentPadding = LocalWindowPadding.current, + contentPadding = LocalWindowPadding.current + contentPadding, state = state.lazyListState, ) { if (header != null) { @@ -72,11 +76,17 @@ internal fun TimelineScreen( } status(state.listState) } - if (state.listState.isRefreshing) { + AnimatedVisibility( + state.listState.isRefreshing, + enter = slideInVertically { -it }, + exit = slideOutVertically { -it }, + modifier = + Modifier + .align(Alignment.TopCenter), + ) { ProgressBar( modifier = Modifier - .align(Alignment.TopCenter) .fillMaxWidth(), ) } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt index 4ae501f44..2cb9886f4 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt @@ -53,6 +53,7 @@ import dev.dimension.flare.data.model.TabSettings import dev.dimension.flare.data.model.Theme import dev.dimension.flare.data.model.VideoAutoplay import dev.dimension.flare.data.repository.SettingsRepository +import dev.dimension.flare.edit import dev.dimension.flare.home_login import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey @@ -122,13 +123,16 @@ import dev.dimension.flare.ui.presenter.settings.AccountsState import dev.dimension.flare.ui.presenter.settings.FlareServerProviderPresenter import dev.dimension.flare.ui.theme.screenHorizontalPadding import io.github.composefluent.FluentTheme +import io.github.composefluent.component.Button import io.github.composefluent.component.CardExpanderItem import io.github.composefluent.component.ContentDialog import io.github.composefluent.component.ContentDialogButton import io.github.composefluent.component.DropDownButton import io.github.composefluent.component.Expander import io.github.composefluent.component.ExpanderItem +import io.github.composefluent.component.ExpanderItemSeparator import io.github.composefluent.component.FlyoutPlacement +import io.github.composefluent.component.HyperlinkButton import io.github.composefluent.component.MenuFlyoutContainer import io.github.composefluent.component.MenuFlyoutItem import io.github.composefluent.component.ProgressRing @@ -413,7 +417,7 @@ internal fun SettingsScreen(toLogin: () -> Unit) { ) }, ) - + ExpanderItemSeparator() ExpanderItem( heading = { Text(stringResource(Res.string.settings_appearance_show_actions)) @@ -433,26 +437,30 @@ internal fun SettingsScreen(toLogin: () -> Unit) { ) }, ) + ExpanderItemSeparator() AnimatedVisibility(LocalAppearanceSettings.current.showActions) { - ExpanderItem( - heading = { - Text(stringResource(Res.string.settings_appearance_show_numbers)) - }, - caption = { - Text(stringResource(Res.string.settings_appearance_show_numbers_description)) - }, - trailing = { - Switcher( - checked = LocalAppearanceSettings.current.showNumbers, - { - state.appearanceState.updateSettings { - copy(showNumbers = it) - } - }, - textBefore = true, - ) - }, - ) + Column { + ExpanderItem( + heading = { + Text(stringResource(Res.string.settings_appearance_show_numbers)) + }, + caption = { + Text(stringResource(Res.string.settings_appearance_show_numbers_description)) + }, + trailing = { + Switcher( + checked = LocalAppearanceSettings.current.showNumbers, + { + state.appearanceState.updateSettings { + copy(showNumbers = it) + } + }, + textBefore = true, + ) + }, + ) + ExpanderItemSeparator() + } } ExpanderItem( heading = { @@ -473,26 +481,30 @@ internal fun SettingsScreen(toLogin: () -> Unit) { ) }, ) + ExpanderItemSeparator() AnimatedVisibility(LocalAppearanceSettings.current.showLinkPreview) { - ExpanderItem( - heading = { - Text(stringResource(Res.string.settings_appearance_compat_link_previews)) - }, - caption = { - Text(stringResource(Res.string.settings_appearance_compat_link_previews_description)) - }, - trailing = { - Switcher( - checked = LocalAppearanceSettings.current.compatLinkPreview, - { - state.appearanceState.updateSettings { - copy(compatLinkPreview = it) - } - }, - textBefore = true, - ) - }, - ) + Column { + ExpanderItem( + heading = { + Text(stringResource(Res.string.settings_appearance_compat_link_previews)) + }, + caption = { + Text(stringResource(Res.string.settings_appearance_compat_link_previews_description)) + }, + trailing = { + Switcher( + checked = LocalAppearanceSettings.current.compatLinkPreview, + { + state.appearanceState.updateSettings { + copy(compatLinkPreview = it) + } + }, + textBefore = true, + ) + }, + ) + ExpanderItemSeparator() + } } ExpanderItem( heading = { @@ -513,47 +525,54 @@ internal fun SettingsScreen(toLogin: () -> Unit) { ) }, ) + ExpanderItemSeparator() AnimatedVisibility(LocalAppearanceSettings.current.showMedia) { - ExpanderItem( - heading = { - Text(stringResource(Res.string.settings_appearance_show_cw_img)) - }, - caption = { - Text(stringResource(Res.string.settings_appearance_show_cw_img_description)) - }, - trailing = { - Switcher( - checked = LocalAppearanceSettings.current.showSensitiveContent, - { - state.appearanceState.updateSettings { - copy(showSensitiveContent = it) - } - }, - textBefore = true, - ) - }, - ) + Column { + ExpanderItem( + heading = { + Text(stringResource(Res.string.settings_appearance_show_cw_img)) + }, + caption = { + Text(stringResource(Res.string.settings_appearance_show_cw_img_description)) + }, + trailing = { + Switcher( + checked = LocalAppearanceSettings.current.showSensitiveContent, + { + state.appearanceState.updateSettings { + copy(showSensitiveContent = it) + } + }, + textBefore = true, + ) + }, + ) + ExpanderItemSeparator() + } } AnimatedVisibility(LocalAppearanceSettings.current.showMedia) { - ExpanderItem( - heading = { - Text(stringResource(Res.string.settings_appearance_expand_media)) - }, - caption = { - Text(stringResource(Res.string.settings_appearance_expand_media_description)) - }, - trailing = { - Switcher( - checked = LocalAppearanceSettings.current.expandMediaSize, - { - state.appearanceState.updateSettings { - copy(expandMediaSize = it) - } - }, - textBefore = true, - ) - }, - ) + Column { + ExpanderItem( + heading = { + Text(stringResource(Res.string.settings_appearance_expand_media)) + }, + caption = { + Text(stringResource(Res.string.settings_appearance_expand_media_description)) + }, + trailing = { + Switcher( + checked = LocalAppearanceSettings.current.expandMediaSize, + { + state.appearanceState.updateSettings { + copy(expandMediaSize = it) + } + }, + textBefore = true, + ) + }, + ) + ExpanderItemSeparator() + } } ExpanderItem( heading = { @@ -650,10 +669,10 @@ internal fun SettingsScreen(toLogin: () -> Unit) { }, icon = null, ) { - CardExpanderItem( - onClick = { - state.aiConfigState.setShowServerDialog(true) - }, + ExpanderItem( +// onClick = { +// state.aiConfigState.setShowServerDialog(true) +// }, heading = { Text(stringResource(Res.string.settings_ai_config_server)) }, @@ -662,7 +681,17 @@ internal fun SettingsScreen(toLogin: () -> Unit) { Text(it) } }, + trailing = { + Button( + onClick = { + state.aiConfigState.setShowServerDialog(true) + }, + ) { + Text(stringResource(Res.string.edit)) + } + }, ) + ExpanderItemSeparator() ExpanderItem( heading = { Text(stringResource(Res.string.settings_ai_config_entable_translation)) @@ -680,6 +709,7 @@ internal fun SettingsScreen(toLogin: () -> Unit) { ) }, ) + ExpanderItemSeparator() ExpanderItem( heading = { Text(stringResource(Res.string.settings_ai_config_enable_tldr)) @@ -711,17 +741,19 @@ internal fun SettingsScreen(toLogin: () -> Unit) { expanded = state.aboutExpanded, onExpandedChanged = { state.setAboutExpanded(it) }, ) { - CardExpanderItem( + ExpanderItem( heading = { Text(text = stringResource(resource = Res.string.settings_about_source_code)) }, - caption = { - Text( - text = "https://github.com/DimensionDev/Flare", - ) - }, - onClick = { - uriHandler.openUri("https://github.com/DimensionDev/Flare") + trailing = { + HyperlinkButton( + "https://github.com/DimensionDev/Flare", + ) { + Text( + text = "https://github.com/DimensionDev/Flare", + maxLines = 1, + ) + } }, icon = { FAIcon( @@ -731,17 +763,23 @@ internal fun SettingsScreen(toLogin: () -> Unit) { ) }, ) - CardExpanderItem( + ExpanderItemSeparator() + ExpanderItem( heading = { Text(text = stringResource(resource = Res.string.settings_about_telegram)) }, caption = { - Text( - text = stringResource(resource = Res.string.settings_about_telegram_description), - ) + Text(text = stringResource(resource = Res.string.settings_about_telegram_description)) }, - onClick = { - uriHandler.openUri("https://t.me/+0UtcP6_qcDoyOWE1") + trailing = { + HyperlinkButton( + "https://t.me/+0UtcP6_qcDoyOWE1", + ) { + Text( + text = "https://t.me/+0UtcP6_qcDoyOWE1", + maxLines = 1, + ) + } }, icon = { FAIcon( @@ -751,17 +789,23 @@ internal fun SettingsScreen(toLogin: () -> Unit) { ) }, ) - CardExpanderItem( + ExpanderItemSeparator() + ExpanderItem( heading = { Text(text = stringResource(resource = Res.string.settings_about_line)) }, caption = { - Text( - text = stringResource(resource = Res.string.settings_about_line_description), - ) + Text(text = stringResource(resource = Res.string.settings_about_line_description)) }, - onClick = { - uriHandler.openUri("https://line.me/ti/g/hf95HyGJ9k") + trailing = { + HyperlinkButton( + "https://line.me/ti/g/hf95HyGJ9k", + ) { + Text( + text = "https://line.me/ti/g/hf95HyGJ9k", + maxLines = 1, + ) + } }, icon = { FAIcon( @@ -771,17 +815,23 @@ internal fun SettingsScreen(toLogin: () -> Unit) { ) }, ) - CardExpanderItem( + ExpanderItemSeparator() + ExpanderItem( heading = { Text(text = stringResource(resource = Res.string.settings_about_localization)) }, caption = { - Text( - text = stringResource(resource = Res.string.settings_about_localization_description), - ) + Text(text = stringResource(resource = Res.string.settings_about_localization_description)) }, - onClick = { - uriHandler.openUri("https://crowdin.com/project/flareapp") + trailing = { + HyperlinkButton( + "https://crowdin.com/project/flareapp", + ) { + Text( + text = "https://crowdin.com/project/flareapp", + maxLines = 1, + ) + } }, icon = { FAIcon( @@ -791,17 +841,20 @@ internal fun SettingsScreen(toLogin: () -> Unit) { ) }, ) - CardExpanderItem( + ExpanderItemSeparator() + ExpanderItem( heading = { Text(text = stringResource(resource = Res.string.settings_privacy_policy)) }, - caption = { - Text( - text = "https://legal.mask.io/maskbook", - ) - }, - onClick = { - uriHandler.openUri("https://legal.mask.io/maskbook/") + trailing = { + HyperlinkButton( + "https://legal.mask.io/maskbook", + ) { + Text( + text = "https://legal.mask.io/maskbook", + maxLines = 1, + ) + } }, icon = { FAIcon( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7be7f7b43..4cb8f1aee 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,6 +25,7 @@ kotlinx-serialization = "1.9.0" coil3 = "3.3.0" ktorfit = "2.6.4" ktor = "3.2.3" +platformtoolsDarkmodedetector = "0.5.0" reorderable = "2.5.1" stately = "2.1.0" twitter-parser = "0.5.5" @@ -75,6 +76,7 @@ activity-compose = { group = "androidx.activity", name = "activity-compose", ver compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelComposeVersion" } openai-client = { module = "com.aallam.openai:openai-client", version.ref = "openaiClient" } +platformtools-darkmodedetector = { module = "io.github.kdroidfilter:platformtools.darkmodedetector", version.ref = "platformtoolsDarkmodedetector" } reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } stately-iso-collections = { module = "co.touchlab:stately-iso-collections", version.ref = "stately" } stately-isolate = { module = "co.touchlab:stately-isolate", version.ref = "stately" } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiRssSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiRssSource.kt index bdd47514d..2af5aea50 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiRssSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiRssSource.kt @@ -2,6 +2,7 @@ package dev.dimension.flare.ui.model import dev.dimension.flare.ui.render.UiDateTime import io.ktor.http.Url +import sh.christian.ozone.api.xrpc.BSKY_SOCIAL public data class UiRssSource internal constructor( val id: Int, @@ -24,7 +25,11 @@ public data class UiRssSource internal constructor( } else { Url("https://$url") } - return "https://${parsedUrl.host}/favicon.ico" + if (parsedUrl.host == BSKY_SOCIAL.host) { + return "https://web-cdn.bsky.app/static/apple-touch-icon.png" + } else { + return "https://${parsedUrl.host}/favicon.ico" + } } } } From b7237bdb83dbe22580ceb9d57a36ba69728db7d0 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Wed, 27 Aug 2025 17:23:45 +0900 Subject: [PATCH 25/33] fix timeline padding --- .../main/kotlin/dev/dimension/flare/common/SandboxHelper.kt | 1 + .../dev/dimension/flare/ui/screen/home/TimelineScreen.kt | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/common/SandboxHelper.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/common/SandboxHelper.kt index b90a0b890..326034bbd 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/common/SandboxHelper.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/common/SandboxHelper.kt @@ -8,6 +8,7 @@ object SandboxHelper { System.setProperty("androidx.sqlite.driver.bundled.path", resourcesPath) System.setProperty("jna.nounpack", "true") System.setProperty("jna.boot.library.path", resourcesPath) + System.setProperty("jna.library.path", resourcesPath) } } } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt index b6192aec9..87e149d69 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt @@ -98,7 +98,8 @@ internal fun TimelineScreen( modifier = Modifier .align(Alignment.TopCenter) - .padding(LocalWindowPadding.current), + .padding(LocalWindowPadding.current) + .padding(contentPadding), ) { AccentButton( onClick = { From 471eca5954f30e69b25258c3985625aa1cb3f291 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Wed, 27 Aug 2025 19:59:04 +0900 Subject: [PATCH 26/33] add macos webview window --- .../ui/screen/serviceselect/VVOLoginScreen.kt | 2 +- desktopApp/build-swift.gradle.kts | 32 ++++ desktopApp/build.gradle.kts | 4 +- desktopApp/install-native-libs.gradle.kts | 10 +- .../dimension/flare/common/WebViewBridge.kt | 24 +++ .../flare/common/macos/WKWebviewBridge.kt | 63 ++++++++ .../serviceselect/ServiceSelectScreen.kt | 48 +++++- desktopApp/src/main/swift/WebviewBridge.swift | 151 ++++++++++++++++++ .../dimension/flare/ui/model/UiApplication.kt | 1 + 9 files changed, 327 insertions(+), 8 deletions(-) create mode 100644 desktopApp/build-swift.gradle.kts create mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/common/WebViewBridge.kt create mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/common/macos/WKWebviewBridge.kt create mode 100644 desktopApp/src/main/swift/WebviewBridge.swift diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/serviceselect/VVOLoginScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/serviceselect/VVOLoginScreen.kt index aa80fe207..ab1928e8b 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/serviceselect/VVOLoginScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/serviceselect/VVOLoginScreen.kt @@ -26,7 +26,7 @@ import kotlin.time.Duration.Companion.seconds @Composable internal fun VVOLoginScreen(toHome: () -> Unit) { val state by producePresenter { presenter(toHome) } - val webViewState = rememberWebViewState("https://${UiApplication.VVo.host}/login?backURL=https://${UiApplication.VVo.host}/") + val webViewState = rememberWebViewState(UiApplication.VVo.loginUrl) LaunchedEffect(Unit) { while (true) { if (!state.loading) { diff --git a/desktopApp/build-swift.gradle.kts b/desktopApp/build-swift.gradle.kts new file mode 100644 index 000000000..f6ae18fdb --- /dev/null +++ b/desktopApp/build-swift.gradle.kts @@ -0,0 +1,32 @@ +val swiftSource = layout.projectDirectory.file("src/main/swift/WebviewBridge.swift") +val targetLib = layout.projectDirectory.dir("resources/macos-arm64").file("libWebviewBridge.dylib").asFile +val isMac = org.gradle.internal.os.OperatingSystem.current().isMacOsX + +tasks.register("compileWebviewBridgeArm64") { + onlyIf { isMac } + + inputs.file(swiftSource) + outputs.file(targetLib) + + doFirst { + targetLib.parentFile.mkdirs() + } + commandLine( + "swiftc", + "-emit-library", + "-module-name", "WebviewBridge", + "-framework", "Cocoa", + "-target", "arm64-apple-macos12", + "-o", targetLib.absolutePath, + swiftSource.asFile.absolutePath + ) +} + +afterEvaluate { + tasks.named("compileKotlin").configure { + dependsOn("compileWebviewBridgeArm64") + } + tasks.named("prepareAppResources").configure { + dependsOn("compileWebviewBridgeArm64") + } +} \ No newline at end of file diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 84d5efe1c..4f9681b9c 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -1,5 +1,5 @@ -import org.jetbrains.compose.desktop.application.dsl.TargetFormat import java.util.Properties +import org.jetbrains.compose.desktop.application.dsl.TargetFormat plugins { alias(libs.plugins.kotlin.jvm) @@ -38,6 +38,7 @@ dependencies { implementation(libs.platformtools.darkmodedetector) implementation(libs.haze) implementation(libs.haze.materials) + implementation("net.java.dev.jna:jna:5.17.0") // implementation(libs.bouncycastle.bcprov) // implementation(libs.bouncycastle.bcpkix) } @@ -145,3 +146,4 @@ extra["jnaVersion"] = "5.17.0" extra["nativeDestDir"] = "resources/macos-arm64" apply(from = File(projectDir, "install-native-libs.gradle.kts")) +apply(from = File(projectDir, "build-swift.gradle.kts")) \ No newline at end of file diff --git a/desktopApp/install-native-libs.gradle.kts b/desktopApp/install-native-libs.gradle.kts index e00416913..be292b41f 100644 --- a/desktopApp/install-native-libs.gradle.kts +++ b/desktopApp/install-native-libs.gradle.kts @@ -133,9 +133,11 @@ val jnaTask = destFile = nativeDestDir.resolve("libjnidispatch.dylib"), ) -val installNativeLibs = - tasks.register("installNativeLibs") { - group = "setup" - description = "Install sqliteJni + NativeVideoPlayer dylibs to $nativeDestDirPath" +afterEvaluate { + tasks.named("compileKotlin").configure { + dependsOn(sqliteTask, cmpTask, jnaTask) + } + tasks.named("prepareAppResources").configure { dependsOn(sqliteTask, cmpTask, jnaTask) } +} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/common/WebViewBridge.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/common/WebViewBridge.kt new file mode 100644 index 000000000..69747c330 --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/common/WebViewBridge.kt @@ -0,0 +1,24 @@ +package dev.dimension.flare.common + +import dev.dimension.flare.common.macos.WKWebviewBridge +import org.apache.commons.lang3.SystemUtils + +internal object WebViewBridge { + fun openAndWaitCookies( + url: String, + callback: (cookies: String?) -> Boolean, + ) { + if (SystemUtils.IS_OS_MAC_OSX) { + WKWebviewBridge.openAndWaitCookies( + url = url, + decisionCallback = { + callback(it) + }, + windowClosedCallback = { _, _ -> + }, + ) + } else { + // TODO: Implement for other platforms + } + } +} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/common/macos/WKWebviewBridge.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/common/macos/WKWebviewBridge.kt new file mode 100644 index 000000000..5a749e8ec --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/common/macos/WKWebviewBridge.kt @@ -0,0 +1,63 @@ +package dev.dimension.flare.common.macos + +import com.sun.jna.Callback +import com.sun.jna.Library +import com.sun.jna.Native + +private interface WebviewBridge : Library { + @Suppress("FunctionName") + fun wkb_set_decision_callback(cb: DecisionCallback?) + + @Suppress("FunctionName") + fun wkb_set_window_closed_callback(cb: WindowClosedCallback?) + + @Suppress("FunctionName") + fun wkb_open_webview_poll( + url: String, + intervalMs: Int, + ): Long + + @Suppress("FunctionName") + fun wkb_close_window(id: Long) + + @Suppress("FunctionName") + fun wkb_clear_persistent_storage() + + fun interface DecisionCallback : Callback { + // return 1 = close window, 0 = continue + fun invoke(cookies: String?): Int + } + + fun interface WindowClosedCallback : Callback { + // reason: 0=user, 1=API, 2=decision callback + fun invoke( + id: Long, + reason: Int, + ) + } +} + +internal object WKWebviewBridge { + private val lib: WebviewBridge by lazy { + Native.load("WebviewBridge", WebviewBridge::class.java) + } + + fun openAndWaitCookies( + url: String, + intervalMs: Int = 1000, + decisionCallback: (cookies: String?) -> Boolean, + windowClosedCallback: (id: Long, reason: Int) -> Unit, + ) { + val decisionCb = + WebviewBridge.DecisionCallback { cookies -> + if (decisionCallback(cookies)) 1 else 0 + } + val closedCb = + WebviewBridge.WindowClosedCallback { id, reason -> + windowClosedCallback(id, reason) + } + lib.wkb_set_decision_callback(decisionCb) + lib.wkb_set_window_closed_callback(closedCb) + val windowId = lib.wkb_open_webview_poll(url, intervalMs) + } +} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt index 17bd16f4e..1f44f672d 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt @@ -41,6 +41,7 @@ import dev.dimension.flare.bluesky_login_password import dev.dimension.flare.bluesky_login_use_password_button import dev.dimension.flare.bluesky_login_username import dev.dimension.flare.common.OnDeepLink +import dev.dimension.flare.common.WebViewBridge import dev.dimension.flare.common.onEmpty import dev.dimension.flare.common.onLoading import dev.dimension.flare.common.onSuccess @@ -57,6 +58,7 @@ import dev.dimension.flare.ui.component.NetworkImage import dev.dimension.flare.ui.component.platform.placeholder import dev.dimension.flare.ui.component.status.AdaptiveCard import dev.dimension.flare.ui.component.status.LazyStatusVerticalStaggeredGrid +import dev.dimension.flare.ui.model.UiApplication import dev.dimension.flare.ui.model.UiInstance import dev.dimension.flare.ui.model.isSuccess import dev.dimension.flare.ui.model.onError @@ -66,6 +68,8 @@ import dev.dimension.flare.ui.model.takeSuccess import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.presenter.login.ServiceSelectPresenter import dev.dimension.flare.ui.presenter.login.ServiceSelectState +import dev.dimension.flare.ui.presenter.login.VVOLoginPresenter +import dev.dimension.flare.ui.presenter.login.XQTLoginPresenter import io.github.composefluent.FluentTheme import io.github.composefluent.component.AccentButton import io.github.composefluent.component.ProgressBar @@ -338,8 +342,28 @@ internal fun ServiceSelectScreen( } PlatformType.xQt -> { + val loginState by producePresenter("xqtLoginState") { + remember { + XQTLoginPresenter(toHome = onBack) + }.invoke() + } AccentButton( - onClick = onXQT, + onClick = { + WebViewBridge.openAndWaitCookies( + "https://${UiApplication.XQT.host}", + callback = { cookies -> + if (cookies.isNullOrEmpty()) { + false + } else { + loginState.checkChocolate(cookies).also { + if (it) { + loginState.login(cookies) + } + } + } + }, + ) + }, modifier = Modifier.width(300.dp), ) { Text( @@ -349,8 +373,28 @@ internal fun ServiceSelectScreen( } PlatformType.VVo -> { + val loginState by producePresenter("vvoLoginState") { + remember { + VVOLoginPresenter(toHome = onBack) + }.invoke() + } AccentButton( - onClick = onVVO, + onClick = { + WebViewBridge.openAndWaitCookies( + UiApplication.VVo.loginUrl, + callback = { cookies -> + if (cookies.isNullOrEmpty()) { + false + } else { + loginState.checkChocolate(cookies).also { + if (it) { + loginState.login(cookies) + } + } + } + }, + ) + }, modifier = Modifier.width(300.dp), ) { Text( diff --git a/desktopApp/src/main/swift/WebviewBridge.swift b/desktopApp/src/main/swift/WebviewBridge.swift new file mode 100644 index 000000000..aebed8da9 --- /dev/null +++ b/desktopApp/src/main/swift/WebviewBridge.swift @@ -0,0 +1,151 @@ +import Cocoa +import WebKit + +private var nextId: Int64 = 1 + +private class Controller { + let id: Int64 + let window: NSWindow + let web: WKWebView + var timer: DispatchSourceTimer? + // 0 = closed by user;1 = API call wkb_close_window;2 = decision callback + var closeReason: Int32 = 0 + + init(id: Int64, window: NSWindow, web: WKWebView) { + self.id = id + self.window = window + self.web = web + } +} +private var ctrls: [Int64: Controller] = [:] + +// when to close callback, return 1 to close, 0 to keep open +public typealias DecisionCB = @convention(c) (_ cookies: UnsafePointer?) -> Int32 +private var gDecision: DecisionCB? + +// when window closed callback +// reason: 0 = closed by user;1 = API call wkb_close_window;2 = decision callback +public typealias WindowClosedCB = @convention(c) (_ id: Int64, _ reason: Int32) -> Void +private var gOnClosed: WindowClosedCB? + +private func makeWindow(with web: WKWebView, title: String) -> NSWindow { + let win = NSWindow(contentRect: NSRect(x: 200, y: 200, width: 1000, height: 700), + styleMask: [.titled, .closable, .resizable, .miniaturizable], + backing: .buffered, defer: false) + win.title = title + win.contentView = web + win.isReleasedWhenClosed = false + return win +} + +private func startCookiePolling(for ctrl: Controller, targetURL: URL?, intervalMs: Int32) { + let q = DispatchQueue(label: "cookie.poller.\(ctrl.id)") + let t = DispatchSource.makeTimerSource(queue: q) + t.schedule(deadline: .now() + .milliseconds(Int(intervalMs)), + repeating: .milliseconds(Int(intervalMs))) + t.setEventHandler { [weak ctrl] in + guard let ctrl = ctrl else { return } + let store = ctrl.web.configuration.websiteDataStore.httpCookieStore + + let sem = DispatchSemaphore(value: 0) + var header = "" + DispatchQueue.main.async { + store.getAllCookies { cookies in + var cookieString = "" + for cookie in cookies { + cookieString += "\(cookie.name)=\(cookie.value); " + } + header = cookieString + sem.signal() + } + } + sem.wait() + + if let cb = gDecision { + header.withCString { cstr in + let shouldClose = cb(cstr) == 1 + if shouldClose { + DispatchQueue.main.async { + ctrl.closeReason = 2 // decision callback + ctrl.window.performClose(nil) + } + } + } + } + } + ctrl.timer = t + t.resume() +} + +private func stopAndDispose(id: Int64) { + if let c = ctrls[id] { + c.timer?.cancel() + c.timer = nil + ctrls[id] = nil + } +} + +@_cdecl("wkb_set_decision_callback") +public func wkb_set_decision_callback(_ cb: DecisionCB?) { + gDecision = cb +} + +@_cdecl("wkb_set_window_closed_callback") +public func wkb_set_window_closed_callback(_ cb: WindowClosedCB?) { + gOnClosed = cb +} + +@_cdecl("wkb_open_webview_poll") +public func wkb_open_webview_poll(_ urlCString: UnsafePointer?, _ intervalMs: Int32) -> Int64 { + let urlStr = urlCString.flatMap { String(cString: $0) } ?? "about:blank" + let targetURL = URL(string: urlStr) + + var outId: Int64 = 0 + DispatchQueue.main.sync { + if NSApp == nil { _ = NSApplication.shared } + + let cfg = WKWebViewConfiguration() + cfg.websiteDataStore = .nonPersistent() + let web = WKWebView(frame: .zero, configuration: cfg) + if let u = targetURL { web.load(URLRequest(url: u)) } + + let win = makeWindow(with: web, title: "WebView") + let id = nextId; nextId += 1 + let ctrl = Controller(id: id, window: win, web: web) + ctrls[id] = ctrl + + NotificationCenter.default.addObserver(forName: NSWindow.willCloseNotification, object: win, queue: nil) { _ in + let reason = ctrl.closeReason + stopAndDispose(id: id) + + if let cb = gOnClosed { + DispatchQueue.global(qos: .userInitiated).async { + cb(id, reason) + } + } + } + + win.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + + startCookiePolling(for: ctrl, targetURL: targetURL, intervalMs: intervalMs) + outId = id + } + return outId +} + +@_cdecl("wkb_close_window") +public func wkb_close_window(_ id: Int64) { + DispatchQueue.main.async { + if let c = ctrls[id] { + c.closeReason = 1 // API call + c.window.performClose(nil) + } + } +} + +@_cdecl("wkb_clear_persistent_storage") +public func wkb_clear_persistent_storage() { + let types = WKWebsiteDataStore.allWebsiteDataTypes() + WKWebsiteDataStore.default().removeData(ofTypes: types, modifiedSince: .distantPast, completionHandler: {}) +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiApplication.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiApplication.kt index 70e4d0bad..c9312c829 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiApplication.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiApplication.kt @@ -37,6 +37,7 @@ public sealed interface UiApplication { @Immutable public data object VVo : UiApplication { override val host: String = vvoHost + val loginUrl: String = "https://$host/login?backURL=https://$host/" } public companion object { From 19f323affba418ba88b70971f6a4acc63b3379b7 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Thu, 28 Aug 2025 02:59:00 +0900 Subject: [PATCH 27/33] fix desktop ux --- .../platform/PlatformVideoPlayer.jvm.kt | 23 +++++++++++++++---- .../flare/common/macos/WKWebviewBridge.kt | 2 +- .../dev/dimension/flare/ui/route/Router.kt | 4 ---- .../ui/screen/home/HomeTimelineScreen.kt | 14 ++++------- .../flare/ui/screen/home/TimelineScreen.kt | 18 ++++++++++++++- .../serviceselect/ServiceSelectScreen.kt | 6 +---- gradle/libs.versions.toml | 4 ++-- settings.gradle.kts | 1 - .../flare/data/network/xqt/model/Entities.kt | 4 ++-- .../network/xqt/model/{Url.kt => XqtUrl.kt} | 2 +- 10 files changed, 46 insertions(+), 32 deletions(-) rename shared/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/model/{Url.kt => XqtUrl.kt} (96%) diff --git a/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformVideoPlayer.jvm.kt b/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformVideoPlayer.jvm.kt index 87b77daa5..b4165113e 100644 --- a/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformVideoPlayer.jvm.kt +++ b/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformVideoPlayer.jvm.kt @@ -58,7 +58,7 @@ internal actual fun PlatformVideoPlayer( if (autoPlay) { play() } - volume = if (muted) 0f else 1f +// volume = if (muted) 0f else 1f } } DisposableEffect(uri) { @@ -83,6 +83,11 @@ internal actual fun PlatformVideoPlayer( null } } + LaunchedEffect(player.isPlaying) { + if (player.isPlaying) { + player.volume = 0f + } + } LaunchedEffect(player) { while (true) { val duration = player.metadata.duration @@ -152,10 +157,17 @@ private fun Modifier.resizeWithContentScale( val placeable = measurable.measure( constraints.copy( - maxWidth = (srcSizePx.width * scaleFactor.scaleX).roundToInt(), - maxHeight = (srcSizePx.height * scaleFactor.scaleY).roundToInt(), - minWidth = (srcSizePx.width * scaleFactor.scaleX).roundToInt(), - minHeight = (srcSizePx.height * scaleFactor.scaleY).roundToInt(), + maxWidth = + (srcSizePx.width * scaleFactor.scaleX) + .takeIf { !it.isNaN() } + ?.roundToInt() + ?: constraints.maxWidth, + maxHeight = + (srcSizePx.height * scaleFactor.scaleY) + .takeIf { !it.isNaN() } + ?.roundToInt() + // or keep 16:9 + ?: (constraints.maxWidth * 9 / 16), ), ) layout(placeable.width, placeable.height) { placeable.place(0, 0) } @@ -173,6 +185,7 @@ public class VideoPlayerPool { create = { uri -> VideoPlayerState() .apply { +// volume = 0f loop = true openUri(uri) } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/common/macos/WKWebviewBridge.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/common/macos/WKWebviewBridge.kt index 5a749e8ec..d51873fbf 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/common/macos/WKWebviewBridge.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/common/macos/WKWebviewBridge.kt @@ -44,7 +44,7 @@ internal object WKWebviewBridge { fun openAndWaitCookies( url: String, - intervalMs: Int = 1000, + intervalMs: Int = 2000, decisionCallback: (cookies: String?) -> Boolean, windowClosedCallback: (id: Long, reason: Int) -> Unit, ) { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt index 15e6435bf..85854d091 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt @@ -344,10 +344,6 @@ internal fun RouteContent( Route.ServiceSelect -> { ServiceSelectScreen( onBack = onBack, - onVVO = { - }, - onXQT = { - }, ) } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt index dbdcbeefb..abc211f01 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt @@ -26,7 +26,6 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.FluentMaterials import dev.chrisbanes.haze.rememberHazeState import dev.dimension.flare.LocalWindowPadding -import dev.dimension.flare.RegisterTabCallback import dev.dimension.flare.model.AccountType import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.TabIcon @@ -56,13 +55,6 @@ internal fun HomeTimelineScreen( state.tabState.onSuccess { tabState -> state.selectedTab.onSuccess { currentTab -> - val lazyListState = currentTab.lazyListState - RegisterTabCallback( - lazyListState = lazyListState, - onRefresh = { - currentTab.refreshSync() - }, - ) Box { TimelineScreen( tabItem = currentTab.timelineTabItem, @@ -79,6 +71,9 @@ internal fun HomeTimelineScreen( }, ), contentPadding = PaddingValues(top = 48.dp), + onScrollToTop = { + state.setTopBarExpanded(true) + }, ) AnimatedVisibility( visible = state.isTopBarExpanded, @@ -98,8 +93,7 @@ internal fun HomeTimelineScreen( FluentTheme.colors.background.mica.base .luminance() < 0.5f, ), - ) -// .background(FluentTheme.colors.background.solid.base) + ).fillMaxWidth() .padding(LocalWindowPadding.current) .padding(horizontal = screenHorizontalPadding), ) { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt index 87e149d69..c458f4aa5 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt @@ -15,9 +15,11 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -50,6 +52,7 @@ internal fun TimelineScreen( modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(0.dp), header: @Composable (() -> Unit)? = null, + onScrollToTop: (() -> Unit)? = null, ) { val scope = rememberCoroutineScope() val state by producePresenter( @@ -57,7 +60,20 @@ internal fun TimelineScreen( ) { presenter(tabItem) } - RegisterTabCallback(state.lazyListState, onRefresh = state::refreshSync) + RegisterTabCallback( + state.lazyListState, + onRefresh = state::refreshSync, + ) + if (onScrollToTop != null) { + LaunchedEffect(state.lazyListState) { + snapshotFlow { state.lazyListState.firstVisibleItemIndex } + .collect { + if (it == 0) { + onScrollToTop() + } + } + } + } Box( modifier = modifier diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt index 1f44f672d..bca71b8a9 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt @@ -85,11 +85,7 @@ import org.apache.commons.lang3.SystemUtils import org.jetbrains.compose.resources.stringResource @Composable -internal fun ServiceSelectScreen( - onBack: () -> Unit, - onXQT: () -> Unit, - onVVO: () -> Unit, -) { +internal fun ServiceSelectScreen(onBack: () -> Unit) { val uriHandler = LocalUriHandler.current var host by remember { mutableStateOf(TextFieldValue("")) } val state by producePresenter { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4cb8f1aee..c24f3c553 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ clikt = "5.0.3" collection = "1.5.0" compileSdk = "36" -composemediaplayer = "0.8.1" +composemediaplayer = "0.8.2" haze = "1.6.10" lifecycleViewmodelComposeVersion = "2.9.2" minSdk = "23" @@ -207,7 +207,7 @@ krypto = { module = "com.soywiz:korlibs-crypto", version = "6.0.1" } androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" } androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" } -androidx-material3-adaptive-navigation3 = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation3", version = "1.0.0-SNAPSHOT" } +androidx-material3-adaptive-navigation3 = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation3", version = "1.0.0-alpha01" } androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version = "2.10.0-alpha02" } bouncycastle-bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 39f51adf5..8afa747bf 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -12,7 +12,6 @@ dependencyResolutionManagement { mavenCentral() maven("https://jitpack.io") maven("https://central.sonatype.com/repository/maven-snapshots/") - maven("https://androidx.dev/snapshots/builds/13932663/artifacts/repository") } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/model/Entities.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/model/Entities.kt index a1f393237..d721cb721 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/model/Entities.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/model/Entities.kt @@ -35,7 +35,7 @@ internal data class Entities( @SerialName(value = "symbols") val symbols: JsonElement? = null, @SerialName(value = "urls") - val urls: kotlin.collections.List? = null, + val urls: kotlin.collections.List? = null, @SerialName(value = "user_mentions") val userMentions: JsonElement? = null, @SerialName(value = "media") @@ -49,5 +49,5 @@ internal data class Entities( @Serializable internal data class Description( @SerialName(value = "urls") - val urls: kotlin.collections.List? = null, + val urls: kotlin.collections.List? = null, ) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/model/Url.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/model/XqtUrl.kt similarity index 96% rename from shared/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/model/Url.kt rename to shared/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/model/XqtUrl.kt index 3ecc43865..f23c6e90e 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/model/Url.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/model/XqtUrl.kt @@ -28,7 +28,7 @@ import kotlinx.serialization.Serializable * @param url */ @Serializable -internal data class Url( +internal data class XqtUrl( @SerialName(value = "display_url") val displayUrl: kotlin.String? = null, @Contextual @SerialName(value = "expanded_url") From 8c37536fde1f7af059383880d13c1d0afc8bc7aa Mon Sep 17 00:00:00 2001 From: Tlaster Date: Thu, 28 Aug 2025 14:50:22 +0900 Subject: [PATCH 28/33] fix home timeline loading progress --- .../ui/screen/home/HomeTimelineScreen.kt | 4 +- .../flare/ui/screen/home/TimelineScreen.kt | 70 +++++++++++-------- 2 files changed, 44 insertions(+), 30 deletions(-) diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt index abc211f01..aeecb301c 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt @@ -56,8 +56,8 @@ internal fun HomeTimelineScreen( state.tabState.onSuccess { tabState -> state.selectedTab.onSuccess { currentTab -> Box { - TimelineScreen( - tabItem = currentTab.timelineTabItem, + TimelineContent( + state = currentTab, modifier = Modifier .hazeSource(hazeState) diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt index c458f4aa5..d7b15658e 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt @@ -30,7 +30,6 @@ import dev.dimension.flare.LocalWindowPadding import dev.dimension.flare.RegisterTabCallback import dev.dimension.flare.Res import dev.dimension.flare.common.isRefreshing -import dev.dimension.flare.common.onSuccess import dev.dimension.flare.data.model.TimelineTabItem import dev.dimension.flare.home_timeline_new_toots import dev.dimension.flare.ui.common.plus @@ -54,12 +53,29 @@ internal fun TimelineScreen( header: @Composable (() -> Unit)? = null, onScrollToTop: (() -> Unit)? = null, ) { - val scope = rememberCoroutineScope() val state by producePresenter( "timeline_$tabItem", ) { presenter(tabItem) } + TimelineContent( + state = state, + modifier = modifier, + contentPadding = contentPadding, + header = header, + onScrollToTop = onScrollToTop, + ) +} + +@Composable +internal fun TimelineContent( + state: TimelineItemPresenter.State, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + header: @Composable (() -> Unit)? = null, + onScrollToTop: (() -> Unit)? = null, +) { + val scope = rememberCoroutineScope() RegisterTabCallback( state.lazyListState, onRefresh = state::refreshSync, @@ -106,33 +122,31 @@ internal fun TimelineScreen( .fillMaxWidth(), ) } - state.listState.onSuccess { - AnimatedVisibility( - state.showNewToots, - enter = slideInVertically { -it } + fadeIn(), - exit = slideOutVertically { -it } + fadeOut(), - modifier = - Modifier - .align(Alignment.TopCenter) - .padding(LocalWindowPadding.current) - .padding(contentPadding), + AnimatedVisibility( + state.showNewToots, + enter = slideInVertically { -it } + fadeIn(), + exit = slideOutVertically { -it } + fadeOut(), + modifier = + Modifier + .align(Alignment.TopCenter) + .padding(LocalWindowPadding.current) + .padding(contentPadding), + ) { + AccentButton( + onClick = { + state.onNewTootsShown() + scope.launch { + state.lazyListState.scrollToItem(0) + } + }, ) { - AccentButton( - 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(Res.string.home_timeline_new_toots)) - } + FAIcon( + imageVector = FontAwesomeIcons.Solid.AnglesUp, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(text = stringResource(Res.string.home_timeline_new_toots)) } } } From 318ccd906d0d494e21cc2177e5f53454f6d952c6 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Thu, 28 Aug 2025 17:57:03 +0900 Subject: [PATCH 29/33] fix cookie fetching --- .../flare/common/macos/WKWebviewBridge.kt | 37 +++++++- .../dev/dimension/flare/ui/route/Route.kt | 2 + .../dimension/flare/ui/route/StackManager.kt | 1 - desktopApp/src/main/swift/WebviewBridge.swift | 90 +++++++++++++++++-- 4 files changed, 123 insertions(+), 7 deletions(-) diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/common/macos/WKWebviewBridge.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/common/macos/WKWebviewBridge.kt index d51873fbf..363750177 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/common/macos/WKWebviewBridge.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/common/macos/WKWebviewBridge.kt @@ -5,6 +5,30 @@ import com.sun.jna.Library import com.sun.jna.Native private interface WebviewBridge : Library { + @Suppress("FunctionName") + fun wkb_set_log_callback(cb: LogCallback?) + + fun interface LogCallback : Callback { + fun invoke( + level: Int, + msg: String?, + ) + } + + @Suppress("FunctionName") + fun wkb_open_webview_poll_with_ua( + url: String, + intervalMs: Int, + ua: String?, + ): Long + + @Suppress("FunctionName") + fun wkb_set_user_agent( + id: Long, + ua: String?, + reload: Boolean, + ) + @Suppress("FunctionName") fun wkb_set_decision_callback(cb: DecisionCallback?) @@ -38,8 +62,19 @@ private interface WebviewBridge : Library { } internal object WKWebviewBridge { + private val iphoneUA = + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) " + + "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 " + + "Mobile/15E148 Safari/604.1" + private val lib: WebviewBridge by lazy { - Native.load("WebviewBridge", WebviewBridge::class.java) + Native.load("WebviewBridge", WebviewBridge::class.java).apply { + wkb_set_log_callback( + WebviewBridge.LogCallback { level, msg -> + println("WebviewBridge log [$level]: $msg") + }, + ) + } } fun openAndWaitCookies( diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt index 074e86528..47b866dfb 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt @@ -238,6 +238,8 @@ internal sealed interface Route { else -> null } + "Login" -> Route.ServiceSelect + "Search" -> { val accountKey = data.parameters["accountKey"]?.let { MicroBlogKey.valueOf(it) } val keyword = data.segments.getOrNull(0) ?: return null diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/StackManager.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/StackManager.kt index 224698fc6..07ded312a 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/StackManager.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/StackManager.kt @@ -176,7 +176,6 @@ internal class StackManager( savableStateHolder.removeState(id) viewModelStoreProvider.clear(id) lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) - println("Entry $id is cleared and destroyed") } else { lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP) } diff --git a/desktopApp/src/main/swift/WebviewBridge.swift b/desktopApp/src/main/swift/WebviewBridge.swift index aebed8da9..5b7201d7a 100644 --- a/desktopApp/src/main/swift/WebviewBridge.swift +++ b/desktopApp/src/main/swift/WebviewBridge.swift @@ -1,5 +1,20 @@ import Cocoa import WebKit +import Foundation + +public typealias LogCB = @convention(c) (_ level: Int32, _ msg: UnsafePointer?) -> Void +private var gLog: LogCB? + +@_cdecl("wkb_set_log_callback") +public func wkb_set_log_callback(_ cb: LogCB?) { gLog = cb } + +@inline(__always) +func swiftLog(_ level: Int32, _ msg: String) { + if let cb = gLog { + msg.withCString { cb(level, $0) } + } + fputs("[wkb \(level)] \(msg)\n", stderr) +} private var nextId: Int64 = 1 @@ -38,6 +53,17 @@ private func makeWindow(with web: WKWebView, title: String) -> NSWindow { return win } + +private func cookieHeaderString(from cookies: [HTTPCookie], for url: URL?) -> String { + let host = url?.host?.lowercased() + let filtered = cookies.filter { c in + guard let h = host else { return true } + let d = c.domain.lowercased() + return d == h || (d.hasPrefix(".") && (d.hasSuffix(h) || h.hasSuffix(d))) + } + return filtered.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") +} + private func startCookiePolling(for ctrl: Controller, targetURL: URL?, intervalMs: Int32) { let q = DispatchQueue(label: "cookie.poller.\(ctrl.id)") let t = DispatchSource.makeTimerSource(queue: q) @@ -51,11 +77,7 @@ private func startCookiePolling(for ctrl: Controller, targetURL: URL?, intervalM var header = "" DispatchQueue.main.async { store.getAllCookies { cookies in - var cookieString = "" - for cookie in cookies { - cookieString += "\(cookie.name)=\(cookie.value); " - } - header = cookieString + header = cookieHeaderString(from: cookies, for: targetURL) sem.signal() } } @@ -149,3 +171,61 @@ public func wkb_clear_persistent_storage() { let types = WKWebsiteDataStore.allWebsiteDataTypes() WKWebsiteDataStore.default().removeData(ofTypes: types, modifiedSince: .distantPast, completionHandler: {}) } + +@_cdecl("wkb_open_webview_poll_with_ua") +public func wkb_open_webview_poll_with_ua(_ urlCString: UnsafePointer?, + _ intervalMs: Int32, + _ uaCString: UnsafePointer?) -> Int64 { + let urlStr = urlCString.flatMap { String(cString: $0) } ?? "about:blank" + let targetURL = URL(string: urlStr) + let ua = uaCString.flatMap { String(cString: $0) } + + var outId: Int64 = 0 + DispatchQueue.main.sync { + if NSApp == nil { _ = NSApplication.shared } + + let cfg = WKWebViewConfiguration() + cfg.websiteDataStore = .nonPersistent() + if #available(macOS 11.0, *) { + cfg.defaultWebpagePreferences.preferredContentMode = .mobile + } + + let web = WKWebView(frame: .zero, configuration: cfg) + if let ua = ua { + web.customUserAgent = ua + } + if let u = targetURL { web.load(URLRequest(url: u)) } + + let win = makeWindow(with: web, title: "WebView") + let id = nextId; nextId += 1 + let ctrl = Controller(id: id, window: win, web: web) + ctrls[id] = ctrl + + NotificationCenter.default.addObserver(forName: NSWindow.willCloseNotification, object: win, queue: nil) { _ in + let reason = ctrl.closeReason + stopAndDispose(id: id) + if let cb = gOnClosed { + DispatchQueue.global(qos: .userInitiated).async { cb(id, reason) } + } + } + + win.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + + startCookiePolling(for: ctrl, targetURL: targetURL, intervalMs: intervalMs) + outId = id + } + return outId +} + +@_cdecl("wkb_set_user_agent") +public func wkb_set_user_agent(_ id: Int64, _ uaCString: UnsafePointer?, _ reload: Bool) { + DispatchQueue.main.async { + guard let ctrl = ctrls[id] else { return } + let ua = uaCString.flatMap { String(cString: $0) } + ctrl.web.customUserAgent = ua + if reload { + ctrl.web.reload() + } + } +} From c42d363e86bfedf18709670fe75835650d215da9 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Thu, 28 Aug 2025 18:47:14 +0900 Subject: [PATCH 30/33] update proguard --- desktopApp/build.gradle.kts | 5 +++-- desktopApp/proguard-rules.pro | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 4f9681b9c..6af521201 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -54,7 +54,8 @@ compose.desktop { macOS { val file = project.file("signing.properties") val hasSigningProps = file.exists() - packageBuildVersion = System.getenv("BUILD_NUMBER") ?: "17" + println("hasSigningProps: ${hasSigningProps}") + packageBuildVersion = System.getenv("BUILD_NUMBER") ?: "18" bundleID = "dev.dimension.flare" minimumSystemVersion = "12.0" appStore = hasSigningProps @@ -146,4 +147,4 @@ extra["jnaVersion"] = "5.17.0" extra["nativeDestDir"] = "resources/macos-arm64" apply(from = File(projectDir, "install-native-libs.gradle.kts")) -apply(from = File(projectDir, "build-swift.gradle.kts")) \ No newline at end of file +apply(from = File(projectDir, "build-swift.gradle.kts")) diff --git a/desktopApp/proguard-rules.pro b/desktopApp/proguard-rules.pro index 5908db5a7..23b0ed679 100644 --- a/desktopApp/proguard-rules.pro +++ b/desktopApp/proguard-rules.pro @@ -133,4 +133,5 @@ # An annotation used for build tooling, won't be directly accessed. -dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement -keep class io.github.kdroidfilter.** { *; } --keep class de.jangassen.jfa.** { *; } \ No newline at end of file +-keep class de.jangassen.jfa.** { *; } +-keep class dev.dimension.flare.common.macos.** { *; } From 78c8bc624b7b593aa2a3ff92dbc01fe792ca1e19 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Fri, 29 Aug 2025 15:37:05 +0900 Subject: [PATCH 31/33] update macos bridge --- compose-ui/build.gradle.kts | 1 - .../platform/PlatformBigscreen.jvm.kt | 14 +- .../platform/PlatformVideoPlayer.jvm.kt | 1 - desktopApp/build-swift.gradle.kts | 28 +- desktopApp/build.gradle.kts | 12 +- .../flare/common/macos/WKWebviewBridge.kt | 22 +- .../ui/screen/media/StatusMediaScreen.kt | 152 ++++----- .../ui/screen/settings/SettingsScreen.kt | 1 - .../dimension/flare/ui/theme/FlareTheme.kt | 2 +- .../macosBridge.xcodeproj/project.pbxproj | 316 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../UserInterfaceState.xcuserstate | Bin 0 -> 28702 bytes .../xcschemes/xcschememanagement.plist | 14 + .../WebViewBridge.swift} | 35 +- gradle/libs.versions.toml | 18 +- 15 files changed, 468 insertions(+), 155 deletions(-) create mode 100644 desktopApp/src/main/swift/macosBridge.xcodeproj/project.pbxproj create mode 100644 desktopApp/src/main/swift/macosBridge.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 desktopApp/src/main/swift/macosBridge.xcodeproj/project.xcworkspace/xcuserdata/tlaster.xcuserdatad/UserInterfaceState.xcuserstate create mode 100644 desktopApp/src/main/swift/macosBridge.xcodeproj/xcuserdata/tlaster.xcuserdatad/xcschemes/xcschememanagement.plist rename desktopApp/src/main/swift/{WebviewBridge.swift => macosBridge/WebViewBridge.swift} (88%) diff --git a/compose-ui/build.gradle.kts b/compose-ui/build.gradle.kts index b69906c50..eea632e4f 100644 --- a/compose-ui/build.gradle.kts +++ b/compose-ui/build.gradle.kts @@ -87,7 +87,6 @@ kotlin { implementation(project.dependencies.platform(libs.koin.bom)) implementation(libs.fluent.ui) implementation(libs.koin.compose) - implementation(compose("org.jetbrains.compose.material3:material3-window-size-class")) implementation(libs.composemediaplayer) implementation(libs.androidx.collection) } diff --git a/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformBigscreen.jvm.kt b/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformBigscreen.jvm.kt index 1071274a9..fc3ab6c66 100644 --- a/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformBigscreen.jvm.kt +++ b/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformBigscreen.jvm.kt @@ -1,13 +1,15 @@ package dev.dimension.flare.ui.component.platform -import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi -import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass -import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toSize -@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Composable public actual fun isBigScreen(): Boolean { - val windowInfo = calculateWindowSizeClass() - return windowInfo.widthSizeClass >= WindowWidthSizeClass.Medium + val density = LocalDensity.current + val windowInfo = LocalWindowInfo.current + val size = with(density) { windowInfo.containerSize.toSize().toDpSize() } + return size.width >= 720.dp } diff --git a/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformVideoPlayer.jvm.kt b/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformVideoPlayer.jvm.kt index b4165113e..8a54e31fd 100644 --- a/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformVideoPlayer.jvm.kt +++ b/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformVideoPlayer.jvm.kt @@ -185,7 +185,6 @@ public class VideoPlayerPool { create = { uri -> VideoPlayerState() .apply { -// volume = 0f loop = true openUri(uri) } diff --git a/desktopApp/build-swift.gradle.kts b/desktopApp/build-swift.gradle.kts index f6ae18fdb..fac9819eb 100644 --- a/desktopApp/build-swift.gradle.kts +++ b/desktopApp/build-swift.gradle.kts @@ -1,24 +1,32 @@ -val swiftSource = layout.projectDirectory.file("src/main/swift/WebviewBridge.swift") -val targetLib = layout.projectDirectory.dir("resources/macos-arm64").file("libWebviewBridge.dylib").asFile +import java.nio.file.Files + +val swiftSource = layout.projectDirectory.dir("src/main/swift/macosBridge.xcodeproj").asFile +val buildOutput = layout.projectDirectory.file("src/main/swift/build/Release/libmacosBridge.dylib") +val targetLib = layout.projectDirectory.dir("resources/macos-arm64").file("libmacosBridge.dylib").asFile val isMac = org.gradle.internal.os.OperatingSystem.current().isMacOsX tasks.register("compileWebviewBridgeArm64") { onlyIf { isMac } - inputs.file(swiftSource) +// inputs.file(swiftSource) outputs.file(targetLib) doFirst { targetLib.parentFile.mkdirs() } + commandLine( - "swiftc", - "-emit-library", - "-module-name", "WebviewBridge", - "-framework", "Cocoa", - "-target", "arm64-apple-macos12", - "-o", targetLib.absolutePath, - swiftSource.asFile.absolutePath + "xcodebuild", + "-project", swiftSource.absolutePath, + "-target", "macosBridge", + "-configuration", "Release", + ) + + // copy buildOutput to targetLib + Files.copy( + buildOutput.asFile.toPath(), + targetLib.toPath(), + java.nio.file.StandardCopyOption.REPLACE_EXISTING, ) } diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 6af521201..026045cfa 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -55,7 +55,7 @@ compose.desktop { val file = project.file("signing.properties") val hasSigningProps = file.exists() println("hasSigningProps: ${hasSigningProps}") - packageBuildVersion = System.getenv("BUILD_NUMBER") ?: "18" + packageBuildVersion = System.getenv("BUILD_NUMBER") ?: "21" bundleID = "dev.dimension.flare" minimumSystemVersion = "12.0" appStore = hasSigningProps @@ -96,11 +96,11 @@ compose.desktop { buildTypes { release { proguard { -// this.isEnabled.set(false) - version.set("7.7.0") - this.configurationFiles.from( - file("proguard-rules.pro") - ) + this.isEnabled.set(false) + // version.set("7.7.0") + // this.configurationFiles.from( + // file("proguard-rules.pro") + // ) } } } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/common/macos/WKWebviewBridge.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/common/macos/WKWebviewBridge.kt index 363750177..b8b158263 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/common/macos/WKWebviewBridge.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/common/macos/WKWebviewBridge.kt @@ -4,7 +4,7 @@ import com.sun.jna.Callback import com.sun.jna.Library import com.sun.jna.Native -private interface WebviewBridge : Library { +private interface MacOSBridge : Library { @Suppress("FunctionName") fun wkb_set_log_callback(cb: LogCallback?) @@ -22,13 +22,6 @@ private interface WebviewBridge : Library { ua: String?, ): Long - @Suppress("FunctionName") - fun wkb_set_user_agent( - id: Long, - ua: String?, - reload: Boolean, - ) - @Suppress("FunctionName") fun wkb_set_decision_callback(cb: DecisionCallback?) @@ -44,9 +37,6 @@ private interface WebviewBridge : Library { @Suppress("FunctionName") fun wkb_close_window(id: Long) - @Suppress("FunctionName") - fun wkb_clear_persistent_storage() - fun interface DecisionCallback : Callback { // return 1 = close window, 0 = continue fun invoke(cookies: String?): Int @@ -67,10 +57,10 @@ internal object WKWebviewBridge { "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 " + "Mobile/15E148 Safari/604.1" - private val lib: WebviewBridge by lazy { - Native.load("WebviewBridge", WebviewBridge::class.java).apply { + private val lib: MacOSBridge by lazy { + Native.load("macosBridge", MacOSBridge::class.java).apply { wkb_set_log_callback( - WebviewBridge.LogCallback { level, msg -> + MacOSBridge.LogCallback { level, msg -> println("WebviewBridge log [$level]: $msg") }, ) @@ -84,11 +74,11 @@ internal object WKWebviewBridge { windowClosedCallback: (id: Long, reason: Int) -> Unit, ) { val decisionCb = - WebviewBridge.DecisionCallback { cookies -> + MacOSBridge.DecisionCallback { cookies -> if (decisionCallback(cookies)) 1 else 0 } val closedCb = - WebviewBridge.WindowClosedCallback { id, reason -> + MacOSBridge.WindowClosedCallback { id, reason -> windowClosedCallback(id, reason) } lib.wkb_set_decision_callback(decisionCb) diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt index 384e93643..cb151cc5f 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt @@ -1,11 +1,8 @@ package dev.dimension.flare.ui.screen.media import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -14,6 +11,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -27,25 +25,15 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.WindowPlacement import coil3.compose.LocalPlatformContext import coil3.compose.SubcomposeAsyncImage import coil3.request.ImageRequest import coil3.request.crossfade import coil3.size.Size -import compose.icons.FontAwesomeIcons -import compose.icons.fontawesomeicons.Solid -import compose.icons.fontawesomeicons.solid.Chalkboard -import compose.icons.fontawesomeicons.solid.FloppyDisk -import compose.icons.fontawesomeicons.solid.UpRightAndDownLeftFromCenter -import dev.dimension.flare.Res -import dev.dimension.flare.media_fullscreen -import dev.dimension.flare.media_hide_thumbnail_list -import dev.dimension.flare.media_save -import dev.dimension.flare.media_show_thumbnail_list import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.component.FAIcon +import dev.dimension.flare.ui.component.ComponentAppearance +import dev.dimension.flare.ui.component.LocalComponentAppearance import dev.dimension.flare.ui.component.NetworkImage import dev.dimension.flare.ui.component.status.MediaItem import dev.dimension.flare.ui.model.UiMedia @@ -56,10 +44,8 @@ import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.presenter.status.StatusPresenter import dev.dimension.flare.ui.presenter.status.StatusState import dev.dimension.flare.ui.theme.LocalComposeWindow -import io.github.composefluent.FluentTheme import io.github.composefluent.component.GridViewItem import io.github.composefluent.component.HorizontalFlipView -import io.github.composefluent.component.SubtleButton import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch import me.saket.telephoto.ExperimentalTelephotoApi @@ -69,7 +55,6 @@ import me.saket.telephoto.zoomable.rememberZoomableState import me.saket.telephoto.zoomable.spatial.CoordinateSpace import me.saket.telephoto.zoomable.zoomable import moe.tlaster.precompose.molecule.producePresenter -import org.jetbrains.compose.resources.stringResource import javax.swing.JFileChooser @Composable @@ -121,10 +106,19 @@ internal fun StatusMediaScreen( ) else -> - MediaItem( - media = media, - modifier = Modifier.fillMaxSize(), - ) + CompositionLocalProvider( + LocalComponentAppearance provides + LocalComponentAppearance + .current + .copy( + videoAutoplay = ComponentAppearance.VideoAutoplay.ALWAYS, + ), + ) { + MediaItem( + media = media, + modifier = Modifier.fillMaxSize(), + ) + } } } AnimatedVisibility( @@ -172,63 +166,63 @@ internal fun StatusMediaScreen( } } } - Row( - modifier = - Modifier - .height(48.dp) - .fillMaxWidth() - .background(FluentTheme.colors.background.layer.default) - .padding(8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Spacer(modifier = Modifier.weight(1f)) - if (medias.size > 1) { - SubtleButton( - onClick = { - state.setShowThumbnailList(!state.showThumbnailList) - }, - content = { - FAIcon( - FontAwesomeIcons.Solid.Chalkboard, - contentDescription = - if (state.showThumbnailList) { - stringResource(Res.string.media_hide_thumbnail_list) - } else { - stringResource(Res.string.media_show_thumbnail_list) - }, - ) - }, - ) - } - SubtleButton( - onClick = { - val current = medias[pagerState.currentPage] - state.save(current) - }, - content = { - FAIcon( - FontAwesomeIcons.Solid.FloppyDisk, - contentDescription = stringResource(Res.string.media_save), - ) - }, - ) - SubtleButton( - onClick = { - val current = window.placement - if (current == WindowPlacement.Fullscreen) { - window.placement = WindowPlacement.Floating - } else { - window.placement = WindowPlacement.Fullscreen - } - }, - content = { - FAIcon( - FontAwesomeIcons.Solid.UpRightAndDownLeftFromCenter, - contentDescription = stringResource(Res.string.media_fullscreen), - ) - }, - ) - } +// Row( +// modifier = +// Modifier +// .height(48.dp) +// .fillMaxWidth() +// .background(FluentTheme.colors.background.layer.default) +// .padding(8.dp), +// horizontalArrangement = Arrangement.spacedBy(8.dp), +// ) { +// Spacer(modifier = Modifier.weight(1f)) +// if (medias.size > 1) { +// SubtleButton( +// onClick = { +// state.setShowThumbnailList(!state.showThumbnailList) +// }, +// content = { +// FAIcon( +// FontAwesomeIcons.Solid.Chalkboard, +// contentDescription = +// if (state.showThumbnailList) { +// stringResource(Res.string.media_hide_thumbnail_list) +// } else { +// stringResource(Res.string.media_show_thumbnail_list) +// }, +// ) +// }, +// ) +// } +// SubtleButton( +// onClick = { +// val current = medias[pagerState.currentPage] +// state.save(current) +// }, +// content = { +// FAIcon( +// FontAwesomeIcons.Solid.FloppyDisk, +// contentDescription = stringResource(Res.string.media_save), +// ) +// }, +// ) +// SubtleButton( +// onClick = { +// val current = window.placement +// if (current == WindowPlacement.Fullscreen) { +// window.placement = WindowPlacement.Floating +// } else { +// window.placement = WindowPlacement.Fullscreen +// } +// }, +// content = { +// FAIcon( +// FontAwesomeIcons.Solid.UpRightAndDownLeftFromCenter, +// contentDescription = stringResource(Res.string.media_fullscreen), +// ) +// }, +// ) +// } } } } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt index 2cb9886f4..0a0d3cf09 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt @@ -587,7 +587,6 @@ internal fun SettingsScreen(toLogin: () -> Unit) { LocalAppearanceSettings.current.videoAutoplay in listOf( VideoAutoplay.ALWAYS, - VideoAutoplay.WIFI, ), { state.appearanceState.updateSettings { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt index 2a9a6df0d..541164ed4 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt @@ -209,7 +209,7 @@ internal fun ProvideThemeSettings(content: @Composable () -> Unit) { videoAutoplay = when (appearanceSettings.videoAutoplay) { VideoAutoplay.ALWAYS -> ComponentAppearance.VideoAutoplay.ALWAYS - VideoAutoplay.WIFI -> ComponentAppearance.VideoAutoplay.WIFI + VideoAutoplay.WIFI -> ComponentAppearance.VideoAutoplay.NEVER VideoAutoplay.NEVER -> ComponentAppearance.VideoAutoplay.NEVER }, expandMediaSize = appearanceSettings.expandMediaSize, diff --git a/desktopApp/src/main/swift/macosBridge.xcodeproj/project.pbxproj b/desktopApp/src/main/swift/macosBridge.xcodeproj/project.pbxproj new file mode 100644 index 000000000..86c568c85 --- /dev/null +++ b/desktopApp/src/main/swift/macosBridge.xcodeproj/project.pbxproj @@ -0,0 +1,316 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + 065A63742E61790F003D9E48 /* libmacosBridge.dylib */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.dylib"; includeInIndex = 0; path = libmacosBridge.dylib; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 065A63762E61790F003D9E48 /* macosBridge */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = macosBridge; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 065A63722E61790F003D9E48 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 065A636B2E61790F003D9E48 = { + isa = PBXGroup; + children = ( + 065A63762E61790F003D9E48 /* macosBridge */, + 065A63752E61790F003D9E48 /* Products */, + ); + sourceTree = ""; + }; + 065A63752E61790F003D9E48 /* Products */ = { + isa = PBXGroup; + children = ( + 065A63742E61790F003D9E48 /* libmacosBridge.dylib */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + 065A63702E61790F003D9E48 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + 065A63732E61790F003D9E48 /* macosBridge */ = { + isa = PBXNativeTarget; + buildConfigurationList = 065A637D2E61790F003D9E48 /* Build configuration list for PBXNativeTarget "macosBridge" */; + buildPhases = ( + 065A63702E61790F003D9E48 /* Headers */, + 065A63712E61790F003D9E48 /* Sources */, + 065A63722E61790F003D9E48 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 065A63762E61790F003D9E48 /* macosBridge */, + ); + name = macosBridge; + packageProductDependencies = ( + ); + productName = macosBridge; + productReference = 065A63742E61790F003D9E48 /* libmacosBridge.dylib */; + productType = "com.apple.product-type.library.dynamic"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 065A636C2E61790F003D9E48 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastUpgradeCheck = 1640; + TargetAttributes = { + 065A63732E61790F003D9E48 = { + CreatedOnToolsVersion = 16.4; + LastSwiftMigration = 1640; + }; + }; + }; + buildConfigurationList = 065A636F2E61790F003D9E48 /* Build configuration list for PBXProject "macosBridge" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 065A636B2E61790F003D9E48; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 065A63752E61790F003D9E48 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 065A63732E61790F003D9E48 /* macosBridge */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + 065A63712E61790F003D9E48 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 065A637B2E61790F003D9E48 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 7LFDZ96332; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 15.5; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + }; + name = Debug; + }; + 065A637C2E61790F003D9E48 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 7LFDZ96332; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 15.5; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = macosx; + }; + name = Release; + }; + 065A637E2E61790F003D9E48 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 7LFDZ96332; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + EXECUTABLE_PREFIX = lib; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/macosBridge/build/EagerLinkingTBDs/Release", + "$(PROJECT_DIR)/macosBridge/build/Release", + "$(PROJECT_DIR)/macosBridge/build/macosBridge.build/Release/macosBridge.build/Objects-normal/arm64/Binary", + "$(PROJECT_DIR)/macosBridge/build/macosBridge.build/Release/macosBridge.build/Objects-normal/x86_64/Binary", + ); + MACOSX_DEPLOYMENT_TARGET = 12.4; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + 065A637F2E61790F003D9E48 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 7LFDZ96332; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + EXECUTABLE_PREFIX = lib; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/macosBridge/build/EagerLinkingTBDs/Release", + "$(PROJECT_DIR)/macosBridge/build/Release", + "$(PROJECT_DIR)/macosBridge/build/macosBridge.build/Release/macosBridge.build/Objects-normal/arm64/Binary", + "$(PROJECT_DIR)/macosBridge/build/macosBridge.build/Release/macosBridge.build/Objects-normal/x86_64/Binary", + ); + MACOSX_DEPLOYMENT_TARGET = 12.4; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 065A636F2E61790F003D9E48 /* Build configuration list for PBXProject "macosBridge" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 065A637B2E61790F003D9E48 /* Debug */, + 065A637C2E61790F003D9E48 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 065A637D2E61790F003D9E48 /* Build configuration list for PBXNativeTarget "macosBridge" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 065A637E2E61790F003D9E48 /* Debug */, + 065A637F2E61790F003D9E48 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 065A636C2E61790F003D9E48 /* Project object */; +} diff --git a/desktopApp/src/main/swift/macosBridge.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/desktopApp/src/main/swift/macosBridge.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/desktopApp/src/main/swift/macosBridge.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/desktopApp/src/main/swift/macosBridge.xcodeproj/project.xcworkspace/xcuserdata/tlaster.xcuserdatad/UserInterfaceState.xcuserstate b/desktopApp/src/main/swift/macosBridge.xcodeproj/project.xcworkspace/xcuserdata/tlaster.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000000000000000000000000000000000000..78ebaf94d625e9747185157627e5dc6494852f15 GIT binary patch literal 28702 zcmeHvcU)6v_xOG8Mg@W(2(k$X1Ofp90c3_fLKtBwB0>Zd0ZCBNs`pWAt*fpM+gb(O zc3E4suG-dCYinDz!@k|E)oQhSXovlsn@bP`eaE+NfB(GXL&km1eb$-JIp>~4LO7yO4^gd zNoUf9^dh}UAJUifBNb!_8A_^1H5pANlPP2>sU@??BC>+4CTqz$(nvOvQ^{tsjWm%n z$eCmZxr$s(t|8Zw_mcOK_mdBh>&OSmhsX`&7V>fO3GzvDANee~pFB#wM7~TOBVQ%o zAm1g=lAn@al3$bGlRuC@l2^&!$v-GWkrYdD6i<0mK9n!zN69IFDu5bI1yVs&Fr}o_ zR5X=HB~iwXygQTbFMWuU64@l-WcPc=}}sF~DUYCg4yT0$+Q?x8kN-P9IpC-n@q zhuTjaq@JT*q>fR?sn@78)LYb9>I3RS>Lcni>T~Kmb%FYlxAmy;`Z@Xt zeUv^yze>MGzeT@IzeB%EpQS&fKczpTzox&TzooyUzo)O$e=-Dv7?R-`8^(?q$+$9Z zjGXaj0+=`^o*Ba=Fo{eOlgy+rsZ1SXWF|57Oas%%OlGDqP0UoLnQ38KnQ2TLV`Anq z^O*(AUCd%;8MB;O#jIu?X4W&0Fk6^Mna7x2%(KiP=6U8N<^=O5bC&sl`H;EDTw=ar zE;Cn{ubJ?!s%dxm|V{ebXwGx-jF7C(o-n_t2&<(Kix`4#*<{7U{lek0$>ck$i)CVn&j z1pg$zi+`Fw%D=?F%pc>A^RMu)8RX6FO-*MATf&a8CuD>p;asLGR!%W?m@473N79zc zYny6JrY^#P7%q?k)kQcFBLo_wdjxs4Os-8)t7FpCF`-dvbxLTIHdY%Ns|t?_O-YZ= zh>eL%iOoolt(G}ur=^#*v`sO!*3=r)T58*;8k^15k=+PCB4iseif|`92v5R`@Fsi+ zUx5)=ffIPaMi?Rt6^3mis&*D@p9 zlmUw?u5B|Ko5%NF&1kEcYOI!p!iB6FQ+{K88)%BU(we4rqbZ}UWvZ^Y$Y?URwAD;( zGFr4qT8+7;T9(*%`Xx0JwJpu&ww9(QV_S8GMw1Z{8=eswu8vfPMnyzuLsOzO8KEjw zWNccRHd3Wc*H+5{dS?K4r`EKU)tE{fO^q;0S);k3xUspu$p|ggGA{s3sWpSb>)o#Y zv`03ps#=ToDg4m`HI$%M4x3^v3!c*ih+G@6VJrBW$Y!KpD)9O^xP8qp7U1&fFk@ zWjC8zLCLqY-2m&ovk#a!K=18U&{uk0W6w?2r4$k6M95~Mm?$9(L@7}wxC(B?zHK$xxTwpQZBuec!pW+rE0|2y+ArSKk=S~6(qdSQOLZB#XMCu;&p3@+{ z?jjZwcUQ~Y0C^(w>P5q{$k^IuG=Ze45oaL;3qb`AXyrt!(TPZkW>`-jAUcq6=9F5% zq{&zZEN`r6G#jT{7GGuR!BaL|F^xCXihQqcF#*lCG)JZ!3x96;?ArQmoUMc72p3oP z)4a`)VI{3K=7tIoL&lj|jm_rnp+kp`omx}dVoGgmtgAQ5&}n36FB@;1*;>;q8o8E% zEPc&X*6olH(8+LF=TLxhl36advg<~|MQ2&pQ1R+W>FSKGC(7Wen+ygRWtiCB*i;9N z9>b)bzQ3!g(+irtp{Fii|1LkdAJOyYcSJ6I>k1g{mpo1S?w7n)BYopR)XTwG9s`DP z2~h>6a6Op8?F1$kz$V{MY$Upftzh~-PV5E4_c`Jy7`&&6cZd(b#JxmZ2J`j@;%DL- zBGFJ}hg_siNhEy|fjyr+AIMuGqGiN#5R>UMTbm^5ThMN9YHT)^HX3J0#=VHu9)j#9 zRuK0PbN*%cYT3l<5_6`uR27|8T5@9y;LC{U8$bIDS<=yJG{y7`S6y0M9jUqjXF*pn zNVews>^h*X#z~E!2&84KB34(+9CgL{?PhVpHyEIsSS7N6mAuyy>jq`%2Vs{VCLR<* zh0%f%-WApxHWHl@&si^QBDVB0$W|dtWJZY#b^@V?7s#HviOxCD0sj{|Kh(HZHF@L4 zE}LOpLxv7QO15@=NolbKy}W~5IVQKnP+Bq0*gS2PgmPr11pEG!KrFB>?Q!C1QEERy zJW1>#o+5S&5kjO8C8&ky&BQaHdiE0gK>f#n+KCksCB^Sl3v5}_3ha9WB_L%*WeM`O zS87-UY-)!^COR5L{;Rv`4&R&Z=uy)}Age8Sx7_M?6A-il9rO{s0Cn18RXtCoK&PaqriZ7bMTEx20AN&9Omt{! zMye(>Jt9J_N{dR5h)IpO0Uf1^ijL8y#)L+zGobHOjXG45rqPB*Xd*JSs?J~ z?IvTJzRftv*wzNLWi@3iou$t-8!Zw)I}L{Q=?AF=wNu~H*3lP6qL9nPm5oBSL_^;Y z-;VE_U;!i{fc|yLR z6AA>qP$(1$#X^Z-5K4tIq5LtB+!Ue_gIL5N9@(HFAjyXzTVy9x2$jMlp&p|V!VHXD zFdB)GD@J}8$uaWB$Ye30BttB}y~*6z3WhW&T(iEdaVn_yyqb=db~6|xX~v1|_4Qz> zh>fC@uk5+WVw?D-8Yi{18S`sqHcoAvWxQe7S->rfDB{g(L*3M#Eoxpp=-_Huu+)*Z zWom0pTcfF^*<$+`07X5F-FH;`9ywuA$sJ8VKHh1@Nj0zxz37cH0rGCLDPR(QtiU}=YZZ2q~11b55^kHD&aJ9(-rHw9YOr`Ow6*4l9@cbXK(Y2 zlguS8MUC|h))N-;LOw)DC-N4?b|PP4oQNX_(1g8vZ?SDG#D)ThD_v+b3KXh@S|PiK zn2-X65zgHx1cf4{FkYw@CUm24A^=4QH6Vp23i2xG?uO(YC;qx|ERn%a5ykm6_05gu z_Bvw)EI?YDUvlGwzMO^PP~w1*k_b6U5$ZtF8$~uC67B|=>se$QRt#f zaSd50TWG*&*o{Txp<*Ir3(7}2RDkrTkoZ|>6ebH(geGCCpxc5*?ucc!z$i;y7ciGcXk8!QIwhH-ulW zVI@o#?h@`6+Jx=!ZK1IE9W)EgMt8vC=AgM~9ug2n^U(stfq7%~EabJ+T1@`&H%L+v z2P1k%{SeS>kYg4=vW_M=Z^E z3~cU(xo<+7(H69|FLEo)jZ=X=YNoadbA@>VQ7x+z2`nxwEKHQbrZBU3_A<7G>DyW? zp8c?lrkXZmSe?-{1swh1d@&7XX8FUc# zoptAet=ZSg+I-&6xo(lFP%Qq?9d_CYg+2z76cDFO+z2ztxgb+RY6u zZ7|Tl6^q=E(b7~0l54Qtn$cF%ng>0bBnf(nc%c)0CEP2Xu0*B(HJqbBHd$VW&&f+BpFh@y8@F*Gd7uP zB&^-kXdU2Bk^;MhBuGS(!h^y?!o%GpO)?}atQR&2;41@f#el7Cy=mlPuoz(H!fpdt z($bOEXcBMiZ?3RnigecN%#AnfyE6KhTsjYv05% zKR0-d2p1mlORn44*PtUulA~bfNmtTM=n}fSNO$5HVG|sdNI5xL?8cuA5Vi{2;KW6`LnoUAxnUBJjOnJ%q*W-%Fe&sf zz)tES!$qkfh9w5HN%uyOky1FJUpsI??UXJuO1xQ&H4JQ&QPvs9knwPeB{gI$87Dj@ zJT5%ZO^zWG$OPd@;bVA#NEi+Vpee=FV=9U^7%+y+da8j&%YJ}#OX~pFYGbYSiiP6I zG_WDabTWg?6m|*!z=#$!18hGshs-7O$b8fe-}Gpv@QiR+cusf3wwoqy(SCvTmvSO%PF8Z7 zMcxUTiJVQ|LCzuPlJiJ`#N>Q(0l82(B0Mj=AiO9X6wc z*8ue7Kv3#SpG_O{bBpiJtbIA@!^X#jN32VKlRP7-nbV+V-n;c?4HUHRk^dmyCqDo| z`yTl*2-=T?3&NMe`ygmP627=u(0)dK-YaN77e44OXfKL__7eFOd0F^S_(b^hhM;|q zOaMXq5hxEh#5hd8csP17llj0SHflC zitx4Yjqt7To$&n*YJ?3U9Cm?(AUn9HF!|PNG6$iXdX{cD?7vX9b6;F*3t_gqsWoH5^g-QoGN~Ka- zDoyxR_)Yk|o64XvL5}_*T>t+rM=2d)PZbOlj@w>8ue-xNZP`}m>m4EQI=%^Vlqv$y z;(?%HFPxt>aYtIlv)g~UBhqWn*tjk|X-T%-0 z)6|2gjCvSZ{L>iO3Og~9i5_Z;g-&e*Uoq8*AxtAVi>XZrJmB`ij<>)O+=M2>DS4~q z)ed4-d~LT5{V4Th&sl|fLOiQrK1$WhRo0xCIx zk(1>FMIE9J!x>TsF7htw5JbMIBh>Stj_c4G>ILwC>xz5alja8L00<|aKG!TcSr|D> zeI2D<0*6!Y4PDgB;27(3p|YUW*qq%g9;d8JdWAYcgg}ytqMyTN5y zd0{jPBOhS_MnI>Y|DiLNdRyFrcQA7AB@zm}K_dD+ZbrZ3yg#MCGstqxp*|M1E=Jyx z+AaC()NWtzqQ#?0eId-p$hTUS{}=c%Z}bjlJS%3&I_d=r%D^8(eI*j(WsCxP12EJ# z)XzZR)VI`k)c4d6)Q{9p7>&j#5ThWBf-zEH1kJxtSE+08{u==y#ZZit7$sT0w27hq zeqqAF!YKbd{PORFb7WQr1kKVzfJJDI=4l&@!Y~TQNYzabm6#;L$|V1%BR%a1ltepW z6gkLIo_3)}qOG(WYKLzgv?oT17>yA?$VCa;EkEKgO6W)Jv^VV|Q9BK>*;tI!H>h25 zo6)1i5dj0yy|ha!B-)Mnw`jMI6-UQ_Xjz0&e6{SqLeG$6P}?$9SVXALz+N|h#3P$X*V!aE@3A9-@}aLL8B+qjlg|$ zEnP<&=}B}w4a>>KCb(ea#c-tY`tnJNel6-?+QnPjAq}}ZC z2aCbw;*_Q)a4SksgH2~Ky$W)z>AUGA^ip~my_{Y_-$Sp&s1&0zjLI>pz^D?Vu^5fR zsA?O%nqEV%rSGNhqwl95px0qE9;0fECSX*9(L{{uFfw8^Nu2t~@Mv{PbYx6;M0&I; z22!HK!z0sT)1x4lN#4^Gd2>^!zNx*wvDsjqgspALqAPF0}lJ1R6^jZZny#wK?r=o59=8F=x6(P zd5}INU8t5t%?{NlV>OznXmxZP(r=0k^g8_pMokz^ z?V?Z7V9PaQ)FLQKC6*|aXn#;v=r8Iad`iDZ|D#&wAz`*hXWY#A-SqqP2cp;i_F#jD zl&v%Wi2m4$nVXY6tRSD$UqW6eeUAQuK2Kl3$c#}tM$<8xv5CG&Um|O0FsWu@G)LHj z(IP=nE@u0PULYwR(VNlJn-^3m#j8Z&P+`dl5`nFw{Xkz8NBfcfiT;`X1)~m(W??iN zqdPW1ER!Q3*%EB78E|T|L@-B{LJpOf)(WXT#u;J)jH#$*hV=9Vcq{w2_M}Z0*S54m zNXxrlqvdu`$!SIi8#cB=)C&Zwb3YJE?_vr{&s-UbVIYTzp)s1<$*>sB6J^dVvY415 zkO9ODWrkrSV6>pu0%hzOc*v!H9uwn41TfARVZj~}Nd$TCj`}6fKhign#f)OS2Hflo zH~V6=P`vq0L4IS4{gNN#``ta72?xo`1TsNPFr#2Xm{3N^gkf|SMvF1J8>1x{EyZXV zM$0i;AwKOQzAVvY@f+Yle2LGwNH18u0wPeoc_noa4>Ywj8O4mKyvB)bkZhOUEF#OA zb0$9YGAMM3HAAC6eDJN{_1S49(AKZ>kXr%g8_hL!kWJJdT+BTfd@g|T{-C#3I0vrAXbnbdF}fF{`!Kp6qX#eoZTcWa4`K8$M(Z(pMC`4E zF;M%MGBKBcsf7P=OcgU8u1sKRhyaW>V6+X6+L+7-uC&FZUVJOWWHBZ|8N>B9OqwKS z9VsOx_m~Jhp;kzk08WFrf+ed_YO~lHri_+0snJ7v{K#rM8_hRTkwCMJlUj>w8;s(E zRPv(sW?*74_$>KWmW&fK7~+t7Yss$^pY4IiPy8X7v({+Q%rFkvD18A<*^_ieW1X>1 z^f2_yQ+kFArs^o6s-yv^x-PRueB=uHbCcS|{MY7sm`F27637S4Zl2TvG6Ci!#>R~^ zAz4$*%Z6^8q-pn`Eu156UaLqP7GuKRY614nBwT!iM$98^GQv|kHxfdntw;rnvd+>Q zEEv*lK)AuEsW9s)tu0^_m@IAL!)6ve&zOl9w$UEUbn;_nCey*pg4pZ~jNkybQTPI* zE{rx|v;_jR3q|9L-YL&4k_PRrmQDBz2km3v-*6`MUF%Y5 z*v-|lh?$`?#pICCrj~k09Q#+Y*86s}QtD{yfR6r^WO*yUuVKJB*UhYD?iJN9oVRxf zi@TWzm~{;B@J@`L!RR|tBaOD05!T1Y`g5NWa>=a}{+}OJtuxxdbb%ct5|2bJT}(Fv zRP!iCPxn4H!fa*0;Ot~TXgt=*?7--8agMH5;O!q-Q5efd3>7EW8c!rq$;Pd-|IUxG{ z;A#C{7oM15iqS67UBnzF?3w2nOKO>@DpE;LjGnTXz|0HGi&ecQuy{{5T!M6W@GOeI zB%qg>V_-gj-0!o$)+Kc_$C+0|!|fLBx3$=qSDBN&+Xs0oF*i2$6!Vt2`=@CSjKCC& z>tfz!-oa=eMy~)+AC`SzqQ1R<2lq%PedZ(Pb1>_ekC{)HPnpj!dKRPo7#+aq;3noA z^96IB0n73bMu#za38Uj8Nep%fzRd}HFz{OvRrO!u1Naxu-GiF^5?OyElJ&P3J=f<* z!~DSf$oxdVg3${YJ&(~5%R%P9F6YEU&7rsdSn<1+nSW*e=zpkTt}}mP^dd$_Mf41E zqVIc*>FI@~Sq^$(85Zp0moYl_m--g0gV?e5{SKV$aMl$ZOROX7#ExK{Sr>LBMkg?O z6{FWMI*HNi7`?HDbz?`d?yLvv$$DY*CPt?)f>Yb)7=4A&WpR!FGJ*#qzT431f7KiG z_j|*vXb5D3tcS@#sKW{s%0|PfgH^I&Y&ff8BiKkbidAED2BWtydK)8^-0)*`@3#pDP~`eHJypHgQZkd*ole`}&jAFG00FHPX5zb8Sl zq3_UL($K&BJws2m;%hStA?h9M7IrJUjor@fV0W^QvX8NkvtX!R!{}Fxe#7W@jQ+sr zI!1qDlE5UwB)Nm#C3#WUXQY%I_E|Vdla%C2!6es{ltVg7=jQ*V>Hn3g^9&+P+Zv$#1TmS=N!U=q3>-Nnu2=3z1rlZwAgm~acZ#e*U3Zf=Q) zv>;3dOGsM*n7C&kCe9_ieqZ>GeP!>u=L<8}j< zx$WEzZYTFB_Zas$_XPJOw~KoUli`?DVKM@fk(i9aq#Bdam;`a6!DQ?X?&%&Z@8h23 z_7igMporx-E0z;5nT<)PABD+W5zpY`9r#F-C8YGf$K@oaqVnH*T#h>-vdpWPjJLAP z>%cPH8;BGCF*ydxIf!qdgkSm$_|`xdy4yTX0VeS^s~Or~Qpqnl*8@3|j@ zjhM{DWR^JDfAWCbfAQhG)7&*+<6j4|@y~g9?Pt4liq@s3-F19k!Z?xR{{YbI13{-; zJ9D6;A^p*(G9EalO59G1kJ&j%&&SDj^lSxB@vMcHc}C>ryxZ-h^_F409n=uwf$4Oe zygeohppXN&QN<_-MCTxjr>~RAvi-ah&=KJvki972NAhmqBNeZ8@}M}2dQ>s*2?FjUn|9YeQi zd^(>|0Uetxn84(C@Y06EUvzkQL@0bl#Du5EMy6z>M1`x;rF^NG(nobo+tlbNX#!b% zw#D&o%_6aM*3D<}IhJ~C5}K{+&Evsc(aGmyazZCxfXNzB5dV9FTDGK^*GOCOVJP%f zY3;t0pFlWo=gatVzJjmh$MWO&Dt3$7BO08!_DUuG*TWP)K!bZHFAEILpt5 z7NBtpdMl;-RBENVEWf5!6&VsCap-JIxghJF&*c|@n}eUn3q0oMW73Stc1%vk)%i+RcRiqsi0M?kOvzZKH#pN zD5_Xyr+une6-c7OVq?9fgo?ouC@xV&XGF`OG=d#4!z$uRH@})+!>`387;&(UMZHNK z{Qdkx5Srs3;MegFVsZ{9=VEeRH~%odo?nPb0h1V3C@xIo{{HjtH!zeaYH72?tHs2M z+W@A*L%_vGC@3UVwhX#Gv^0h#OU5r*652$ew-rBI_#J~yekcDZ{}?70U=o&ar=ajl z>Ukczmhko`32#f#jDLzbQs6)`H&=T5&&y}?HYIIP7)3xkh7NOda`*5L2np*c6Ke}4 z3mt|-3CEuGNM#-&KP|-!GG!u=s0p-!jX+ftw?idGF`$2DgtMKC>nKq=PU&A%wsWLF zce+7|S01Ee1_bNh^7Qhy^YQhQ!|!6K_yprj%4mYrQ1~sXBfsPTY3m$#nOGMVs8k8H zL7=#lB?ZKgEq0O$PlBHw9T*fmSUJj`fx3bey=5o?HdNWaOl7D7E{3as$@?q}GH66) z*cAaZG{|+RIqpEXL6S}oR2GjRvWYyXB3?|4hx*}D2s1GoiY6{0mO;(J2cTy7Rw!uq zG_eON*zJeni7yhbL4x@QP)O`Dl<|VXiBM+C0l6c86oSH_?so#pfqF$nQ1xfBwEW&O zijh5()boMnLCDlfQ_t`RAs_<`v6tV+KgjRr4_Md&7=1Bt1}5*uOSP?k0x9MxSs?wA zrPL}Ylqo9U6Oy($)b`-qy`XKzmp}>74(}SXw@Mus9u3%WC47kxLIo9VlK?ZN#5iI+ z)U{p+)vVVso0y%#= zwNRlt6CC}yY(87S)o#<-7)JJXe5fn+7$V+o7Iw2R|ER@?xmwybLNiuLPm@ z4F5iV&BoTo%O=D|Z4+Y?YZGsiV3T2!Ws_r*XQQ*x+tl04wpnZQgv~*lLpFzPj@Z0n z^OntrHkWL^vAH_LaftU2zajoZ0*3?-2^o?(L_cKekd`6ShB3qJhQ$m^99BQg+veKl+m_mv z+g94y+qv6~whOXT*oE4K*{STt*d^K}+ojs2*=5*e*;U)kwp(Sl$L<5WFYM0SeQEcd z-8H-G_ME-Uez?7py|evDdpG+m`|0+Z>`&WYmf6ePWqvY$*=SjiEL;{Li;_jlG_nb@ zJ7wEsyJUxDFUnq$9h1E#dtLUX?6mB62XBWUhhT>g2em_*L#{)KLyf~74s#s@hxrZ* z9Tqt(c39%D%wdJYN{7`BYaQ-$*yXU>;Tea$4$nFqbU5tryu(q4V-6=APCC5laK_e%Yo=Gg8y!?DA0x#N9~4>_)P z+~C;h__*UUj(Z)Sbv)qsg5y!gmmQBgo^U+t_=V$DC(>!C(?};TCm$z2Cx539C#6%k zQ-o8LQ-)K4Q=wC(Q?*l#Q=QW!CzF%lG~a2V(;}zEPD`AYIjwVg$Z5UP2B%J^Zl}#o zTb*`0opHM8^vj5$BRoa~j0hT`7!f)mdPL!f;t_@s(?_fxv1i2b5$8wz>dZRJodcW$ zor9f2oR!Yu&JoU0&iT$0oLijla(>WxkMlm~{muuS4?7=me!=-i=RaI%7uJP$8R9a` z#nnab66_M+IL${CJK6U%t?F+XHZWrCY8pV$qJt}w9)KRNO?H%?0s9)V(-J{);+*94t+%wz@ z+{@i7-N(6)cc0)s(Y?jp>^|Lnru!WCdG6SKf%_Ks7v0}Ja%~;_BiEn#^VEziymKleCzSO$1fh&Jbv@| z!*iJDNKZFUKhJPawP%cHtY@NUvS+GinrDt@h37=i4$rxs_j*3!xySQ>=NZqpJwNlj z==qiB70+)xfA+lU`K#yep4Yt`y#l;6UMXH#UO8TQUIku-UTt1xujyVhy=Hmc;WgJw z@S5-Skk@*z4PKpI-Cmo$wt7A3wb$!euLE9(yq@!V&FgiqH@!}Ked6_**Ee3@dHvw^ zlQ-#2d$ZoWx0APzx1YDa_h|1G-Vb@N_uk;$>BIZTeN;XfK3P6FK6ySmAH7eJPl->d zPq|N}Pm9kqACphJ&kUaqpV>ZheCGLJp9Ma5`gHj`@AHK(=d187^lkTD>$}_cP2cx? zKk)s?_Y>cXzTf-)==-zpRo`EIfA_QTv-6Yr4fk{LbM+hL=iwLUSLHX^uf=bgpUH2g z-z>j7{O0;C_q)gMVZUy_&3;?`w);Kl_mtn$etZ1(`F$#Plt;-E&xd9}Pj-Y#D( zUn0L>{;>QJ`9}F>`BwRM`A+$M`TOz@z0?mQb0~Z7?4qOtrEO1ren!tMl?+@G`_-^32z^?+Y1b!3vUEtNgUju&+ydFdZ z1qF=_x+Cb$pw&T}gSG}e8FVP<&7jjk9|xTax)5|R= z2Kxp32L}cR2ge3i29FCKA3PzrHrNfgMv_y3R=M`92MRQUxi!|pa@h1 zD?${pig-nWB3Y5DNLOSkvK2)NgQ8qfsi;*HiZ>K*Dc)6lqPU>Athg3p8{!?}7ZMVp2}uaa4k-z#37HsD z8)6KZ8-ha?hTIi$cgV7kdqSQMIT3O)f)7IH4+e8~49zlHLlLqlyt?L#Mp zwuDX#HHRJzeL3_@=sTewhkh3NMd*dlAC*W+D>>y5rL9t?bX0mP{geU9AZ3U$OsP^v zDif7O$_C|h<$PtAa+7kGa-Z^m^04xG<#FY!%9F}Bl&6&EmDj?ChB<|~gt>*ehb4q1 zhiSvo!?MD1!t%rPVZ~uhVJ%^8VeMfv!e)id30o9)ci6J96=4sBJrcGttSjvCu)SeN z!;Xc$6837?*|2|veGv9BoY3{*OTzC9-w@svzBznr_>R%ujmsxhi8RiSFUszz0-GOF5C^HlRy zcd8bvma0~$R;eCQb*eV0wyJih9#cK3dP?ad57Fif+h@2cbBXV)%(#RE&t0LD%-XHm3T1-lYEI3oZPY{6 z!_*_x&T3b+uR2T}tsbM!R_Ca5)%og5^;q>d^?3EY>POU_>P_k`>b>gc)koFG)F;#@ zqvg?|(Q(m9(W%kt(K*ri(fa73=!)nG(e2SVdP($-=v~pLqAx`M82wZ9&(T+7hQzqU zc*pp~1jGczgv7v8EHUvh2{FkrsWG`RwK2^xGh^oxgy2UDD zLu12YRk4w=>e!gr*x2~kgxD#u55*pj{Uy#ft|)F{-0rv|ai7MWi@OkaDeg+#w{btj z{Tz2K?)SJqj?o{t|IZxg^i1?l^iA|ntW2y=Y)qVz*qqp%_+;YV#Qlkf z5}!+aIq{Xm*Am}IJeBxck~%3hDKjZ2DL<(w$&gf@RGCztG&yN%QfrbasXb{`(t}Cs zlQt%GCGAZ*k@Qy5yGidQ{Uhnaq)(ErB>kC8CNs%)$$+gLo zlNThfOWu;aJ^9h(Cz5w1?@m6F{8IAq$Q`$P1>c}$F%#k2eb#ZhqbS2&uZV- zex&_Wdro^ndrA9?_E+s6X+#>8#-`b%4NZ$mt4&*#b|mfF^r&<(Ygn6}o?e+=pFT5v zcKY0OoW3yquJk48%hOk;ccyPj-9oBe`Jizh|g%uSeUUP z<9No~8SiAgoAF-8`HTx0UuIm&_$A|N#u}cd*~;wr?1b#3?9}Y-*?Y40W$({Elq1hk{eY=8Vs&$*Iff$hjkDZjO*MKj+SzyK?T%S(>vVXJyXnoF{T#&-p&rDOZy_K6id@ zXYP^Q4|C7wUd+9m`*rRwc|;zS$L87O4a>93ljRN1^U9Owjm`_s3(JehQ|HCxRp!me zTbQ>rZ$;j!ytR1`NB!}&+@U(7$Ae~+I+BXlEm zqja9SNL{o}ql?px(Ix3pbXr}yE=!lA%hMTj<+`!DD%~Vqi*B0Eq-)pB&~@k*>6YqN z=vL|O)2-7ztb0UvK=+aEE8Vxc?{z=ve%1Z1yIw#P*cZ4L_!R^c1Qmo7L>5FB#1_OC zBot&66cYC^ zRB*N6w}R_>q^I?+dUw62-dpdh_t%fs2k8}hr9NCAp^w)m>QnSueV*Q+FVk1($Lg!} z)%qs=G`(3rLw|>Uo_@Z5p?<6WsQ#4xtp0udNBU3oU+TZof35#k|GoaVLcVZRp+{jr zVPs)OVOF7`u(GhKu)46Wa8hAI;pD=O!bOEk3zrwJEWEey{=#*I4;Ahx+*|ls;Twgg z3*Rn0TljwAM}?mjo-4dic&YHuBC?1sVvG2qp+$B@4n>Yd&P5}O+=^m~N{eO{JzR98 z=zQ^z;-KQJVqGIqme`jJFL5bxEAc4tDp8iilxRxgN-|4yC50s=C1oY!OD2@mmP{&XD7m}j zsgiRgS4w_0*c$8&BMtrrjUmpUHKZGg3}uE2!#G2Yq0UfmXf(_;+-X>BSZY{dSY=pa z=rlZL*kgFsaKLcLaKdoX@P^^E;cdgahEEKi8@@1HF#Krv&2YUGmC~g{OYKS>N}Wob zOJhn~N*^fQT)M4vXX)dmyGnPLK396Y^v%-uN>3Sngcz zTJB!%Rqk7^E{`dXD<4yyRGwO%UY=E+Tb^HDP+nO+t$a!O`tqmCUnxIR{#6BCF|@*= zBD!KsMP@}oMR`R<#n_65ifI+*iWwEND&|xys<^viS;ajSt1H%4JXUeM;`2)9O7}{i zO25jG%E-#-%Gk;=m5G(Pl|_|?%JRywl@lwCl?|0sDyLT7S9!GZ+sbQ|zm4U`+K(MR zcEs3`W4*@uj`bfKI5v1}+n)T3WTdYGu`$ rs(Y*MuUc1ixa#xqw&P>QCy&n_Uo_s( + + + + SchemeUserState + + macosBridge.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/desktopApp/src/main/swift/WebviewBridge.swift b/desktopApp/src/main/swift/macosBridge/WebViewBridge.swift similarity index 88% rename from desktopApp/src/main/swift/WebviewBridge.swift rename to desktopApp/src/main/swift/macosBridge/WebViewBridge.swift index 5b7201d7a..a3cda15b3 100644 --- a/desktopApp/src/main/swift/WebviewBridge.swift +++ b/desktopApp/src/main/swift/macosBridge/WebViewBridge.swift @@ -3,7 +3,7 @@ import WebKit import Foundation public typealias LogCB = @convention(c) (_ level: Int32, _ msg: UnsafePointer?) -> Void -private var gLog: LogCB? +nonisolated(unsafe) private var gLog: LogCB? @_cdecl("wkb_set_log_callback") public func wkb_set_log_callback(_ cb: LogCB?) { gLog = cb } @@ -16,7 +16,7 @@ func swiftLog(_ level: Int32, _ msg: String) { fputs("[wkb \(level)] \(msg)\n", stderr) } -private var nextId: Int64 = 1 +nonisolated(unsafe) private var nextId: Int64 = 1 private class Controller { let id: Int64 @@ -32,17 +32,19 @@ private class Controller { self.web = web } } -private var ctrls: [Int64: Controller] = [:] +nonisolated(unsafe) private var ctrls: [Int64: Controller] = [:] // when to close callback, return 1 to close, 0 to keep open public typealias DecisionCB = @convention(c) (_ cookies: UnsafePointer?) -> Int32 -private var gDecision: DecisionCB? +nonisolated(unsafe) private var gDecision: DecisionCB? // when window closed callback // reason: 0 = closed by user;1 = API call wkb_close_window;2 = decision callback public typealias WindowClosedCB = @convention(c) (_ id: Int64, _ reason: Int32) -> Void -private var gOnClosed: WindowClosedCB? +nonisolated(unsafe) private var gOnClosed: WindowClosedCB? + +@MainActor private func makeWindow(with web: WKWebView, title: String) -> NSWindow { let win = NSWindow(contentRect: NSRect(x: 200, y: 200, width: 1000, height: 700), styleMask: [.titled, .closable, .resizable, .miniaturizable], @@ -64,6 +66,7 @@ private func cookieHeaderString(from cookies: [HTTPCookie], for url: URL?) -> St return filtered.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") } +@MainActor private func startCookiePolling(for ctrl: Controller, targetURL: URL?, intervalMs: Int32) { let q = DispatchQueue(label: "cookie.poller.\(ctrl.id)") let t = DispatchSource.makeTimerSource(queue: q) @@ -131,7 +134,7 @@ public func wkb_open_webview_poll(_ urlCString: UnsafePointer?, _ interva let web = WKWebView(frame: .zero, configuration: cfg) if let u = targetURL { web.load(URLRequest(url: u)) } - let win = makeWindow(with: web, title: "WebView") + let win = makeWindow(with: web, title: "Login") let id = nextId; nextId += 1 let ctrl = Controller(id: id, window: win, web: web) ctrls[id] = ctrl @@ -166,12 +169,6 @@ public func wkb_close_window(_ id: Int64) { } } -@_cdecl("wkb_clear_persistent_storage") -public func wkb_clear_persistent_storage() { - let types = WKWebsiteDataStore.allWebsiteDataTypes() - WKWebsiteDataStore.default().removeData(ofTypes: types, modifiedSince: .distantPast, completionHandler: {}) -} - @_cdecl("wkb_open_webview_poll_with_ua") public func wkb_open_webview_poll_with_ua(_ urlCString: UnsafePointer?, _ intervalMs: Int32, @@ -196,7 +193,7 @@ public func wkb_open_webview_poll_with_ua(_ urlCString: UnsafePointer?, } if let u = targetURL { web.load(URLRequest(url: u)) } - let win = makeWindow(with: web, title: "WebView") + let win = makeWindow(with: web, title: "Login") let id = nextId; nextId += 1 let ctrl = Controller(id: id, window: win, web: web) ctrls[id] = ctrl @@ -217,15 +214,3 @@ public func wkb_open_webview_poll_with_ua(_ urlCString: UnsafePointer?, } return outId } - -@_cdecl("wkb_set_user_agent") -public func wkb_set_user_agent(_ id: Int64, _ uaCString: UnsafePointer?, _ reload: Bool) { - DispatchQueue.main.async { - guard let ctrl = ctrls[id] else { return } - let ua = uaCString.flatMap { String(cString: $0) } - ctrl.web.customUserAgent = ua - if reload { - ctrl.web.reload() - } - } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c24f3c553..dd5d9191b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,15 +7,15 @@ haze = "1.6.10" lifecycleViewmodelComposeVersion = "2.9.2" minSdk = "23" java = "21" -agp = "8.12.1" +agp = "8.12.2" kotlin = "2.2.10" core-ktx = "1.17.0" junit = "4.13.2" androidx-test-ext-junit = "1.3.0" espresso-core = "3.7.0" -lifecycle-runtime-ktx = "2.9.2" +lifecycle-runtime-ktx = "2.9.3" activity-compose = "1.10.1" -compose-bom = "2025.08.00" +compose-bom = "2025.08.01" ksp = "2.2.10-2.0.2" openaiClient = "4.0.1" paging = "3.3.6" @@ -45,15 +45,15 @@ koin = "4.1.0" composeIcons = "1.3.0" media3 = "1.8.0" desugar_jdk_libs = "2.1.5" -firebase-bom = "34.1.0" +firebase-bom = "34.2.0" google-services = "4.4.3" firebase-crashlytics = "3.0.6" materialKolor = "3.0.1" room = "2.7.2" sqlite = "2.5.2" -compose-multiplatform = "1.8.2" +compose-multiplatform = "1.9.0-rc01" logback = "1.5.18" -navigation3 = "1.0.0-alpha07" +navigation3 = "1.0.0-alpha08" zoomable = "0.16.0" bouncycastle = "1.81" @@ -88,7 +88,7 @@ ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } -material3 = { group = "androidx.compose.material3", name = "material3", version = "1.5.0-alpha02" } +material3 = { group = "androidx.compose.material3", name = "material3", version = "1.5.0-alpha03" } material3WindowSizeClass = { group = "androidx.compose.material3", name = "material3-window-size-class" } material3-adaptive-navigation-suite = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite" } material3-adaptive = { group = "androidx.compose.material3.adaptive", name = "adaptive" } @@ -184,7 +184,7 @@ kotlin-codepoints-deluxe = { group = "de.cketti.unicode", name = "kotlin-codepoi firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebase-bom" } firebase-analytics-ktx = { group = "com.google.firebase", name = "firebase-analytics" } firebase-crashlytics-ktx = { group = "com.google.firebase", name = "firebase-crashlytics" } -play-integrity = { group = "com.google.android.play", name = "integrity", version = "1.4.0" } +play-integrity = { group = "com.google.android.play", name = "integrity", version = "1.5.0" } materialKolor = { module = "com.materialkolor:material-kolor", version.ref = "materialKolor" } colorpicker-compose = { module = "com.github.skydoves:colorpicker-compose", version = "1.1.2" } @@ -208,7 +208,7 @@ krypto = { module = "com.soywiz:korlibs-crypto", version = "6.0.1" } androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" } androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" } androidx-material3-adaptive-navigation3 = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation3", version = "1.0.0-alpha01" } -androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version = "2.10.0-alpha02" } +androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version = "2.10.0-alpha03" } bouncycastle-bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" } bouncycastle-bcpkix = { module = "org.bouncycastle:bcpkix-jdk18on", version.ref = "bouncycastle" } From 83074fdc7269fb54c77c37b519ff985da83c5147 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Fri, 29 Aug 2025 15:53:34 +0900 Subject: [PATCH 32/33] fix build --- desktopApp/build-swift.gradle.kts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/desktopApp/build-swift.gradle.kts b/desktopApp/build-swift.gradle.kts index fac9819eb..3aea0779e 100644 --- a/desktopApp/build-swift.gradle.kts +++ b/desktopApp/build-swift.gradle.kts @@ -23,11 +23,13 @@ tasks.register("compileWebviewBridgeArm64") { ) // copy buildOutput to targetLib - Files.copy( - buildOutput.asFile.toPath(), - targetLib.toPath(), - java.nio.file.StandardCopyOption.REPLACE_EXISTING, - ) + if (isMac) { + Files.copy( + buildOutput.asFile.toPath(), + targetLib.toPath(), + java.nio.file.StandardCopyOption.REPLACE_EXISTING, + ) + } } afterEvaluate { From 9c94dfe2f4d15f0a8497eb89f27b5f49cf2d22b5 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Fri, 29 Aug 2025 18:47:58 +0900 Subject: [PATCH 33/33] update media window --- .../main/kotlin/dev/dimension/flare/App.kt | 3 +- .../main/kotlin/dev/dimension/flare/Main.kt | 6 + .../dev/dimension/flare/ui/route/Router.kt | 5 +- .../dimension/flare/ui/route/WindowRouter.kt | 4 +- .../ui/screen/home/HomeTimelineScreen.kt | 16 +- .../flare/ui/screen/media/RawMediaScreen.kt | 17 +- .../ui/screen/media/StatusMediaScreen.kt | 335 ++++++++++-------- .../dimension/flare/ui/theme/FlareTheme.kt | 26 +- 8 files changed, 242 insertions(+), 170 deletions(-) diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt index 677946efb..02402463b 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowScope import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.Gear @@ -89,7 +90,7 @@ import org.apache.commons.lang3.SystemUtils import org.jetbrains.compose.resources.stringResource @Composable -internal fun FlareApp(onWindowRoute: (Route.WindowRoute) -> Unit) { +internal fun WindowScope.FlareApp(onWindowRoute: (Route.WindowRoute) -> Unit) { val state by producePresenter { presenter() } val uriHandler = LocalUriHandler.current diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt index 2aedaa2ce..71b24e3a4 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt @@ -87,12 +87,18 @@ fun main(args: Array) { } extraWindowRoutes.forEach { (key, value) -> + val windowState = + rememberWindowState( + position = WindowPosition(Alignment.Center), + size = DpSize(1200.dp, 800.dp), + ) Window( title = stringResource(Res.string.app_name), icon = painterResource(Res.drawable.flare_logo), onCloseRequest = { extraWindowRoutes.remove(key) }, + state = windowState, onKeyEvent = { if (it.key == Key.Escape) { extraWindowRoutes.remove(key) diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt index 85854d091..fd9196de9 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowScope import dev.dimension.flare.common.OnDeepLink import dev.dimension.flare.data.model.Bluesky.FeedTabItem import dev.dimension.flare.data.model.IconType @@ -57,7 +58,7 @@ import io.github.composefluent.component.Flyout import io.github.composefluent.component.Text @Composable -internal fun Router( +internal fun WindowScope.Router( manager: StackManager, onWindowRoute: (Route.WindowRoute) -> Unit, modifier: Modifier = Modifier, @@ -104,7 +105,7 @@ internal fun Router( } @Composable -internal fun RouteContent( +internal fun WindowScope.RouteContent( route: Route, onBack: () -> Unit, navigate: (Route) -> Unit, diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/WindowRouter.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/WindowRouter.kt index 4ac762447..748d01e0d 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/WindowRouter.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/WindowRouter.kt @@ -1,10 +1,10 @@ package dev.dimension.flare.ui.route import androidx.compose.runtime.Composable -import androidx.compose.ui.window.FrameWindowScope +import androidx.compose.ui.window.WindowScope @Composable -internal fun FrameWindowScope.WindowRouter( +internal fun WindowScope.WindowRouter( route: Route.WindowRoute, onBack: () -> Unit, ) { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt index aeecb301c..504e66730 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt @@ -3,6 +3,7 @@ package dev.dimension.flare.ui.screen.home import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth @@ -79,7 +80,10 @@ internal fun HomeTimelineScreen( visible = state.isTopBarExpanded, modifier = Modifier - .fillMaxWidth(), + .fillMaxWidth() + .clickable { + // prevent click through + }, enter = slideInVertically { -it }, exit = slideOutVertically { -it }, ) { @@ -101,7 +105,15 @@ internal fun HomeTimelineScreen( PillButton( selected = tab.timelineTabItem.key == currentTab.timelineTabItem.key, onSelectedChanged = { - state.setSelectedIndex(index) + if (tab.timelineTabItem.key == currentTab.timelineTabItem.key) { + if (currentTab.lazyListState.firstVisibleItemIndex == 0) { + currentTab.refreshSync() + } else { + currentTab.lazyListState.requestScrollToItem(0) + } + } else { + state.setSelectedIndex(index) + } }, ) { TabIcon( diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/media/RawMediaScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/media/RawMediaScreen.kt index 4e76e390e..697da5775 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/media/RawMediaScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/media/RawMediaScreen.kt @@ -3,18 +3,15 @@ package dev.dimension.flare.ui.screen.media import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import dev.dimension.flare.ui.component.NetworkImage -import me.saket.telephoto.zoomable.rememberZoomableState -import me.saket.telephoto.zoomable.zoomable @Composable internal fun RawMediaScreen(url: String) { - NetworkImage( - modifier = - Modifier - .fillMaxSize() - .zoomable(rememberZoomableState()), - model = url, - contentDescription = null, + ImageItem( + url = url, + previewUrl = url, + description = null, + setLockPager = {}, + isFocused = true, + modifier = Modifier.fillMaxSize(), ) } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt index cb151cc5f..09b22956b 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt @@ -1,8 +1,15 @@ package dev.dimension.flare.ui.screen.media import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -25,14 +32,24 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowScope import coil3.compose.LocalPlatformContext import coil3.compose.SubcomposeAsyncImage import coil3.request.ImageRequest import coil3.request.crossfade import coil3.size.Size +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.FloppyDisk +import compose.icons.fontawesomeicons.solid.UpRightAndDownLeftFromCenter +import dev.dimension.flare.Res +import dev.dimension.flare.media_fullscreen +import dev.dimension.flare.media_save import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.component.ComponentAppearance +import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.LocalComponentAppearance import dev.dimension.flare.ui.component.NetworkImage import dev.dimension.flare.ui.component.status.MediaItem @@ -43,9 +60,12 @@ import dev.dimension.flare.ui.model.onSuccess import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.presenter.status.StatusPresenter import dev.dimension.flare.ui.presenter.status.StatusState +import dev.dimension.flare.ui.theme.FlareTheme import dev.dimension.flare.ui.theme.LocalComposeWindow +import io.github.composefluent.FluentTheme import io.github.composefluent.component.GridViewItem import io.github.composefluent.component.HorizontalFlipView +import io.github.composefluent.component.SubtleButton import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch import me.saket.telephoto.ExperimentalTelephotoApi @@ -55,10 +75,11 @@ import me.saket.telephoto.zoomable.rememberZoomableState import me.saket.telephoto.zoomable.spatial.CoordinateSpace import me.saket.telephoto.zoomable.zoomable import moe.tlaster.precompose.molecule.producePresenter -import javax.swing.JFileChooser +import org.jetbrains.compose.resources.stringResource +import java.awt.FileDialog @Composable -internal fun StatusMediaScreen( +internal fun WindowScope.StatusMediaScreen( accountType: AccountType, statusKey: MicroBlogKey, index: Int, @@ -74,168 +95,176 @@ internal fun StatusMediaScreen( window = window, ) } - Column( - modifier = - Modifier - .fillMaxSize(), + FlareTheme( + isDarkTheme = true, ) { - state.medias.onSuccess { medias -> - val pagerState = - rememberPagerState( - initialPage = index, + Box( + modifier = + Modifier + .fillMaxSize(), + ) { + state.medias.onSuccess { medias -> + val pagerState = + rememberPagerState( + initialPage = index, + ) { + medias.size + } + HorizontalFlipView( + state = pagerState, + enabled = state.lockPager, + modifier = + Modifier.fillMaxSize(), ) { - medias.size - } - HorizontalFlipView( - state = pagerState, - enabled = state.lockPager, - modifier = - Modifier - .weight(1f), - ) { - val media = medias[it] - when (media) { - is UiMedia.Image -> - ImageItem( - modifier = Modifier.fillMaxSize(), - url = media.url, - previewUrl = media.previewUrl, - description = media.description, - isFocused = pagerState.currentPage == it, - setLockPager = state::setLockPager, - ) - - else -> - CompositionLocalProvider( - LocalComponentAppearance provides - LocalComponentAppearance - .current - .copy( - videoAutoplay = ComponentAppearance.VideoAutoplay.ALWAYS, - ), - ) { - MediaItem( - media = media, + val media = medias[it] + when (media) { + is UiMedia.Image -> + ImageItem( modifier = Modifier.fillMaxSize(), + url = media.url, + previewUrl = media.previewUrl, + description = media.description, + isFocused = pagerState.currentPage == it, + setLockPager = state::setLockPager, + onClick = { + state.setShowThumbnailList(!state.showThumbnailList) + }, ) - } + + else -> + CompositionLocalProvider( + LocalComponentAppearance provides + LocalComponentAppearance + .current + .copy( + videoAutoplay = ComponentAppearance.VideoAutoplay.ALWAYS, + ), + ) { + MediaItem( + media = media, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + ) + } + } } - } - AnimatedVisibility( - state.showThumbnailList, - ) { - LazyRow( + AnimatedVisibility( + state.showThumbnailList, modifier = Modifier .fillMaxWidth() - .height(96.dp) - .padding(8.dp), - horizontalArrangement = - Arrangement.spacedBy( - 8.dp, - Alignment.CenterHorizontally, - ), - verticalAlignment = Alignment.CenterVertically, + .align(Alignment.TopCenter), + enter = slideInVertically { -it } + fadeIn(), + exit = slideOutVertically { -it } + fadeOut(), ) { - items(medias.size) { index -> - val media = medias[index] - GridViewItem( - selected = pagerState.currentPage == index, - onSelectedChange = { - if (it) { - scope.launch { - pagerState.scrollToPage(index) + Row( + modifier = + Modifier + .background(FluentTheme.colors.background.layer.default) + .height(48.dp) + .padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Spacer(modifier = Modifier.weight(1f)) + SubtleButton( + onClick = { + val current = medias[pagerState.currentPage] + state.save(current) + }, + content = { + FAIcon( + FontAwesomeIcons.Solid.FloppyDisk, + contentDescription = stringResource(Res.string.media_save), + ) + }, + ) + SubtleButton( + onClick = { + if (window != null) { + val current = window.placement + if (current == WindowPlacement.Fullscreen) { + window.placement = WindowPlacement.Floating + } else { + window.placement = WindowPlacement.Fullscreen } } }, - ) { - NetworkImage( - model = - when (media) { - is UiMedia.Audio -> media.previewUrl - is UiMedia.Gif -> media.previewUrl - is UiMedia.Image -> media.previewUrl - is UiMedia.Video -> media.thumbnailUrl - }, - contentDescription = null, - modifier = - Modifier - .aspectRatio(1f), - ) + content = { + FAIcon( + FontAwesomeIcons.Solid.UpRightAndDownLeftFromCenter, + contentDescription = stringResource(Res.string.media_fullscreen), + ) + }, + ) + } + } + + AnimatedVisibility( + state.showThumbnailList, + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + enter = slideInVertically { it } + fadeIn(), + exit = slideOutVertically { it } + fadeOut(), + ) { + LazyRow( + modifier = + Modifier + .background(FluentTheme.colors.background.layer.default) + .height(96.dp) + .padding(8.dp), + horizontalArrangement = + Arrangement.spacedBy( + 8.dp, + Alignment.CenterHorizontally, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + items(medias.size) { index -> + val media = medias[index] + GridViewItem( + selected = pagerState.currentPage == index, + onSelectedChange = { + if (it) { + scope.launch { + pagerState.scrollToPage(index) + } + } + }, + ) { + NetworkImage( + model = + when (media) { + is UiMedia.Audio -> media.previewUrl + is UiMedia.Gif -> media.previewUrl + is UiMedia.Image -> media.previewUrl + is UiMedia.Video -> media.thumbnailUrl + }, + contentDescription = null, + modifier = + Modifier + .aspectRatio(1f), + ) + } } } } } -// Row( -// modifier = -// Modifier -// .height(48.dp) -// .fillMaxWidth() -// .background(FluentTheme.colors.background.layer.default) -// .padding(8.dp), -// horizontalArrangement = Arrangement.spacedBy(8.dp), -// ) { -// Spacer(modifier = Modifier.weight(1f)) -// if (medias.size > 1) { -// SubtleButton( -// onClick = { -// state.setShowThumbnailList(!state.showThumbnailList) -// }, -// content = { -// FAIcon( -// FontAwesomeIcons.Solid.Chalkboard, -// contentDescription = -// if (state.showThumbnailList) { -// stringResource(Res.string.media_hide_thumbnail_list) -// } else { -// stringResource(Res.string.media_show_thumbnail_list) -// }, -// ) -// }, -// ) -// } -// SubtleButton( -// onClick = { -// val current = medias[pagerState.currentPage] -// state.save(current) -// }, -// content = { -// FAIcon( -// FontAwesomeIcons.Solid.FloppyDisk, -// contentDescription = stringResource(Res.string.media_save), -// ) -// }, -// ) -// SubtleButton( -// onClick = { -// val current = window.placement -// if (current == WindowPlacement.Fullscreen) { -// window.placement = WindowPlacement.Floating -// } else { -// window.placement = WindowPlacement.Fullscreen -// } -// }, -// content = { -// FAIcon( -// FontAwesomeIcons.Solid.UpRightAndDownLeftFromCenter, -// contentDescription = stringResource(Res.string.media_fullscreen), -// ) -// }, -// ) -// } } } } @OptIn(ExperimentalTelephotoApi::class) @Composable -private fun ImageItem( +internal fun ImageItem( url: String, previewUrl: String, description: String?, setLockPager: (Boolean) -> Unit, isFocused: Boolean, modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, ) { val focusRequester = remember { FocusRequester() } LaunchedEffect(isFocused) { @@ -246,7 +275,7 @@ private fun ImageItem( } } val zoomableState = - rememberZoomableState(zoomSpec = ZoomSpec(maxZoomFactor = 10f, minZoomFactor = 0.01f)) + rememberZoomableState(zoomSpec = ZoomSpec(maxZoomFactor = 10f)) LaunchedEffect(zoomableState.zoomFraction) { zoomableState.zoomFraction?.let { setLockPager(it > 0.01f) @@ -294,7 +323,12 @@ private fun ImageItem( modifier = modifier .focusRequester(focusRequester) - .zoomable(state = zoomableState), + .zoomable( + state = zoomableState, + onClick = { + onClick?.invoke() + }, + ), contentScale = contentScale, alignment = alignment, ) @@ -304,7 +338,7 @@ private fun ImageItem( private fun presenter( accountType: AccountType, statusKey: MicroBlogKey, - window: ComposeWindow, + window: ComposeWindow?, ) = run { var lockPager by remember { mutableStateOf(false) } var showThumbnailList by remember { mutableStateOf(false) } @@ -320,6 +354,14 @@ private fun presenter( (it.content as? UiTimeline.ItemContent.Status)?.images.orEmpty().toImmutableList() } + medias.onSuccess { + LaunchedEffect(it.size) { + if (it.size > 1) { + showThumbnailList = true + } + } + } + object : StatusState by state { val medias = medias val lockPager = lockPager @@ -334,13 +376,16 @@ private fun presenter( } fun save(item: UiMedia) { - val fileChooser = JFileChooser() val url = item.url val fileName = url.substring(url.lastIndexOf("/") + 1) - fileChooser.selectedFile = java.io.File(fileName) - if (fileChooser.showSaveDialog(window) == JFileChooser.APPROVE_OPTION) { - val file = fileChooser.selectedFile - // TODO: Save file + FileDialog(window).apply { + mode = FileDialog.SAVE + file = fileName + isVisible = true + val dir = directory + val file = file + if (!dir.isNullOrEmpty() && !file.isNullOrEmpty()) { + } } } } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt index 541164ed4..9b37197fb 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.node.DrawModifierNode import androidx.compose.ui.node.invalidateDraw import androidx.compose.ui.unit.dp import androidx.compose.ui.window.FrameWindowScope +import androidx.compose.ui.window.WindowScope import dev.dimension.flare.LocalWindowPadding import dev.dimension.flare.data.model.AppSettings import dev.dimension.flare.data.model.AppearanceSettings @@ -46,13 +47,13 @@ import org.apache.commons.lang3.SystemUtils import org.koin.compose.koinInject internal val LocalComposeWindow = - staticCompositionLocalOf { + staticCompositionLocalOf { error("No ComposeWindow provided") } @OptIn(ExperimentalFluentApi::class) @Composable -internal fun FrameWindowScope.FlareTheme( +internal fun WindowScope.FlareTheme( isDarkTheme: Boolean = isDarkTheme(), content: @Composable () -> Unit, ) { @@ -67,13 +68,22 @@ internal fun FrameWindowScope.FlareTheme( ) { if (SystemUtils.IS_OS_MAC) { LaunchedEffect(window) { - window.rootPane.apply { - rootPane.putClientProperty("apple.awt.fullWindowContent", true) - rootPane.putClientProperty("apple.awt.transparentTitleBar", true) - rootPane.putClientProperty("apple.awt.windowTitleVisible", false) - } + window + .let { + it as? javax.swing.RootPaneContainer + }?.let { + it.rootPane.putClientProperty("apple.awt.fullWindowContent", true) + it.rootPane.putClientProperty("apple.awt.transparentTitleBar", true) + it.rootPane.putClientProperty("apple.awt.windowTitleVisible", false) + } } } + val composeWindow = + if (this is FrameWindowScope) { + window + } else { + null + } CompositionLocalProvider( LocalIndication provides @@ -92,7 +102,7 @@ internal fun FrameWindowScope.FlareTheme( } else { PaddingValues(vertical = 8.dp) }, - LocalComposeWindow provides window, + LocalComposeWindow provides composeWindow, ) { Box( modifier =