diff --git a/.editorconfig b/.editorconfig index c91f60f..8dc7d85 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,2 +1,4 @@ [*.{kt,kts}] ktlint_standard_package-name = disabled +ktlint_function_naming_ignore_when_annotated_with = Composable + diff --git a/app/build.gradle b/app/build.gradle index 2499a35..d1c2efa 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -7,6 +7,7 @@ plugins { id("org.jlleitschuh.gradle.ktlint") version "12.1.2" id 'com.google.dagger.hilt.android' id 'jacoco' + id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" } android { @@ -39,6 +40,10 @@ android { buildFeatures { viewBinding true buildConfig true + compose true + } + composeOptions { + kotlinCompilerExtensionVersion '1.5.0' } } @@ -69,6 +74,7 @@ dependencies { kapt "com.google.dagger:hilt-compiler:2.55" testImplementation "org.mockito:mockito-core:5.2.0" testImplementation 'org.mockito:mockito-inline:5.2.0' + implementation 'androidx.hilt:hilt-navigation-compose:1.2.0' // Retrofit @@ -76,6 +82,21 @@ dependencies { implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") implementation("com.squareup.retrofit2:retrofit:2.11.0") implementation("com.squareup.moshi:moshi-kotlin:1.14.0") + + // jetpack compose + def composeBom = platform('androidx.compose:compose-bom:2025.01.00') + implementation composeBom + androidTestImplementation composeBom + implementation 'androidx.compose.material3:material3' + implementation 'androidx.compose.foundation:foundation' + implementation 'androidx.compose.ui:ui' + implementation 'androidx.compose.ui:ui-tooling-preview' + debugImplementation 'androidx.compose.ui:ui-tooling' + implementation 'androidx.activity:activity-compose:1.10.0' + implementation 'androidx.navigation:navigation-compose:2.8.5' + implementation "io.coil-kt:coil-compose:2.4.0" + implementation "androidx.compose.material:material-icons-extended:1.7.6" + } kapt { @@ -86,11 +107,6 @@ jacoco { toolVersion = "0.8.8" } -//tasks.withType(Test) { -// useJUnitPlatform() -// finalizedBy(tasks.jacocoTestReport) // report is always generated after tests run -//} - tasks.register("jacocoTestReport", JacocoReport) { dependsOn(tasks.test) @@ -111,4 +127,4 @@ tasks.register("jacocoTestReport", JacocoReport) { "jacoco/testDebugUnitTest.exec", "outputs/code-coverage/connected/*coverage.ec" ])) -} \ No newline at end of file +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2e1287a..b83f7e1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,7 +14,7 @@ android:fullBackupContent="@xml/backup_descriptor" android:name=".CodeCheckApplication"> diff --git a/app/src/main/kotlin/jp/co/yumemi/android/code_check/MainActivity.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/MainActivity.kt new file mode 100644 index 0000000..60a049b --- /dev/null +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/MainActivity.kt @@ -0,0 +1,23 @@ +/* + * Copyright © 2021 YUMEMI Inc. All rights reserved. + */ +package jp.co.yumemi.android.code_check + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import dagger.hilt.android.AndroidEntryPoint +import jp.co.yumemi.android.code_check.core.presenter.MainScreen +import jp.co.yumemi.android.code_check.core.presenter.theme.CodeCheckAppTheme + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + CodeCheckAppTheme { + MainScreen() + } + } + } +} diff --git a/app/src/main/kotlin/jp/co/yumemi/android/code_check/TopActivity.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/TopActivity.kt deleted file mode 100644 index 926636a..0000000 --- a/app/src/main/kotlin/jp/co/yumemi/android/code_check/TopActivity.kt +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright © 2021 YUMEMI Inc. All rights reserved. - */ -package jp.co.yumemi.android.code_check - -import androidx.appcompat.app.AppCompatActivity -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class TopActivity : AppCompatActivity(R.layout.activity_top) diff --git a/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/entity/RepositoryItem.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/entity/RepositoryEntity.kt similarity index 88% rename from app/src/main/kotlin/jp/co/yumemi/android/code_check/core/entity/RepositoryItem.kt rename to app/src/main/kotlin/jp/co/yumemi/android/code_check/core/entity/RepositoryEntity.kt index 1136dce..e172ea5 100644 --- a/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/entity/RepositoryItem.kt +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/entity/RepositoryEntity.kt @@ -4,7 +4,8 @@ import android.os.Parcelable import kotlinx.parcelize.Parcelize @Parcelize -data class RepositoryItem( +data class RepositoryEntity( + val id: Int, val name: String, val ownerIconUrl: String, val language: String, diff --git a/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/MainScreen.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/MainScreen.kt new file mode 100644 index 0000000..6e9794d --- /dev/null +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/MainScreen.kt @@ -0,0 +1,126 @@ +package jp.co.yumemi.android.code_check.core.presenter + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import jp.co.yumemi.android.code_check.R +import jp.co.yumemi.android.code_check.core.presenter.router.BottomNavigationBarRoute +import jp.co.yumemi.android.code_check.core.presenter.router.MainRouter +import jp.co.yumemi.android.code_check.core.presenter.widget.EmptyCompose +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainScreen() { + val context = LocalContext.current + val appName = context.getString(R.string.app_name) + + val navController = rememberNavController() + val navBackStackEntry by navController.currentBackStackEntryAsState() + var topBarTitle by remember { + mutableStateOf(appName) + } + + val hostState = remember { SnackbarHostState() } + var isErrorMessage by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + val navigationIcon: @Composable () -> Unit = + if (navBackStackEntry?.destination?.route != BottomNavigationBarRoute.SEARCH.route) { + { + IconButton(onClick = { + navController.popBackStack() + }) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "Back", + ) + } + } + } else { + { + EmptyCompose() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = topBarTitle, + fontWeight = FontWeight.Bold, + ) + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.primary, + ), + navigationIcon = navigationIcon, + ) + }, + snackbarHost = { CustomSnackbarHost(hostState = hostState, isErrorMessage = isErrorMessage) }, + ) { innerPadding -> + MainRouter( + toDetailScreen = { id -> + navController.navigate("${BottomNavigationBarRoute.DETAIL.route}/$id") + }, + toBackScreen = { + navController.popBackStack() + }, + changeTopBarTitle = { + topBarTitle = it + }, + showSnackbar = { message, isError -> + scope.launch { + isErrorMessage = isError + hostState.showSnackbar(message) + } + }, + navController = navController, + modifier = + Modifier + .padding(innerPadding), + ) + } +} + +@Composable +fun CustomSnackbarHost( + hostState: SnackbarHostState, + isErrorMessage: Boolean, +) { + SnackbarHost( + hostState = hostState, + ) { snackbarData -> + Snackbar( + snackbarData = snackbarData, + containerColor = if (isErrorMessage) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primaryContainer, + contentColor = if (isErrorMessage) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer, + ) + } +} diff --git a/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/detail/RepositoryDetailFragment.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/detail/RepositoryDetailFragment.kt deleted file mode 100644 index 614ee71..0000000 --- a/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/detail/RepositoryDetailFragment.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright © 2021 YUMEMI Inc. All rights reserved. - */ -package jp.co.yumemi.android.code_check.core.presenter.detail - -import android.os.Bundle -import android.view.View -import androidx.fragment.app.Fragment -import androidx.navigation.fragment.navArgs -import coil.load -import dagger.hilt.android.AndroidEntryPoint -import jp.co.yumemi.android.code_check.R -import jp.co.yumemi.android.code_check.core.entity.RepositoryItem -import jp.co.yumemi.android.code_check.databinding.FragmentRepositoryDetailBinding - -@AndroidEntryPoint -class RepositoryDetailFragment : Fragment(R.layout.fragment_repository_detail) { - private val args: RepositoryDetailFragmentArgs by navArgs() - - private var _binding: FragmentRepositoryDetailBinding? = null - private val binding get() = _binding ?: throw IllegalStateException("Binding is null") - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - - _binding = FragmentRepositoryDetailBinding.bind(view) - - val item = args.item - bindViews(item) - } - - private fun bindViews(item: RepositoryItem) { - binding.ownerIconView.load(item.ownerIconUrl) - binding.nameView.text = item.name - binding.languageView.text = resources.getString(R.string.written_language, item.language) - binding.starsView.text = resources.getString(R.string.stars_count, item.stargazersCount) - binding.watchersView.text = resources.getString(R.string.watchers_count, item.watchersCount) - binding.forksView.text = resources.getString(R.string.forks_count, item.forksCount) - binding.openIssuesView.text = resources.getString(R.string.open_issues_count, item.openIssuesCount) - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} diff --git a/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/detail/RepositoryDetailScreen.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/detail/RepositoryDetailScreen.kt new file mode 100644 index 0000000..260b384 --- /dev/null +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/detail/RepositoryDetailScreen.kt @@ -0,0 +1,270 @@ +package jp.co.yumemi.android.code_check.core.presenter.detail + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Report +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.LocalLifecycleOwner +import coil.compose.rememberAsyncImagePainter +import jp.co.yumemi.android.code_check.R +import jp.co.yumemi.android.code_check.core.entity.RepositoryEntity +import jp.co.yumemi.android.code_check.core.presenter.widget.ProgressCycle + +@Composable +fun RepositoryDetailScreen( + toBack: () -> Unit, + repositoryId: Int, + viewModel: RepositoryDetailViewModel = hiltViewModel(), + showSnackBar: (String, Boolean) -> Unit, +) { + val repositoryDetail = remember { mutableStateOf(null) } + val context = LocalContext.current + var isLoading by remember { mutableStateOf(false) } + val lifecycleOwner = LocalLifecycleOwner.current + + BackHandler(onBack = toBack) + + var error by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + isLoading = true + viewModel.searchResults.observe(lifecycleOwner) { + repositoryDetail.value = it + isLoading = false + error = null + } + viewModel.errorMessage.observe(lifecycleOwner) { errorMessage -> + errorMessage?.let { + showSnackBar(context.getString(it), true) + error = context.getString(it) + } + isLoading = false + } + viewModel.searchRepositories(repositoryId) + } + + if (error != null && !isLoading) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth().fillMaxHeight(), + ) { + Text(text = error!!) + } + } + + if (error == null) { + RepositoryDetailScaffold( + isLoading = isLoading, + repositoryDetail = repositoryDetail.value, + toBack = toBack, + ) + } +} + +@Composable +fun RepositoryDetailScaffold( + isLoading: Boolean, + repositoryDetail: RepositoryEntity?, + toBack: () -> Unit, +) { + Box( + modifier = + Modifier + .fillMaxSize() + .padding(8.dp), + ) { + if (isLoading) { + ProgressCycle() + } else { + repositoryDetail?.let { + RepositoryDetailContent(repository = it) + } + } + } +} + +@Composable +fun RepositoryDetailContent(repository: RepositoryEntity) { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + RepositoryOverviewCard(repository = repository) + RepositoryStatsCard(repository = repository) + } +} + +@Composable +fun RepositoryOverviewCard(repository: RepositoryEntity) { + val context = LocalContext.current + + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(4.dp), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = + rememberAsyncImagePainter( + model = repository.ownerIconUrl, + error = painterResource(R.drawable.ic_launcher_foreground), + placeholder = painterResource(R.drawable.ic_launcher_background), + ), + contentDescription = context.getString(R.string.owner_icon_description), + modifier = Modifier.size(80.dp), + ) + Text( + text = repository.name, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Text( + text = context.getString(R.string.language_format, repository.language), + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} + +@Composable +fun RepositoryStatsCard(repository: RepositoryEntity) { + val context = LocalContext.current + + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(4.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Star, + contentDescription = "Stars", + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = context.getString(R.string.stars_count, repository.stargazersCount), + fontSize = 18.sp, + fontWeight = FontWeight.Medium, + ) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = "Forks", + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = context.getString(R.string.forks_count, repository.forksCount), + fontSize = 18.sp, + fontWeight = FontWeight.Medium, + ) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Report, + contentDescription = "Open Issues", + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = context.getString(R.string.open_issues_count, repository.openIssuesCount), + fontSize = 18.sp, + fontWeight = FontWeight.Medium, + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun PreviewRepositoryOverviewCard() { + RepositoryOverviewCard( + repository = + RepositoryEntity( + id = 1, + name = "Example Repo", + ownerIconUrl = "https://via.placeholder.com/150", + language = "Kotlin", + stargazersCount = 123, + forksCount = 45, + openIssuesCount = 2, + watchersCount = 1, + ), + ) +} + +@Preview(showBackground = true) +@Composable +fun PreviewRepositoryStatsCard() { + RepositoryStatsCard( + repository = + RepositoryEntity( + id = 1, + name = "Example Repo", + ownerIconUrl = "https://via.placeholder.com/150", + language = "Kotlin", + stargazersCount = 123, + forksCount = 45, + openIssuesCount = 2, + watchersCount = 1, + ), + ) +} diff --git a/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/detail/RepositoryDetailViewModel.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/detail/RepositoryDetailViewModel.kt new file mode 100644 index 0000000..46b2eb9 --- /dev/null +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/detail/RepositoryDetailViewModel.kt @@ -0,0 +1,70 @@ +package jp.co.yumemi.android.code_check.core.presenter.detail + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import jp.co.yumemi.android.code_check.R +import jp.co.yumemi.android.code_check.core.entity.RepositoryEntity +import jp.co.yumemi.android.code_check.features.github.reposiotory.NetworkException +import jp.co.yumemi.android.code_check.features.github.usecase.GitHubServiceUsecase +import jp.co.yumemi.android.code_check.features.github.utils.GitHubError +import jp.co.yumemi.android.code_check.features.github.utils.NetworkResult +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class RepositoryDetailViewModel + @Inject + constructor( + private val networkRepository: GitHubServiceUsecase, + ) : ViewModel() { + private val _errorMessage = MutableLiveData() + val errorMessage: LiveData get() = _errorMessage + + private val _searchResults = MutableLiveData() + val searchResults: LiveData get() = _searchResults + + /** + * GitHubのレポジトリ検索を行う + * @param id 検索キーワード + */ + fun searchRepositories(id: Int) { + if (id == 0) { + _errorMessage.postValue(R.string.invalid_repository_id) + return + } + viewModelScope.launch { + try { + val results = networkRepository.fetchRepositoryDetail(id) + if (results is NetworkResult.Error) { + handleError(results.exception) + return@launch + } + if (results is NetworkResult.Success) { + _searchResults.postValue(results.data) + } + } catch (e: NetworkException) { + Log.e("RepositoryDetailViewModel", "Failed to fetch repository details for id: $id", e) + handleError(GitHubError.NetworkError(e)) + } + } + } + + /** + * エラーが発生した時に、Viewに問題を表示するためのもの + * @param error エラー情報 + */ + private fun handleError(error: GitHubError) { + _errorMessage.value = + when (error) { + is GitHubError.NetworkError -> R.string.network_error + is GitHubError.ApiError -> R.string.api_error + is GitHubError.ParseError -> R.string.parse_error + is GitHubError.RateLimitError -> R.string.rate_limit_error + is GitHubError.AuthenticationError -> R.string.auth_error + } + } + } diff --git a/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/router/MainRouter.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/router/MainRouter.kt new file mode 100644 index 0000000..aa6a49d --- /dev/null +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/router/MainRouter.kt @@ -0,0 +1,57 @@ +package jp.co.yumemi.android.code_check.core.presenter.router + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import jp.co.yumemi.android.code_check.R +import jp.co.yumemi.android.code_check.core.presenter.detail.RepositoryDetailScreen +import jp.co.yumemi.android.code_check.core.presenter.search.RepositorySearchScreen + +@Composable +fun MainRouter( + toDetailScreen: (Int) -> Unit, + toBackScreen: () -> Unit, + changeTopBarTitle: (String) -> Unit, + showSnackbar: (String, Boolean) -> Unit, + navController: NavHostController, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + + NavHost( + navController = navController, + startDestination = BottomNavigationBarRoute.SEARCH.route, + modifier = modifier.fillMaxSize(), + ) { + composable(BottomNavigationBarRoute.SEARCH.route) { + RepositorySearchScreen( + toDetailScreen = toDetailScreen, + showSnackBar = showSnackbar, + ) + changeTopBarTitle(context.getString(R.string.app_name)) + } + composable( + BottomNavigationBarRoute.DETAIL.route + "/{id}", + arguments = listOf(navArgument("id") { type = NavType.IntType }), + ) { backStackEntry -> + val id = backStackEntry.arguments?.getInt("id") + RepositoryDetailScreen( + toBack = toBackScreen, + repositoryId = id ?: 0, + showSnackBar = showSnackbar, + ) + changeTopBarTitle(context.getString(R.string.detail)) + } + } +} + +enum class BottomNavigationBarRoute(val route: String, val title: Int) { + SEARCH("search", R.string.search), + DETAIL("detail", R.string.detail), +} diff --git a/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/search/RepositoryListRecyclerViewAdapter.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/search/RepositoryListRecyclerViewAdapter.kt deleted file mode 100644 index f0d1656..0000000 --- a/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/search/RepositoryListRecyclerViewAdapter.kt +++ /dev/null @@ -1,74 +0,0 @@ -package jp.co.yumemi.android.code_check.core.presenter.search - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.TextView -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import jp.co.yumemi.android.code_check.R -import jp.co.yumemi.android.code_check.core.entity.RepositoryItem - -/** - * DiffUtilの実装 - */ -private val diffUtilCallback = - object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: RepositoryItem, - newItem: RepositoryItem, - ): Boolean { - return oldItem.name == newItem.name - } - - override fun areContentsTheSame( - oldItem: RepositoryItem, - newItem: RepositoryItem, - ): Boolean { - return oldItem == newItem - } - } - -/** - * RecyclerView Adapter - */ -class RepositoryListRecyclerViewAdapter( - private val itemClickListener: OnItemClickListener, -) : ListAdapter(diffUtilCallback) { - class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { - private val repositoryNameView: TextView? = view.findViewById(R.id.repositoryNameView) - - /** - * ビューにデータをバインド - */ - fun bind( - item: RepositoryItem, - clickListener: OnItemClickListener, - ) { - repositoryNameView?.text = item.name - itemView.setOnClickListener { clickListener.itemClick(item) } - } - } - - interface OnItemClickListener { - fun itemClick(repositoryItem: RepositoryItem) - } - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): ViewHolder { - val view = - LayoutInflater.from(parent.context) - .inflate(R.layout.layout_item, parent, false) - return ViewHolder(view) - } - - override fun onBindViewHolder( - holder: ViewHolder, - position: Int, - ) { - holder.bind(getItem(position), itemClickListener) - } -} diff --git a/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/search/RepositorySearchFragment.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/search/RepositorySearchFragment.kt deleted file mode 100644 index 12a133c..0000000 --- a/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/search/RepositorySearchFragment.kt +++ /dev/null @@ -1,97 +0,0 @@ -package jp.co.yumemi.android.code_check.core.presenter.search - -import android.os.Bundle -import android.view.View -import android.view.inputmethod.EditorInfo -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.navigation.fragment.findNavController -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.LinearLayoutManager -import dagger.hilt.android.AndroidEntryPoint -import jp.co.yumemi.android.code_check.R -import jp.co.yumemi.android.code_check.core.entity.RepositoryItem -import jp.co.yumemi.android.code_check.core.utils.DialogHelper -import jp.co.yumemi.android.code_check.databinding.FragmentRepositorySearchBinding - -@AndroidEntryPoint -class RepositorySearchFragment : Fragment(R.layout.fragment_repository_search) { - private var _binding: FragmentRepositorySearchBinding? = null - private val binding get() = _binding ?: throw IllegalStateException("Binding is null") - private val viewModel: RepositorySearchViewModel by viewModels() - - private val adapter by lazy { - RepositoryListRecyclerViewAdapter( - object : RepositoryListRecyclerViewAdapter.OnItemClickListener { - override fun itemClick(repositoryItem: RepositoryItem) { - onItemClick(repositoryItem) - } - }, - ) - } - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - _binding = FragmentRepositorySearchBinding.bind(view) - - observeViewModel() - setupRecyclerView() - setupSearchInput() - } - - private fun observeViewModel() { - viewModel.searchResults.observe(viewLifecycleOwner) { - adapter.submitList(it) - } - viewModel.errorMessage.observe(viewLifecycleOwner) { - it?.let { - DialogHelper.showErrorDialog( - requireContext(), - requireContext().getString(it), - ) - } - } - } - - private fun setupRecyclerView() { - val layoutManager = LinearLayoutManager(requireContext()) - val dividerItemDecoration = - DividerItemDecoration(requireContext(), layoutManager.orientation) - - binding.recyclerView.apply { - this.layoutManager = layoutManager - addItemDecoration(dividerItemDecoration) - adapter = this@RepositorySearchFragment.adapter - } - } - - private fun setupSearchInput() { - binding.searchInputText.setOnEditorActionListener { editText, action, _ -> - if (action == EditorInfo.IME_ACTION_SEARCH) { - viewModel.searchRepositories(editText.text.toString().trim()) - true - } else { - false - } - } - } - - /** - * リポジトリ検索結果のクリックイベント - * リサイクラービューでアイテムが押された時に動作を行います。 - */ - private fun onItemClick(item: RepositoryItem) { - val action = - RepositorySearchFragmentDirections - .actionRepositoriesFragmentToRepositoryFragment(item = item) - findNavController().navigate(action) - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} diff --git a/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/search/RepositorySearchScreen.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/search/RepositorySearchScreen.kt new file mode 100644 index 0000000..a9184b0 --- /dev/null +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/search/RepositorySearchScreen.kt @@ -0,0 +1,348 @@ +package jp.co.yumemi.android.code_check.core.presenter.search + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.sharp.Search +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.isTraversalGroup +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.LocalLifecycleOwner +import jp.co.yumemi.android.code_check.R +import jp.co.yumemi.android.code_check.core.entity.RepositoryEntity +import jp.co.yumemi.android.code_check.core.presenter.theme.CodeCheckAppTheme +import jp.co.yumemi.android.code_check.core.presenter.widget.ProgressCycle +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun RepositorySearchScreen( + toDetailScreen: (Int) -> Unit, + viewModel: RepositorySearchViewModel = hiltViewModel(), + showSnackBar: (String, Boolean) -> Unit, +) { + var inputText by remember { mutableStateOf("") } + val repositoryList = remember { mutableStateListOf() } + val lifecycleOwner = LocalLifecycleOwner.current + val context = LocalContext.current + var isLoading by remember { mutableStateOf(false) } + var isError by remember { mutableStateOf(false) } + + // デバウンス処理の追加 + val scope = rememberCoroutineScope() + var searchJob by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + viewModel.searchResults.observe(lifecycleOwner) { + repositoryList.clear() + repositoryList.addAll(it) + isLoading = false + isError = false + } + viewModel.errorMessage.observe(lifecycleOwner) { + it?.let { + showSnackBar( + context.getString(it), + true, + ) + } + isLoading = false + isError = true + } + } + + Column { + CustomSearchBar( + inputText = inputText, + onValueChange = { inputText = it }, + searchAction = { searchWord -> + searchJob?.cancel() + searchJob = + scope.launch { + delay(500) // 500ms遅延 + if (searchWord.isBlank()) return@launch + repositoryList.clear() + viewModel.searchRepositories(searchWord.trim()) + isLoading = true + } + }, + ) + if (isLoading) { + ProgressCycle() + } else if (repositoryList.isEmpty() && !isError) { + EmptyState() + } else if (isError) { + ErrorState( + onRetry = { + viewModel.searchRepositories(inputText.trim()) + isLoading = true + }, + ) + } + RepositoryListView( + repositoryList = repositoryList, + onTapping = toDetailScreen, + ) + } +} + +@Composable +fun RepositoryListView( + repositoryList: List, + onTapping: (Int) -> Unit = {}, +) { + LazyColumn( + modifier = + Modifier + .fillMaxWidth() + .padding(8.dp) + .semantics { isTraversalGroup = true }, + ) { + items(repositoryList.size) { index -> + Column( + modifier = + Modifier + .clickable { onTapping(repositoryList[index].id) } + .fillMaxWidth() + .padding(vertical = 8.dp, horizontal = 16.dp), + ) { + Text( + text = repositoryList[index].name, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(8.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.2f)) + } + } + } +} + +@Composable +fun CustomSearchBar( + inputText: String = "", + onValueChange: (String) -> Unit = {}, + searchAction: (String) -> Unit, +) { + val context = LocalContext.current + val keyboardController = LocalSoftwareKeyboardController.current + + // キーボードアクションを定義 + val keyboardActions = + KeyboardActions( + onSearch = { + searchAction(inputText) + keyboardController?.hide() + }, + ) + + TextField( + value = inputText, + onValueChange = onValueChange, + placeholder = { Text(context.getString(R.string.searchInputText_hint)) }, + leadingIcon = { + Icon( + Icons.Sharp.Search, + contentDescription = context.getString(R.string.search_icon_description), + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = + Modifier + .fillMaxWidth() + .padding(8.dp) + .background( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(40.dp), + ) + .semantics { + contentDescription = context.getString(R.string.search_bar_description) + }, + colors = + TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.primaryContainer, + unfocusedContainerColor = MaterialTheme.colorScheme.primaryContainer, + focusedTextColor = MaterialTheme.colorScheme.onSurface, + unfocusedTextColor = MaterialTheme.colorScheme.onSurface, + cursorColor = MaterialTheme.colorScheme.primary, + focusedIndicatorColor = MaterialTheme.colorScheme.primaryContainer, + unfocusedIndicatorColor = MaterialTheme.colorScheme.primaryContainer, + ), + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Search, + ), + keyboardActions = keyboardActions, + maxLines = 1, + singleLine = true, + ) +} + +@Composable +fun ErrorState( + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Filled.Error, + contentDescription = stringResource(R.string.error_icon_description), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(48.dp), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.error_data_fetch_failed), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = onRetry, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text(stringResource(R.string.retry)) + } + } +} + +@Composable +fun EmptyState(modifier: Modifier = Modifier) { + Column( + modifier = + modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = stringResource(R.string.empty_state_icon_description), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(48.dp), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.empty_state_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.empty_state_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} + +@Preview(showBackground = true) +@Composable +fun EmptyStatePreview() { + CodeCheckAppTheme { + EmptyState() + } +} + +@Preview(showBackground = true) +@Composable +fun ErrorStatePreview() { + CodeCheckAppTheme { + ErrorState(onRetry = {}) + } +} + +@Composable +@Preview(showBackground = true) +fun CustomSearchBarPreview() { + CodeCheckAppTheme { + CustomSearchBar(inputText = "Example") {} + } +} + +@Composable +@Preview(showBackground = true) +fun RepositoryListViewPreview() { + CodeCheckAppTheme { + RepositoryListView( + repositoryList = + listOf( + RepositoryEntity( + name = "Jetpack Compose", + ownerIconUrl = "", + language = "Jetpack Compose", + stargazersCount = 1, + forksCount = 1, + openIssuesCount = 1, + watchersCount = 1, + id = 1, + ), + ), + onTapping = {}, + ) + } +} diff --git a/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/search/RepositorySearchViewModel.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/search/RepositorySearchViewModel.kt index d19b58f..33fef69 100644 --- a/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/search/RepositorySearchViewModel.kt +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/search/RepositorySearchViewModel.kt @@ -7,7 +7,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import jp.co.yumemi.android.code_check.R -import jp.co.yumemi.android.code_check.core.entity.RepositoryItem +import jp.co.yumemi.android.code_check.core.entity.RepositoryEntity import jp.co.yumemi.android.code_check.features.github.reposiotory.NetworkException import jp.co.yumemi.android.code_check.features.github.usecase.GitHubServiceUsecase import jp.co.yumemi.android.code_check.features.github.utils.GitHubError @@ -27,8 +27,8 @@ class RepositorySearchViewModel private val _errorMessage = MutableLiveData() val errorMessage: LiveData get() = _errorMessage - private val _searchResults = MutableLiveData>() - val searchResults: LiveData> get() = _searchResults + private val _searchResults = MutableLiveData>() + val searchResults: LiveData> get() = _searchResults /** * GitHubのレポジトリ検索を行う diff --git a/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/theme/Color.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/theme/Color.kt new file mode 100644 index 0000000..889d0f2 --- /dev/null +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/theme/Color.kt @@ -0,0 +1,11 @@ +package jp.co.yumemi.android.code_check.core.presenter.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) diff --git a/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/theme/Theme.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/theme/Theme.kt new file mode 100644 index 0000000..36a45d3 --- /dev/null +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/theme/Theme.kt @@ -0,0 +1,59 @@ +package jp.co.yumemi.android.code_check.core.presenter.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = + darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80, + background = Color(0xFF121212), + surface = Color(0xFF1E1E1E), + onPrimary = Color.Black, + onSecondary = Color.Black, + ) + +private val LightColorScheme = + lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40, + background = Color(0xFFFFFFFF), + surface = Color(0xFFF5F5F5), + onPrimary = Color.White, + onSecondary = Color.White, + ) + +@Composable +fun CodeCheckAppTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit, +) { + val colorScheme = + when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content, + ) +} diff --git a/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/theme/Type.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/theme/Type.kt new file mode 100644 index 0000000..e35dc03 --- /dev/null +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/theme/Type.kt @@ -0,0 +1,52 @@ +package jp.co.yumemi.android.code_check.core.presenter.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = + Typography( + bodyLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), + titleLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp, + ), + titleMedium = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 18.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp, + ), + bodyMedium = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp, + ), + labelSmall = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), + ) diff --git a/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/widget/EmptyCompose.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/widget/EmptyCompose.kt new file mode 100644 index 0000000..7546c96 --- /dev/null +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/widget/EmptyCompose.kt @@ -0,0 +1,7 @@ +package jp.co.yumemi.android.code_check.core.presenter.widget + +import androidx.compose.runtime.Composable + +@Composable +fun EmptyCompose() { +} diff --git a/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/widget/ProgressCycle.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/widget/ProgressCycle.kt new file mode 100644 index 0000000..d0a5bd1 --- /dev/null +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/widget/ProgressCycle.kt @@ -0,0 +1,49 @@ +package jp.co.yumemi.android.code_check.core.presenter.widget + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.isTraversalGroup +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import jp.co.yumemi.android.code_check.R + +@Composable +fun ProgressCycle( + message: String = stringResource(R.string.searching), + contentDescription: String = stringResource(R.string.loading_content_description), +) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(8.dp) + .semantics { + isTraversalGroup = true + this.contentDescription = contentDescription + }, + ) { + CircularProgressIndicator( + modifier = + Modifier.semantics { + this.contentDescription = contentDescription + }, + ) + Text( + text = message, + modifier = Modifier.padding(top = 8.dp), + ) + } +} diff --git a/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/api/GitHubServiceApi.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/api/GitHubServiceApi.kt index 8a51f27..721b856 100644 --- a/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/api/GitHubServiceApi.kt +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/api/GitHubServiceApi.kt @@ -1,7 +1,10 @@ package jp.co.yumemi.android.code_check.features.github.api +import jp.co.yumemi.android.code_check.features.github.entity.RepositoryItem import jp.co.yumemi.android.code_check.features.github.entity.RepositoryList interface GitHubServiceApi { - suspend fun getRepository(searchWord: String): RepositoryList + suspend fun getRepositoryList(searchWord: String): RepositoryList + + suspend fun getRepositoryDetail(id: Int): RepositoryItem } diff --git a/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/api/GitHubServiceApiBuilderInterface.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/api/GitHubServiceApiBuilderInterface.kt index fcc38eb..fd0ddba 100644 --- a/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/api/GitHubServiceApiBuilderInterface.kt +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/api/GitHubServiceApiBuilderInterface.kt @@ -1,13 +1,20 @@ package jp.co.yumemi.android.code_check.features.github.api +import jp.co.yumemi.android.code_check.features.github.entity.RepositoryItem import jp.co.yumemi.android.code_check.features.github.entity.RepositoryList import retrofit2.Response import retrofit2.http.GET +import retrofit2.http.Path import retrofit2.http.Query interface GitHubServiceApiBuilderInterface { @GET("/search/repositories") - suspend fun getRepository( + suspend fun getRepositoryList( @Query("q") searchWord: String, ): Response + + @GET("/repositories/{id}") + suspend fun getRepositoryDetail( + @Path("id") id: Int, + ): Response } diff --git a/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/api/GitHubServiceApiImpl.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/api/GitHubServiceApiImpl.kt index e1997e0..edff3be 100644 --- a/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/api/GitHubServiceApiImpl.kt +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/api/GitHubServiceApiImpl.kt @@ -3,6 +3,7 @@ package jp.co.yumemi.android.code_check.features.github.api import com.squareup.moshi.Moshi import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import jp.co.yumemi.android.code_check.BuildConfig +import jp.co.yumemi.android.code_check.features.github.entity.RepositoryItem import jp.co.yumemi.android.code_check.features.github.entity.RepositoryList import jp.co.yumemi.android.code_check.features.github.reposiotory.NetworkException import okhttp3.OkHttpClient @@ -40,8 +41,22 @@ class GitHubServiceApiImpl : GitHubServiceApi { .create(GitHubServiceApiBuilderInterface::class.java) } - override suspend fun getRepository(searchWord: String): RepositoryList { - val response = githubService.getRepository(searchWord) + override suspend fun getRepositoryList(searchWord: String): RepositoryList { + val response = githubService.getRepositoryList(searchWord) + + if (!response.isSuccessful) { + throw when (response.code()) { + 404 -> NetworkException("リポジトリが見つかりませんでした") + 403 -> NetworkException("APIレート制限に達しました") + 500 -> NetworkException("サーバーエラーが発生しました") + else -> NetworkException("エラーが発生しました: ${response.code()}") + } + } + return response.body() ?: throw NetworkException("レスポンスが空でした") + } + + override suspend fun getRepositoryDetail(id: Int): RepositoryItem { + val response = githubService.getRepositoryDetail(id) if (!response.isSuccessful) { throw when (response.code()) { diff --git a/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/entity/GitHubServiceEntity.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/entity/GitHubServiceEntity.kt index 09453d6..d8579f7 100644 --- a/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/entity/GitHubServiceEntity.kt +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/entity/GitHubServiceEntity.kt @@ -7,6 +7,7 @@ data class RepositoryList( ) data class RepositoryItem( + val id: Int, val name: String, val owner: RepositoryOwner, val language: String?, diff --git a/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/reposiotory/GitHubServiceRepository.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/reposiotory/GitHubServiceRepository.kt index 0ccedef..9641df3 100644 --- a/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/reposiotory/GitHubServiceRepository.kt +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/reposiotory/GitHubServiceRepository.kt @@ -1,8 +1,10 @@ package jp.co.yumemi.android.code_check.features.github.reposiotory -import jp.co.yumemi.android.code_check.core.entity.RepositoryItem +import jp.co.yumemi.android.code_check.core.entity.RepositoryEntity import jp.co.yumemi.android.code_check.features.github.utils.NetworkResult interface GitHubServiceRepository { - suspend fun fetchSearchResults(inputText: String): NetworkResult> + suspend fun fetchSearchResults(inputText: String): NetworkResult> + + suspend fun fetchRepositoryDetail(id: Int): NetworkResult } diff --git a/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/reposiotory/GitHubServiceRepositoryImpl.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/reposiotory/GitHubServiceRepositoryImpl.kt index ecec1d2..db846f4 100644 --- a/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/reposiotory/GitHubServiceRepositoryImpl.kt +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/reposiotory/GitHubServiceRepositoryImpl.kt @@ -1,6 +1,6 @@ package jp.co.yumemi.android.code_check.features.github.reposiotory -import jp.co.yumemi.android.code_check.core.entity.RepositoryItem +import jp.co.yumemi.android.code_check.core.entity.RepositoryEntity import jp.co.yumemi.android.code_check.features.github.api.GitHubServiceApi import jp.co.yumemi.android.code_check.features.github.utils.GitHubError import jp.co.yumemi.android.code_check.features.github.utils.NetworkResult @@ -14,12 +14,12 @@ class GitHubServiceRepositoryImpl constructor( private val gitHubRepositoryApi: GitHubServiceApi, ) : GitHubServiceRepository { - override suspend fun fetchSearchResults(inputText: String): NetworkResult> { + override suspend fun fetchSearchResults(inputText: String): NetworkResult> { return try { - val repositoryList = gitHubRepositoryApi.getRepository(inputText) + val repositoryList = gitHubRepositoryApi.getRepositoryList(inputText) val items = repositoryList.items.map { item -> - RepositoryItem( + RepositoryEntity( name = item.name, ownerIconUrl = item.owner.avatarUrl, language = item.language ?: "none", @@ -27,6 +27,7 @@ class GitHubServiceRepositoryImpl watchersCount = item.watchersCount, forksCount = item.forksCount, openIssuesCount = item.openIssuesCount, + id = item.id, ) } NetworkResult.Success(items) @@ -44,6 +45,36 @@ class GitHubServiceRepositoryImpl NetworkResult.Error(GitHubError.NetworkError(e)) } } + + override suspend fun fetchRepositoryDetail(id: Int): NetworkResult { + return try { + val repositoryDetail = gitHubRepositoryApi.getRepositoryDetail(id) + val repositoryEntity = + RepositoryEntity( + name = repositoryDetail.name, + ownerIconUrl = repositoryDetail.owner.avatarUrl, + language = repositoryDetail.language ?: "none", + stargazersCount = repositoryDetail.stargazersCount, + watchersCount = repositoryDetail.watchersCount, + forksCount = repositoryDetail.forksCount, + openIssuesCount = repositoryDetail.openIssuesCount, + id = repositoryDetail.id, + ) + NetworkResult.Success(repositoryEntity) + } catch (e: HttpException) { + val error = + when (e.code()) { + 429 -> GitHubError.RateLimitError + 401 -> GitHubError.AuthenticationError + else -> GitHubError.ApiError(e.code(), e.message()) + } + NetworkResult.Error(error) + } catch (e: JSONException) { + NetworkResult.Error(GitHubError.ParseError(e)) + } catch (e: IOException) { + NetworkResult.Error(GitHubError.NetworkError(e)) + } + } } class NetworkException(message: String, cause: Throwable? = null) : Exception(message, cause) diff --git a/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/usecase/GitHubServiceUsecase.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/usecase/GitHubServiceUsecase.kt index 3583218..ad87471 100644 --- a/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/usecase/GitHubServiceUsecase.kt +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/usecase/GitHubServiceUsecase.kt @@ -1,8 +1,10 @@ package jp.co.yumemi.android.code_check.features.github.usecase -import jp.co.yumemi.android.code_check.core.entity.RepositoryItem +import jp.co.yumemi.android.code_check.core.entity.RepositoryEntity import jp.co.yumemi.android.code_check.features.github.utils.NetworkResult interface GitHubServiceUsecase { - suspend fun fetchSearchResults(inputText: String): NetworkResult> + suspend fun fetchSearchResults(inputText: String): NetworkResult> + + suspend fun fetchRepositoryDetail(id: Int): NetworkResult } diff --git a/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/usecase/GitHubServiceUsecaseImpl.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/usecase/GitHubServiceUsecaseImpl.kt index a2e4ba6..1e388cf 100644 --- a/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/usecase/GitHubServiceUsecaseImpl.kt +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/usecase/GitHubServiceUsecaseImpl.kt @@ -1,7 +1,7 @@ package jp.co.yumemi.android.code_check.features.github.usecase import jp.co.yumemi.android.code_check.core.api.NetworkConnectivityService -import jp.co.yumemi.android.code_check.core.entity.RepositoryItem +import jp.co.yumemi.android.code_check.core.entity.RepositoryEntity import jp.co.yumemi.android.code_check.features.github.reposiotory.GitHubServiceRepository import jp.co.yumemi.android.code_check.features.github.reposiotory.NetworkException import jp.co.yumemi.android.code_check.features.github.utils.NetworkResult @@ -13,10 +13,17 @@ class GitHubServiceUsecaseImpl private val repository: GitHubServiceRepository, private val networkConnectivityService: NetworkConnectivityService, ) : GitHubServiceUsecase { - override suspend fun fetchSearchResults(inputText: String): NetworkResult> { + override suspend fun fetchSearchResults(inputText: String): NetworkResult> { if (!networkConnectivityService.isNetworkAvailable()) { throw NetworkException("オフライン状態です") } return repository.fetchSearchResults(inputText) } + + override suspend fun fetchRepositoryDetail(id: Int): NetworkResult { + if (!networkConnectivityService.isNetworkAvailable()) { + throw NetworkException("オフライン状態です") + } + return repository.fetchRepositoryDetail(id) + } } diff --git a/app/src/main/res/layout/activity_top.xml b/app/src/main/res/layout/activity_top.xml deleted file mode 100644 index bba2b95..0000000 --- a/app/src/main/res/layout/activity_top.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_repository_detail.xml b/app/src/main/res/layout/fragment_repository_detail.xml deleted file mode 100644 index 8e1b54f..0000000 --- a/app/src/main/res/layout/fragment_repository_detail.xml +++ /dev/null @@ -1,108 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_repository_search.xml b/app/src/main/res/layout/fragment_repository_search.xml deleted file mode 100644 index 4e4c6ef..0000000 --- a/app/src/main/res/layout/fragment_repository_search.xml +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/layout_item.xml b/app/src/main/res/layout/layout_item.xml deleted file mode 100644 index 5ade5cc..0000000 --- a/app/src/main/res/layout/layout_item.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml deleted file mode 100644 index ea34d7b..0000000 --- a/app/src/main/res/navigation/nav_graph.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a665d57..49b65d0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,7 +1,6 @@ Android Engineer CodeCheck GitHub のリポジトリを検索できるよー - Written in %s "%1$d stars" "%1$d watchers" "%1$d forks" @@ -12,4 +11,19 @@ セッションの時間切れ APIのエラー フォームが空欄になっています + 正しいIDが返されませんでした + 詳細 + 検索 + 検索中 + データを読み込んでいます + 検索アイコン + 検索バー + ユーザーアイコン + データの取得に失敗しました + Language: %s + 再度行う + エラーアイコン + 検索アイコン + リポジトリが見つかりません + 検索ワードを入力してリポジトリを探してください \ No newline at end of file diff --git a/app/src/test/kotlin/jp/co/yumemi/android/code_check/ExampleUnitTest.kt b/app/src/test/kotlin/jp/co/yumemi/android/code_check/ExampleUnitTest.kt deleted file mode 100644 index b435cbc..0000000 --- a/app/src/test/kotlin/jp/co/yumemi/android/code_check/ExampleUnitTest.kt +++ /dev/null @@ -1,16 +0,0 @@ -package jp.co.yumemi.android.code_check - -import org.junit.Assert.assertEquals -import org.junit.Test - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} diff --git a/app/src/test/kotlin/jp/co/yumemi/android/code_check/features/github/GitHubMockData.kt b/app/src/test/kotlin/jp/co/yumemi/android/code_check/features/github/GitHubMockData.kt new file mode 100644 index 0000000..26e9a85 --- /dev/null +++ b/app/src/test/kotlin/jp/co/yumemi/android/code_check/features/github/GitHubMockData.kt @@ -0,0 +1,133 @@ +package jp.co.yumemi.android.code_check.features.github + +import jp.co.yumemi.android.code_check.core.api.NetworkConnectivityService +import jp.co.yumemi.android.code_check.core.entity.RepositoryEntity +import jp.co.yumemi.android.code_check.features.github.entity.RepositoryItem +import jp.co.yumemi.android.code_check.features.github.entity.RepositoryList +import jp.co.yumemi.android.code_check.features.github.entity.RepositoryOwner +import jp.co.yumemi.android.code_check.features.github.utils.GitHubError +import jp.co.yumemi.android.code_check.features.github.utils.NetworkResult +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import java.io.IOException + +object GitHubMockData { + // 正常なリポジトリ検索結果 + fun getMockRepositoryList(): RepositoryList { + return RepositoryList( + items = + listOf( + RepositoryItem( + name = "repo1", + owner = + RepositoryOwner( + avatarUrl = "url1", + ), + language = "Kotlin", + stargazersCount = 100, + forksCount = 50, + openIssuesCount = 30, + watchersCount = 70, + id = 1, + ), + RepositoryItem( + name = "repo2", + owner = + RepositoryOwner( + avatarUrl = "url2", + ), + language = "Java", + stargazersCount = 200, + forksCount = 80, + openIssuesCount = 20, + watchersCount = 60, + id = 2, + ), + ), + ) + } + + fun getMockRepositoryEntityList(): List { + return listOf( + RepositoryEntity( + name = "repo1", + ownerIconUrl = "url1", + language = "Kotlin", + stargazersCount = 100, + forksCount = 50, + openIssuesCount = 30, + watchersCount = 70, + id = 1, + ), + RepositoryEntity( + name = "repo2", + ownerIconUrl = "url2", + language = "Java", + stargazersCount = 200, + forksCount = 80, + openIssuesCount = 20, + watchersCount = 60, + id = 2, + ), + ) + } + + // 正常なリポジトリ詳細 + fun getMockRepositoryItem(): RepositoryItem { + return RepositoryItem( + name = "repo1", + language = "Kotlin", + stargazersCount = 100, + forksCount = 50, + openIssuesCount = 30, + watchersCount = 70, + id = 1, + owner = + RepositoryOwner( + avatarUrl = "url1", + ), + ) + } + + fun getMockRepositoryEntity(): RepositoryEntity { + return RepositoryEntity( + name = "repo1", + ownerIconUrl = "url1", + language = "Kotlin", + stargazersCount = 100, + forksCount = 50, + openIssuesCount = 30, + watchersCount = 70, + id = 1, + ) + } + + // ネットワークエラー + fun getMockNetworkError(): NetworkResult.Error { + return NetworkResult.Error(GitHubError.NetworkError(IOException("Network issue"))) + } + + // APIエラー (例えば404) + fun getMockApiError404(): NetworkResult.Error { + return NetworkResult.Error(GitHubError.ApiError(404, "Not Found")) + } + + // APIエラー (例えば500) + fun getMockApiError500(): NetworkResult.Error { + return NetworkResult.Error(GitHubError.ApiError(500, "Internal Server Error")) + } + + // オフライン時のネットワーク接続サービス + fun getMockOfflineNetworkService(): NetworkConnectivityService { + val networkService = mock(NetworkConnectivityService::class.java) + `when`(networkService.isNetworkAvailable()).thenReturn(false) + return networkService + } + + // オンライン時のネットワーク接続サービス + fun getMockOnlineNetworkService(): NetworkConnectivityService { + val networkService = mock(NetworkConnectivityService::class.java) + `when`(networkService.isNetworkAvailable()).thenReturn(true) + return networkService + } +} diff --git a/app/src/test/kotlin/jp/co/yumemi/android/code_check/features/github/GitHubServiceRepositoryImplTest.kt b/app/src/test/kotlin/jp/co/yumemi/android/code_check/features/github/GitHubServiceRepositoryImplTest.kt index d9eb4b7..ce9ec3a 100644 --- a/app/src/test/kotlin/jp/co/yumemi/android/code_check/features/github/GitHubServiceRepositoryImplTest.kt +++ b/app/src/test/kotlin/jp/co/yumemi/android/code_check/features/github/GitHubServiceRepositoryImplTest.kt @@ -1,9 +1,6 @@ package jp.co.yumemi.android.code_check.features.github import jp.co.yumemi.android.code_check.features.github.api.GitHubServiceApi -import jp.co.yumemi.android.code_check.features.github.entity.RepositoryItem -import jp.co.yumemi.android.code_check.features.github.entity.RepositoryList -import jp.co.yumemi.android.code_check.features.github.entity.RepositoryOwner import jp.co.yumemi.android.code_check.features.github.reposiotory.GitHubServiceRepositoryImpl import jp.co.yumemi.android.code_check.features.github.utils.GitHubError import jp.co.yumemi.android.code_check.features.github.utils.NetworkResult @@ -31,8 +28,8 @@ class GitHubServiceRepositoryImplTest { fun `fetchSearchResults returns success with valid data`() = runBlocking { // Arrange - val mockResponse = mockRepositoryList() - `when`(api.getRepository("test")).thenReturn(mockResponse) + val mockResponse = GitHubMockData.getMockRepositoryList() + `when`(api.getRepositoryList("test")).thenReturn(mockResponse) // Act val result = repository.fetchSearchResults("test") @@ -42,7 +39,7 @@ class GitHubServiceRepositoryImplTest { val success = result as NetworkResult.Success assertEquals(2, success.data.size) assertEquals("repo1", success.data[0].name) - assertEquals("owner1", success.data[0].ownerIconUrl) + assertEquals("url1", success.data[0].ownerIconUrl) // 修正: ownerIconUrl -> avatarUrl } @Test @@ -51,7 +48,7 @@ class GitHubServiceRepositoryImplTest { // Arrange val httpException = mock(HttpException::class.java) `when`(httpException.code()).thenReturn(401) - `when`(api.getRepository("test")).thenThrow(httpException) + `when`(api.getRepositoryList("test")).thenThrow(httpException) // Act val result = repository.fetchSearchResults("test") @@ -66,7 +63,7 @@ class GitHubServiceRepositoryImplTest { fun `fetchSearchResults returns error on IOException`() = runBlocking { // Arrange - `when`(api.getRepository("test")).thenAnswer { + `when`(api.getRepositoryList("test")).thenAnswer { throw IOException("Network error") } @@ -83,7 +80,7 @@ class GitHubServiceRepositoryImplTest { fun `fetchSearchResults returns error on JSONException`() = runBlocking { // Arrange - `when`(api.getRepository("test")).thenAnswer { + `when`(api.getRepositoryList("test")).thenAnswer { throw JSONException("Parsing error") } @@ -96,30 +93,54 @@ class GitHubServiceRepositoryImplTest { assert(error.exception is GitHubError.ParseError) } - // Mockデータ生成関数 - private fun mockRepositoryList(): RepositoryList { - return RepositoryList( - items = - listOf( - RepositoryItem( - name = "repo1", - owner = RepositoryOwner("owner1"), - language = "Kotlin", - stargazersCount = 100, - watchersCount = 50, - forksCount = 20, - openIssuesCount = 5, - ), - RepositoryItem( - name = "repo2", - owner = RepositoryOwner("owner2"), - language = "Java", - stargazersCount = 200, - watchersCount = 80, - forksCount = 30, - openIssuesCount = 10, - ), - ), - ) - } + @Test + fun `fetchRepositoryDetail returns success with valid data`() = + runBlocking { + // Arrange + val mockDetail = GitHubMockData.getMockRepositoryItem() + `when`(api.getRepositoryDetail(1)).thenReturn(mockDetail) + + // Act + val result = repository.fetchRepositoryDetail(1) + + // Assert + assert(result is NetworkResult.Success) + val success = result as NetworkResult.Success + assertEquals("repo1", success.data.name) + assertEquals("url1", success.data.ownerIconUrl) + } + + @Test + fun `fetchRepositoryDetail returns error on HttpException`() = + runBlocking { + // Arrange + val httpException = mock(HttpException::class.java) + `when`(httpException.code()).thenReturn(429) + `when`(api.getRepositoryDetail(1)).thenThrow(httpException) + + // Act + val result = repository.fetchRepositoryDetail(1) + + // Assert + assert(result is NetworkResult.Error) + val error = result as NetworkResult.Error + assert(error.exception is GitHubError.RateLimitError) + } + + @Test + fun `fetchRepositoryDetail returns error on IOException`() = + runBlocking { + // Arrange + `when`(api.getRepositoryDetail(1)).thenAnswer { + throw IOException("Network error") + } + + // Act + val result = repository.fetchRepositoryDetail(1) + + // Assert + assert(result is NetworkResult.Error) + val error = result as NetworkResult.Error + assert(error.exception is GitHubError.NetworkError) + } } diff --git a/app/src/test/kotlin/jp/co/yumemi/android/code_check/features/github/GitHubServiceUsecaseImplTest.kt b/app/src/test/kotlin/jp/co/yumemi/android/code_check/features/github/GitHubServiceUsecaseImplTest.kt index f473f52..4a9ae28 100644 --- a/app/src/test/kotlin/jp/co/yumemi/android/code_check/features/github/GitHubServiceUsecaseImplTest.kt +++ b/app/src/test/kotlin/jp/co/yumemi/android/code_check/features/github/GitHubServiceUsecaseImplTest.kt @@ -1,19 +1,19 @@ package jp.co.yumemi.android.code_check.features.github import jp.co.yumemi.android.code_check.core.api.NetworkConnectivityService -import jp.co.yumemi.android.code_check.core.entity.RepositoryItem import jp.co.yumemi.android.code_check.features.github.reposiotory.GitHubServiceRepository import jp.co.yumemi.android.code_check.features.github.reposiotory.NetworkException import jp.co.yumemi.android.code_check.features.github.usecase.GitHubServiceUsecaseImpl +import jp.co.yumemi.android.code_check.features.github.utils.GitHubError import jp.co.yumemi.android.code_check.features.github.utils.NetworkResult +import junit.framework.Assert.assertEquals import kotlinx.coroutines.runBlocking -import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.mockito.Mockito.mock import org.mockito.Mockito.`when` -import org.mockito.MockitoAnnotations class GitHubServiceUsecaseImplTest { private lateinit var repository: GitHubServiceRepository @@ -22,7 +22,6 @@ class GitHubServiceUsecaseImplTest { @Before fun setUp() { - MockitoAnnotations.openMocks(this) repository = mock(GitHubServiceRepository::class.java) networkConnectivityService = mock(NetworkConnectivityService::class.java) usecase = GitHubServiceUsecaseImpl(repository, networkConnectivityService) @@ -43,34 +42,92 @@ class GitHubServiceUsecaseImplTest { } @Test - fun `fetchSearchResults returns results when online`() = + fun `fetchSearchResults returns success when online`() = runBlocking { - val mockResults = - listOf( - RepositoryItem( - name = "repo1", - ownerIconUrl = "description1", - language = "url1", - stargazersCount = 100, - forksCount = 50, - openIssuesCount = 30, - watchersCount = 70, - ), - RepositoryItem( - name = "repo2", - ownerIconUrl = "description2", - language = "url2", - stargazersCount = 100, - forksCount = 50, - openIssuesCount = 30, - watchersCount = 70, - ), - ) + val mockResults = GitHubMockData.getMockRepositoryEntityList() + `when`(networkConnectivityService.isNetworkAvailable()).thenReturn(true) `when`(repository.fetchSearchResults("test")).thenReturn(NetworkResult.Success(mockResults)) val result = usecase.fetchSearchResults("test") - assertEquals(NetworkResult.Success(mockResults), result) + assertTrue(result is NetworkResult.Success) + val success = result as NetworkResult.Success + assertEquals(2, success.data.size) + assertEquals("repo1", success.data[0].name) + assertEquals("repo2", success.data[1].name) + } + + @Test + fun `fetchSearchResults returns error on API failure`() = + runBlocking { + val mockError = GitHubMockData.getMockApiError404() + `when`(networkConnectivityService.isNetworkAvailable()).thenReturn(true) + `when`(repository.fetchSearchResults("test")).thenReturn(mockError) + + val result = usecase.fetchSearchResults("test") + + assertTrue(result is NetworkResult.Error) + val error = result as NetworkResult.Error + assertTrue(error.exception is GitHubError.ApiError) + assertEquals(404, (error.exception as GitHubError.ApiError).code) + } + + @Test + fun `fetchRepositoryDetail throws NetworkException when offline`() { + `when`(networkConnectivityService.isNetworkAvailable()).thenReturn(false) + + val exception = + assertThrows(NetworkException::class.java) { + runBlocking { + usecase.fetchRepositoryDetail(1) + } + } + + assertEquals("オフライン状態です", exception.message) + } + + @Test + fun `fetchRepositoryDetail returns success when online`() = + runBlocking { + val mockDetail = GitHubMockData.getMockRepositoryEntity() + + `when`(networkConnectivityService.isNetworkAvailable()).thenReturn(true) + `when`(repository.fetchRepositoryDetail(1)).thenReturn(NetworkResult.Success(mockDetail)) + + val result = usecase.fetchRepositoryDetail(1) + + assertTrue(result is NetworkResult.Success) + val success = result as NetworkResult.Success + assertEquals("repo1", success.data.name) + } + + @Test + fun `fetchRepositoryDetail returns error on API failure`() = + runBlocking { + val mockError = GitHubMockData.getMockApiError500() + `when`(networkConnectivityService.isNetworkAvailable()).thenReturn(true) + `when`(repository.fetchRepositoryDetail(1)).thenReturn(mockError) + + val result = usecase.fetchRepositoryDetail(1) + + assertTrue(result is NetworkResult.Error) + val error = result as NetworkResult.Error + assertTrue(error.exception is GitHubError.ApiError) + assertEquals(500, (error.exception as GitHubError.ApiError).code) + } + + @Test + fun `fetchRepositoryDetail handles network error`() = + runBlocking { + val mockError = GitHubMockData.getMockNetworkError() + `when`(networkConnectivityService.isNetworkAvailable()).thenReturn(true) + `when`(repository.fetchRepositoryDetail(1)).thenReturn(mockError) + + val result = usecase.fetchRepositoryDetail(1) + + assertTrue(result is NetworkResult.Error) + val error = result as NetworkResult.Error + assertTrue(error.exception is GitHubError.NetworkError) } }