diff --git a/app/build.gradle b/app/build.gradle index b0cf5058..fc9814c5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -92,9 +92,12 @@ dependencies { implementation 'androidx.compose.material:material-icons-extended' implementation 'androidx.activity:activity-compose:1.8.2' implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0' + implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.7.0' implementation "com.google.accompanist:accompanist-adaptive:0.34.0" implementation "androidx.navigation:navigation-compose:2.7.7" + kapt 'androidx.lifecycle:lifecycle-compiler:2.7.0' + implementation "androidx.paging:paging-compose:3.2.1" implementation "io.ktor:ktor-client-core:2.3.8" diff --git a/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt b/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt index ff619f34..22ab0eca 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt @@ -24,8 +24,10 @@ import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.adaptive.calculateDisplayFeatures -import xyz.quaver.pupil.ui.composable.PupilApp +import xyz.quaver.pupil.ui.composable.MainApp import xyz.quaver.pupil.ui.theme.AppTheme import xyz.quaver.pupil.ui.viewmodel.MainViewModel @@ -42,10 +44,13 @@ class MainActivity : BaseActivity() { val windowSize = calculateWindowSizeClass(this) val displayFeatures = calculateDisplayFeatures(this) - PupilApp( + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + MainApp( windowSize = windowSize, displayFeatures = displayFeatures, - uiState = viewModel.uiState + uiState = uiState, + navigateToDestination = viewModel::navigateToDestination ) } } diff --git a/app/src/main/java/xyz/quaver/pupil/ui/composable/GalleryList.kt b/app/src/main/java/xyz/quaver/pupil/ui/composable/GalleryList.kt new file mode 100644 index 00000000..e4dac9c2 --- /dev/null +++ b/app/src/main/java/xyz/quaver/pupil/ui/composable/GalleryList.kt @@ -0,0 +1,10 @@ +package xyz.quaver.pupil.ui.composable + +import androidx.compose.runtime.Composable + +@Composable +fun GalleryList( + +) { + +} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/ui/composable/MainApp.kt b/app/src/main/java/xyz/quaver/pupil/ui/composable/MainApp.kt index 7b13c1af..8c24d21e 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/composable/MainApp.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/composable/MainApp.kt @@ -1,6 +1,14 @@ package xyz.quaver.pupil.ui.composable +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +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.material3.DrawerValue +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.PermanentNavigationDrawer import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass @@ -8,16 +16,18 @@ import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope -import androidx.navigation.compose.rememberNavController +import androidx.compose.ui.Modifier import androidx.window.layout.DisplayFeature import androidx.window.layout.FoldingFeature +import kotlinx.coroutines.launch import xyz.quaver.pupil.ui.viewmodel.MainUIState @Composable -fun PupilApp( +fun MainApp( windowSize: WindowSizeClass, displayFeatures: List, - uiState: MainUIState + uiState: MainUIState, + navigateToDestination: (MainDestination) -> Unit ) { val navigationType: NavigationType val contentType: ContentType @@ -31,7 +41,7 @@ fun PupilApp( when (windowSize.widthSizeClass) { WindowWidthSizeClass.Compact -> { - navigationType = NavigationType.NAVIGATION_RAIL + navigationType = NavigationType.BOTTOM_NAVIGATION contentType = ContentType.SINGLE_PANE } WindowWidthSizeClass.Medium -> { @@ -51,7 +61,7 @@ fun PupilApp( contentType = ContentType.DUAL_PANE } else -> { - navigationType = NavigationType.NAVIGATION_RAIL + navigationType = NavigationType.BOTTOM_NAVIGATION contentType = ContentType.SINGLE_PANE } } @@ -63,30 +73,113 @@ fun PupilApp( else -> NavigationContentPosition.TOP } - PupilNavigationWrapper( + MainNavigationWrapper( navigationType, contentType, - navigationContentPosition + displayFeatures, + navigationContentPosition, + uiState, + navigateToDestination ) } @Composable -private fun PupilNavigationWrapper( +private fun MainNavigationWrapper( navigationType: NavigationType, contentType: ContentType, - navigationContentPosition: NavigationContentPosition + displayFeatures: List, + navigationContentPosition: NavigationContentPosition, + uiState: MainUIState, + navigateToDestination: (MainDestination) -> Unit ) { val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val coroutineScope = rememberCoroutineScope() + val openDrawer: () -> Unit = { + coroutineScope.launch { + drawerState.open() + } + } + if (navigationType == NavigationType.PERMANENT_NAVIGATION_DRAWER) { PermanentNavigationDrawer(drawerContent = { PermanentNavigationDrawerContent( - navigationContentPosition = navigationContentPosition + selectedDestination = uiState.currentDestination, + navigationContentPosition = navigationContentPosition, + navigateToDestination = navigateToDestination ) }) { -// PupilMain() + MainContent( + navigationType = navigationType, + contentType = contentType, + displayFeatures = displayFeatures, + navigationContentPosition = navigationContentPosition, + uiState = uiState, + navigateToDestination = navigateToDestination, + onDrawerClicked = openDrawer + ) + } + } else { + ModalNavigationDrawer( + drawerContent = { + ModalNavigationDrawerContent( + selectedDestination = uiState.currentDestination, + navigationContentPosition = navigationContentPosition, + navigateToDestination = navigateToDestination, + onDrawerClicked = { + coroutineScope.launch { + drawerState.close() + } + } + ) + }, + drawerState = drawerState + ) { + MainContent( + navigationType = navigationType, + contentType = contentType, + displayFeatures = displayFeatures, + navigationContentPosition = navigationContentPosition, + uiState = uiState, + navigateToDestination = navigateToDestination, + onDrawerClicked = openDrawer + ) + } + } +} + +@Composable +fun MainContent( + navigationType: NavigationType, + contentType: ContentType, + displayFeatures: List, + navigationContentPosition: NavigationContentPosition, + uiState: MainUIState, + navigateToDestination: (MainDestination) -> Unit, + onDrawerClicked: () -> Unit +) { + Row(modifier = Modifier.fillMaxSize()) { + AnimatedVisibility(visible = navigationType == NavigationType.NAVIGATION_RAIL) { + MainNavigationRail( + selectedDestination = uiState.currentDestination, + navigationContentPosition = navigationContentPosition, + navigateToDestination = navigateToDestination, + onDrawerClicked = onDrawerClicked + ) + } + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.inverseOnSurface) + ) { + Box(modifier = Modifier.weight(1f)) + AnimatedVisibility(visible = navigationType == NavigationType.BOTTOM_NAVIGATION) { + BottomNavigationBar( + selectedDestination = uiState.currentDestination, + navigateToDestination = navigateToDestination + ) + } } } } \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/ui/composable/MainNavigationActions.kt b/app/src/main/java/xyz/quaver/pupil/ui/composable/MainNavigationActions.kt index d32c2f31..4eae9d17 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/composable/MainNavigationActions.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/composable/MainNavigationActions.kt @@ -2,37 +2,46 @@ package xyz.quaver.pupil.ui.composable import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Star import androidx.compose.ui.graphics.vector.ImageVector import xyz.quaver.pupil.R -data class MainDestination( - val route: String, - val icon: ImageVector, +sealed interface MainDestination { + val route: String + val icon: ImageVector val textId: Int -) + + object Search: MainDestination { + override val route = "search" + override val icon = Icons.Default.Search + override val textId = R.string.main_destination_search + } + + object History: MainDestination { + override val route = "history" + override val icon = Icons.Default.History + override val textId = R.string.main_destination_history + } + + object Downloads: MainDestination { + override val route = "downloads" + override val icon = Icons.Default.Download + override val textId = R.string.main_destination_downloads + } + + object Favorites: MainDestination { + override val route = "favorites" + override val icon = Icons.Default.Favorite + override val textId = R.string.main_destination_favorites + } +} val mainDestinations = listOf( - MainDestination( - "search", - Icons.Default.Search, - R.string.main_destination_search - ), - MainDestination( - "history", - Icons.Default.History, - R.string.main_destination_history - ), - MainDestination( - "downloads", - Icons.Default.Download, - R.string.main_destination_downloads - ), - MainDestination( - "favorites", - Icons.Default.Star, - R.string.main_destination_favorites - ), + MainDestination.Search, + MainDestination.History, + MainDestination.Downloads, + MainDestination.Favorites ) \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/ui/composable/NavigationDrawerContent.kt b/app/src/main/java/xyz/quaver/pupil/ui/composable/NavigationDrawerContent.kt index 2513c175..751375e3 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/composable/NavigationDrawerContent.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/composable/NavigationDrawerContent.kt @@ -4,56 +4,289 @@ 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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.MenuOpen +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.MenuOpen import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.NavigationDrawerItemDefaults +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.PermanentDrawerSheet import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasurePolicy +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.layoutId import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.offset import xyz.quaver.pupil.R @Composable fun PermanentNavigationDrawerContent( - navigationContentPosition: NavigationContentPosition + selectedDestination: MainDestination, + navigationContentPosition: NavigationContentPosition, + navigateToDestination: (MainDestination) -> Unit ) { PermanentDrawerSheet( modifier = Modifier.sizeIn(minWidth = 200.dp, maxWidth = 300.dp), drawerContainerColor = MaterialTheme.colorScheme.inverseOnSurface ) { - Column( + Layout( modifier = Modifier .background(MaterialTheme.colorScheme.inverseOnSurface) - .padding(16.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { + .padding(16.dp), + content = { + Row( + modifier = Modifier.layoutId(LayoutType.HEADER), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier.size(32.dp), + painter = painterResource(R.drawable.app_icon), + tint = Color.Unspecified, + contentDescription = "app icon" + ) + Text( + modifier = Modifier.padding(16.dp), + text = "Pupil", + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.primary + ) + } + Column( + modifier = Modifier + .layoutId(LayoutType.CONTENT) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + mainDestinations.forEach { destination -> + NavigationDrawerItem( + label = { + Text( + text = stringResource(destination.textId), + modifier = Modifier.padding(16.dp) + ) + }, + icon = { + Icon( + imageVector = destination.icon, + contentDescription = stringResource(destination.textId) + ) + }, + selected = selectedDestination.route == destination.route, + colors = NavigationDrawerItemDefaults.colors( + unselectedContainerColor = Color.Transparent + ), + onClick = { navigateToDestination(destination) } + ) + } + } + }, + measurePolicy = navigationMeasurePolicy(navigationContentPosition) + ) + } +} + +@Composable +fun ModalNavigationDrawerContent( + selectedDestination: MainDestination, + navigationContentPosition: NavigationContentPosition, + navigateToDestination: (MainDestination) -> Unit, + onDrawerClicked: () -> Unit +) { + ModalDrawerSheet { + Layout( + modifier = Modifier + .background(MaterialTheme.colorScheme.inverseOnSurface) + .padding(16.dp), + content = { + Row( + modifier = Modifier + .layoutId(LayoutType.HEADER) + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier.size(32.dp), + painter = painterResource(R.drawable.app_icon), + tint = Color.Unspecified, + contentDescription = "app icon" + ) + Text( + modifier = Modifier.padding(16.dp), + text = "Pupil", + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.primary + ) + } + + IconButton(onClick = onDrawerClicked) { + Icon( + imageVector = Icons.AutoMirrored.Default.MenuOpen, + contentDescription = stringResource(R.string.main_open_navigation_drawer) + ) + } + } + + Column ( + modifier = Modifier + .layoutId(LayoutType.CONTENT) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + mainDestinations.forEach { destination -> + NavigationDrawerItem( + label = { + Text( + text = stringResource(destination.textId), + modifier = Modifier.padding(16.dp) + ) + }, + icon = { + Icon( + imageVector = destination.icon, + contentDescription = stringResource(destination.textId) + ) + }, + selected = selectedDestination.route == destination.route, + colors = NavigationDrawerItemDefaults.colors( + unselectedContainerColor = Color.Transparent + ), + onClick = { navigateToDestination(destination) } + ) + } + } + }, + measurePolicy = navigationMeasurePolicy(navigationContentPosition) + ) + } +} + +@Composable +fun MainNavigationRail( + selectedDestination: MainDestination, + navigationContentPosition: NavigationContentPosition, + navigateToDestination: (MainDestination) -> Unit, + onDrawerClicked: () -> Unit +) { + NavigationRail ( + modifier = Modifier.fillMaxHeight(), + containerColor = MaterialTheme.colorScheme.inverseOnSurface + ) { + NavigationRailItem( + selected = false, + onClick = onDrawerClicked, + icon = { Icon( - modifier = Modifier.size(32.dp), - painter = painterResource(R.drawable.app_icon), - tint = Color.Unspecified, - contentDescription = "app icon" + imageVector = Icons.Default.Menu, + contentDescription = stringResource(R.string.main_open_navigation_drawer) ) - Text( - modifier = Modifier.padding(16.dp), - text = "Pupil", - style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), - color = MaterialTheme.colorScheme.primary + } + ) + + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + mainDestinations.forEach { destination -> + NavigationRailItem( + selected = selectedDestination.route == destination.route, + onClick = { navigateToDestination(destination) }, + icon = { + Icon( + imageVector = destination.icon, + contentDescription = stringResource(destination.textId) + ) + } ) } } - Column( + } +} - ) { - Text("Help") +@Composable +fun BottomNavigationBar( + selectedDestination: MainDestination, + navigateToDestination: (MainDestination) -> Unit +) { + NavigationBar(modifier = Modifier.fillMaxWidth()) { + mainDestinations.forEach { destination -> + NavigationBarItem( + selected = selectedDestination.route == destination.route, + onClick = { navigateToDestination(destination) }, + icon = { + Icon( + imageVector = destination.icon, + contentDescription = stringResource(destination.textId) + ) + } + ) } } -} \ No newline at end of file +} + +fun navigationMeasurePolicy( + navigationContentPosition: NavigationContentPosition, +): MeasurePolicy { + return MeasurePolicy { measurables, constraints -> + lateinit var headerMeasurable: Measurable + lateinit var contentMeasurable: Measurable + measurables.forEach { + when (it.layoutId) { + LayoutType.HEADER -> headerMeasurable = it + LayoutType.CONTENT -> contentMeasurable = it + else -> error("Unknown layoutId encountered!") + } + } + + val headerPlaceable = headerMeasurable.measure(constraints) + val contentPlaceable = contentMeasurable.measure( + constraints.offset(vertical = -headerPlaceable.height) + ) + layout(constraints.maxWidth, constraints.maxHeight) { + headerPlaceable.placeRelative(0, 0) + + val nonContentVerticalSpace = constraints.maxHeight - contentPlaceable.height + + val contentPlaceableY = when (navigationContentPosition) { + NavigationContentPosition.TOP -> 0 + NavigationContentPosition.CENTER -> nonContentVerticalSpace / 2 + }.coerceAtLeast(headerPlaceable.height) + + contentPlaceable.placeRelative(0, contentPlaceableY) + } + } +} + +enum class LayoutType { + HEADER, CONTENT +} + diff --git a/app/src/main/java/xyz/quaver/pupil/ui/composable/NavigationType.kt b/app/src/main/java/xyz/quaver/pupil/ui/composable/NavigationType.kt index 73796fab..182bc191 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/composable/NavigationType.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/composable/NavigationType.kt @@ -1,5 +1,5 @@ package xyz.quaver.pupil.ui.composable enum class NavigationType { - NAVIGATION_RAIL, PERMANENT_NAVIGATION_DRAWER + NAVIGATION_RAIL, PERMANENT_NAVIGATION_DRAWER, BOTTOM_NAVIGATION } \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/ui/viewmodel/MainViewModel.kt b/app/src/main/java/xyz/quaver/pupil/ui/viewmodel/MainViewModel.kt index b2c84488..35e68460 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/viewmodel/MainViewModel.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/viewmodel/MainViewModel.kt @@ -1,14 +1,25 @@ package xyz.quaver.pupil.ui.viewmodel import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import xyz.quaver.pupil.networking.SearchQuery -import xyz.quaver.pupil.ui.composable.MainRoutes +import xyz.quaver.pupil.ui.composable.MainDestination +import xyz.quaver.pupil.ui.composable.mainDestinations class MainViewModel : ViewModel() { - val uiState: MainUIState = MainUIState() + private val _uiState = MutableStateFlow(MainUIState()) + val uiState: StateFlow = _uiState + + fun navigateToDestination(destination: MainDestination) { + _uiState.value = MainUIState( + currentDestination = destination + ) + } } data class MainUIState( - val route: MainRoutes = MainRoutes.SEARCH, - val query: SearchQuery? = null + val currentDestination: MainDestination = mainDestinations.first(), + val query: SearchQuery? = null, + val loading: Boolean = true ) \ No newline at end of file diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 26d6fbc1..004b8675 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -33,6 +33,7 @@ グロフィルター "言語: " デフォルトキーワード設定 + メニューを開く お問い合わせ先 ホームページ ヘルプ @@ -159,4 +160,5 @@ アンドロイド11以上では外部からのアプリ内部空間接近が不可能です。ダウンロードフォルダを変更しますか? ネットワーク ダウンロードデータベースを再構築 + メニューを閉じる \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index b242d065..76f53fa9 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -36,6 +36,7 @@ Github 도움말 홈페이지 + 메뉴 열기 문의 전체 화면 다운로드 @@ -159,4 +160,5 @@ 안드로이드 11 이상에서는 외부에서 현재 다운로드 폴더에 접근할 수 없습니다. 변경하시겠습니까? 네트워크 다운로드 데이터베이스 복구 + 메뉴 닫기 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9a7720a7..7a256c22 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -57,6 +57,8 @@ History Downloads Favorites + Open Navigation Drawer + Close Navigation Drawer Contact Help Visit homepage