diff --git a/app-android/src/main/java/io/github/droidkaigi/confsched/KaigiApp.kt b/app-android/src/main/java/io/github/droidkaigi/confsched/KaigiApp.kt index 4491ff040..a20d1be92 100644 --- a/app-android/src/main/java/io/github/droidkaigi/confsched/KaigiApp.kt +++ b/app-android/src/main/java/io/github/droidkaigi/confsched/KaigiApp.kt @@ -35,6 +35,7 @@ import io.github.droidkaigi.confsched.main.MainNestedGraphStateHolder import io.github.droidkaigi.confsched.main.MainScreenTab import io.github.droidkaigi.confsched.main.MainScreenTab.About import io.github.droidkaigi.confsched.main.MainScreenTab.EventMap +import io.github.droidkaigi.confsched.main.MainScreenTab.Favorite import io.github.droidkaigi.confsched.main.MainScreenTab.ProfileCard import io.github.droidkaigi.confsched.main.MainScreenTab.Timetable import io.github.droidkaigi.confsched.main.mainScreen @@ -141,7 +142,8 @@ class KaigiAppMainNestedGraphStateHolder : MainNestedGraphStateHolder { when (tab) { Timetable -> mainNestedNavController.navigateTimetableScreen() EventMap -> mainNestedNavController.navigateEventMapScreen() - About -> TODO() + Favorite -> {} + About -> {} ProfileCard -> mainNestedNavController.navigateProfileCardScreen() } } @@ -164,7 +166,6 @@ private class ExternalNavController( private val context: Context, private val shareNavigator: ShareNavigator, ) { - fun navigate(url: String) { val uri: Uri = url.toUri() val launched = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { @@ -216,10 +217,14 @@ private class ExternalNavController( @Suppress("SwallowedException") @RequiresApi(Build.VERSION_CODES.R) - private fun navigateToNativeAppApi30(context: Context, uri: Uri): Boolean { - val nativeAppIntent = Intent(Intent.ACTION_VIEW, uri) - .addCategory(Intent.CATEGORY_BROWSABLE) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_REQUIRE_NON_BROWSER) + private fun navigateToNativeAppApi30( + context: Context, + uri: Uri, + ): Boolean { + val nativeAppIntent = + Intent(Intent.ACTION_VIEW, uri) + .addCategory(Intent.CATEGORY_BROWSABLE) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_REQUIRE_NON_BROWSER) return try { context.startActivity(nativeAppIntent) true @@ -229,7 +234,10 @@ private class ExternalNavController( } @SuppressLint("QueryPermissionsNeeded") - private fun navigateToNativeApp(context: Context, uri: Uri): Boolean { + private fun navigateToNativeApp( + context: Context, + uri: Uri, + ): Boolean { val pm = context.packageManager // Get all Apps that resolve a generic url @@ -264,7 +272,10 @@ private class ExternalNavController( return true } - private fun navigateToCustomTab(context: Context, uri: Uri) { + private fun navigateToCustomTab( + context: Context, + uri: Uri, + ) { CustomTabsIntent.Builder() .setShowTitle(true) .build() diff --git a/feature/main/build.gradle.kts b/feature/main/build.gradle.kts index 047889f75..dd3d7c5b0 100644 --- a/feature/main/build.gradle.kts +++ b/feature/main/build.gradle.kts @@ -12,6 +12,7 @@ kotlin { implementation(projects.core.model) implementation(projects.core.designsystem) implementation(projects.core.ui) + implementation(libs.haze) } } } diff --git a/feature/main/src/commonMain/kotlin/io/github/droidkaigi/confsched/main/MainScreen.kt b/feature/main/src/commonMain/kotlin/io/github/droidkaigi/confsched/main/MainScreen.kt index b81cec9f3..014f03b7a 100644 --- a/feature/main/src/commonMain/kotlin/io/github/droidkaigi/confsched/main/MainScreen.kt +++ b/feature/main/src/commonMain/kotlin/io/github/droidkaigi/confsched/main/MainScreen.kt @@ -14,11 +14,12 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.outlined.CalendarMonth +import androidx.compose.material.icons.outlined.Favorite import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Map +import androidx.compose.material.icons.outlined.People import androidx.compose.material3.Button import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHostState @@ -29,21 +30,23 @@ import androidx.compose.runtime.Composable 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.vector.ImageVector +import androidx.compose.ui.unit.dp import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController -import conference_app_2024.feature.main.generated.resources.Res -import conference_app_2024.feature.main.generated.resources.icon_achievement_fill -import conference_app_2024.feature.main.generated.resources.icon_achievement_outline -import conference_app_2024.feature.main.generated.resources.icon_map_fill +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.HazeStyle +import dev.chrisbanes.haze.haze import io.github.droidkaigi.confsched.compose.EventEmitter import io.github.droidkaigi.confsched.compose.rememberEventEmitter import io.github.droidkaigi.confsched.main.NavigationType.BottomNavigation import io.github.droidkaigi.confsched.main.NavigationType.NavigationRail +import io.github.droidkaigi.confsched.main.section.GlassLikeBottomNavigation import io.github.droidkaigi.confsched.main.strings.MainStrings import io.github.droidkaigi.confsched.ui.SnackbarMessageEffect import io.github.droidkaigi.confsched.ui.UserMessageStateHolder @@ -51,7 +54,6 @@ import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.ExperimentalResourceApi const val mainScreenRoute = "main" -const val MainScreenTestTag = "MainScreen" fun NavGraphBuilder.mainScreen( windowSize: WindowSizeClass, @@ -69,12 +71,18 @@ fun NavGraphBuilder.mainScreen( interface MainNestedGraphStateHolder { val startDestination: String + fun routeToTab(route: String): MainScreenTab? - fun onTabSelected(mainNestedNavController: NavController, tab: MainScreenTab) + + fun onTabSelected( + mainNestedNavController: NavController, + tab: MainScreenTab, + ) } enum class NavigationType { - BottomNavigation, NavigationRail + BottomNavigation, + NavigationRail, } @Composable @@ -87,12 +95,13 @@ fun MainScreen( ) { val snackbarHostState = remember { SnackbarHostState() } - val navigationType: NavigationType = when (windowSize.widthSizeClass) { - WindowWidthSizeClass.Compact -> BottomNavigation - WindowWidthSizeClass.Medium -> NavigationRail - WindowWidthSizeClass.Expanded -> NavigationRail - else -> BottomNavigation - } + val navigationType: NavigationType = + when (windowSize.widthSizeClass) { + WindowWidthSizeClass.Compact -> BottomNavigation + WindowWidthSizeClass.Medium -> NavigationRail + WindowWidthSizeClass.Expanded -> NavigationRail + else -> BottomNavigation + } SnackbarMessageEffect( snackbarHostState = snackbarHostState, @@ -116,7 +125,7 @@ sealed class IconRepresentation { } enum class MainScreenTab( - val icon: IconRepresentation, + val icon: IconRepresentation.Vector, val selectedIcon: IconRepresentation, val label: String, val contentDescription: String, @@ -124,15 +133,21 @@ enum class MainScreenTab( ) { Timetable( icon = IconRepresentation.Vector(Icons.Outlined.CalendarMonth), - selectedIcon = IconRepresentation.Vector(Icons.Filled.CalendarMonth), + selectedIcon = IconRepresentation.Vector(Icons.Outlined.CalendarMonth), label = MainStrings.Timetable.asString(), contentDescription = MainStrings.Timetable.asString(), ), - @OptIn(ExperimentalResourceApi::class) EventMap( icon = IconRepresentation.Vector(Icons.Outlined.Map), - selectedIcon = IconRepresentation.Drawable(drawableId = Res.drawable.icon_map_fill), + selectedIcon = IconRepresentation.Vector(Icons.Outlined.Map), + label = MainStrings.EventMap.asString(), + contentDescription = MainStrings.EventMap.asString(), + ), + + Favorite( + icon = IconRepresentation.Vector(Icons.Outlined.Favorite), + selectedIcon = IconRepresentation.Vector(Icons.Outlined.Favorite), label = MainStrings.EventMap.asString(), contentDescription = MainStrings.EventMap.asString(), ), @@ -144,13 +159,19 @@ enum class MainScreenTab( contentDescription = MainStrings.About.asString(), ), - @OptIn(ExperimentalResourceApi::class) ProfileCard( - icon = IconRepresentation.Drawable(drawableId = Res.drawable.icon_achievement_outline), - selectedIcon = IconRepresentation.Drawable(drawableId = Res.drawable.icon_achievement_fill), + icon = IconRepresentation.Vector(Icons.Outlined.People), + selectedIcon = IconRepresentation.Vector(Icons.Outlined.People), label = MainStrings.ProfileCard.asString(), contentDescription = MainStrings.ProfileCard.asString(), ), + ; + + companion object { + val size: Int get() = values().size + fun indexOf(tab: MainScreenTab): Int = values().indexOf(tab) + fun fromIndex(index: Int): MainScreenTab = values()[index] + } } data class MainScreenUiState( @@ -184,26 +205,32 @@ fun MainScreen( } } } + + val hazeState = remember { HazeState() } + Scaffold( bottomBar = { - AnimatedVisibility(visible = navigationType == BottomNavigation) { - Row { - MainScreenTab.entries.forEach { tab -> - Button( - modifier = Modifier.weight(1F), - onClick = { onTabSelected(mainNestedNavController, tab) }, - ) { - Text(text = tab.label + " " + (currentTab == tab)) - } - } - } - } + GlassLikeBottomNavigation( + hazeState = hazeState, + onTabSelected = { + onTabSelected(mainNestedNavController, it) + }, + ) }, ) { padding -> + val hazeStyle = + HazeStyle( + tint = Color.Black.copy(alpha = .2f), + blurRadius = 30.dp, + ) NavHost( navController = mainNestedNavController, startDestination = "timetable", - modifier = Modifier, + modifier = + Modifier.haze( + hazeState, + hazeStyle, + ), enterTransition = { materialFadeThroughIn() }, exitTransition = { materialFadeThroughOut() }, ) { @@ -213,25 +240,31 @@ fun MainScreen( } } -private fun materialFadeThroughIn(): EnterTransition = fadeIn( - animationSpec = tween( - durationMillis = 195, - delayMillis = 105, - easing = LinearOutSlowInEasing, - ), -) + scaleIn( - animationSpec = tween( - durationMillis = 195, - delayMillis = 105, - easing = LinearOutSlowInEasing, - ), - initialScale = 0.92f, -) +private fun materialFadeThroughIn(): EnterTransition = + fadeIn( + animationSpec = + tween( + durationMillis = 195, + delayMillis = 105, + easing = LinearOutSlowInEasing, + ), + ) + + scaleIn( + animationSpec = + tween( + durationMillis = 195, + delayMillis = 105, + easing = LinearOutSlowInEasing, + ), + initialScale = 0.92f, + ) -private fun materialFadeThroughOut(): ExitTransition = fadeOut( - animationSpec = tween( - durationMillis = 105, - delayMillis = 0, - easing = FastOutLinearInEasing, - ), -) +private fun materialFadeThroughOut(): ExitTransition = + fadeOut( + animationSpec = + tween( + durationMillis = 105, + delayMillis = 0, + easing = FastOutLinearInEasing, + ), + ) diff --git a/feature/main/src/commonMain/kotlin/io/github/droidkaigi/confsched/main/section/GlassLikeBottomNavigation.kt b/feature/main/src/commonMain/kotlin/io/github/droidkaigi/confsched/main/section/GlassLikeBottomNavigation.kt new file mode 100644 index 000000000..a0bb1093d --- /dev/null +++ b/feature/main/src/commonMain/kotlin/io/github/droidkaigi/confsched/main/section/GlassLikeBottomNavigation.kt @@ -0,0 +1,238 @@ +package io.github.droidkaigi.confsched.main.section + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectTapGestures +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.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.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.BlurredEdgeTreatment +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.toRect +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.PathMeasure +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeChild +import io.github.droidkaigi.confsched.designsystem.theme.KaigiTheme +import io.github.droidkaigi.confsched.main.MainScreenTab + +@Composable +fun GlassLikeBottomNavigation( + hazeState: HazeState, + onTabSelected: (MainScreenTab) -> Unit, + modifier: Modifier = Modifier, +) { + var selectedTabIndex by remember { mutableIntStateOf(1) } + Box( + modifier = modifier + .padding(vertical = 24.dp, horizontal = 64.dp) + .fillMaxWidth() + .height(64.dp) + .hazeChild(state = hazeState, shape = CircleShape) + .border( + width = Dp.Hairline, + brush = + Brush.verticalGradient( + colors = + listOf( + Color.White.copy(alpha = .8f), + Color.White.copy(alpha = .2f), + ), + ), + shape = CircleShape, + ), + ) { + BottomBarTabs( + selectedTab = selectedTabIndex, + onTabSelected = { + selectedTabIndex = MainScreenTab.indexOf(it) + onTabSelected(it) + }, + ) + + val animatedSelectedTabIndex by animateFloatAsState( + targetValue = selectedTabIndex.toFloat(), + label = "animatedSelectedTabIndex", + animationSpec = + spring( + stiffness = Spring.StiffnessLow, + dampingRatio = Spring.DampingRatioLowBouncy, + ), + ) + + val animatedColor by animateColorAsState( + // FIXME: apply theme + targetValue = Color(0xFF67FF8D), + label = "animatedColor", + animationSpec = + spring( + stiffness = Spring.StiffnessLow, + ), + ) + + Canvas( + modifier = + Modifier + .fillMaxSize() + .clip(CircleShape) + .blur(50.dp, edgeTreatment = BlurredEdgeTreatment.Unbounded), + ) { + val tabWidth = size.width / MainScreenTab.size + drawCircle( + color = animatedColor.copy(alpha = .6f), + radius = size.height / 2, + center = + Offset( + (tabWidth * animatedSelectedTabIndex) + tabWidth / 2, + size.height / 2, + ), + ) + } + + Canvas( + modifier = + Modifier + .fillMaxSize() + .clip(CircleShape), + ) { + val path = + Path().apply { + addRoundRect(RoundRect(size.toRect(), CornerRadius(size.height))) + } + val length = PathMeasure().apply { setPath(path, false) }.length + + val tabWidth = size.width / MainScreenTab.size + drawPath( + path, + brush = + Brush.horizontalGradient( + colors = + listOf( + animatedColor.copy(alpha = 0f), + animatedColor.copy(alpha = 1f), + animatedColor.copy(alpha = 1f), + animatedColor.copy(alpha = 0f), + ), + startX = tabWidth * animatedSelectedTabIndex, + endX = tabWidth * (animatedSelectedTabIndex + 1), + ), + style = + Stroke( + width = 6f, + pathEffect = + PathEffect.dashPathEffect( + intervals = floatArrayOf(length / 2, length), + ), + ), + ) + } + } +} + +@Composable +fun BottomBarTabs( + selectedTab: Int, + onTabSelected: (MainScreenTab) -> Unit, + modifier: Modifier = Modifier, +) { + CompositionLocalProvider( + LocalTextStyle provides + LocalTextStyle.current.copy( + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ), + LocalContentColor provides Color.White, + ) { + Row( + modifier = modifier.fillMaxSize(), + ) { + for (tab in MainScreenTab.values()) { + val alpha by animateFloatAsState( + targetValue = if (selectedTab == MainScreenTab.indexOf(tab)) 1f else .35f, + label = "alpha", + ) + val scale by animateFloatAsState( + targetValue = if (selectedTab == MainScreenTab.indexOf(tab)) 1f else .98f, + visibilityThreshold = .000001f, + animationSpec = + spring( + stiffness = Spring.StiffnessLow, + dampingRatio = Spring.DampingRatioMediumBouncy, + ), + label = "scale", + ) + Column( + modifier = + Modifier + .scale(scale) + .alpha(alpha) + .fillMaxHeight() + .weight(1f) + .pointerInput(Unit) { + detectTapGestures { + onTabSelected(tab) + } + }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon(imageVector = tab.icon.imageVector, contentDescription = "tab ${tab.contentDescription}") + } + } + } + } +} + +@Preview +@Composable +fun GlassLikeBottomNavigationPreview() { + val hazeState = remember { HazeState() } + + KaigiTheme { + Scaffold { + GlassLikeBottomNavigation( + hazeState = hazeState, + {}, + ) + } + } +} diff --git a/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confshed/profilecard/ProfileCardScreen.kt b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confshed/profilecard/ProfileCardScreen.kt index ff690cf56..dce8133d2 100644 --- a/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confshed/profilecard/ProfileCardScreen.kt +++ b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confshed/profilecard/ProfileCardScreen.kt @@ -56,9 +56,7 @@ object ProfileCardTestTag { } } -fun NavGraphBuilder.profileCardScreen( - contentPadding: PaddingValues, -) { +fun NavGraphBuilder.profileCardScreen(contentPadding: PaddingValues) { composable(profileCardScreenRoute) { ProfileCardScreen(contentPadding) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 862b71e32..bca1c0ff8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -37,6 +37,7 @@ okHttp = "4.12.0" ktorfit = "1.12.0" ossLicensesPlugin = "0.10.6" ossLicenses = "17.1.0" +haze = "0.7.2" detekt = "1.23.6" twitterComposeRule = "0.0.26" lottie = "6.1.0" @@ -115,6 +116,7 @@ composeShimmer = { module = "com.valentinilk.shimmer:compose-shimmer", version = rin = { module = "io.github.takahirom.rin:rin", version.ref = "rin" } lottieCompose = { module = "com.airbnb.android:lottie-compose", version.ref = "lottie" } animation-graphics-android = { group = "androidx.compose.animation", name = "animation-graphics-android", version = "1.5.1" } +haze = { group = "dev.chrisbanes.haze", name = "haze", version.ref = "haze" } androidxFragment = { module = "androidx.fragment:fragment", version.ref = "androidxFragment" } androidxCoreKtx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" }