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/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 index ff7a691..3d95f13 100644 --- 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 @@ -1,26 +1,66 @@ 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.component.ThemePreviews -import com.wei.picquest.core.designsystem.theme.PqTheme +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 /** * @@ -54,15 +94,49 @@ import com.wei.picquest.core.designsystem.theme.PqTheme @Composable internal fun PhotoLibraryRoute( navController: NavController, + viewModel: PhotoLibraryViewModel = hiltViewModel(), ) { - PhotoLibraryScreen() + 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 -internal fun PhotoLibraryScreen( - withTopSpacer: Boolean = true, - withBottomSpacer: Boolean = true, +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) { @@ -73,40 +147,244 @@ internal fun PhotoLibraryScreen( ) } - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, + IconButton( + onClick = { + /* TODO: Implement back button action */ + showPopup.value = true + }, + modifier = Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .semantics { contentDescription = "Back" }, ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (withTopSpacer) { - Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) + 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 { - Spacer(modifier = Modifier.weight(1f)) - Text( - text = "Photo Library Screen not available \uD83D\uDE48", - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.headlineMedium, + 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 - .semantics { contentDescription = "" }, - ) - Spacer(modifier = Modifier.weight(1f)) + .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, + ) + }, + ) + } } - if (withBottomSpacer) { - Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + else -> { + SubcomposeAsyncImageContent() } } } } -@ThemePreviews @Composable -fun HomeScreenPreview() { - PqTheme { - PhotoLibraryScreen() +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/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" }