diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8dc7d85 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,4 @@ +[*.{kt,kts}] +ktlint_standard_package-name = disabled +ktlint_function_naming_ignore_when_annotated_with = Composable + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..cce09f1 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,27 @@ +## 概要 + +このセクションでは、このPRの目的と概要を簡潔に説明してください。 + +## 関連Issue + +このセクションでは、このPRが関連するIssueやタスクをリンクしてください。以下のように記述します。 + +- 関連Issue: #123 + +## 変更点 + +このセクションでは、具体的な変更点や修正箇所を箇条書きでリストアップしてください。 + +- 変更点1 +- 変更点2 +- 変更点3 + +## テスト + +このセクションでは、このPRに関連するテストケースやテスト方法を記載してください。 + +- テストケース1 +- テストケース2 +- テストケース3 + + diff --git a/.github/workflows/check_workflow.yaml b/.github/workflows/check_workflow.yaml new file mode 100644 index 0000000..e6c72b0 --- /dev/null +++ b/.github/workflows/check_workflow.yaml @@ -0,0 +1,43 @@ +name: Run Gradle on PRs +on: pull_request +jobs: + ktlint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Gradle + uses: gradle/gradle-build-action@v2 + + - name: Change Permission + run: chmod +x ./gradlew + + - name: Execute Ktlint Check + run: ./gradlew ktlintCheck + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Gradle + uses: gradle/gradle-build-action@v2 + + - name: Change Permission + run: chmod +x ./gradlew + + - name: Execute Unit Tests + run: ./gradlew test jacocoTestReport + + - name: Upload Test Results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results + path: app/build/test-results + + - name: Upload Coverage Report + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: app/build/reports/tests/testDebugUnitTest/ diff --git a/README.md b/README.md index f8f9577..25a777f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ## 概要 -本プロジェクトは株式会社ゆめみ(以下弊社)が、弊社に Android エンジニアを希望する方に出す課題のベースプロジェクトです。本課題が与えられた方は、下記の概要を詳しく読んだ上で課題を取り組んでください。 +このプロジェクトは、GitHubリポジトリの検索と表示を行うAndroidアプリケーションです。Jetpack Composeを使用してUIを構築し、Hiltを使用して依存性注入を行っています。 ## アプリ仕様 @@ -10,50 +10,123 @@ +## ディレクトリ構成 +```bash +. +├── .editorconfig +├── .github/ +│ ├── PULL_REQUEST_TEMPLATE.md +│ └── workflows/ +├── .gitignore +├── .gradle/ +├── .idea/ +├── .kotlin/ +├── app/ +│ ├── src/ +│ │ ├── main/ +│ │ │ ├── java/ +│ │ │ ├── kotlin/ +│ │ │ └── res/ +│ │ └── test/ +│ └── build.gradle +├── build.gradle +├── docs/ +├── gradle/ +├── gradle.properties +├── gradlew +├── gradlew.bat +├── LICENSE +├── local.properties +├── README.md +└── settings.gradle +``` + ### 環境 -- IDE:Android Studio Flamingo | 2022.2.1 Patch 2 -- Kotlin:1.6.21 +- IDE:Android Studio Ladybug | 2024.2.1 Patch 3 +- Kotlin: 2.0.21 - Java:17 -- Gradle:8.0 +- Gradle:8.9 - minSdk:23 -- targetSdk:31 +- targetSdk:35 ※ ライブラリの利用はオープンソースのものに限ります。 ※ 環境は適宜更新してください。 +### linterについて + +このプロジェクトはktlintを用いて静的コード解析を行なっている。 + +ルールとして、パッケージ名に"_"を使うのは許可するものとしている。 +理由としては、コーディングテストのパッケージ名が変わってしまうとアプリとして別物となってしまい、 +リファクタリングの趣旨としてそぐわないと判断したため + +コードは以下の二つがある適宜PRを出す前にチェックをすること。 +```bash +# 自動でフォーマットをかける + ./gradlew ktlintFormat + +# コードのルール違反をチェックする +./gradlew ktlintCheck +``` + +### Unitテストについて + +- Hilt, JUnit, Mockitoを持ちいてUnitテストを作成しました。 + +```bash +# 全件実行をする方法 +./gradlew test jacocoTestReport + +# 単体で動かす方法 +./gradlew :app:testDebugUnitTest --tests "jp.co.yumemi.android.code_check.features.github.GitHubServiceRepositoryImplTest" +``` + +レポートの保存場所 +以下のパスにWeb表示ができるレポートが格納されます。 +`app/build/reports/tests/testDebugUnitTest/` + ### 動作 1. 何かしらのキーワードを入力 2. GitHub API(`search/repositories`)でリポジトリを検索し、結果一覧を概要(リポジトリ名)で表示 3. 特定の結果を選択したら、該当リポジトリの詳細(リポジトリ名、オーナーアイコン、プロジェクト言語、Star 数、Watcher 数、Fork 数、Issue 数)を表示 -## 課題取り組み方法 - -Issues を確認した上、本プロジェクトを [**Duplicate** してください](https://help.github.com/en/github/creating-cloning-and-archiving-repositories/duplicating-a-repository)(Fork しないようにしてください。必要ならプライベートリポジトリにしても大丈夫です)。今後のコミットは全てご自身のリポジトリで行ってください。 -コードチェックの課題 Issue は全て [`課題`](https://github.com/yumemi-inc/android-engineer-codecheck/milestone/1) Milestone がついており、難易度に応じて Label が [`初級`](https://github.com/yumemi-inc/android-engineer-codecheck/issues?q=is%3Aopen+is%3Aissue+label%3A初級+milestone%3A課題)、[`中級`](https://github.com/yumemi-inc/android-engineer-codecheck/issues?q=is%3Aopen+is%3Aissue+label%3A中級+milestone%3A課題+) と [`ボーナス`](https://github.com/yumemi-inc/android-engineer-codecheck/issues?q=is%3Aopen+is%3Aissue+label%3Aボーナス+milestone%3A課題+) に分けられています。課題の必須/選択は下記の表とします。 +### 依存関係 -| | 初級 | 中級 | ボーナス -|--:|:--:|:--:|:--:| -| 新卒/未経験者 | 必須 | 選択 | 選択 | -| 中途/経験者 | 必須 | 必須 | 選択 | +主要な依存関係は以下の通りです。 -課題 Issueをご自身のリポジトリーにコピーするGitHub Actionsをご用意しております。 -[こちらのWorkflow](./.github/workflows/copy-issues.yml)を[手動でトリガーする](https://docs.github.com/ja/actions/managing-workflow-runs/manually-running-a-workflow)ことでコピーできますのでご活用下さい。 +- Jetpack Compose +- Hilt +- Retrofit +- Moshi +- Coil -課題が完成したら、リポジトリのアドレスを教えてください。 +依存関係の詳細は、`app/build.gradle` ファイルを参照してください。 -## 参考記事 +### 重要なファイルとディレクトリ -提出された課題の評価ポイントに関しては、[こちらの記事](https://qiita.com/blendthink/items/aa70b8b3106fb4e3555f)に詳しく書かれてありますので、ぜひご覧ください。 +app/src/main/java - Javaソースコード +kotlin - Kotlinソースコード +res - リソースファイル(レイアウト、文字列、画像など) +test - ユニットテスト +androidTest - インストルメンテーションテスト +build.gradle - モジュールのビルド設定 +gradle.properties - プロジェクト全体のプロパティ設定 -## AIサービスの利用について +### トラブルシューティング -ChatGPTなどAIサービスの利用は禁止しておりません。 +- ビルドエラーが発生する場合: + - 依存関係が正しくインストールされているか確認してください。 + - キャッシュをクリアして再ビルドを試みてください。 -利用にあたって工夫したプロンプトやソースコメント等をご提出頂くことで、加点評価する場合もございます。 (減点評価はありません) +```bash +./gradlew clean +./gradlew build +``` -また、弊社コードチェック担当者もAIサービスを利用させていただく場合があります。 +- テストが失敗する場合: + - テストコードが最新の実装に対応しているか確認してください。 + - 必要に応じてモックデータを更新してください。 -AIサービスの利用は差し控えてもらいたいなどのご要望がある場合は、お気軽にお申し出ください。 diff --git a/app/build.gradle b/app/build.gradle index aabd8f0..d1c2efa 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -4,16 +4,20 @@ plugins { id 'kotlin-kapt' id 'kotlin-parcelize' id 'androidx.navigation.safeargs.kotlin' + 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 { namespace 'jp.co.yumemi.android.code_check' - compileSdk 31 + compileSdk 35 defaultConfig { applicationId "jp.co.yumemi.android.codecheck" minSdk 23 - targetSdk 31 + targetSdk 35 versionCode 1 versionName "1.0" @@ -27,38 +31,100 @@ android { } } compileOptions { - sourceCompatibility JavaVersion.VERSION_11 - targetCompatibility JavaVersion.VERSION_11 + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 } kotlinOptions { - jvmTarget = '11' + jvmTarget = '1.8' } buildFeatures { viewBinding true + buildConfig true + compose true + } + composeOptions { + kotlinCompilerExtensionVersion '1.5.0' } } dependencies { - implementation 'androidx.core:core-ktx:1.6.0' - implementation 'androidx.appcompat:appcompat:1.3.1' - implementation 'com.google.android.material:material:1.4.0' - implementation 'androidx.constraintlayout:constraintlayout:2.1.1' - implementation 'androidx.recyclerview:recyclerview:1.2.1' + implementation 'androidx.core:core-ktx:1.15.0' + implementation 'androidx.appcompat:appcompat:1.7.0' + implementation 'com.google.android.material:material:1.12.0' + implementation 'androidx.constraintlayout:constraintlayout:2.2.0' + implementation 'androidx.recyclerview:recyclerview:1.4.0' - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1' - implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.1' - implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7' + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.8.7' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.7' - implementation 'androidx.navigation:navigation-fragment-ktx:2.5.3' - implementation 'androidx.navigation:navigation-ui-ktx:2.5.3' + implementation 'androidx.navigation:navigation-fragment-ktx:2.8.5' + implementation 'androidx.navigation:navigation-ui-ktx:2.8.5' - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1' implementation 'io.ktor:ktor-client-android:1.6.4' - implementation 'io.coil-kt:coil:1.3.2' + implementation 'io.coil-kt:coil:2.7.0' testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.1.3' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' + implementation "com.google.dagger:hilt-android:2.55" + 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 + implementation("com.squareup.retrofit2:converter-moshi:2.9.0") + 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 { + correctErrorTypes true +} + +jacoco { + toolVersion = "0.8.8" +} + +tasks.register("jacocoTestReport", JacocoReport) { + dependsOn(tasks.test) + + reports { + xml.required.set(true) + html.required.set(true) + } + + def fileFilter = ['**/R.class', '**/R$*.class', '**/BuildConfig.*', '**/Manifest*.*', '**/*Test*.*'] + def debugTree = fileTree(dir: "$buildDir/intermediates/javac/debug", excludes: fileFilter) + def mainSrc = "$projectDir/src/main/java" + + sourceDirectories.setFrom(files([ + mainSrc, + "$projectDir/src/main/kotlin" + ])) + executionData.setFrom(fileTree(dir: "$buildDir", includes: [ + "jacoco/testDebugUnitTest.exec", + "outputs/code-coverage/connected/*coverage.ec" + ])) } diff --git a/app/src/androidTest/kotlin/jp/co/yumemi/android/code_check/ExampleInstrumentedTest.kt b/app/src/androidTest/kotlin/jp/co/yumemi/android/code_check/ExampleInstrumentedTest.kt index eb56878..d9f2dd9 100644 --- a/app/src/androidTest/kotlin/jp/co/yumemi/android/code_check/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/kotlin/jp/co/yumemi/android/code_check/ExampleInstrumentedTest.kt @@ -1,13 +1,11 @@ package jp.co.yumemi.android.code_check -import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 - +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith -import org.junit.Assert.* - /** * Instrumented test, which will execute on an Android device. * @@ -21,4 +19,4 @@ class ExampleInstrumentedTest { val appContext = InstrumentationRegistry.getInstrumentation().targetContext assertEquals("jp.co.yumemi.android.codecheck", appContext.packageName) } -} \ No newline at end of file +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 188ab2a..b83f7e1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + + android:fullBackupContent="@xml/backup_descriptor" + android:name=".CodeCheckApplication"> diff --git a/app/src/main/kotlin/jp/co/yumemi/android/code_check/CodeCheckApplication.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/CodeCheckApplication.kt new file mode 100644 index 0000000..39efc83 --- /dev/null +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/CodeCheckApplication.kt @@ -0,0 +1,7 @@ +package jp.co.yumemi.android.code_check + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class CodeCheckApplication : Application() diff --git a/app/src/main/kotlin/jp/co/yumemi/android/code_check/CodeCheckModule.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/CodeCheckModule.kt new file mode 100644 index 0000000..486fcf9 --- /dev/null +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/CodeCheckModule.kt @@ -0,0 +1,38 @@ +package jp.co.yumemi.android.code_check + +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import jp.co.yumemi.android.code_check.features.github.api.GitHubServiceApi +import jp.co.yumemi.android.code_check.features.github.api.GitHubServiceApiImpl +import jp.co.yumemi.android.code_check.features.github.reposiotory.GitHubServiceRepository +import jp.co.yumemi.android.code_check.features.github.reposiotory.GitHubServiceRepositoryImpl +import jp.co.yumemi.android.code_check.features.github.usecase.GitHubServiceUsecase +import jp.co.yumemi.android.code_check.features.github.usecase.GitHubServiceUsecaseImpl +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class GitHubUsecaseModule { + @Singleton + @Binds + abstract fun provideGitHubServiceUsecase(impl: GitHubServiceUsecaseImpl): GitHubServiceUsecase +} + +@Module +@InstallIn(SingletonComponent::class) +abstract class GitHubRepositoryModule { + @Singleton + @Binds + abstract fun provideGitHubServiceRepository(impl: GitHubServiceRepositoryImpl): GitHubServiceRepository + + companion object { + @Provides + @Singleton + fun provideGitHubServiceApi(): GitHubServiceApi { + return GitHubServiceApiImpl() + } + } +} 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/OneFragment.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/OneFragment.kt deleted file mode 100644 index 5f6dc72..0000000 --- a/app/src/main/kotlin/jp/co/yumemi/android/code_check/OneFragment.kt +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Copyright © 2021 YUMEMI Inc. All rights reserved. - */ -package jp.co.yumemi.android.code_check - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.inputmethod.EditorInfo -import android.widget.TextView -import androidx.fragment.app.Fragment -import androidx.navigation.fragment.findNavController -import androidx.recyclerview.widget.* -import jp.co.yumemi.android.code_check.databinding.FragmentOneBinding - -class OneFragment: Fragment(R.layout.fragment_one){ - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) - { - super.onViewCreated(view, savedInstanceState) - - val _binding= FragmentOneBinding.bind(view) - - val _viewModel= OneViewModel(context!!) - - val _layoutManager= LinearLayoutManager(context!!) - val _dividerItemDecoration= - DividerItemDecoration(context!!, _layoutManager.orientation) - val _adapter= CustomAdapter(object : CustomAdapter.OnItemClickListener{ - override fun itemClick(item: item){ - gotoRepositoryFragment(item) - } - }) - - _binding.searchInputText - .setOnEditorActionListener{ editText, action, _ -> - if (action== EditorInfo.IME_ACTION_SEARCH){ - editText.text.toString().let { - _viewModel.searchResults(it).apply{ - _adapter.submitList(this) - } - } - return@setOnEditorActionListener true - } - return@setOnEditorActionListener false - } - - _binding.recyclerView.also{ - it.layoutManager= _layoutManager - it.addItemDecoration(_dividerItemDecoration) - it.adapter= _adapter - } - } - - fun gotoRepositoryFragment(item: item) - { - val _action= OneFragmentDirections - .actionRepositoriesFragmentToRepositoryFragment(item= item) - findNavController().navigate(_action) - } -} - -val diff_util= object: DiffUtil.ItemCallback(){ - override fun areItemsTheSame(oldItem: item, newItem: item): Boolean - { - return oldItem.name== newItem.name - } - - override fun areContentsTheSame(oldItem: item, newItem: item): Boolean - { - return oldItem== newItem - } - -} - -class CustomAdapter( - private val itemClickListener: OnItemClickListener, -) : ListAdapter(diff_util){ - - class ViewHolder(view: View): RecyclerView.ViewHolder(view) - - interface OnItemClickListener{ - fun itemClick(item: item) - } - - 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) - { - val _item= getItem(position) - (holder.itemView.findViewById(R.id.repositoryNameView) as TextView).text= - _item.name - - holder.itemView.setOnClickListener{ - itemClickListener.itemClick(_item) - } - } -} diff --git a/app/src/main/kotlin/jp/co/yumemi/android/code_check/OneViewModel.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/OneViewModel.kt deleted file mode 100644 index 402b065..0000000 --- a/app/src/main/kotlin/jp/co/yumemi/android/code_check/OneViewModel.kt +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright © 2021 YUMEMI Inc. All rights reserved. - */ -package jp.co.yumemi.android.code_check - -import android.content.Context -import android.os.Parcelable -import androidx.lifecycle.ViewModel -import io.ktor.client.* -import io.ktor.client.call.* -import io.ktor.client.engine.android.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import jp.co.yumemi.android.code_check.TopActivity.Companion.lastSearchDate -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.async -import kotlinx.coroutines.runBlocking -import kotlinx.parcelize.Parcelize -import org.json.JSONObject -import java.util.* - -/** - * TwoFragment で使う - */ -class OneViewModel( - val context: Context -) : ViewModel() { - - // 検索結果 - fun searchResults(inputText: String): List = runBlocking { - val client = HttpClient(Android) - - return@runBlocking GlobalScope.async { - val response: HttpResponse = client?.get("https://api.github.com/search/repositories") { - header("Accept", "application/vnd.github.v3+json") - parameter("q", inputText) - } - - val jsonBody = JSONObject(response.receive()) - - val jsonItems = jsonBody.optJSONArray("items")!! - - val items = mutableListOf() - - /** - * アイテムの個数分ループする - */ - for (i in 0 until jsonItems.length()) { - val jsonItem = jsonItems.optJSONObject(i)!! - val name = jsonItem.optString("full_name") - val ownerIconUrl = jsonItem.optJSONObject("owner")!!.optString("avatar_url") - val language = jsonItem.optString("language") - val stargazersCount = jsonItem.optLong("stargazers_count") - val watchersCount = jsonItem.optLong("watchers_count") - val forksCount = jsonItem.optLong("forks_conut") - val openIssuesCount = jsonItem.optLong("open_issues_count") - - items.add( - item( - name = name, - ownerIconUrl = ownerIconUrl, - language = context.getString(R.string.written_language, language), - stargazersCount = stargazersCount, - watchersCount = watchersCount, - forksCount = forksCount, - openIssuesCount = openIssuesCount - ) - ) - } - - lastSearchDate = Date() - - return@async items.toList() - }.await() - } -} - -@Parcelize -data class item( - val name: String, - val ownerIconUrl: String, - val language: String, - val stargazersCount: Long, - val watchersCount: Long, - val forksCount: Long, - val openIssuesCount: Long, -) : Parcelable \ No newline at end of file diff --git a/app/src/main/kotlin/jp/co/yumemi/android/code_check/TwoFragment.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/TwoFragment.kt deleted file mode 100644 index 8720eb5..0000000 --- a/app/src/main/kotlin/jp/co/yumemi/android/code_check/TwoFragment.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright © 2021 YUMEMI Inc. All rights reserved. - */ -package jp.co.yumemi.android.code_check - -import android.os.Bundle -import android.util.Log -import android.view.View -import androidx.fragment.app.Fragment -import androidx.navigation.fragment.navArgs -import coil.load -import jp.co.yumemi.android.code_check.TopActivity.Companion.lastSearchDate -import jp.co.yumemi.android.code_check.databinding.FragmentTwoBinding - -class TwoFragment : Fragment(R.layout.fragment_two) { - - private val args: TwoFragmentArgs by navArgs() - - private var binding: FragmentTwoBinding? = null - private val _binding get() = binding!! - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - Log.d("検索した日時", lastSearchDate.toString()) - - binding = FragmentTwoBinding.bind(view) - - var item = args.item - - _binding.ownerIconView.load(item.ownerIconUrl); - _binding.nameView.text = item.name; - _binding.languageView.text = item.language; - _binding.starsView.text = "${item.stargazersCount} stars"; - _binding.watchersView.text = "${item.watchersCount} watchers"; - _binding.forksView.text = "${item.forksCount} forks"; - _binding.openIssuesView.text = "${item.openIssuesCount} open issues"; - } -} diff --git a/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/api/NetworkConnectivityService.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/api/NetworkConnectivityService.kt new file mode 100644 index 0000000..d11f4f9 --- /dev/null +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/api/NetworkConnectivityService.kt @@ -0,0 +1,22 @@ +package jp.co.yumemi.android.code_check.core.api + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NetworkConnectivityService + @Inject + constructor( + @ApplicationContext private val context: Context, + ) { + fun isNetworkAvailable(): Boolean { + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val network = connectivityManager.activeNetwork ?: return false + val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false + return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } + } diff --git a/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/entity/RepositoryEntity.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/entity/RepositoryEntity.kt new file mode 100644 index 0000000..e172ea5 --- /dev/null +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/entity/RepositoryEntity.kt @@ -0,0 +1,16 @@ +package jp.co.yumemi.android.code_check.core.entity + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class RepositoryEntity( + val id: Int, + val name: String, + val ownerIconUrl: String, + val language: String, + val stargazersCount: Long, + val watchersCount: Long, + val forksCount: Long, + val openIssuesCount: Long, +) : Parcelable 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/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/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 new file mode 100644 index 0000000..33fef69 --- /dev/null +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/presenter/search/RepositorySearchViewModel.kt @@ -0,0 +1,73 @@ +package jp.co.yumemi.android.code_check.core.presenter.search + +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 + +/** + * RepositorySearchFragmentで利用するリポジトリ検索用のViewModel + */ +@HiltViewModel +class RepositorySearchViewModel + @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 query 検索キーワード + */ + fun searchRepositories(query: String) { + if (query.isBlank()) { + _errorMessage.postValue(R.string.form_is_empty) + return + } + viewModelScope.launch { + try { + val results = networkRepository.fetchSearchResults(query) + if (results is NetworkResult.Error) { + handleError(results.exception) + return@launch + } + if (results is NetworkResult.Success) { + _searchResults.postValue(results.data) + } + } catch (e: NetworkException) { + Log.e("NetworkException", e.message, 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/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/core/utils/DialogHelper.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/utils/DialogHelper.kt new file mode 100644 index 0000000..5798c1b --- /dev/null +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/core/utils/DialogHelper.kt @@ -0,0 +1,17 @@ +package jp.co.yumemi.android.code_check.core.utils + +import android.app.AlertDialog +import android.content.Context + +object DialogHelper { + fun showErrorDialog( + context: Context, + message: String, + ) { + AlertDialog.Builder(context) + .setTitle("エラー") + .setMessage(message) + .setPositiveButton("OK", null) + .show() + } +} 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 new file mode 100644 index 0000000..721b856 --- /dev/null +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/api/GitHubServiceApi.kt @@ -0,0 +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 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 new file mode 100644 index 0000000..fd0ddba --- /dev/null +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/api/GitHubServiceApiBuilderInterface.kt @@ -0,0 +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 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 new file mode 100644 index 0000000..edff3be --- /dev/null +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/api/GitHubServiceApiImpl.kt @@ -0,0 +1,71 @@ +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 +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory + +class GitHubServiceApiImpl : GitHubServiceApi { + companion object { + val client = + OkHttpClient.Builder() + .addInterceptor( + HttpLoggingInterceptor().apply { + level = + if (BuildConfig.DEBUG) { + HttpLoggingInterceptor.Level.BODY + } else { + HttpLoggingInterceptor.Level.NONE + } + }, + ) + .build() + + val moshi = + Moshi.Builder() + .add(KotlinJsonAdapterFactory()) + .build() + + val githubService = + Retrofit.Builder() + .baseUrl("https://api.github.com") + .client(client) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() + .create(GitHubServiceApiBuilderInterface::class.java) + } + + 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()) { + 404 -> NetworkException("リポジトリが見つかりませんでした") + 403 -> NetworkException("APIレート制限に達しました") + 500 -> NetworkException("サーバーエラーが発生しました") + else -> NetworkException("エラーが発生しました: ${response.code()}") + } + } + return response.body() ?: throw NetworkException("レスポンスが空でした") + } +} 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 new file mode 100644 index 0000000..d8579f7 --- /dev/null +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/entity/GitHubServiceEntity.kt @@ -0,0 +1,22 @@ +package jp.co.yumemi.android.code_check.features.github.entity + +import com.squareup.moshi.Json + +data class RepositoryList( + val items: List, +) + +data class RepositoryItem( + val id: Int, + val name: String, + val owner: RepositoryOwner, + val language: String?, + @Json(name = "stargazers_count") val stargazersCount: Long, + @Json(name = "watchers_count") val watchersCount: Long, + @Json(name = "forks_count") val forksCount: Long, + @Json(name = "open_issues_count") val openIssuesCount: Long, +) + +data class RepositoryOwner( + @Json(name = "avatar_url") val avatarUrl: 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 new file mode 100644 index 0000000..9641df3 --- /dev/null +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/reposiotory/GitHubServiceRepository.kt @@ -0,0 +1,10 @@ +package jp.co.yumemi.android.code_check.features.github.reposiotory + +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 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 new file mode 100644 index 0000000..db846f4 --- /dev/null +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/reposiotory/GitHubServiceRepositoryImpl.kt @@ -0,0 +1,80 @@ +package jp.co.yumemi.android.code_check.features.github.reposiotory + +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 +import org.json.JSONException +import retrofit2.HttpException +import java.io.IOException +import javax.inject.Inject + +class GitHubServiceRepositoryImpl + @Inject + constructor( + private val gitHubRepositoryApi: GitHubServiceApi, + ) : GitHubServiceRepository { + override suspend fun fetchSearchResults(inputText: String): NetworkResult> { + return try { + val repositoryList = gitHubRepositoryApi.getRepositoryList(inputText) + val items = + repositoryList.items.map { item -> + RepositoryEntity( + name = item.name, + ownerIconUrl = item.owner.avatarUrl, + language = item.language ?: "none", + stargazersCount = item.stargazersCount, + watchersCount = item.watchersCount, + forksCount = item.forksCount, + openIssuesCount = item.openIssuesCount, + id = item.id, + ) + } + NetworkResult.Success(items) + } 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)) + } + } + + 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 new file mode 100644 index 0000000..ad87471 --- /dev/null +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/usecase/GitHubServiceUsecase.kt @@ -0,0 +1,10 @@ +package jp.co.yumemi.android.code_check.features.github.usecase + +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 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 new file mode 100644 index 0000000..1e388cf --- /dev/null +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/usecase/GitHubServiceUsecaseImpl.kt @@ -0,0 +1,29 @@ +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.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 +import javax.inject.Inject + +class GitHubServiceUsecaseImpl + @Inject + constructor( + private val repository: GitHubServiceRepository, + private val networkConnectivityService: NetworkConnectivityService, + ) : GitHubServiceUsecase { + 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/kotlin/jp/co/yumemi/android/code_check/features/github/utils/GitHubError.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/utils/GitHubError.kt new file mode 100644 index 0000000..622fdd7 --- /dev/null +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/utils/GitHubError.kt @@ -0,0 +1,13 @@ +package jp.co.yumemi.android.code_check.features.github.utils + +sealed class GitHubError { + data class NetworkError(val exception: Exception) : GitHubError() + + data class ApiError(val code: Int, val message: String) : GitHubError() + + data class ParseError(val exception: Exception) : GitHubError() + + data object RateLimitError : GitHubError() + + data object AuthenticationError : GitHubError() +} diff --git a/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/utils/NetworkResult.kt b/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/utils/NetworkResult.kt new file mode 100644 index 0000000..05d20a3 --- /dev/null +++ b/app/src/main/kotlin/jp/co/yumemi/android/code_check/features/github/utils/NetworkResult.kt @@ -0,0 +1,7 @@ +package jp.co.yumemi.android.code_check.features.github.utils + +sealed class NetworkResult { + data class Success(val data: T) : NetworkResult() + + data class Error(val exception: GitHubError) : NetworkResult() +} 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 e15957c..0000000 --- a/app/src/main/kotlin/jp/co/yumemi/android/code_check/topActivity.kt +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright © 2021 YUMEMI Inc. All rights reserved. - */ -package jp.co.yumemi.android.code_check - -import androidx.appcompat.app.AppCompatActivity -import java.util.* - -class TopActivity : AppCompatActivity(R.layout.activity_top) { - - companion object { - lateinit var lastSearchDate: Date - } -} 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_one.xml b/app/src/main/res/layout/fragment_one.xml deleted file mode 100644 index 0f60d37..0000000 --- a/app/src/main/res/layout/fragment_one.xml +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_two.xml b/app/src/main/res/layout/fragment_two.xml deleted file mode 100644 index 8e1b54f..0000000 --- a/app/src/main/res/layout/fragment_two.xml +++ /dev/null @@ -1,108 +0,0 @@ - - - - - - - - - - - - - - - - - - - - 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 57a016e..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 33dbeae..49b65d0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,29 @@ Android Engineer CodeCheck GitHub のリポジトリを検索できるよー - Written in %s + "%1$d stars" + "%1$d watchers" + "%1$d forks" + "%1$d open issues" + 認証エラー + ネットワークエラー + パースエラー + セッションの時間切れ + 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 63201e4..0000000 --- a/app/src/test/kotlin/jp/co/yumemi/android/code_check/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package jp.co.yumemi.android.code_check - -import org.junit.Test - -import org.junit.Assert.* - -/** - * 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) - } -} \ No newline at end of file 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 new file mode 100644 index 0000000..ce9ec3a --- /dev/null +++ b/app/src/test/kotlin/jp/co/yumemi/android/code_check/features/github/GitHubServiceRepositoryImplTest.kt @@ -0,0 +1,146 @@ +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.reposiotory.GitHubServiceRepositoryImpl +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.runBlocking +import org.json.JSONException +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import retrofit2.HttpException +import java.io.IOException + +class GitHubServiceRepositoryImplTest { + private lateinit var api: GitHubServiceApi + private lateinit var repository: GitHubServiceRepositoryImpl + + @Before + fun setUp() { + api = mock(GitHubServiceApi::class.java) + repository = GitHubServiceRepositoryImpl(api) + } + + @Test + fun `fetchSearchResults returns success with valid data`() = + runBlocking { + // Arrange + val mockResponse = GitHubMockData.getMockRepositoryList() + `when`(api.getRepositoryList("test")).thenReturn(mockResponse) + + // Act + val result = repository.fetchSearchResults("test") + + // Assert + assert(result is NetworkResult.Success) + val success = result as NetworkResult.Success + assertEquals(2, success.data.size) + assertEquals("repo1", success.data[0].name) + assertEquals("url1", success.data[0].ownerIconUrl) // 修正: ownerIconUrl -> avatarUrl + } + + @Test + fun `fetchSearchResults returns error on HttpException`() = + runBlocking { + // Arrange + val httpException = mock(HttpException::class.java) + `when`(httpException.code()).thenReturn(401) + `when`(api.getRepositoryList("test")).thenThrow(httpException) + + // Act + val result = repository.fetchSearchResults("test") + + // Assert + assert(result is NetworkResult.Error) + val error = result as NetworkResult.Error + assert(error.exception is GitHubError.AuthenticationError) + } + + @Test + fun `fetchSearchResults returns error on IOException`() = + runBlocking { + // Arrange + `when`(api.getRepositoryList("test")).thenAnswer { + throw IOException("Network error") + } + + // Act + val result = repository.fetchSearchResults("test") + + // Assert + assert(result is NetworkResult.Error) + val error = result as NetworkResult.Error + assert(error.exception is GitHubError.NetworkError) + } + + @Test + fun `fetchSearchResults returns error on JSONException`() = + runBlocking { + // Arrange + `when`(api.getRepositoryList("test")).thenAnswer { + throw JSONException("Parsing error") + } + + // Act + val result = repository.fetchSearchResults("test") + + // Assert + assert(result is NetworkResult.Error) + val error = result as NetworkResult.Error + assert(error.exception is GitHubError.ParseError) + } + + @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 new file mode 100644 index 0000000..4a9ae28 --- /dev/null +++ b/app/src/test/kotlin/jp/co/yumemi/android/code_check/features/github/GitHubServiceUsecaseImplTest.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.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.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` + +class GitHubServiceUsecaseImplTest { + private lateinit var repository: GitHubServiceRepository + private lateinit var networkConnectivityService: NetworkConnectivityService + private lateinit var usecase: GitHubServiceUsecaseImpl + + @Before + fun setUp() { + repository = mock(GitHubServiceRepository::class.java) + networkConnectivityService = mock(NetworkConnectivityService::class.java) + usecase = GitHubServiceUsecaseImpl(repository, networkConnectivityService) + } + + @Test + fun `fetchSearchResults throws NetworkException when offline`() { + `when`(networkConnectivityService.isNetworkAvailable()).thenReturn(false) + + val exception = + assertThrows(NetworkException::class.java) { + runBlocking { + usecase.fetchSearchResults("test") + } + } + + assertEquals("オフライン状態です", exception.message) + } + + @Test + fun `fetchSearchResults returns success when online`() = + runBlocking { + val mockResults = GitHubMockData.getMockRepositoryEntityList() + + `when`(networkConnectivityService.isNetworkAvailable()).thenReturn(true) + `when`(repository.fetchSearchResults("test")).thenReturn(NetworkResult.Success(mockResults)) + + val result = usecase.fetchSearchResults("test") + + 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) + } +} diff --git a/build.gradle b/build.gradle index 9d87c3e..67879fa 100644 --- a/build.gradle +++ b/build.gradle @@ -5,9 +5,10 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:8.0.2' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.21" - classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.5.3" + classpath 'com.android.tools.build:gradle:8.7.3' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.21" + classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.8.5" + classpath 'com.google.dagger:hilt-android-gradle-plugin:2.55' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 7454180..2c35211 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index da1db5f..09523c0 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 1b6c787..f5feea6 100755 --- a/gradlew +++ b/gradlew @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -55,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -80,13 +82,12 @@ do esac done -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" +# This is normally unused +# shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -133,22 +134,29 @@ location of your Java installation." fi else JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -193,11 +201,15 @@ if "$cygwin" || "$msys" ; then done fi -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ @@ -205,6 +217,12 @@ set -- \ org.gradle.wrapper.GradleWrapperMain \ "$@" +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. diff --git a/gradlew.bat b/gradlew.bat index 107acd3..9d21a21 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,8 +13,10 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +27,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,13 +43,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -56,11 +59,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -75,13 +78,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/settings.gradle b/settings.gradle index 213904f..5541150 100644 --- a/settings.gradle +++ b/settings.gradle @@ -3,7 +3,6 @@ dependencyResolutionManagement { repositories { google() mavenCentral() - jcenter() // Warning: this repository is going to shut down soon } } rootProject.name = "Android Engineer CodeCheck"