diff --git a/app/src/main/kotlin/com/lilin/gamelibrary/domain/model/SortOption.kt b/app/src/main/kotlin/com/lilin/gamelibrary/domain/model/SortOption.kt index 14f0c83..977af19 100644 --- a/app/src/main/kotlin/com/lilin/gamelibrary/domain/model/SortOption.kt +++ b/app/src/main/kotlin/com/lilin/gamelibrary/domain/model/SortOption.kt @@ -2,6 +2,17 @@ package com.lilin.gamelibrary.domain.model import com.lilin.gamelibrary.R +enum class SortType { + ADDED_DATE, + TITLE, + RATING, +} + +enum class SortDirection { + DESC, + ASC, +} + enum class SortOption { ADDED_DATE_DESC, ADDED_DATE_ASC, @@ -21,3 +32,45 @@ enum class SortOption { RATING_ASC -> R.string.favorite_rating_asc } } + +fun SortOption.toTypeAndDirection(): Pair { + return when (this) { + SortOption.ADDED_DATE_DESC -> Pair(SortType.ADDED_DATE, SortDirection.DESC) + SortOption.ADDED_DATE_ASC -> Pair(SortType.ADDED_DATE, SortDirection.ASC) + SortOption.TITLE_DESC -> Pair(SortType.TITLE, SortDirection.DESC) + SortOption.TITLE_ASC -> Pair(SortType.TITLE, SortDirection.ASC) + SortOption.RATING_DESC -> Pair(SortType.RATING, SortDirection.DESC) + SortOption.RATING_ASC -> Pair(SortType.RATING, SortDirection.ASC) + } +} + +fun combineSortOption( + type: SortType, + direction: SortDirection, +): SortOption { + return when (type) { + SortType.ADDED_DATE -> { + if (direction == SortDirection.ASC) { + SortOption.ADDED_DATE_ASC + } else { + SortOption.ADDED_DATE_DESC + } + } + + SortType.TITLE -> { + if (direction == SortDirection.ASC) { + SortOption.TITLE_ASC + } else { + SortOption.TITLE_DESC + } + } + + SortType.RATING -> { + if (direction == SortDirection.ASC) { + SortOption.RATING_ASC + } else { + SortOption.RATING_DESC + } + } + } +} diff --git a/app/src/main/kotlin/com/lilin/gamelibrary/domain/usecase/favorite/GetFavoriteGamesUseCase.kt b/app/src/main/kotlin/com/lilin/gamelibrary/domain/usecase/favorite/GetFavoriteGamesUseCase.kt index 51957a9..4c75637 100644 --- a/app/src/main/kotlin/com/lilin/gamelibrary/domain/usecase/favorite/GetFavoriteGamesUseCase.kt +++ b/app/src/main/kotlin/com/lilin/gamelibrary/domain/usecase/favorite/GetFavoriteGamesUseCase.kt @@ -3,7 +3,6 @@ package com.lilin.gamelibrary.domain.usecase.favorite import com.lilin.gamelibrary.data.repository.FavoriteGameRepository import com.lilin.gamelibrary.domain.model.FavoriteGame import com.lilin.gamelibrary.domain.model.SortOption -import com.lilin.gamelibrary.domain.model.SortOrder import kotlinx.coroutines.flow.Flow import javax.inject.Inject @@ -13,18 +12,13 @@ import javax.inject.Inject * 指定されたソート順でお気に入り登録されたゲームのリストを取得する。 * Flowを返すため、データベースの変更が自動的にUIに反映される。 * - * @param sortOrder ソート順(新しい順/古い順) + * @param sortOption ソート順 * @return お気に入りゲームのリストを流すFlow */ class GetFavoriteGamesUseCase @Inject constructor( private val repository: FavoriteGameRepository, ) { - @Deprecated("use invoke2") - fun invoke(sortOrder: SortOrder): Flow> { - return repository.getFavoriteGames(sortOrder) - } - - fun invoke2(sortOption: SortOption): Flow> { + operator fun invoke(sortOption: SortOption): Flow> { return repository.getFavoriteGames2(sortOption) } } diff --git a/app/src/main/kotlin/com/lilin/gamelibrary/feature/detail/GameDetailMediumExpandedLayout.kt b/app/src/main/kotlin/com/lilin/gamelibrary/feature/detail/GameDetailMediumExpandedLayout.kt index babcbee..b0fc71f 100644 --- a/app/src/main/kotlin/com/lilin/gamelibrary/feature/detail/GameDetailMediumExpandedLayout.kt +++ b/app/src/main/kotlin/com/lilin/gamelibrary/feature/detail/GameDetailMediumExpandedLayout.kt @@ -50,7 +50,7 @@ import com.lilin.gamelibrary.ui.theme.DetailDescriptionGradientEnd import com.lilin.gamelibrary.ui.theme.DetailDescriptionGradientStart import com.lilin.gamelibrary.ui.theme.DetailRatingGradientStart import com.lilin.gamelibrary.ui.theme.DetailTagGradientStart -import java.util.Locale +import com.lilin.gamelibrary.ui.util.formatCount @Composable fun GameDetailMediumExpandedLayout( @@ -192,7 +192,11 @@ private fun BasicInfoSection( InfoRow( icon = Icons.Outlined.Star, label = stringResource(R.string.detail_label_rating), - value = stringResource(R.string.game_rating_format, rating, formatCount(ratingsCount)), + value = stringResource( + R.string.game_rating_format, + rating, + formatCount(ratingsCount), + ), ) } } @@ -353,19 +357,6 @@ private fun PlatformChipsRow( } } -/** - * 数値をフォーマット(例:1234 → 1.2k) - */ -private fun formatCount(count: Int): String { - return when { - count >= 1000 -> { - String.format(Locale.ROOT, "%.1f k", count / 1000.0) - } - - else -> count.toString() - } -} - @Preview( name = "Game Detail Medium Expanded Layout", showBackground = true, diff --git a/app/src/main/kotlin/com/lilin/gamelibrary/feature/favorite/FavoriteScreen.kt b/app/src/main/kotlin/com/lilin/gamelibrary/feature/favorite/FavoriteScreen.kt index 828faa3..00dc58e 100644 --- a/app/src/main/kotlin/com/lilin/gamelibrary/feature/favorite/FavoriteScreen.kt +++ b/app/src/main/kotlin/com/lilin/gamelibrary/feature/favorite/FavoriteScreen.kt @@ -80,7 +80,8 @@ fun FavoriteScreen( viewModel: FavoriteViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val sortOrder by viewModel.sortOrder.collectAsStateWithLifecycle() + val sortOption by viewModel.sortOption.collectAsStateWithLifecycle() + val isExpanded by viewModel.isExpanded.collectAsStateWithLifecycle() val expandedUiState by viewModel.expandedUiState.collectAsStateWithLifecycle() val showDeleteDialog by viewModel.showDeleteDialog.collectAsStateWithLifecycle() @@ -103,8 +104,10 @@ fun FavoriteScreen( floatingActionButton = { if (!isAtLeastMedium) { FavoriteSortFabMenu( - sortOrder = sortOrder, - onSortClick = viewModel::toggleSortOrder, + currentSortOption = sortOption, + isExpanded = isExpanded, + onClickFab = viewModel::toggleFabMenu, + onSortChange = viewModel::toggleSortOrder, ) } }, @@ -163,7 +166,7 @@ private fun FavoriteScreen( is FavoriteUiState.Success -> { val listState = rememberLazyListState() - LaunchedEffect(uiState.sortOrder) { + LaunchedEffect(uiState.sortOption) { listState.scrollToItem(0) } diff --git a/app/src/main/kotlin/com/lilin/gamelibrary/feature/favorite/FavoriteUiState.kt b/app/src/main/kotlin/com/lilin/gamelibrary/feature/favorite/FavoriteUiState.kt index 916a778..bcee027 100644 --- a/app/src/main/kotlin/com/lilin/gamelibrary/feature/favorite/FavoriteUiState.kt +++ b/app/src/main/kotlin/com/lilin/gamelibrary/feature/favorite/FavoriteUiState.kt @@ -3,7 +3,6 @@ package com.lilin.gamelibrary.feature.favorite import com.lilin.gamelibrary.domain.model.FavoriteGame import com.lilin.gamelibrary.domain.model.FavoriteStatistics import com.lilin.gamelibrary.domain.model.SortOption -import com.lilin.gamelibrary.domain.model.SortOrder sealed interface FavoriteUiState { data object Empty : FavoriteUiState @@ -12,7 +11,7 @@ sealed interface FavoriteUiState { data class Success( val games: List, - val sortOrder: SortOrder, + val sortOption: SortOption, ) : FavoriteUiState data class Error(val message: String) : FavoriteUiState diff --git a/app/src/main/kotlin/com/lilin/gamelibrary/feature/favorite/FavoriteViewModel.kt b/app/src/main/kotlin/com/lilin/gamelibrary/feature/favorite/FavoriteViewModel.kt index d7df2c1..a4a7a94 100644 --- a/app/src/main/kotlin/com/lilin/gamelibrary/feature/favorite/FavoriteViewModel.kt +++ b/app/src/main/kotlin/com/lilin/gamelibrary/feature/favorite/FavoriteViewModel.kt @@ -5,7 +5,6 @@ import androidx.lifecycle.viewModelScope import com.lilin.gamelibrary.domain.model.FavoriteGame import com.lilin.gamelibrary.domain.model.FavoriteStatistics import com.lilin.gamelibrary.domain.model.SortOption -import com.lilin.gamelibrary.domain.model.SortOrder import com.lilin.gamelibrary.domain.usecase.favorite.GetFavoriteGamesUseCase import com.lilin.gamelibrary.domain.usecase.favorite.RemoveFavoriteGameUseCase import dagger.hilt.android.lifecycle.HiltViewModel @@ -28,34 +27,36 @@ class FavoriteViewModel @Inject constructor( MutableStateFlow(FavoriteExpandedUiState.Empty) val expandedUiState: StateFlow = _expandedUiState.asStateFlow() - private val _sortOrder = MutableStateFlow(SortOrder.NEWEST_FIRST) - val sortOrder: StateFlow = _sortOrder.asStateFlow() - private val _showDeleteDialog = MutableStateFlow(false) val showDeleteDialog: StateFlow = _showDeleteDialog.asStateFlow() private val _selectedGameId = MutableStateFlow(0) - val selectedGameId: StateFlow = _selectedGameId.asStateFlow() private val _selectedGameName = MutableStateFlow("") val selectedGameName: StateFlow = _selectedGameName.asStateFlow() + private val _sortOption = MutableStateFlow(SortOption.ADDED_DATE_DESC) + val sortOption: StateFlow = _sortOption.asStateFlow() + + private val _isExpanded = MutableStateFlow(false) + val isExpanded: StateFlow = _isExpanded.asStateFlow() + init { - getFavoriteGames(SortOrder.NEWEST_FIRST) + getFavoriteGames(SortOption.ADDED_DATE_DESC) loadFavoriteGamesExpanded(SortOption.ADDED_DATE_DESC) } - private fun getFavoriteGames(sortOrder: SortOrder) { + private fun getFavoriteGames(sortOption: SortOption) { _uiState.value = FavoriteUiState.Loading viewModelScope.launch { runCatching { - getFavoriteGamesUseCase.invoke(sortOrder).collect { games -> + getFavoriteGamesUseCase(sortOption).collect { games -> _uiState.value = if (games.isEmpty()) { FavoriteUiState.Empty } else { - _sortOrder.value = sortOrder - FavoriteUiState.Success(games, sortOrder) + _sortOption.value = sortOption + FavoriteUiState.Success(games, sortOption) } } }.onFailure { @@ -64,25 +65,12 @@ class FavoriteViewModel @Inject constructor( } } - fun toggleSortOrder() { - val currentState = _uiState.value - if (currentState is FavoriteUiState.Success) { - val newOrder = when (currentState.sortOrder) { - SortOrder.NEWEST_FIRST -> SortOrder.OLDEST_FIRST - SortOrder.OLDEST_FIRST -> SortOrder.NEWEST_FIRST - } - getFavoriteGames(newOrder) - } + fun toggleFabMenu() { + _isExpanded.value = !_isExpanded.value } - fun removeFavoriteGame(gameId: Int) { - viewModelScope.launch { - runCatching { - removeFavoriteGameUseCase(gameId) - }.onFailure { - _uiState.value = FavoriteUiState.Error(it.message ?: "Unknown error") - } - } + fun toggleSortOrder(sortOption: SortOption) { + getFavoriteGames(sortOption) } // Expanded @@ -91,11 +79,10 @@ class FavoriteViewModel @Inject constructor( viewModelScope.launch { runCatching { - getFavoriteGamesUseCase.invoke2(sortOption).collect { games -> + getFavoriteGamesUseCase(sortOption).collect { games -> if (games.isEmpty()) { _expandedUiState.value = FavoriteExpandedUiState.Empty } else { - // 統計計算 val statistics = calculateStatistics(games) _expandedUiState.value = FavoriteExpandedUiState.Success( @@ -117,11 +104,13 @@ class FavoriteViewModel @Inject constructor( is FavoriteExpandedUiState.Success -> { state.games.find { it.id == gameId }?.name ?: "" } + else -> { when (val compactState = _uiState.value) { is FavoriteUiState.Success -> { compactState.games.find { it.id == gameId }?.name ?: "" } + else -> "" } } diff --git a/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/BottomBarTemplate.kt b/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/BottomBarTemplate.kt new file mode 100644 index 0000000..e193b56 --- /dev/null +++ b/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/BottomBarTemplate.kt @@ -0,0 +1,71 @@ +package com.lilin.gamelibrary.ui.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun BottomBarTemplate( + modifier: Modifier = Modifier, + content: @Composable RowScope.() -> Unit, +) { + Box( + modifier = modifier + .fillMaxWidth() + .background( + brush = Brush.verticalGradient( + colors = listOf( + Color.Transparent, + MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), + MaterialTheme.colorScheme.surface, + ), + ), + ), + ) { + BottomAppBar( + modifier = Modifier + .defaultMinSize(80.dp) + .padding(bottom = 10.dp), + containerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onSurface, + tonalElevation = 0.dp, + ) { + Box( + modifier = Modifier + .padding(horizontal = 20.dp) + .fillMaxWidth() + .shadow( + elevation = 8.dp, + shape = RoundedCornerShape(90.dp), + ) + .background( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(90.dp), + ), + contentAlignment = Alignment.Center, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 25.dp), + verticalAlignment = Alignment.CenterVertically, + content = content, + ) + } + } + } +} diff --git a/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/detail/GameDetailBottomBar.kt b/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/detail/GameDetailBottomBar.kt index 8ceabd7..21e852b 100644 --- a/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/detail/GameDetailBottomBar.kt +++ b/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/detail/GameDetailBottomBar.kt @@ -1,34 +1,26 @@ package com.lilin.gamelibrary.ui.component.detail -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -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.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.Favorite import androidx.compose.material.icons.rounded.FavoriteBorder import androidx.compose.material.icons.rounded.Share -import androidx.compose.material3.BottomAppBar import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.lilin.gamelibrary.R +import com.lilin.gamelibrary.ui.component.BottomBarTemplate @Composable fun GameDetailBottomBar( @@ -39,96 +31,59 @@ fun GameDetailBottomBar( isFavorite: Boolean, modifier: Modifier = Modifier, ) { - Box(modifier = modifier) { - Box( + BottomBarTemplate(modifier = modifier) { + Row( modifier = Modifier .fillMaxWidth() - .height(90.dp) - .background( - brush = Brush.verticalGradient( - colors = listOf( - Color.Transparent, - MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), - MaterialTheme.colorScheme.surface, - ), - ), - ), - ) - - BottomAppBar( - modifier = Modifier.height(90.dp), - containerColor = Color.Transparent, - contentColor = MaterialTheme.colorScheme.onSurface, - tonalElevation = 0.dp, + .padding(vertical = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Box( - modifier = Modifier - .padding(horizontal = 60.dp) - .fillMaxSize() - .shadow( - elevation = 8.dp, - shape = RoundedCornerShape(32.dp), - ) - .background( - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(32.dp), - ), - contentAlignment = Alignment.Center, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton(onClick = onBackClick) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = stringResource(R.string.detail_bottom_bar_back), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.detail_bottom_bar_back), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } - Spacer(modifier = Modifier.weight(1f)) - IconButton( - onClick = onFavoriteClick, - enabled = isSuccessState, - ) { - Icon( - imageVector = if (isFavorite) { - Icons.Rounded.Favorite - } else { - Icons.Rounded.FavoriteBorder - }, - contentDescription = stringResource(R.string.detail_bottom_bar_favorite), - tint = if (isSuccessState) { - if (isFavorite) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - }, - ) - } + Spacer(modifier = Modifier.weight(1f)) + IconButton( + onClick = onFavoriteClick, + enabled = isSuccessState, + ) { + Icon( + imageVector = if (isFavorite) { + Icons.Rounded.Favorite + } else { + Icons.Rounded.FavoriteBorder + }, + contentDescription = stringResource(R.string.detail_bottom_bar_favorite), + tint = if (isSuccessState) { + if (isFavorite) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + } - IconButton( - onClick = onShareClick, - enabled = isSuccessState, - ) { - Icon( - imageVector = Icons.Rounded.Share, - contentDescription = stringResource(R.string.detail_bottom_bar_share), - tint = if (isSuccessState) { - MaterialTheme.colorScheme.onSurfaceVariant - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - }, - ) - } - } + IconButton( + onClick = onShareClick, + enabled = isSuccessState, + ) { + Icon( + imageVector = Icons.Rounded.Share, + contentDescription = stringResource(R.string.detail_bottom_bar_share), + tint = if (isSuccessState) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) } } } diff --git a/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/detail/GameDetailSectionCards.kt b/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/detail/GameDetailSectionCards.kt index e9c22e1..0a65da6 100644 --- a/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/detail/GameDetailSectionCards.kt +++ b/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/detail/GameDetailSectionCards.kt @@ -57,7 +57,7 @@ import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import com.lilin.gamelibrary.R import com.lilin.gamelibrary.ui.component.discovery.getMetacriticColor -import java.util.Locale +import com.lilin.gamelibrary.ui.util.formatCount @Composable fun GameBackgroundImage( @@ -429,19 +429,6 @@ fun GameTags( } } -/** - * 数値をフォーマット(例:1234 → 1.2k) - */ -private fun formatCount(count: Int): String { - return when { - count >= 1000 -> { - String.format(Locale.ROOT, "%.1fk", count / 1000.0) - } - - else -> count.toString() - } -} - @Preview @Composable private fun GameBackgroundImage90ScorePreview() { diff --git a/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/discovery/DiscoveryBottomBar.kt b/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/discovery/DiscoveryBottomBar.kt index 62774d1..ff654cf 100644 --- a/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/discovery/DiscoveryBottomBar.kt +++ b/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/discovery/DiscoveryBottomBar.kt @@ -3,10 +3,7 @@ package com.lilin.gamelibrary.ui.component.discovery import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -16,7 +13,6 @@ import androidx.compose.material.icons.rounded.LocalFireDepartment import androidx.compose.material.icons.rounded.NewReleases import androidx.compose.material.icons.rounded.Stars import androidx.compose.material.ripple.RippleAlpha -import androidx.compose.material3.BottomAppBar import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalRippleConfiguration @@ -25,15 +21,12 @@ import androidx.compose.material3.RippleConfiguration import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.lilin.gamelibrary.R +import com.lilin.gamelibrary.ui.component.BottomBarTemplate import com.lilin.gamelibrary.ui.theme.DetailDescriptionGradientEnd import com.lilin.gamelibrary.ui.theme.HighRatedGradientStart import com.lilin.gamelibrary.ui.theme.HighRatedRippleConfiguration @@ -55,115 +48,77 @@ fun DiscoveryBottomBar( val interactionSourceHighRated = remember { MutableInteractionSource() } val interactionSourceNewRelease = remember { MutableInteractionSource() } - Box(modifier = modifier) { - Box( + BottomBarTemplate(modifier = modifier) { + Row( modifier = Modifier .fillMaxWidth() - .defaultMinSize(minHeight = 80.dp) - .background( - brush = Brush.verticalGradient( - colors = listOf( - Color.Transparent, - MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), - MaterialTheme.colorScheme.surface, - ), - ), - ), - ) - - BottomAppBar( - modifier = Modifier.defaultMinSize(minHeight = 80.dp), - containerColor = Color.Transparent, - contentColor = MaterialTheme.colorScheme.onSurface, - tonalElevation = 0.dp, + .padding(vertical = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, ) { - Box( + CustomRippleIconButton( + interactionSource = interactionSourceTrending, + rippleConfiguration = TrendingRippleConfiguration, + onClick = onClickTrendingTab, modifier = Modifier - .padding(horizontal = 20.dp) - .fillMaxSize() - .shadow( - elevation = 8.dp, - shape = RoundedCornerShape(90.dp), - ) + .size(80.dp, 44.dp) .background( - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(90.dp), + color = if (selectedTab == SectionType.TRENDING) { + TrendingGradientEnd.copy(alpha = 0.15f) + } else { + MaterialTheme.colorScheme.surface + }, + shape = RoundedCornerShape(32.dp), ), - contentAlignment = Alignment.Center, ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 25.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - CustomRippleIconButton( - interactionSource = interactionSourceTrending, - rippleConfiguration = TrendingRippleConfiguration, - onClick = onClickTrendingTab, - modifier = Modifier - .size(80.dp, 44.dp) - .background( - color = if (selectedTab == SectionType.TRENDING) { - TrendingGradientEnd.copy(alpha = 0.15f) - } else { - MaterialTheme.colorScheme.surface - }, - shape = RoundedCornerShape(32.dp), - ), - ) { - Icon( - imageVector = Icons.Rounded.LocalFireDepartment, - contentDescription = stringResource(R.string.detail_bottom_bar_back), - tint = TrendingGradientStart, - ) - } + Icon( + imageVector = Icons.Rounded.LocalFireDepartment, + contentDescription = stringResource(R.string.detail_bottom_bar_back), + tint = TrendingGradientStart, + ) + } - CustomRippleIconButton( - interactionSource = interactionSourceHighRated, - rippleConfiguration = HighRatedRippleConfiguration, - onClick = onClickHighRatedTab, - modifier = Modifier - .size(80.dp, 44.dp) - .background( - color = if (selectedTab == SectionType.HIGH_RATED) { - DetailDescriptionGradientEnd.copy(alpha = 0.25f) - } else { - MaterialTheme.colorScheme.surface - }, - shape = RoundedCornerShape(32.dp), - ), - ) { - Icon( - imageVector = Icons.Rounded.Stars, - contentDescription = stringResource(R.string.detail_bottom_bar_back), - tint = HighRatedGradientStart, - ) - } + CustomRippleIconButton( + interactionSource = interactionSourceHighRated, + rippleConfiguration = HighRatedRippleConfiguration, + onClick = onClickHighRatedTab, + modifier = Modifier + .size(80.dp, 44.dp) + .background( + color = if (selectedTab == SectionType.HIGH_RATED) { + DetailDescriptionGradientEnd.copy(alpha = 0.25f) + } else { + MaterialTheme.colorScheme.surface + }, + shape = RoundedCornerShape(32.dp), + ), + ) { + Icon( + imageVector = Icons.Rounded.Stars, + contentDescription = stringResource(R.string.detail_bottom_bar_back), + tint = HighRatedGradientStart, + ) + } - CustomRippleIconButton( - interactionSource = interactionSourceNewRelease, - rippleConfiguration = NewReleaseRippleConfiguration, - onClick = onClickNewReleaseTab, - modifier = Modifier - .size(80.dp, 44.dp) - .background( - color = if (selectedTab == SectionType.NEW_RELEASE) { - NewReleaseGradientStart.copy(alpha = 0.15f) - } else { - MaterialTheme.colorScheme.surface - }, - shape = RoundedCornerShape(32.dp), - ), - ) { - Icon( - imageVector = Icons.Rounded.NewReleases, - contentDescription = stringResource(R.string.detail_bottom_bar_back), - tint = NewReleaseGradientStart, - ) - } - } + CustomRippleIconButton( + interactionSource = interactionSourceNewRelease, + rippleConfiguration = NewReleaseRippleConfiguration, + onClick = onClickNewReleaseTab, + modifier = Modifier + .size(80.dp, 44.dp) + .background( + color = if (selectedTab == SectionType.NEW_RELEASE) { + NewReleaseGradientStart.copy(alpha = 0.15f) + } else { + MaterialTheme.colorScheme.surface + }, + shape = RoundedCornerShape(32.dp), + ), + ) { + Icon( + imageVector = Icons.Rounded.NewReleases, + contentDescription = stringResource(R.string.detail_bottom_bar_back), + tint = NewReleaseGradientStart, + ) } } } diff --git a/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/discovery/GameCard.kt b/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/discovery/GameCard.kt index bdcd256..e4e5657 100644 --- a/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/discovery/GameCard.kt +++ b/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/discovery/GameCard.kt @@ -51,7 +51,7 @@ import com.lilin.gamelibrary.ui.theme.RatingGold import com.lilin.gamelibrary.ui.theme.RatingSilver import com.lilin.gamelibrary.ui.theme.TrendingGradientEnd import com.lilin.gamelibrary.ui.theme.TrendingGradientStart -import java.util.Locale +import com.lilin.gamelibrary.ui.util.formatCount private const val METACRITIC_GOLD_THRESHOLD = 90 private const val METACRITIC_SILVER_THRESHOLD = 70 @@ -548,19 +548,6 @@ fun getMetacriticColor(score: Int): Color { } } -/** - * 数値をフォーマット(例:1234 → 1.2k) - */ -fun formatCount(count: Int): String { - return when { - count >= 1000 -> { - String.format(Locale.ROOT, "%.1fk", count / 1000.0) - } - - else -> count.toString() - } -} - @Preview @Composable private fun TrendingGameCardPreview() { diff --git a/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/favorite/FavoriteSortFabMenu.kt b/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/favorite/FavoriteSortFabMenu.kt index 6a5f6dd..8df1116 100644 --- a/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/favorite/FavoriteSortFabMenu.kt +++ b/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/favorite/FavoriteSortFabMenu.kt @@ -10,6 +10,7 @@ import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -19,21 +20,20 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Sort import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material3.BadgedBox import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme 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.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -42,25 +42,32 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.lilin.gamelibrary.R -import com.lilin.gamelibrary.domain.model.SortOrder +import com.lilin.gamelibrary.domain.model.SortDirection +import com.lilin.gamelibrary.domain.model.SortOption +import com.lilin.gamelibrary.domain.model.SortType +import com.lilin.gamelibrary.domain.model.combineSortOption +import com.lilin.gamelibrary.domain.model.toTypeAndDirection +import com.lilin.gamelibrary.ui.theme.BadgeColor import com.lilin.gamelibrary.ui.theme.FavoriteGradientStart @Composable fun FavoriteSortFabMenu( - sortOrder: SortOrder, - onSortClick: () -> Unit, + currentSortOption: SortOption, + isExpanded: Boolean, + onClickFab: () -> Unit, + onSortChange: (SortOption) -> Unit, modifier: Modifier = Modifier, ) { - var isExpanded by remember { mutableStateOf(false) } + val (currentType, currentDirection) = currentSortOption.toTypeAndDirection() - Box(modifier = modifier.fillMaxSize()) { + Box(modifier = modifier) { if (isExpanded) { Box( modifier = Modifier .fillMaxSize() .pointerInput(Unit) { detectTapGestures( - onTap = { isExpanded = false }, + onTap = { onClickFab() }, ) }, ) @@ -76,22 +83,73 @@ fun FavoriteSortFabMenu( enter = fadeIn(tween(200)) + slideInVertically(tween(200)) { it / 2 }, exit = fadeOut(tween(200)) + slideOutVertically(tween(200)) { it / 2 }, ) { - FavoriteSortMenuItem( - sortOrder = sortOrder, - onClick = onSortClick, - ) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + SortType.entries.forEach { sortType -> + item { + FavoriteSortMenuItem( + sortType = sortType, + currentDirection = if (currentType == sortType) { + currentDirection + } else { + SortDirection.DESC + }, + isSelected = currentType == sortType, + onClick = { + val newDirection = if (currentType == sortType) { + if (currentDirection == SortDirection.DESC) { + SortDirection.ASC + } else { + SortDirection.DESC + } + } else { + SortDirection.DESC + } + onSortChange(combineSortOption(sortType, newDirection)) + }, + ) + } + } + } } - FloatingActionButton( - onClick = { isExpanded = !isExpanded }, - containerColor = FavoriteGradientStart, - contentColor = Color.White, - modifier = Modifier.size(56.dp), + BadgedBox( + badge = { + if (!isExpanded) { + Box( + modifier = Modifier + .background( + color = BadgeColor, + shape = CircleShape, + ) + .padding(2.dp), + ) { + Icon( + imageVector = if (currentDirection == SortDirection.ASC) { + Icons.Default.ArrowUpward + } else { + Icons.Default.ArrowDownward + }, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = Color.White, + ) + } + } + }, ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Sort, - contentDescription = "Sort Menu", - ) + FloatingActionButton( + onClick = onClickFab, + containerColor = FavoriteGradientStart, + contentColor = Color.White, + modifier = Modifier.size(56.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Sort, + contentDescription = "Sort Menu", + ) + } } } } @@ -99,102 +157,92 @@ fun FavoriteSortFabMenu( @Composable private fun FavoriteSortMenuItem( - sortOrder: SortOrder, + sortType: SortType, + currentDirection: SortDirection, + isSelected: Boolean, onClick: () -> Unit, + modifier: Modifier = Modifier, ) { Surface( onClick = onClick, shape = RoundedCornerShape(12.dp), color = FavoriteGradientStart, - tonalElevation = 6.dp, - modifier = Modifier.widthIn(min = 140.dp), + tonalElevation = if (isSelected) 8.dp else 6.dp, + modifier = modifier + .widthIn(min = 140.dp), ) { Row( - modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .padding(horizontal = 20.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { AnimatedContent( - targetState = sortOrder, - transitionSpec = { - fadeIn(tween(150)) + scaleIn(tween(150)) togetherWith - fadeOut(tween(150)) + scaleOut(tween(150)) - }, - label = "sort_icon_animation", - ) { order -> - Icon( - imageVector = when (order) { - SortOrder.NEWEST_FIRST -> Icons.Default.ArrowUpward - SortOrder.OLDEST_FIRST -> Icons.Default.ArrowDownward - }, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = Color.White, - ) - } - - AnimatedContent( - targetState = sortOrder, + targetState = sortType, transitionSpec = { fadeIn(tween(150)) togetherWith fadeOut(tween(150)) }, label = "sort_text_animation", - ) { order -> + ) { sortType -> Text( - text = when (order) { - SortOrder.NEWEST_FIRST -> stringResource(R.string.favorite_sort_newest) - SortOrder.OLDEST_FIRST -> stringResource(R.string.favorite_sort_oldest) + text = when (sortType) { + SortType.ADDED_DATE -> stringResource(R.string.favorite_sort_added_date) + SortType.TITLE -> stringResource(R.string.favorite_sort_title) + SortType.RATING -> stringResource(R.string.favorite_sort_rating) }, color = Color.White, style = MaterialTheme.typography.titleMedium, ) } + + AnimatedContent( + targetState = currentDirection, + transitionSpec = { + fadeIn(tween(150)) + scaleIn(tween(150)) togetherWith + fadeOut(tween(150)) + scaleOut(tween(150)) + }, + label = "sort_icon_animation", + ) { direction -> + if (isSelected) { + Icon( + imageVector = when (direction) { + SortDirection.ASC -> Icons.Default.ArrowUpward + SortDirection.DESC -> Icons.Default.ArrowDownward + }, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = Color.White, + ) + } + } } } } -@Preview(showBackground = true) +@Preview(showBackground = true, name = "Expanded - Added Date DESC") @Composable -private fun FavoriteSortFabMenuClosedPreview() { - FavoriteSortFabMenu( - sortOrder = SortOrder.NEWEST_FIRST, - onSortClick = {}, - ) -} - -@Preview(showBackground = true, name = "Expanded - Newest First") -@Composable -private fun FavoriteSortFabMenuExpandedNewestPreview() { +private fun FavoriteSortFabMenuExpandedPreview() { Column( horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(16.dp), ) { FavoriteSortMenuItem( - sortOrder = SortOrder.NEWEST_FIRST, + sortType = SortType.ADDED_DATE, + currentDirection = SortDirection.DESC, + isSelected = true, onClick = {}, ) - FloatingActionButton( + FavoriteSortMenuItem( + sortType = SortType.TITLE, + currentDirection = SortDirection.DESC, + isSelected = false, onClick = {}, - containerColor = FavoriteGradientStart, - contentColor = Color.White, - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Sort, - contentDescription = null, - ) - } - } -} - -@Preview(showBackground = true, name = "Expanded - Oldest First") -@Composable -private fun FavoriteSortFabMenuExpandedOldestPreview() { - Column( - horizontalAlignment = Alignment.End, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { + ) FavoriteSortMenuItem( - sortOrder = SortOrder.OLDEST_FIRST, + sortType = SortType.RATING, + currentDirection = SortDirection.DESC, + isSelected = false, onClick = {}, ) FloatingActionButton( @@ -209,3 +257,14 @@ private fun FavoriteSortFabMenuExpandedOldestPreview() { } } } + +@Preview(showBackground = true, name = "Not Expanded") +@Composable +private fun FavoriteSortFabMenuExpanded1Preview() { + FavoriteSortFabMenu( + currentSortOption = SortOption.ADDED_DATE_DESC, + isExpanded = false, + onClickFab = {}, + onSortChange = {}, + ) +} diff --git a/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/search/SearchComponents.kt b/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/search/SearchComponents.kt index e7d0374..ec624c0 100644 --- a/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/search/SearchComponents.kt +++ b/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/search/SearchComponents.kt @@ -55,7 +55,7 @@ import com.lilin.gamelibrary.ui.theme.RatingBronze import com.lilin.gamelibrary.ui.theme.RatingEmpty import com.lilin.gamelibrary.ui.theme.RatingGold import com.lilin.gamelibrary.ui.theme.RatingSilver -import java.util.Locale +import com.lilin.gamelibrary.ui.util.formatCount private const val METACRITIC_GOLD_THRESHOLD = 90 private const val METACRITIC_SILVER_THRESHOLD = 70 @@ -312,18 +312,6 @@ private fun getMetacriticColor(score: Int): Color { } } -/** - * 数値をフォーマット(例:1234 → 1.2k) - */ -private fun formatCount(count: Int): String { - return when { - count >= 1000 -> { - String.format(Locale.ROOT, "%.1fk", count / 1000.0) - } - else -> count.toString() - } -} - @Preview @Composable private fun SearchResultCardHighScorePreview() { diff --git a/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/sectiondetail/GameGridCard.kt b/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/sectiondetail/GameGridCard.kt index 43ee1b4..0110c26 100644 --- a/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/sectiondetail/GameGridCard.kt +++ b/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/sectiondetail/GameGridCard.kt @@ -60,7 +60,7 @@ import com.lilin.gamelibrary.ui.theme.RatingGold import com.lilin.gamelibrary.ui.theme.RatingSilver import com.lilin.gamelibrary.ui.theme.TrendingGradientEnd import com.lilin.gamelibrary.ui.theme.TrendingGradientStart -import java.util.Locale +import com.lilin.gamelibrary.ui.util.formatCount private const val METACRITIC_GOLD_THRESHOLD = 90 private const val METACRITIC_SILVER_THRESHOLD = 70 @@ -581,19 +581,6 @@ fun getMetacriticColor(score: Int): Color { } } -/** - * 数値をフォーマット(例:1234 → 1.2k) - */ -fun formatCount(count: Int): String { - return when { - count >= 1000 -> { - String.format(Locale.ROOT, "%.1fk", count / 1000.0) - } - - else -> count.toString() - } -} - @Preview @Composable private fun TrendingGameCardPreview() { diff --git a/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/sectiondetail/SectionDetailBottomBar.kt b/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/sectiondetail/SectionDetailBottomBar.kt index 53632b8..8bd5af7 100644 --- a/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/sectiondetail/SectionDetailBottomBar.kt +++ b/app/src/main/kotlin/com/lilin/gamelibrary/ui/component/sectiondetail/SectionDetailBottomBar.kt @@ -1,34 +1,26 @@ package com.lilin.gamelibrary.ui.component.sectiondetail -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -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.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ViewList import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.filled.GridView -import androidx.compose.material3.BottomAppBar import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.lilin.gamelibrary.R import com.lilin.gamelibrary.feature.sectiondetail.DisplayMode +import com.lilin.gamelibrary.ui.component.BottomBarTemplate @Composable fun SectionDetailBottomBar( @@ -37,89 +29,52 @@ fun SectionDetailBottomBar( onDisplayModeChange: (DisplayMode) -> Unit, modifier: Modifier = Modifier, ) { - Box(modifier = modifier) { - Box( + BottomBarTemplate(modifier = modifier) { + Row( modifier = Modifier .fillMaxWidth() - .height(100.dp) - .background( - brush = Brush.verticalGradient( - colors = listOf( - Color.Transparent, - MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), - MaterialTheme.colorScheme.surface, - ), - ), - ), - ) - - BottomAppBar( - modifier = Modifier.height(90.dp), - containerColor = Color.Transparent, - contentColor = MaterialTheme.colorScheme.onSurface, - tonalElevation = 0.dp, + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Box( - modifier = Modifier - .padding(horizontal = 60.dp) - .fillMaxSize() - .shadow( - elevation = 8.dp, - shape = RoundedCornerShape(32.dp), - ) - .background( - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(32.dp), - ), - contentAlignment = Alignment.Center, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton(onClick = onBackClick) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = stringResource(R.string.detail_bottom_bar_back), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.detail_bottom_bar_back), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } - Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.weight(1f)) - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - IconButton( - onClick = { onDisplayModeChange(DisplayMode.GRID_COLUMN) }, - ) { - Icon( - imageVector = Icons.Filled.GridView, - contentDescription = "2列グリッド", - tint = if (currentDisplayMode == DisplayMode.GRID_COLUMN) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + IconButton( + onClick = { onDisplayModeChange(DisplayMode.GRID_COLUMN) }, + ) { + Icon( + imageVector = Icons.Filled.GridView, + contentDescription = "2列グリッド", + tint = if (currentDisplayMode == DisplayMode.GRID_COLUMN) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } - IconButton( - onClick = { onDisplayModeChange(DisplayMode.LIST) }, - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ViewList, - contentDescription = "リスト表示", - tint = if (currentDisplayMode == DisplayMode.LIST) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } - } + IconButton( + onClick = { onDisplayModeChange(DisplayMode.LIST) }, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ViewList, + contentDescription = "リスト表示", + tint = if (currentDisplayMode == DisplayMode.LIST) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) } } } diff --git a/app/src/main/kotlin/com/lilin/gamelibrary/ui/theme/Color.kt b/app/src/main/kotlin/com/lilin/gamelibrary/ui/theme/Color.kt index 7451aff..ad42278 100644 --- a/app/src/main/kotlin/com/lilin/gamelibrary/ui/theme/Color.kt +++ b/app/src/main/kotlin/com/lilin/gamelibrary/ui/theme/Color.kt @@ -69,46 +69,53 @@ val OnSurfaceVariantDark = Color(0xFFC4C6CF) val OutlineDark = Color(0xFF938F99) val OutlineVariantDark = Color(0xFF49454F) -// 評価用カラー -val RatingGold = Color(0xFFFFB300) -val RatingSilver = Color(0xFFC0C0C0) -val RatingBronze = Color(0xFFCD7F32) -val RatingEmpty = Color(0xFFE0E0E0) - -// Trending Section Colors -val TrendingGradientStart = Color(0xFFFF6B6B) -val TrendingGradientEnd = Color(0xFFFF8E53) - -// High Rated Section Colors -val HighRatedGradientStart = Color(0xFFFFD700) -val HighRatedGradientEnd = Color(0xFFFFA500) - -// New Release Section Colors -val NewReleaseGradientStart = Color(0xFF26C6DA) -val NewReleaseGradientEnd = Color(0xFF00BFA5) - -// その他の機能的なカラー -val SuccessColor = Color(0xFF00C853) -val WarningColor = Color(0xFFFF9800) -val InfoColor = Color(0xFF0066CC) -val LoadingColor = Color(0xFFCCCCCC) - -// 評価セクション -val DetailRatingGradientStart = Color(0xFF7C4DFF) -val DetailRatingGradientEnd = Color(0xFFB47CFF) - -// 説明セクション -val DetailDescriptionGradientStart = Color(0xFFFF6B6B) -val DetailDescriptionGradientEnd = Color(0xFFFFB74D) - -// タグセクション -val DetailTagGradientStart = Color(0xFF26C6DA) -val DetailTagGradientEnd = Color(0xFF7C4DFF) - -// フォーカス時 -val OutlineColor = Color(0xFF7C4DFF) -val IconTint = Color(0xFF7C4DFF) - -// FavoriteSection -val FavoriteGradientStart = Color(0xFF7C4DFF) -val FavoriteGradientEnd = Color(0xFFFFD700) +/* Color Palette */ +// Purple +val Purple500 = Color(0xFF7C4DFF) +val Purple300 = Color(0xFFB47CFF) + +// Coral & Orange +val Coral500 = Color(0xFFFF6B6B) +val Orange500 = Color(0xFFFFB74D) +val Orange600 = Color(0xFFFF8E53) +val Orange700 = Color(0xFFFFA500) + +// Teal +val Teal500 = Color(0xFF26C6DA) +val Teal700 = Color(0xFF00BFA5) + +// Gold +val Gold500 = Color(0xFFFFD700) +val Gold600 = Color(0xFFFFB300) + +// Gray +val Gray300 = Color(0xFFE0E0E0) +val Silver = Color(0xFFC0C0C0) +val Bronze = Color(0xFFCD7F32) + +/* Semantic Colors - Discovery Section */ +val TrendingGradientStart = Coral500 +val TrendingGradientEnd = Orange600 +val HighRatedGradientStart = Gold500 +val HighRatedGradientEnd = Orange700 +val NewReleaseGradientStart = Teal500 +val NewReleaseGradientEnd = Teal700 + +/* Semantic Colors - Detail Section */ +val DetailRatingGradientStart = Purple500 +val DetailRatingGradientEnd = Purple300 +val DetailDescriptionGradientStart = Coral500 +val DetailDescriptionGradientEnd = Orange500 +val DetailTagGradientStart = Teal500 +val DetailTagGradientEnd = Purple500 + +/* Semantic Colors - Favorite Section */ +val FavoriteGradientStart = Purple500 +val FavoriteGradientEnd = Gold500 +val BadgeColor = Orange500 + +/* Semantic Colors - Rating */ +val RatingGold = Gold600 +val RatingSilver = Silver +val RatingBronze = Bronze +val RatingEmpty = Gray300 diff --git a/app/src/main/kotlin/com/lilin/gamelibrary/ui/util/Formatter.kt b/app/src/main/kotlin/com/lilin/gamelibrary/ui/util/Formatter.kt index 1efacbd..eb31a07 100644 --- a/app/src/main/kotlin/com/lilin/gamelibrary/ui/util/Formatter.kt +++ b/app/src/main/kotlin/com/lilin/gamelibrary/ui/util/Formatter.kt @@ -1,5 +1,20 @@ package com.lilin.gamelibrary.ui.util +import java.util.Locale + +/** + * 数値をフォーマット(例:1234 → 1.2k) + */ +fun formatCount(count: Int): String { + return when { + count >= 1000 -> { + String.format(Locale.ROOT, "%.1f k", count / 1000.0) + } + + else -> count.toString() + } +} + fun Double.formatAvgRating(): String { return if (this > 0) { "%.1f".format(this) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 84b988f..fc588ef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -88,4 +88,8 @@ 合計 平均評価 最新追加 + + 追加日 + タイトル + 評価 diff --git a/app/src/test/kotlin/com/lilin/gamelibrary/domain/usecase/favorite/GetFavoriteGamesUseCaseTest.kt b/app/src/test/kotlin/com/lilin/gamelibrary/domain/usecase/favorite/GetFavoriteGamesUseCaseTest.kt index 51047fb..7c0f4b9 100644 --- a/app/src/test/kotlin/com/lilin/gamelibrary/domain/usecase/favorite/GetFavoriteGamesUseCaseTest.kt +++ b/app/src/test/kotlin/com/lilin/gamelibrary/domain/usecase/favorite/GetFavoriteGamesUseCaseTest.kt @@ -2,7 +2,7 @@ package com.lilin.gamelibrary.domain.usecase.favorite import com.lilin.gamelibrary.data.repository.FavoriteGameRepository import com.lilin.gamelibrary.domain.model.FavoriteGame -import com.lilin.gamelibrary.domain.model.SortOrder +import com.lilin.gamelibrary.domain.model.SortOption import io.mockk.coEvery import io.mockk.mockk import kotlinx.coroutines.flow.first @@ -23,12 +23,12 @@ class GetFavoriteGamesUseCaseTest { } @Test - fun invoke_withNewestFirstOrder_returnsCorrectFlow() = runTest { - val order = SortOrder.NEWEST_FIRST + fun invoke_withAddedDateDescOrder_returnsCorrectFlow() = runTest { + val order = SortOption.ADDED_DATE_DESC val games = newestGames() coEvery { - repository.getFavoriteGames(order) + repository.getFavoriteGames2(order) } returns flowOf(games) val result = useCase.invoke(order) @@ -38,12 +38,12 @@ class GetFavoriteGamesUseCaseTest { } @Test - fun invoke_withOldestFirstOrder_returnsCorrectFlow() = runTest { - val order = SortOrder.OLDEST_FIRST + fun invoke_withAddedDateAscOrder_returnsCorrectFlow() = runTest { + val order = SortOption.ADDED_DATE_ASC val games = oldestGames() coEvery { - repository.getFavoriteGames(order) + repository.getFavoriteGames2(order) } returns flowOf(games) val result = useCase.invoke(order) @@ -54,11 +54,11 @@ class GetFavoriteGamesUseCaseTest { @Test fun invoke_withEmptyList_returnsEmptyFlow() = runTest { - val order = SortOrder.NEWEST_FIRST + val order = SortOption.ADDED_DATE_DESC val emptyGames = emptyList() coEvery { - repository.getFavoriteGames(order) + repository.getFavoriteGames2(order) } returns flowOf(emptyGames) val result = useCase.invoke(order) diff --git a/app/src/test/kotlin/com/lilin/gamelibrary/feature/favorite/FavoriteScreenShotTest.kt b/app/src/test/kotlin/com/lilin/gamelibrary/feature/favorite/FavoriteScreenShotTest.kt index 91204e0..2558787 100644 --- a/app/src/test/kotlin/com/lilin/gamelibrary/feature/favorite/FavoriteScreenShotTest.kt +++ b/app/src/test/kotlin/com/lilin/gamelibrary/feature/favorite/FavoriteScreenShotTest.kt @@ -27,7 +27,7 @@ import coil3.asImage import coil3.test.FakeImageLoaderEngine import com.lilin.gamelibrary.R import com.lilin.gamelibrary.domain.model.FavoriteGame -import com.lilin.gamelibrary.domain.model.SortOrder +import com.lilin.gamelibrary.domain.model.SortOption import com.lilin.gamelibrary.navigation.TOP_LEVEL_ROUTES import com.lilin.gamelibrary.ui.component.GameLibraryNavigationBar import com.lilin.gamelibrary.util.captureMultiDevice @@ -95,7 +95,7 @@ class FavoriteScreenShotTest { FavoriteScreenSample( uiState = FavoriteUiState.Success( games = FAVORITE_GAMES, - sortOrder = SortOrder.NEWEST_FIRST, + sortOption = SortOption.ADDED_DATE_DESC, ), modifier = Modifier.padding(paddingValues), ) @@ -168,7 +168,7 @@ class FavoriteScreenShotTest { FavoriteScreenSample( uiState = FavoriteUiState.Success( games = FAVORITE_GAMES, - sortOrder = SortOrder.OLDEST_FIRST, + sortOption = SortOption.TITLE_DESC, ), modifier = Modifier.padding(paddingValues), ) diff --git a/app/src/test/kotlin/com/lilin/gamelibrary/feature/favorite/FavoriteViewModelTest.kt b/app/src/test/kotlin/com/lilin/gamelibrary/feature/favorite/FavoriteViewModelTest.kt index f5d6ee9..15135aa 100644 --- a/app/src/test/kotlin/com/lilin/gamelibrary/feature/favorite/FavoriteViewModelTest.kt +++ b/app/src/test/kotlin/com/lilin/gamelibrary/feature/favorite/FavoriteViewModelTest.kt @@ -2,7 +2,7 @@ package com.lilin.gamelibrary.feature.favorite import app.cash.turbine.test import com.lilin.gamelibrary.domain.model.FavoriteGame -import com.lilin.gamelibrary.domain.model.SortOrder +import com.lilin.gamelibrary.domain.model.SortOption import com.lilin.gamelibrary.domain.usecase.favorite.GetFavoriteGamesUseCase import com.lilin.gamelibrary.domain.usecase.favorite.RemoveFavoriteGameUseCase import io.mockk.every @@ -19,7 +19,6 @@ import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import kotlin.collections.reversed @ExperimentalCoroutinesApi class FavoriteViewModelTest { @@ -42,7 +41,7 @@ class FavoriteViewModelTest { } @Test - fun toggleSortOrder_withNewestFirstOrder_updatesToOldestFirst() = + fun toggleSortOrder_withAddedDateDescOrder_updatesToAddedDateAsc() = runTest { val testGames = listOf( FavoriteGame( @@ -65,8 +64,8 @@ class FavoriteViewModelTest { ), ) - every { getFavoriteGamesUseCase.invoke(SortOrder.NEWEST_FIRST) } returns flowOf(testGames) - every { getFavoriteGamesUseCase.invoke(SortOrder.OLDEST_FIRST) } returns flowOf(testGames.reversed()) + every { getFavoriteGamesUseCase.invoke(SortOption.ADDED_DATE_DESC) } returns flowOf(testGames) + every { getFavoriteGamesUseCase.invoke(SortOption.ADDED_DATE_ASC) } returns flowOf(testGames.reversed()) viewModel = FavoriteViewModel(getFavoriteGamesUseCase, removeFavoriteGameUseCase) @@ -77,12 +76,12 @@ class FavoriteViewModelTest { val initialSuccessState = awaitItem() assertEquals(true, initialSuccessState is FavoriteUiState.Success) assertEquals( - SortOrder.NEWEST_FIRST, - (initialSuccessState as FavoriteUiState.Success).sortOrder, + SortOption.ADDED_DATE_DESC, + (initialSuccessState as FavoriteUiState.Success).sortOption, ) assertEquals(testGames, initialSuccessState.games) - viewModel.toggleSortOrder() + viewModel.toggleSortOrder(SortOption.ADDED_DATE_ASC) assertEquals(FavoriteUiState.Loading, awaitItem()) @@ -91,15 +90,15 @@ class FavoriteViewModelTest { val toggledSuccessState = awaitItem() assertEquals(true, toggledSuccessState is FavoriteUiState.Success) assertEquals( - SortOrder.OLDEST_FIRST, - (toggledSuccessState as FavoriteUiState.Success).sortOrder, + SortOption.ADDED_DATE_ASC, + (toggledSuccessState as FavoriteUiState.Success).sortOption, ) assertEquals(testGames.reversed(), toggledSuccessState.games) } } @Test - fun toggleSortOrder_withOldestFirstOrder_updatesToNewestFirst() = + fun toggleSortOrder_withAddedDateAscOrder_updatesToAddedDateDesc() = runTest { val testGames = listOf( FavoriteGame( @@ -113,24 +112,24 @@ class FavoriteViewModelTest { ), ) - every { getFavoriteGamesUseCase.invoke(SortOrder.NEWEST_FIRST) } returns flowOf(testGames) - every { getFavoriteGamesUseCase.invoke(SortOrder.OLDEST_FIRST) } returns flowOf(testGames) + every { getFavoriteGamesUseCase.invoke(SortOption.ADDED_DATE_DESC) } returns flowOf(testGames) + every { getFavoriteGamesUseCase.invoke(SortOption.ADDED_DATE_ASC) } returns flowOf(testGames) viewModel = FavoriteViewModel(getFavoriteGamesUseCase, removeFavoriteGameUseCase) advanceUntilIdle() - viewModel.toggleSortOrder() + viewModel.toggleSortOrder(SortOption.ADDED_DATE_ASC) advanceUntilIdle() viewModel.uiState.test { val currentState = awaitItem() assertEquals(true, currentState is FavoriteUiState.Success) assertEquals( - SortOrder.OLDEST_FIRST, - (currentState as FavoriteUiState.Success).sortOrder, + SortOption.ADDED_DATE_ASC, + (currentState as FavoriteUiState.Success).sortOption, ) - viewModel.toggleSortOrder() + viewModel.toggleSortOrder(SortOption.ADDED_DATE_DESC) assertEquals(FavoriteUiState.Loading, awaitItem()) @@ -139,16 +138,17 @@ class FavoriteViewModelTest { val toggledSuccessState = awaitItem() assertEquals(true, toggledSuccessState is FavoriteUiState.Success) assertEquals( - SortOrder.NEWEST_FIRST, - (toggledSuccessState as FavoriteUiState.Success).sortOrder, + SortOption.ADDED_DATE_DESC, + (toggledSuccessState as FavoriteUiState.Success).sortOption, ) } } @Test - fun toggleSortOrder_withNonSuccessState_stateRemainsUnchanged() = + fun toggleSortOrder_withEmptyState_transitionsToLoadingThenEmpty() = runTest { - every { getFavoriteGamesUseCase.invoke(SortOrder.NEWEST_FIRST) } returns flowOf(emptyList()) + every { getFavoriteGamesUseCase.invoke(SortOption.ADDED_DATE_DESC) } returns flowOf(emptyList()) + every { getFavoriteGamesUseCase.invoke(SortOption.ADDED_DATE_ASC) } returns flowOf(emptyList()) viewModel = FavoriteViewModel(getFavoriteGamesUseCase, removeFavoriteGameUseCase) @@ -159,9 +159,13 @@ class FavoriteViewModelTest { assertEquals(FavoriteUiState.Empty, awaitItem()) - viewModel.toggleSortOrder() + viewModel.toggleSortOrder(SortOption.ADDED_DATE_ASC) - expectNoEvents() + assertEquals(FavoriteUiState.Loading, awaitItem()) + + advanceUntilIdle() + + assertEquals(FavoriteUiState.Empty, awaitItem()) } } }