From 978843c166688d16a35608184aa95b3b411aa61e Mon Sep 17 00:00:00 2001 From: Tlaster Date: Thu, 19 Dec 2024 17:34:33 +0900 Subject: [PATCH] Android XR support --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 2 +- .../ui/component/NavigationSuiteScaffold2.kt | 368 +++++++++++------- .../ui/screen/bluesky/BlueskyFeedsScreen.kt | 21 +- .../flare/ui/screen/dm/DMListScreen.kt | 29 +- .../flare/ui/screen/home/HomeScreen.kt | 38 ++ .../flare/ui/screen/list/ListScreen.kt | 25 +- .../ui/screen/settings/SettingsScreen.kt | 41 +- gradle/libs.versions.toml | 6 + 9 files changed, 345 insertions(+), 186 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a3bce7e7e..92becda4b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -149,6 +149,7 @@ dependencies { implementation(libs.nestedScrollView) implementation(libs.precompose.molecule) implementation(libs.compose.placeholder.material3) + implementation(libs.bundles.xr) if (project.file("google-services.json").exists()) { implementation(platform(libs.firebase.bom)) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d844da2a1..29052b411 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,7 +4,7 @@ - + disabledIconColor - it.selected -> MaterialTheme.colorScheme.primary - else -> unselectedIconColor + scope.itemList.forEach { + Box( + modifier = + it.modifier + .weight(1f) + .combinedClickable( + interactionSource = it.interactionSource, + onClick = it.onClick, + indication = LocalIndication.current, + onLongClick = it.onLongClick, + ).height(bottomBarHeight), + contentAlignment = Alignment.Center, + ) { + val colors = + it.colors?.navigationBarItemColors ?: NavigationBarItemDefaults.colors() + val color = + with(colors) { + when { + !it.enabled -> disabledIconColor + it.selected -> MaterialTheme.colorScheme.primary + else -> unselectedIconColor + } + } + val iconColor by animateColorAsState( + targetValue = color, + animationSpec = tween(100), + ) + CompositionLocalProvider(LocalContentColor provides iconColor) { + NavigationItemIcon(icon = it.icon, badge = it.badge) } } - val iconColor by animateColorAsState( - targetValue = color, - animationSpec = tween(100), - ) - CompositionLocalProvider(LocalContentColor provides iconColor) { - NavigationItemIcon(icon = it.icon, badge = it.badge) } } } @@ -320,6 +347,45 @@ fun NavigationSuiteScaffold2( } } +@Composable +private fun OptionalSubspace(content: @Composable () -> Unit) { + val session = LocalSession.current + if (LocalSpatialCapabilities.current.isSpatialUiEnabled) { + androidx.xr.compose.spatial.Subspace { + SpatialPanel( + SubspaceModifier + .width(1280.dp) + .height(800.dp) + .resizable() + .movable(), + ) { + content.invoke() + + Orbiter( + position = OrbiterEdge.Top, + offset = EdgeOffset.inner(offset = 20.dp), + alignment = Alignment.End, + shape = SpatialRoundedCornerShape(CornerSize(28.dp)), + ) { + FilledTonalIconButton( + onClick = { + session?.requestHomeSpaceMode() + }, + modifier = Modifier.size(56.dp), + ) { + FAIcon( + FontAwesomeIcons.Solid.DownLeftAndUpRightToCenter, + contentDescription = null, + ) + } + } + } + } + } else { + content.invoke() + } +} + @Composable private fun ColumnScope.DrawerContent( drawerHeader: @Composable (ColumnScope.() -> Unit)?, 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 be7011cf5..a8c4bf38b 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 @@ -78,10 +78,13 @@ internal fun BlueskyFeedsRoute( ) { val scaffoldNavigator = rememberListDetailPaneScaffoldNavigator() + val scope = rememberCoroutineScope() BackHandler( scaffoldNavigator.canNavigateBack(), ) { - scaffoldNavigator.navigateBack() + scope.launch { + scaffoldNavigator.navigateBack() + } } ListDetailPaneScaffold( @@ -92,22 +95,26 @@ internal fun BlueskyFeedsRoute( BlueskyFeedsScreen( accountType = accountType, toFeed = { - scaffoldNavigator.navigateTo( - ListDetailPaneScaffoldRole.Detail, - BlueskyFeedUri(it.id), - ) + scope.launch { + scaffoldNavigator.navigateTo( + ListDetailPaneScaffoldRole.Detail, + BlueskyFeedUri(it.id), + ) + } }, ) } }, detailPane = { AnimatedPane { - scaffoldNavigator.currentDestination?.content?.let { + scaffoldNavigator.currentDestination?.contentKey?.let { BlueskyFeedScreen( accountType = accountType, uri = it.value, onBack = { - scaffoldNavigator.navigateBack() + scope.launch { + scaffoldNavigator.navigateBack() + } }, ) } 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 d6a46e3b6..b8eada8eb 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 @@ -81,6 +81,7 @@ internal fun DMScreenRoute( ) { val scaffoldNavigator = rememberListDetailPaneScaffoldNavigator() + val scope = rememberCoroutineScope() if (initialUserKey != null) { LaunchedEffect(initialUserKey) { scaffoldNavigator.navigateTo( @@ -92,7 +93,9 @@ internal fun DMScreenRoute( BackHandler( scaffoldNavigator.canNavigateBack(), ) { - scaffoldNavigator.navigateBack() + scope.launch { + scaffoldNavigator.navigateBack() + } } } @@ -104,22 +107,28 @@ internal fun DMScreenRoute( DMListScreen( accountType = accountType, onItemClicked = { key -> - scaffoldNavigator.navigateTo( - ListDetailPaneScaffoldRole.Detail, - DMPaneNavArgs(key.toString(), isUserKey = false), - ) + scope.launch { + scaffoldNavigator.navigateTo( + ListDetailPaneScaffoldRole.Detail, + DMPaneNavArgs(key.toString(), isUserKey = false), + ) + } }, ) } }, detailPane = { AnimatedPane { - scaffoldNavigator.currentDestination?.content?.let { args -> + scaffoldNavigator.currentDestination?.contentKey?.let { args -> if (args.isUserKey) { UserDMConversationScreen( accountType = accountType, userKey = MicroBlogKey.valueOf(args.key), - onBack = scaffoldNavigator::navigateBack, + onBack = { + scope.launch { + scaffoldNavigator.navigateBack() + } + }, navigationState = navigationState, toProfile = { navigator.navigate(ProfileRouteDestination(userKey = it, accountType = accountType)) @@ -129,7 +138,11 @@ internal fun DMScreenRoute( DMConversationScreen( accountType = accountType, roomKey = MicroBlogKey.valueOf(args.key), - onBack = scaffoldNavigator::navigateBack, + onBack = { + scope.launch { + scaffoldNavigator.navigateBack() + } + }, navigationState = navigationState, toProfile = { navigator.navigate(ProfileRouteDestination(userKey = it, accountType = accountType)) 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 b47bbd574..b869ece07 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 @@ -62,6 +62,8 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import androidx.xr.compose.platform.LocalSession +import androidx.xr.compose.platform.LocalSpatialCapabilities import com.ramcosta.composedestinations.DestinationsNavHost import com.ramcosta.composedestinations.animations.NavHostAnimatedDestinationStyle import com.ramcosta.composedestinations.generated.NavGraphs @@ -86,9 +88,11 @@ import com.ramcosta.composedestinations.utils.dialogComposable import com.ramcosta.composedestinations.utils.toDestinationsNavigator import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.DownLeftAndUpRightToCenter import compose.icons.fontawesomeicons.solid.EllipsisVertical import compose.icons.fontawesomeicons.solid.Gear import compose.icons.fontawesomeicons.solid.Pen +import compose.icons.fontawesomeicons.solid.UpRightAndDownLeftFromCenter import dev.dimension.flare.R import dev.dimension.flare.data.model.AllListTabItem import dev.dimension.flare.data.model.Bluesky @@ -166,6 +170,8 @@ internal fun HomeScreen( navBackStackEntry?.destination?.route } } + val session = LocalSession.current + val spatialCapabilities = LocalSpatialCapabilities.current val hapticFeedback = LocalHapticFeedback.current state.tabs .onSuccess { tabs -> @@ -359,6 +365,38 @@ internal fun HomeScreen( } }, footerItems = { + if (!spatialCapabilities.isSpatialUiEnabled) { + item( + selected = false, + onClick = { + if (spatialCapabilities.isSpatialUiEnabled) { + session?.requestHomeSpaceMode() + } else { + session?.requestFullSpaceMode() + } + }, + icon = { + if (spatialCapabilities.isSpatialUiEnabled) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.DownLeftAndUpRightToCenter, + contentDescription = null, + ) + } else { + FAIcon( + imageVector = FontAwesomeIcons.Solid.UpRightAndDownLeftFromCenter, + contentDescription = null, + ) + } + }, + label = { + if (spatialCapabilities.isSpatialUiEnabled) { + Text("Spatial") + } else { + Text("Spatial") + } + }, + ) + } accountTypeState.user.onSuccess { item( selected = currentRoute == SettingsRouteDestination.route, 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 e5903c52d..a7538987b 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 @@ -32,6 +32,7 @@ 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 @@ -78,6 +79,7 @@ import dev.dimension.flare.ui.screen.home.TimelineRoute import dev.dimension.flare.ui.theme.MediumAlpha import dev.dimension.flare.ui.theme.screenHorizontalPadding import io.github.fornewid.placeholder.material3.placeholder +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import moe.tlaster.precompose.molecule.producePresenter @@ -93,10 +95,13 @@ internal fun ListScreenRoute( ) { val scaffoldNavigator = rememberListDetailPaneScaffoldNavigator() + val scope = rememberCoroutineScope() BackHandler( scaffoldNavigator.canNavigateBack(), ) { - scaffoldNavigator.navigateBack() + scope.launch { + scaffoldNavigator.navigateBack() + } } ListDetailPaneScaffold( directive = scaffoldNavigator.scaffoldDirective, @@ -106,13 +111,15 @@ internal fun ListScreenRoute( ListScreen( accountType = accountType, toList = { item -> - scaffoldNavigator.navigateTo( - ListDetailPaneScaffoldRole.Detail, - ListDetailPaneNavArgs( - id = item.id, - title = item.title, - ), - ) + scope.launch { + scaffoldNavigator.navigateTo( + ListDetailPaneScaffoldRole.Detail, + ListDetailPaneNavArgs( + id = item.id, + title = item.title, + ), + ) + } }, createList = { navigator.navigate(CreateListRouteDestination(accountType = accountType)) @@ -139,7 +146,7 @@ internal fun ListScreenRoute( }, detailPane = { AnimatedPane { - scaffoldNavigator.currentDestination?.content?.let { args -> + scaffoldNavigator.currentDestination?.contentKey?.let { args -> TimelineRoute( navigator = navigator, tabItem = diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt index 5b8ee082e..30fe228f2 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaf import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.UriHandler @@ -60,6 +61,7 @@ import dev.dimension.flare.ui.presenter.home.UserState import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.screen.home.NavigationState import dev.dimension.flare.ui.screen.home.Router +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import moe.tlaster.precompose.molecule.producePresenter @@ -75,10 +77,13 @@ internal fun SettingsRoute( val uriHandler = LocalUriHandler.current val scaffoldNavigator = rememberListDetailPaneScaffoldNavigator() + val scope = rememberCoroutineScope() BackHandler( scaffoldNavigator.canNavigateBack(), ) { - scaffoldNavigator.navigateBack() + scope.launch { + scaffoldNavigator.navigateBack() + } } ListDetailPaneScaffold( directive = scaffoldNavigator.scaffoldDirective, @@ -87,42 +92,58 @@ internal fun SettingsRoute( AnimatedPane { SettingsScreen( toAccounts = { - scaffoldNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail, SettingsDetailDestination.Accounts) + scope.launch { + scaffoldNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail, SettingsDetailDestination.Accounts) + } }, toAppearance = { - scaffoldNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail, SettingsDetailDestination.Appearance) + scope.launch { + scaffoldNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail, SettingsDetailDestination.Appearance) + } }, toStorage = { - scaffoldNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail, SettingsDetailDestination.Storage) + scope.launch { + scaffoldNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail, SettingsDetailDestination.Storage) + } }, toAbout = { - scaffoldNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail, SettingsDetailDestination.About) + scope.launch { + scaffoldNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail, SettingsDetailDestination.About) + } }, toTabCustomization = { - scaffoldNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail, SettingsDetailDestination.TabCustomization) + scope.launch { + scaffoldNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail, SettingsDetailDestination.TabCustomization) + } }, toLocalFilter = { - scaffoldNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail, SettingsDetailDestination.LocalFilter) + scope.launch { + scaffoldNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail, SettingsDetailDestination.LocalFilter) + } }, toGuestSettings = { navigator.navigate(GuestSettingRouteDestination) }, toLocalHistory = { - scaffoldNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail, SettingsDetailDestination.LocalHistory) + scope.launch { + scaffoldNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail, SettingsDetailDestination.LocalHistory) + } }, ) } }, detailPane = { AnimatedPane { - scaffoldNavigator.currentDestination?.content?.let { item -> + scaffoldNavigator.currentDestination?.contentKey?.let { item -> Router(navGraph = NavGraphs.root, item.toDestination()) { dependency( ProxyDestinationsNavigator( scaffoldNavigator, destinationsNavigator, navigateBack = { - scaffoldNavigator.navigateBack() + scope.launch { + scaffoldNavigator.navigateBack() + } }, uriHandler = uriHandler, ), diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5a5907d3a..3733f893e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -47,6 +47,7 @@ firebase-crashlytics = "3.0.2" materialKolor = "2.0.0" room = "2.7.0-alpha12" compose-multiplatform = "1.7.1" +xr = "1.0.0-alpha01" [libraries] bluesky = { module = "moe.tlaster.ozone:bluesky", version.ref = "bluesky" } @@ -90,6 +91,10 @@ room-paging = { group = "androidx.room", name = "room-paging", version.ref = "ro room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } sqlite-bundled = { group = "androidx.sqlite", name = "sqlite-bundled", version = "2.5.0-alpha12" } nestedScrollView = { group = "com.github.Tlaster", name = "NestedScrollView", version = "1.0.3" } +xr-compose = { group = "androidx.xr.compose", name = "compose", version.ref = "xr" } +xr-runtime = { group = "androidx.xr.runtime", name = "runtime", version.ref = "xr" } +xr-scenecore = { group = "androidx.xr.scenecore", name = "scenecore", version.ref = "xr" } +xr-material3 = { group = "androidx.xr.compose.material3", name = "material3", version.ref = "xr" } media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" } @@ -185,6 +190,7 @@ compose-destinations = ["compose-destinations"] media3 = ["media3-exoplayer", "media3-ui", "media3-hls"] firebase = ["firebase-analytics-ktx", "firebase-crashlytics-ktx"] ktorfit = ["ktorfit-lib", "ktorfit-converters-response", "ktorfit-converters-flow", "ktorfit-converters-call"] +xr = ["xr-compose", "xr-runtime", "xr-scenecore", "xr-material3"] [plugins] android-application = { id = "com.android.application", version.ref = "agp" }