diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0f921f5..72fd56f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -67,9 +67,9 @@ android { dependencies { implementation(project(":feature:home")) + implementation(project(":feature:photolibrary")) // TODO Wei -// implementation(project(":feature:login")) // implementation(project(":feature:contactme")) implementation(project(":core:designsystem")) diff --git a/app/src/main/java/com/wei/picquest/navigation/PqNavHost.kt b/app/src/main/java/com/wei/picquest/navigation/PqNavHost.kt index d7db6db..4062567 100644 --- a/app/src/main/java/com/wei/picquest/navigation/PqNavHost.kt +++ b/app/src/main/java/com/wei/picquest/navigation/PqNavHost.kt @@ -7,6 +7,7 @@ import androidx.window.layout.DisplayFeature import com.wei.picquest.core.designsystem.ui.DeviceOrientation import com.wei.picquest.feature.home.home.navigation.homeGraph import com.wei.picquest.feature.home.home.navigation.homeRoute +import com.wei.picquest.feature.photolibrary.photolibrary.navigation.photoLibraryGraph import com.wei.picquest.ui.PqAppState /** @@ -36,5 +37,8 @@ fun PqNavHost( homeGraph( navController = navController, ) + photoLibraryGraph( + navController = navController, + ) } } diff --git a/app/src/main/java/com/wei/picquest/ui/PqAppState.kt b/app/src/main/java/com/wei/picquest/ui/PqAppState.kt index 9ce1550..15698f1 100644 --- a/app/src/main/java/com/wei/picquest/ui/PqAppState.kt +++ b/app/src/main/java/com/wei/picquest/ui/PqAppState.kt @@ -27,6 +27,8 @@ import com.wei.picquest.core.designsystem.ui.isBookPosture import com.wei.picquest.core.designsystem.ui.isSeparating import com.wei.picquest.feature.home.home.navigation.homeRoute import com.wei.picquest.feature.home.home.navigation.navigateToHome +import com.wei.picquest.feature.photolibrary.photolibrary.navigation.navigateToPhotoLibrary +import com.wei.picquest.feature.photolibrary.photolibrary.navigation.photoLibraryRoute import com.wei.picquest.navigation.TopLevelDestination import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharingStarted @@ -149,6 +151,7 @@ class PqAppState( val currentTopLevelDestination: TopLevelDestination? @Composable get() = when (currentDestination?.route) { homeRoute -> TopLevelDestination.HOME + photoLibraryRoute -> TopLevelDestination.PHOTO_LIBRARY else -> null } @@ -196,10 +199,10 @@ class PqAppState( topLevelNavOptions, ) -// TopLevelDestination.PHOTO_LIBRARY -> navController.navigateToPhotoLibrary( -// topLevelNavOptions, -// ) -// + TopLevelDestination.PHOTO_LIBRARY -> navController.navigateToPhotoLibrary( + topLevelNavOptions, + ) + // TopLevelDestination.CONTACT_ME -> navController.navigateToContactMe( // topLevelNavOptions, // ) diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt index 9cf2027..d230e7a 100644 --- a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt @@ -36,6 +36,11 @@ class AndroidLibraryConventionPlugin : Plugin { add("androidTestImplementation", kotlin("test")) add("androidTestImplementation", project(":core:testing")) + // Paging 3 + add("implementation", libs.findLibrary("paging-runtime").get()) + add("implementation", libs.findLibrary("paging-compose").get()) + add("testImplementation", libs.findLibrary("paging-common").get()) + // Timber add("implementation", libs.findLibrary("timber").get()) } diff --git a/core/data/src/main/java/com/wei/picquest/core/data/model/ImageDetail.kt b/core/data/src/main/java/com/wei/picquest/core/data/model/ImageDetail.kt new file mode 100644 index 0000000..c188c9c --- /dev/null +++ b/core/data/src/main/java/com/wei/picquest/core/data/model/ImageDetail.kt @@ -0,0 +1,53 @@ +package com.wei.picquest.core.data.model + +import com.wei.picquest.core.network.model.NetworkImageDetail + +data class ImageDetail( + val id: Int, + val pageURL: String, + val type: String, + val tags: String, + val previewURL: String, + val previewWidth: Int, + val previewHeight: Int, + val webformatURL: String, + val webformatWidth: Int, + val webformatHeight: Int, + val largeImageURL: String, + val imageWidth: Int, + val imageHeight: Int, + val imageSize: Long, + val views: Int, + val downloads: Int, + val likes: Int, + val comments: Int, + val userId: Int, + val user: String, + val userImageURL: String, +) { + val aspectRatio get() = imageWidth.toFloat() / imageHeight.toFloat() +} + +fun NetworkImageDetail.asExternalModel() = ImageDetail( + id = this.id, + pageURL = this.pageURL, + type = this.type, + tags = this.tags, + previewURL = this.previewURL, + previewWidth = this.previewWidth, + previewHeight = this.previewHeight, + webformatURL = this.webformatURL, + webformatWidth = this.webformatWidth, + webformatHeight = this.webformatHeight, + largeImageURL = this.largeImageURL, + imageWidth = this.imageWidth, + imageHeight = this.imageHeight, + imageSize = this.imageSize, + views = this.views, + downloads = this.downloads, + likes = this.likes, + comments = this.comments, + userId = this.userId, + user = this.user, + userImageURL = this.userImageURL, +) diff --git a/core/data/src/main/java/com/wei/picquest/core/data/model/SearchImages.kt b/core/data/src/main/java/com/wei/picquest/core/data/model/SearchImages.kt deleted file mode 100644 index df45fee..0000000 --- a/core/data/src/main/java/com/wei/picquest/core/data/model/SearchImages.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.wei.picquest.core.data.model - -import com.wei.picquest.core.network.model.NetworkSearchImages - -data class SearchImages( - val total: Int, - val totalHits: Int, - val images: List, -) - -data class ImageDetail( - val id: Int, - val pageURL: String, - val type: String, - val tags: String, - val previewURL: String, - val previewWidth: Int, - val previewHeight: Int, - val webformatURL: String, - val webformatWidth: Int, - val webformatHeight: Int, - val largeImageURL: String, - val imageWidth: Int, - val imageHeight: Int, - val imageSize: Long, - val views: Int, - val downloads: Int, - val likes: Int, - val comments: Int, - val userId: Int, - val user: String, - val userImageURL: String, -) - -fun NetworkSearchImages.asExternalModel() = SearchImages( - total = this.total, - totalHits = this.totalHits, - images = this.hits.map { networkImageDetail -> - ImageDetail( - id = networkImageDetail.id, - pageURL = networkImageDetail.pageURL, - type = networkImageDetail.type, - tags = networkImageDetail.tags, - previewURL = networkImageDetail.previewURL, - previewWidth = networkImageDetail.previewWidth, - previewHeight = networkImageDetail.previewHeight, - webformatURL = networkImageDetail.webformatURL, - webformatWidth = networkImageDetail.webformatWidth, - webformatHeight = networkImageDetail.webformatHeight, - largeImageURL = networkImageDetail.largeImageURL, - imageWidth = networkImageDetail.imageWidth, - imageHeight = networkImageDetail.imageHeight, - imageSize = networkImageDetail.imageSize, - views = networkImageDetail.views, - downloads = networkImageDetail.downloads, - likes = networkImageDetail.likes, - comments = networkImageDetail.comments, - userId = networkImageDetail.userId, - user = networkImageDetail.user, - userImageURL = networkImageDetail.userImageURL, - ) - }, -) diff --git a/core/data/src/main/java/com/wei/picquest/core/data/repository/DefaultSearchImagesRepository.kt b/core/data/src/main/java/com/wei/picquest/core/data/repository/DefaultSearchImagesRepository.kt index f20cfdf..3f922cd 100644 --- a/core/data/src/main/java/com/wei/picquest/core/data/repository/DefaultSearchImagesRepository.kt +++ b/core/data/src/main/java/com/wei/picquest/core/data/repository/DefaultSearchImagesRepository.kt @@ -1,35 +1,31 @@ package com.wei.picquest.core.data.repository -import com.wei.picquest.core.network.Dispatcher -import com.wei.picquest.core.network.PqDispatchers +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.map +import com.wei.picquest.core.data.model.ImageDetail +import com.wei.picquest.core.data.model.asExternalModel import com.wei.picquest.core.network.PqNetworkDataSource -import com.wei.picquest.core.network.model.NetworkSearchImages -import kotlinx.coroutines.CoroutineDispatcher +import com.wei.picquest.core.network.pagingsource.PixabayPagingSource import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.map import javax.inject.Inject -/** - * Implementation of the [SearchImagesRepository]. - * @param ioDispatcher 用於執行 IO 相關操作的 CoroutineDispatcher。 - * @param network 數據源的網路接口。 - */ class DefaultSearchImagesRepository @Inject constructor( - @Dispatcher(PqDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, - private val network: PqNetworkDataSource, + private val pqNetworkDataSource: PqNetworkDataSource, ) : SearchImagesRepository { - /** - * @param query。A URL encoded search term. If omitted, all images are returned. This value may not exceed 100 characters. - * Example: "yellow+flower" - * @return 一個 Flow,內容為 Search Images 的數據。 - */ - override suspend fun getSearchImages( - query: String, - ): Flow = withContext(ioDispatcher) { - flow { - emit(network.searchImages(query)) + override suspend fun getSearchImages(query: String): Flow> { + return Pager( + config = PagingConfig( + pageSize = 20, + prefetchDistance = 5, + enablePlaceholders = false, + ), + pagingSourceFactory = { PixabayPagingSource(pqNetworkDataSource, query) }, + ).flow.map { pagingData -> + pagingData.map { it.asExternalModel() } } } } diff --git a/core/data/src/main/java/com/wei/picquest/core/data/repository/SearchImagesRepository.kt b/core/data/src/main/java/com/wei/picquest/core/data/repository/SearchImagesRepository.kt index 5e1a293..436a903 100644 --- a/core/data/src/main/java/com/wei/picquest/core/data/repository/SearchImagesRepository.kt +++ b/core/data/src/main/java/com/wei/picquest/core/data/repository/SearchImagesRepository.kt @@ -1,9 +1,10 @@ package com.wei.picquest.core.data.repository -import com.wei.picquest.core.network.model.NetworkSearchImages +import androidx.paging.PagingData +import com.wei.picquest.core.data.model.ImageDetail import kotlinx.coroutines.flow.Flow interface SearchImagesRepository { - suspend fun getSearchImages(query: String): Flow + suspend fun getSearchImages(query: String): Flow> } diff --git a/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/icon/PqIcons.kt b/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/icon/PqIcons.kt index 624842e..3ef3b6f 100644 --- a/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/icon/PqIcons.kt +++ b/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/icon/PqIcons.kt @@ -14,8 +14,10 @@ import androidx.compose.material.icons.rounded.ArrowForward import androidx.compose.material.icons.rounded.ArrowForwardIos import androidx.compose.material.icons.rounded.CalendarMonth import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.GridView import androidx.compose.material.icons.rounded.Home import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.List import androidx.compose.material.icons.rounded.Menu import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.Phone @@ -55,4 +57,6 @@ object PqIcons { val Person = Icons.Rounded.Person val Menu = Icons.Rounded.Menu val Add = Icons.Rounded.Add + val ListView = Icons.Rounded.List + val GridView = Icons.Rounded.GridView } diff --git a/core/network/src/main/java/com/wei/picquest/core/network/PqNetworkDataSource.kt b/core/network/src/main/java/com/wei/picquest/core/network/PqNetworkDataSource.kt index 16dc2ff..2ddc85c 100644 --- a/core/network/src/main/java/com/wei/picquest/core/network/PqNetworkDataSource.kt +++ b/core/network/src/main/java/com/wei/picquest/core/network/PqNetworkDataSource.kt @@ -7,5 +7,5 @@ import com.wei.picquest.core.network.model.NetworkSearchImages */ interface PqNetworkDataSource { - suspend fun searchImages(query: String): NetworkSearchImages + suspend fun searchImages(query: String, page: Int, perPage: Int): NetworkSearchImages } diff --git a/core/network/src/main/java/com/wei/picquest/core/network/pagingsource/PixabayPagingSource.kt b/core/network/src/main/java/com/wei/picquest/core/network/pagingsource/PixabayPagingSource.kt new file mode 100644 index 0000000..a77c907 --- /dev/null +++ b/core/network/src/main/java/com/wei/picquest/core/network/pagingsource/PixabayPagingSource.kt @@ -0,0 +1,37 @@ +package com.wei.picquest.core.network.pagingsource + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.wei.picquest.core.network.PqNetworkDataSource +import com.wei.picquest.core.network.model.NetworkImageDetail + +class PixabayPagingSource( + private val pqNetworkDataSource: PqNetworkDataSource, + private val query: String, +) : PagingSource() { + + override suspend fun load(params: LoadParams): LoadResult { + try { + val currentPage = params.key ?: 1 + val response = pqNetworkDataSource.searchImages( + query = query, + page = currentPage, + perPage = 20, + ) + + val endOfPaginationReached = response.hits.isEmpty() + + return LoadResult.Page( + data = response.hits, + prevKey = if (currentPage == 1) null else currentPage - 1, + nextKey = if (endOfPaginationReached) null else currentPage + 1, + ) + } catch (exception: Exception) { + return LoadResult.Error(exception) + } + } + + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition + } +} diff --git a/core/network/src/main/java/com/wei/picquest/core/network/retrofit/RetrofitPqNetwork.kt b/core/network/src/main/java/com/wei/picquest/core/network/retrofit/RetrofitPqNetwork.kt index e18a633..6315504 100644 --- a/core/network/src/main/java/com/wei/picquest/core/network/retrofit/RetrofitPqNetwork.kt +++ b/core/network/src/main/java/com/wei/picquest/core/network/retrofit/RetrofitPqNetwork.kt @@ -28,6 +28,8 @@ interface RetrofitPixabayApi { @Query("key") apiKey: String = API_KEY, @Query("q") query: String, @Query("image_type") imageType: String = "photo", + @Query("page") page: Int, + @Query("perPage") perPage: Int, // Add more parameters as needed ): NetworkSearchImages } @@ -50,7 +52,7 @@ class RetrofitPqNetwork @Inject constructor( .build() .create(RetrofitPixabayApi::class.java) - override suspend fun searchImages(query: String): NetworkSearchImages { - return pixabayApi.searchImages(query = query) + override suspend fun searchImages(query: String, page: Int, perPage: Int): NetworkSearchImages { + return pixabayApi.searchImages(query = query, page = page, perPage = perPage) } } diff --git a/feature/home/src/main/java/com/wei/picquest/feature/home/home/HomeScreen.kt b/feature/home/src/main/java/com/wei/picquest/feature/home/home/HomeScreen.kt index 6e49664..73ea884 100644 --- a/feature/home/src/main/java/com/wei/picquest/feature/home/home/HomeScreen.kt +++ b/feature/home/src/main/java/com/wei/picquest/feature/home/home/HomeScreen.kt @@ -62,7 +62,6 @@ internal fun HomeRoute( internal fun HomeScreen( withTopSpacer: Boolean = true, withBottomSpacer: Boolean = true, - isPreview: Boolean = false, ) { val showPopup = remember { mutableStateOf(false) } @@ -108,8 +107,6 @@ internal fun HomeScreen( @Composable fun HomeScreenPreview() { PqTheme { - HomeScreen( - isPreview = true, - ) + HomeScreen() } } diff --git a/feature/photolibrary/.gitignore b/feature/photolibrary/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature/photolibrary/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/photolibrary/build.gradle.kts b/feature/photolibrary/build.gradle.kts new file mode 100644 index 0000000..85b8308 --- /dev/null +++ b/feature/photolibrary/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + alias(libs.plugins.pq.android.feature) + alias(libs.plugins.pq.android.library.compose) + alias(libs.plugins.pq.android.hilt) +} + +android { + namespace = "com.wei.picquest.feature.photolibrary" +} + +dependencies { +} \ No newline at end of file diff --git a/feature/photolibrary/src/main/AndroidManifest.xml b/feature/photolibrary/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature/photolibrary/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/photolibrary/src/main/java/com/wei/picquest/feature/photolibrary/photolibrary/PhotoLibraryScreen.kt b/feature/photolibrary/src/main/java/com/wei/picquest/feature/photolibrary/photolibrary/PhotoLibraryScreen.kt new file mode 100644 index 0000000..3d95f13 --- /dev/null +++ b/feature/photolibrary/src/main/java/com/wei/picquest/feature/photolibrary/photolibrary/PhotoLibraryScreen.kt @@ -0,0 +1,390 @@ +package com.wei.picquest.feature.photolibrary.photolibrary + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.ScrollableDefaults +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.WindowInsets +import androidx.compose.foundation.layout.aspectRatio +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.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.windowInsetsTopHeight +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import coil.compose.AsyncImagePainter +import coil.compose.SubcomposeAsyncImage +import coil.compose.SubcomposeAsyncImageContent +import com.wei.picquest.core.data.model.ImageDetail +import com.wei.picquest.core.designsystem.component.FunctionalityNotAvailablePopup +import com.wei.picquest.core.designsystem.icon.PqIcons +import com.wei.picquest.core.designsystem.theme.SPACING_LARGE +import com.wei.picquest.core.designsystem.theme.SPACING_MEDIUM +import com.wei.picquest.core.designsystem.theme.SPACING_SMALL +import com.wei.picquest.feature.photolibrary.R +import com.wei.picquest.feature.photolibrary.photolibrary.component.LayoutSwitchWarningDialog + +/** + * + * UI 事件決策樹 + * 下圖顯示了一個決策樹,用於查找處理特定事件用例的最佳方法。 + * + * ┌───────┐ + * │ Start │ + * └───┬───┘ + * ↓ + * ┌───────────────────────────────────┐ + * │ Where is event originated? │ + * └──────┬─────────────────────┬──────┘ + * ↓ ↓ + * UI ViewModel + * │ │ + * ┌─────────────────────────┐ ┌───────────────┐ + * │ When the event requires │ │ Update the UI │ + * │ ... │ │ State │ + * └─┬─────────────────────┬─┘ └───────────────┘ + * ↓ ↓ + * Business logic UI behavior logic + * │ │ + * ┌─────────────────────────────────┐ ┌──────────────────────────────────────┐ + * │ Delegate the business logic to │ │ Modify the UI element state in the │ + * │ the ViewModel │ │ UI directly │ + * └─────────────────────────────────┘ └──────────────────────────────────────┘ + * + * + */ +@Composable +internal fun PhotoLibraryRoute( + navController: NavController, + viewModel: PhotoLibraryViewModel = hiltViewModel(), +) { + val query = "your_search_query" // 這應該是動態查詢字串 + val lazyPagingItems = viewModel.imagesState.collectAsLazyPagingItems() + val uiStates: PhotoLibraryViewState by viewModel.states.collectAsStateWithLifecycle() + + Surface(modifier = Modifier.fillMaxSize()) { + Box { + when (uiStates.layoutType) { + LayoutType.LIST -> PhotoLibraryListScreen(lazyPagingItems = lazyPagingItems) + LayoutType.GRID -> PhotoLibraryGridScreen(lazyPagingItems = lazyPagingItems) + } + + TopBarActions( + layoutType = uiStates.layoutType, + onSwitchLayoutClick = { + viewModel.dispatch(PhotoLibraryViewAction.SwitchLayoutType) + }, + ) + } + } +} + +@Composable +fun TopBarActions( + layoutType: LayoutType, + onSwitchLayoutClick: () -> Unit, +) { + Column { + Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) + Row(modifier = Modifier.padding(SPACING_MEDIUM.dp)) { + BackButton() + Spacer(modifier = Modifier.weight(1f)) + SwitchLayoutButton( + layoutType = layoutType, + onClick = onSwitchLayoutClick, + ) + } + } +} + +@Composable +fun BackButton() { + val showPopup = remember { mutableStateOf(false) } + + if (showPopup.value) { + FunctionalityNotAvailablePopup( + onDismiss = { + showPopup.value = false + }, + ) + } + + IconButton( + onClick = { + /* TODO: Implement back button action */ + showPopup.value = true + }, + modifier = Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .semantics { contentDescription = "Back" }, + ) { + Icon( + imageVector = PqIcons.ArrowBack, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } +} + +@Composable +fun SwitchLayoutButton( + layoutType: LayoutType, + onClick: () -> Unit, +) { + val showPopup = remember { mutableStateOf(false) } + + if (showPopup.value) { + LayoutSwitchWarningDialog( + onDismiss = { + showPopup.value = false + onClick() + }, + ) + } + + IconButton( + onClick = { + showPopup.value = true + }, + modifier = Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .semantics { contentDescription = "Switch Layout" }, + ) { + Icon( + imageVector = if (layoutType == LayoutType.LIST) PqIcons.GridView else PqIcons.ListView, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } +} + +@Composable +fun PhotoLibraryListScreen( + lazyPagingItems: LazyPagingItems, + withTopSpacer: Boolean = true, + withBottomSpacer: Boolean = true, +) { + Surface { + Box(modifier = Modifier.fillMaxSize()) { + Column { + LazyColumn(modifier = Modifier.weight(1f)) { + if (withTopSpacer) { + item { + Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) + } + } + + items(lazyPagingItems.itemCount) { index -> + lazyPagingItems[index]?.let { + ImageDetailItem( + layoutType = LayoutType.LIST, + imageDetail = it, + ) + } + } + + if (withBottomSpacer) { + item { + Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + } + } + } + + PagingStateHandling(lazyPagingItems) + } + } + } +} + +@Composable +fun PhotoLibraryGridScreen( + lazyPagingItems: LazyPagingItems, + withTopSpacer: Boolean = true, + withBottomSpacer: Boolean = true, +) { + Surface { + Box(modifier = Modifier.fillMaxSize()) { + Column { + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Adaptive(150.dp), + modifier = Modifier.weight(1f), + state = rememberLazyStaggeredGridState(), + contentPadding = PaddingValues(0.dp), + horizontalArrangement = Arrangement.spacedBy(0.dp), + verticalItemSpacing = 0.dp, + flingBehavior = ScrollableDefaults.flingBehavior(), + ) { + if (withTopSpacer) { + item { + Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) + } + } + + items(lazyPagingItems.itemCount) { index -> + lazyPagingItems[index]?.let { + ImageDetailItem( + layoutType = LayoutType.GRID, + imageDetail = it, + ) + } + } + + if (withBottomSpacer) { + item { + Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + } + } + } + + PagingStateHandling(lazyPagingItems) + } + } + } +} + +@Composable +fun ImageDetailItem( + layoutType: LayoutType, + imageDetail: ImageDetail, +) { + val imageAspectRatio = imageDetail.aspectRatio + + SubcomposeAsyncImage( + model = imageDetail.webformatURL, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(imageAspectRatio), + ) { + when (painter.state) { + is AsyncImagePainter.State.Loading, is AsyncImagePainter.State.Error -> { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(imageAspectRatio), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(id = R.drawable.placeholder_image), + contentDescription = null, + modifier = if (layoutType == LayoutType.LIST) { + Modifier.size(300.dp) + } else { + Modifier.size( + 80.dp, + ) + }, + ) + } + } + + else -> { + SubcomposeAsyncImageContent() + } + } + } +} + +@Composable +fun PagingStateHandling(lazyPagingItems: LazyPagingItems) { + lazyPagingItems.apply { + when { + loadState.refresh is LoadState.Loading -> PageLoader() + loadState.refresh is LoadState.Error -> PageLoaderError { retry() } + loadState.append is LoadState.Loading -> LoadingNextPageItem() + loadState.append is LoadState.Error -> ErrorMessage { retry() } + } + } +} + +@Composable +fun PageLoaderError(onClickRetry: () -> Unit) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize(), + ) { + OutlinedButton(onClick = onClickRetry) { + Text(text = stringResource(R.string.retry)) + } + } +} + +@Composable +fun PageLoader() { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize(), + ) { + CircularProgressIndicator() + } +} + +@Composable +fun ErrorMessage( + onClickRetry: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = stringResource(R.string.error_message), color = MaterialTheme.colorScheme.error) + Spacer(modifier = Modifier.height(SPACING_SMALL.dp)) + OutlinedButton(onClick = onClickRetry) { + Text(text = stringResource(R.string.retry)) + } + } +} + +@Composable +fun LoadingNextPageItem(modifier: Modifier = Modifier) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .fillMaxWidth() + .padding(SPACING_LARGE.dp), + ) { + CircularProgressIndicator(modifier = Modifier.size(30.dp)) + } +} diff --git a/feature/photolibrary/src/main/java/com/wei/picquest/feature/photolibrary/photolibrary/PhotoLibraryViewModel.kt b/feature/photolibrary/src/main/java/com/wei/picquest/feature/photolibrary/photolibrary/PhotoLibraryViewModel.kt new file mode 100644 index 0000000..7dee194 --- /dev/null +++ b/feature/photolibrary/src/main/java/com/wei/picquest/feature/photolibrary/photolibrary/PhotoLibraryViewModel.kt @@ -0,0 +1,57 @@ +package com.wei.picquest.feature.photolibrary.photolibrary + +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import com.wei.picquest.core.base.BaseViewModel +import com.wei.picquest.core.data.model.ImageDetail +import com.wei.picquest.core.data.repository.SearchImagesRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class PhotoLibraryViewModel @Inject constructor( + private val searchImagesRepository: SearchImagesRepository, +) : BaseViewModel< + PhotoLibraryViewAction, + PhotoLibraryViewState, + >(PhotoLibraryViewState()) { + + private val _imagesState: MutableStateFlow> = + MutableStateFlow(value = PagingData.empty()) + val imagesState: MutableStateFlow> get() = _imagesState + + init { + // TODO Wei + searchImages("") + } + + private fun searchImages(query: String) { + viewModelScope.launch { + searchImagesRepository.getSearchImages(query) + .distinctUntilChanged() + .cachedIn(viewModelScope) + .collect { pagingData -> + _imagesState.value = pagingData + } + } + } + + private fun switchLayoutType() { + updateState { + copy( + layoutType = if (layoutType == LayoutType.LIST) LayoutType.GRID else LayoutType.LIST, + ) + } + } + + override fun dispatch(action: PhotoLibraryViewAction) { + when (action) { + is PhotoLibraryViewAction.SearchImages -> searchImages(action.query) + is PhotoLibraryViewAction.SwitchLayoutType -> switchLayoutType() + } + } +} diff --git a/feature/photolibrary/src/main/java/com/wei/picquest/feature/photolibrary/photolibrary/PhotoLibraryViewState.kt b/feature/photolibrary/src/main/java/com/wei/picquest/feature/photolibrary/photolibrary/PhotoLibraryViewState.kt new file mode 100644 index 0000000..177d4cf --- /dev/null +++ b/feature/photolibrary/src/main/java/com/wei/picquest/feature/photolibrary/photolibrary/PhotoLibraryViewState.kt @@ -0,0 +1,20 @@ +package com.wei.picquest.feature.photolibrary.photolibrary + +import com.wei.picquest.core.base.Action +import com.wei.picquest.core.base.State + +enum class LayoutType { + LIST, GRID +} + +sealed class PhotoLibraryViewAction : Action { + data class SearchImages( + val query: String, + ) : PhotoLibraryViewAction() + + object SwitchLayoutType : PhotoLibraryViewAction() +} + +data class PhotoLibraryViewState( + val layoutType: LayoutType = LayoutType.LIST, +) : State diff --git a/feature/photolibrary/src/main/java/com/wei/picquest/feature/photolibrary/photolibrary/component/LayoutSwitchWarningDialog.kt b/feature/photolibrary/src/main/java/com/wei/picquest/feature/photolibrary/photolibrary/component/LayoutSwitchWarningDialog.kt new file mode 100644 index 0000000..09b3527 --- /dev/null +++ b/feature/photolibrary/src/main/java/com/wei/picquest/feature/photolibrary/photolibrary/component/LayoutSwitchWarningDialog.kt @@ -0,0 +1,38 @@ +package com.wei.picquest.feature.photolibrary.photolibrary.component + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import com.wei.picquest.feature.photolibrary.R + +@Composable +fun LayoutSwitchWarningDialog(onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + text = { + val warningText = stringResource(R.string.layout_switch_warning) + + Text( + text = warningText, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.semantics { contentDescription = warningText }, + ) + }, + confirmButton = { + val buttonText = stringResource(R.string.understand) + + TextButton(onClick = onDismiss) { + Text( + text = buttonText, + modifier = Modifier.semantics { contentDescription = buttonText }, + ) + } + }, + ) +} diff --git a/feature/photolibrary/src/main/java/com/wei/picquest/feature/photolibrary/photolibrary/navigation/PhotoLibraryNavigation.kt b/feature/photolibrary/src/main/java/com/wei/picquest/feature/photolibrary/photolibrary/navigation/PhotoLibraryNavigation.kt new file mode 100644 index 0000000..910d257 --- /dev/null +++ b/feature/photolibrary/src/main/java/com/wei/picquest/feature/photolibrary/photolibrary/navigation/PhotoLibraryNavigation.kt @@ -0,0 +1,23 @@ +package com.wei.picquest.feature.photolibrary.photolibrary.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import com.wei.picquest.feature.photolibrary.photolibrary.PhotoLibraryRoute + +const val photoLibraryRoute = "photo_library_route" + +fun NavController.navigateToPhotoLibrary(navOptions: NavOptions? = null) { + this.navigate(photoLibraryRoute, navOptions) +} + +fun NavGraphBuilder.photoLibraryGraph( + navController: NavController, +) { + composable(route = photoLibraryRoute) { + PhotoLibraryRoute( + navController = navController, + ) + } +} diff --git a/feature/photolibrary/src/main/res/drawable/placeholder_image.png b/feature/photolibrary/src/main/res/drawable/placeholder_image.png new file mode 100644 index 0000000..b8c902e Binary files /dev/null and b/feature/photolibrary/src/main/res/drawable/placeholder_image.png differ diff --git a/feature/photolibrary/src/main/res/values-zh-rTW/strings.xml b/feature/photolibrary/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000..2740d43 --- /dev/null +++ b/feature/photolibrary/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,7 @@ + + + Retry + 發生錯誤,請稍後重試 + 因為網格採動態佈局,故切換佈局時會重新滾動至頂部。 + 了解 + \ No newline at end of file diff --git a/feature/photolibrary/src/main/res/values/strings.xml b/feature/photolibrary/src/main/res/values/strings.xml new file mode 100644 index 0000000..21de76d --- /dev/null +++ b/feature/photolibrary/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + Retry + An error occurred, please try again later + Switching layouts will reset the scroll position to the top due to the dynamic grid layout. + Understand + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7be5d0a..5c78329 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,6 +39,8 @@ secrets = "2.0.1" lint = "31.0.2" robolectric = "4.10.3" roborazzi = "1.5.0-alpha-2" +paging = "3.2.1" +pagingCompose = "3.3.0-alpha02" [libraries] @@ -170,6 +172,11 @@ robolectric-shadows = { group = "org.robolectric", name = "shadows-framework", v # roborazzi - A Screenshot tests lib in jvm. roborazzi = { group = "io.github.takahirom.roborazzi", name = "roborazzi", version.ref = "roborazzi" } +# paging 3 +paging-runtime = { group = "androidx.paging", name = "paging-runtime", version.ref = "paging" } +paging-common = { group = "androidx.paging", name = "paging-common", version.ref = "paging" } +paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "pagingCompose" } + # Dependencies of the included build-logic android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } diff --git a/settings.gradle.kts b/settings.gradle.kts index baf125c..d130eb7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -29,3 +29,4 @@ include(":core:network") include(":ui-test-hilt-manifest") include(":feature:home") +include(":feature:photolibrary")