├── .github └── workflows │ └── GitHubUserFinder.yml ├── .gitignore ├── README.md ├── androidApp ├── build.gradle.kts └── src │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── io │ │ └── imrekaszab │ │ └── githubuserfinder │ │ └── android │ │ ├── GitHubUsersApplication.kt │ │ ├── MainActivity.kt │ │ └── ui │ │ ├── navigation │ │ ├── GitHubUserScreens.kt │ │ └── Navigation.kt │ │ ├── theme │ │ ├── Color.kt │ │ ├── Dimens.kt │ │ ├── Shape.kt │ │ ├── Theme.kt │ │ └── Type.kt │ │ ├── view │ │ ├── FavouriteUsersScreen.kt │ │ ├── GitHubUserDetailScreen.kt │ │ └── GitHubUserListScreen.kt │ │ └── widget │ │ ├── AlertDialog.kt │ │ ├── CommonAppBar.kt │ │ ├── EmptyView.kt │ │ ├── ErrorView.kt │ │ ├── FavouriteButton.kt │ │ ├── GitHubUserDetailItemView.kt │ │ ├── GitHubUserDetailsView.kt │ │ ├── GitHubUserListView.kt │ │ ├── GitHubUserRow.kt │ │ ├── InfiniteLoadingListView.kt │ │ ├── LoadingView.kt │ │ └── SearchAppBar.kt │ └── res │ ├── ic_launcher-playstore.png │ ├── mipmap-anydpi-v26 │ ├── ic_launcher.xml │ └── ic_launcher_round.xml │ ├── mipmap-hdpi │ ├── ic_launcher.png │ ├── ic_launcher_background.png │ └── ic_launcher_round.png │ ├── mipmap-mdpi │ ├── ic_launcher.png │ ├── ic_launcher_background.png │ └── ic_launcher_round.png │ ├── mipmap-xhdpi │ ├── ic_launcher.png │ ├── ic_launcher_background.png │ └── ic_launcher_round.png │ ├── mipmap-xxhdpi │ ├── ic_launcher.png │ ├── ic_launcher_background.png │ └── ic_launcher_round.png │ ├── mipmap-xxxhdpi │ ├── ic_launcher.png │ ├── ic_launcher_background.png │ └── ic_launcher_round.png │ ├── values-night │ └── colors.xml │ └── values │ ├── colors.xml │ ├── strings.xml │ └── styles.xml ├── build.gradle.kts ├── config └── detekt.yml ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── iosApp ├── .swiftlint.yml ├── iosApp.xcodeproj │ ├── project.pbxproj │ └── project.xcworkspace │ │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── iosApp │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json │ ├── ContentView.swift │ ├── GitHubUserFinder.swift │ ├── Info.plist │ ├── Koin │ ├── Koin.swift │ └── LazyKoin.swift │ ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json │ ├── UI │ ├── Details │ │ ├── GitHubUserDetailBioView.swift │ │ ├── GitHubUserDetailItemView.swift │ │ ├── GitHubUserDetailItemViews.swift │ │ └── GitHubUserDetailsScreen.swift │ ├── Favourite │ │ └── FavouriteUsersScreen.swift │ ├── List │ │ ├── GitHubUserListScreen.swift │ │ └── GitHubUserRow.swift │ └── Widget │ │ ├── ClearButtonModifier.swift │ │ ├── ListStateViewModifier.swift │ │ └── SearchBar.swift │ ├── Util │ ├── CombineAdapters.swift │ ├── ImageResource+Extensions.swift │ └── StringResource+Extensions.swift │ └── ViewModel │ └── ReducerViewModel.swift ├── renovate.json ├── screenshots ├── android.gif └── ios.gif ├── settings.gradle.kts └── shared ├── build.gradle.kts └── src ├── androidMain └── kotlin │ └── io │ └── imrekaszab │ └── githubuserfinder │ ├── di │ └── KoinAndroid.kt │ └── util │ └── ViewModel.kt ├── androidUnitTest └── kotlin │ └── io │ └── imrekaszab │ └── githubuserfinder │ └── TestUtilAndroid.kt ├── commonMain ├── kotlin │ └── io │ │ └── imrekaszab │ │ └── githubuserfinder │ │ ├── api │ │ ├── GitHubApi.kt │ │ └── GitHubApiImpl.kt │ │ ├── database │ │ ├── CoroutinesExtensions.kt │ │ └── DatabaseHelper.kt │ │ ├── di │ │ ├── DataModule.kt │ │ └── Koin.kt │ │ ├── mapper │ │ └── Mappers.kt │ │ ├── model │ │ ├── api │ │ │ ├── GitHubUserApiModel.kt │ │ │ ├── GitHubUserDetailsApiModel.kt │ │ │ └── SearchResponse.kt │ │ └── domain │ │ │ ├── GitHubPagingInfo.kt │ │ │ └── GitHubUser.kt │ │ ├── repository │ │ ├── GitHubUserRepository.kt │ │ └── GitHubUserRepositoryImpl.kt │ │ ├── service │ │ ├── GitHubUserService.kt │ │ ├── action │ │ │ └── GitHubUserAction.kt │ │ └── store │ │ │ └── GitHubUserStore.kt │ │ ├── util │ │ ├── CoroutineAdapters.kt │ │ ├── ViewModel.kt │ │ └── reducer │ │ │ └── Reducer.kt │ │ └── viewmodel │ │ ├── details │ │ ├── GitHubUserDetailsModel.kt │ │ └── GitHubUserDetailsViewModel.kt │ │ ├── favourite │ │ ├── FavouriteUsersModel.kt │ │ └── FavouriteUsersViewModel.kt │ │ └── list │ │ ├── GitHubUserListModel.kt │ │ └── GitHubUserListViewModel.kt ├── resources │ └── MR │ │ ├── base │ │ └── strings.xml │ │ └── images │ │ ├── ic_arrow_left.svg │ │ ├── ic_clear.svg │ │ ├── ic_star.svg │ │ ├── ic_star_fill.svg │ │ └── ic_trash.svg └── sqldelight │ └── io │ └── imrekaszab │ └── githubuserfinder │ └── db │ └── GitHubUserDataModel.sq ├── commonTest └── kotlin │ └── io │ └── imrekaszab │ └── githubuserfinder │ ├── FavouriteUsersViewModelTest.kt │ ├── GitHubUserDetailsViewModelTest.kt │ ├── GitHubUserListViewModelTest.kt │ ├── GitHubUserServiceTest.kt │ ├── MockData.kt │ ├── MockHttpClient.kt │ ├── MockModule.kt │ └── TestUtil.kt ├── iosMain └── kotlin │ └── io │ └── imrekaszab │ └── githubuserfinder │ ├── di │ └── KoinIOS.kt │ └── util │ └── ViewModel.kt └── iosTest └── kotlin └── io └── imrekaszab └── githubuserfinder └── TestUtilIOS.kt /.github/workflows/GitHubUserFinder.yml: -------------------------------------------------------------------------------- 1 | name: GitHubUserFinder 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | - development 9 | pull_request: 10 | paths-ignore: 11 | - "**.md" 12 | - "*.png" 13 | - docs 14 | 15 | jobs: 16 | pre-conditions: 17 | runs-on: ubuntu-24.04 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v4 21 | 22 | - name: Set up JDK 23 | uses: actions/setup-java@v4.2.1 24 | with: 25 | distribution: corretto 26 | java-version: 18 27 | 28 | - name: detekt 29 | run: ./gradlew detekt --stacktrace 30 | 31 | - name: GitHub Action for SwiftLint (Only files changed in the PR) 32 | uses: norio-nomura/action-swiftlint@3.2.1 33 | env: 34 | WORKING_DIRECTORY: ./iosApp 35 | 36 | - name: Verify and generate coverage report 37 | run: ./gradlew koverVerify koverXmlReport --stacktrace 38 | 39 | - name: Upload test reports 40 | uses: actions/upload-artifact@v4 41 | if: always() 42 | with: 43 | name: shared-test-report 44 | path: ${{ github.workspace }}/build/reports/kover/report.xml 45 | retention-days: 5 46 | 47 | build-android: 48 | needs: pre-conditions 49 | runs-on: ubuntu-24.04 50 | steps: 51 | - name: Checkout repository 52 | uses: actions/checkout@v4 53 | 54 | - name: Set up JDK 55 | uses: actions/setup-java@v4.2.1 56 | with: 57 | distribution: corretto 58 | java-version: 18 59 | 60 | - name: Build 61 | run: ./gradlew build --stacktrace 62 | 63 | test-report: 64 | needs: pre-conditions 65 | runs-on: ubuntu-24.04 66 | permissions: 67 | checks: write 68 | pull-requests: write 69 | steps: 70 | - name: Download report 71 | uses: actions/download-artifact@v4 72 | with: 73 | name: shared-test-report 74 | 75 | - name: Add coverage report to PR 76 | id: kover 77 | uses: mi-kas/kover-report@v1.9 78 | with: 79 | path: ${{ github.workspace }}/report.xml 80 | token: ${{ secrets.GITHUB_TOKEN }} 81 | title: Code Coverage 82 | update-comment: true 83 | 84 | build-ios: 85 | needs: pre-conditions 86 | runs-on: macos-14 87 | 88 | steps: 89 | - name: Checkout repository 90 | uses: actions/checkout@v4 91 | 92 | - name: Set up JDK 93 | uses: actions/setup-java@v4.2.1 94 | with: 95 | distribution: corretto 96 | java-version: 18 97 | 98 | - name: Grant execute permission for gradlew 99 | run: chmod +x ./gradlew 100 | 101 | - name: Select Xcode version 102 | run: | 103 | sudo xcode-select -s /Applications/Xcode_14.3.app 104 | 105 | - name: Build 106 | run: | 107 | cd iosApp 108 | xcodebuild build-for-testing \ 109 | -scheme iosApp \ 110 | -project iosApp.xcodeproj \ 111 | -destination 'platform=iOS Simulator,name=iPhone 14,OS=16.4' 112 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Ignore Gradle GUI config 3 | gradle-app.setting 4 | 5 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 6 | !gradle-wrapper.jar 7 | 8 | # Cache of project 9 | .gradletasknamecache 10 | 11 | # # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 12 | # gradle/wrapper/gradle-wrapper.properties 13 | 14 | *.iml 15 | .gradle 16 | /local.properties 17 | /.idea 18 | .DS_Store 19 | **/build 20 | /captures 21 | 22 | *.xcworkspacedata 23 | *.xcuserstate 24 | *.xcscheme 25 | xcschememanagement.plist 26 | *.xcbkptlist 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitHubUserFinder 2 | ![GitHub Actions build status](https://github.com/kaszabimre/GitHubUserFinder/actions/workflows/GitHubUserFinder.yml/badge.svg) 3 | 4 | Kotlin Multiplatform sample project with Jetpack Compose and SwiftUI 5 | 6 | Check this basic, simple KMP project with latest and greatest libraries. You can also easily learn and keep your knowledge up-to-date in both [Jeptack Compose](https://developer.android.com/jetpack/compose?gclid=CjwKCAjw7vuUBhBUEiwAEdu2pHTM59Y0NTVLcoFuOJHq5g8p3dJludRLuITkxy54fKMp-3YafHSjNRoCSIwQAvD_BwE&gclsrc=aw.ds) and [SwiftUI](https://developer.apple.com/xcode/swiftui/). 7 | 8 | ![iOS](https://img.shields.io/badge/iOS-000000?style=for-the-badge&logo=ios&logoColor=white) 9 | ![Swift](https://img.shields.io/badge/Swift-FA7343?style=for-the-badge&logo=swift&logoColor=white) 10 | ![Android](https://img.shields.io/badge/Android-3DDC84?style=for-the-badge&logo=android&logoColor=white) 11 | ![Kotlin](https://img.shields.io/badge/Kotlin-0095D5?&style=for-the-badge&logo=kotlin&logoColor=white) 12 | ![GitHub](https://img.shields.io/badge/github-%23121011.svg?style=for-the-badge&logo=github&logoColor=white) 13 | 14 | Android | iOS 15 | :--: | :--: 16 | | 17 | 18 | 19 | ### About the project 20 | 21 | - Shared ViewModels :rocket: 22 | - Shared resources (svg, strings) with moko-resources 23 | - MVVM + Reducer implementation = MVI 24 | - List and detail screens 25 | - Favourite user feature with SQLDelight 26 | - Simple paging functionality 27 | - Linter & formatter (detekt, swiftlint) 28 | - Common tests 29 | - Dark mode 30 | - Automated dependency update with Renovate 31 | - GitHub Actions config: [GitHubUserFinder.yml](https://github.com/kaszabimre/GitHubUserFinder/blob/main/.github/workflows/GitHubUserFinder.yml) 32 | - [GitHub API](https://docs.github.com/en/rest/search#search-users) 33 | - Coverage report (kover) 34 | 35 | ### Libraries 36 | > Check [Dependencies.kt](https://github.com/kaszabimre/GitHubUserFinder/blob/main/buildSrc/src/main/java/Dependencies.kt) for more details 37 | 38 | - 🌎 [Ktor](https://github.com/ktorio/ktor) - Network 39 | [![GitHub Repo stars](https://img.shields.io/github/stars/ktorio/ktor)](https://github.com/ktorio/ktor) 40 | - 🔒 [SQLDelight](https://github.com/cashapp/sqldelight) - LocalDB 41 | [![GitHub Repo stars](https://img.shields.io/github/stars/cashapp/sqldelight)](https://github.com/cashapp/sqldelight) 42 | - 💉 [Koin](https://github.com/InsertKoinIO/koin) - DI framework 43 | [![GitHub Repo stars](https://img.shields.io/github/stars/InsertKoinIO/koin)](https://github.com/InsertKoinIO/koin) 44 | - 📋 [Kermit](https://github.com/touchlab/Kermit) - Logger 45 | [![GitHub Repo stars](https://img.shields.io/github/stars/touchlab/Kermit)](https://github.com/touchlab/Kermit) 46 | - 🎨 [moko resources](https://github.com/icerockdev/moko-resources) - Shared resources 47 | [![GitHub Repo stars](https://img.shields.io/github/stars/icerockdev/moko-resources)](https://github.com/icerockdev/moko-resources) 48 | - 🚦 Testing - Common unit tests in `shared` module with [MockHttpClient](https://github.com/kaszabimre/GitHubUserFinder/blob/main/shared/src/commonTest/kotlin/io/imrekaszab/githubuserfinder/MockHttpClient.kt) 49 | - 🔍 Linter & formatter 50 | - [Detekt](https://github.com/detekt/detekt) - `shared + Android` 51 | ![GitHub Repo stars](https://img.shields.io/github/stars/detekt/Detekt) 52 | ``` 53 | ./gradlew detekt 54 | ``` 55 | - [Swiftlint](https://github.com/realm/SwiftLint) - `iOS` 56 | ![GitHub Repo stars](https://img.shields.io/github/stars/realm/SwiftLint) 57 | - [Rules in swiftlint.yaml](https://github.com/kaszabimre/GitHubUserFinder/blob/main/iosApp/.swiftlint.yml) 58 | ``` 59 | swiftlint --fix 60 | ``` 61 | 62 | ### Code coverage 63 | 64 | - 📋 [Kover](https://github.com/Kotlin/kotlinx-kover) - Kotlin code coverage tool 65 | [![GitHub Repo stars](https://img.shields.io/github/stars/Kotlin/kotlinx-kover)](https://github.com/Kotlin/kotlinx-kover) 66 | 67 | > Use `./gradlew koverVerify koverHtmlReport` to verify and generate the coverage report with a custom rule: 68 | 69 | ```Kotlin 70 | kover { 71 | verify { 72 | rule { 73 | isEnabled = true 74 | name = "Minimum coverage verification error" 75 | target = 76 | kotlinx.kover.api.VerificationTarget.ALL 77 | 78 | bound { 79 | minValue = 90 80 | maxValue = 100 81 | counter = 82 | kotlinx.kover.api.CounterType.LINE 83 | valueType = 84 | kotlinx.kover.api.VerificationValueType.COVERED_PERCENTAGE 85 | } 86 | } 87 | } 88 | } 89 | ``` 90 | 91 | > After that we can use the [Kotlinx Kover Report](https://github.com/marketplace/actions/kotlinx-kover-report) to add the coverage report to the PR as a comment 92 | 93 | 94 | ### IDEs 95 | 96 | - Android Studio Flamingo | 2022.2.1 | with [KMM plugin](https://plugins.jetbrains.com/plugin/14936-kotlin-multiplatform-mobile) 97 | - Xcode 14.3 98 | -------------------------------------------------------------------------------- /androidApp/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.application) 3 | id(libs.plugins.kotlin.android.get().pluginId) 4 | } 5 | 6 | val androidNameSpace = "io.imrekaszab.githubuserfinder.android" 7 | android { 8 | namespace = androidNameSpace 9 | compileSdk = libs.versions.targetSdk.get().toInt() 10 | defaultConfig { 11 | applicationId = "io.imrekaszab.githubuserfinder.android" 12 | minSdk = libs.versions.minSdk.get().toInt() 13 | targetSdk = libs.versions.targetSdk.get().toInt() 14 | versionCode = 1 15 | versionName = "1.0" 16 | 17 | buildConfigField("String", "BASE_PATH", "\"https://api.github.com\"") 18 | } 19 | 20 | buildFeatures { 21 | compose = true 22 | buildConfig = true 23 | } 24 | 25 | compileOptions { 26 | sourceCompatibility = JavaVersion.valueOf( 27 | "VERSION_" + libs.versions.javaSourceCompatibility.get().replace(".", "_") 28 | ) 29 | targetCompatibility = JavaVersion.valueOf( 30 | "VERSION_" + libs.versions.javaTargetCompatibility.get().replace(".", "_") 31 | ) 32 | } 33 | 34 | composeOptions { 35 | kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() 36 | } 37 | 38 | buildTypes { 39 | getByName("release") { 40 | isMinifyEnabled = false 41 | } 42 | } 43 | } 44 | 45 | kotlin { 46 | jvmToolchain { 47 | languageVersion.set(JavaLanguageVersion.of(libs.versions.javaTargetCompatibility.get().toInt())) 48 | } 49 | } 50 | 51 | dependencies { 52 | kover(project(":shared")) 53 | implementation(project(":shared")) 54 | 55 | // AndroidX 56 | implementation(libs.androidx.appcompat) 57 | 58 | // Compose 59 | debugImplementation(libs.compose.tooling) 60 | implementation(libs.bundles.compose) 61 | 62 | // DI 63 | implementation(libs.bundles.koinAndroid) 64 | 65 | // Detekt 66 | detektPlugins(libs.detekt.formatting) 67 | } 68 | 69 | val defaultRequiredMinimumCoverage = 80 70 | val defaultRequiredMaximumCoverage = 100 71 | 72 | koverReport { 73 | filters { 74 | excludes { 75 | classes("$androidNameSpace.*") 76 | } 77 | } 78 | androidReports("debug") { 79 | verify { 80 | rule("Minimum coverage verification error") { 81 | isEnabled = true 82 | entity = kotlinx.kover.gradle.plugin.dsl.GroupingEntityType.APPLICATION 83 | 84 | bound { 85 | minValue = defaultRequiredMinimumCoverage 86 | maxValue = defaultRequiredMaximumCoverage 87 | metric = kotlinx.kover.gradle.plugin.dsl.MetricType.LINE 88 | aggregation = kotlinx.kover.gradle.plugin.dsl.AggregationType.COVERED_PERCENTAGE 89 | } 90 | } 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /androidApp/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 13 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /androidApp/src/main/java/io/imrekaszab/githubuserfinder/android/GitHubUsersApplication.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.android 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import android.util.Log 6 | import io.imrekaszab.githubuserfinder.di.initKoin 7 | import org.koin.dsl.module 8 | 9 | class GitHubUsersApplication : Application() { 10 | override fun onCreate() { 11 | super.onCreate() 12 | val baseUrl = BuildConfig.BASE_PATH 13 | initKoin( 14 | baseUrl, 15 | module { 16 | single { this@GitHubUsersApplication } 17 | single { 18 | { Log.i("Startup", "App started on Android!") } 19 | } 20 | } 21 | ) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /androidApp/src/main/java/io/imrekaszab/githubuserfinder/android/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.android 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.tooling.preview.Preview 8 | import io.imrekaszab.githubuserfinder.android.ui.navigation.Navigation 9 | import io.imrekaszab.githubuserfinder.android.ui.theme.GitHubUserFinderTheme 10 | 11 | class MainActivity : ComponentActivity() { 12 | override fun onCreate(savedInstanceState: Bundle?) { 13 | super.onCreate(savedInstanceState) 14 | setContent { 15 | GitHubUserFinderApp { 16 | Navigation() 17 | } 18 | } 19 | } 20 | } 21 | 22 | @Composable 23 | fun GitHubUserFinderApp(content: @Composable () -> Unit) { 24 | GitHubUserFinderTheme { 25 | content() 26 | } 27 | } 28 | 29 | @Preview(showBackground = true) 30 | @Composable 31 | fun DefaultPreview() { 32 | GitHubUserFinderApp { 33 | Navigation() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /androidApp/src/main/java/io/imrekaszab/githubuserfinder/android/ui/navigation/GitHubUserScreens.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.android.ui.navigation 2 | 3 | sealed class GitHubUserScreens(val route: String) { 4 | object GitHubUserListScreen : GitHubUserScreens("list_screen") 5 | object GitHubUserDetailScreen : GitHubUserScreens("detail_screen") 6 | object FavouriteGitHubUsersScreen : GitHubUserScreens("favourite_users_screen") 7 | } 8 | -------------------------------------------------------------------------------- /androidApp/src/main/java/io/imrekaszab/githubuserfinder/android/ui/navigation/Navigation.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.android.ui.navigation 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.navigation.NavType 5 | import androidx.navigation.compose.NavHost 6 | import androidx.navigation.compose.composable 7 | import androidx.navigation.compose.rememberNavController 8 | import androidx.navigation.navArgument 9 | import io.imrekaszab.githubuserfinder.android.ui.view.FavouriteGitHubUsersScreen 10 | import io.imrekaszab.githubuserfinder.android.ui.view.GitHubUserDetailScreen 11 | import io.imrekaszab.githubuserfinder.android.ui.view.GitHubUserListScreen 12 | 13 | @Composable 14 | fun Navigation() { 15 | val navController = rememberNavController() 16 | NavHost( 17 | navController = navController, 18 | startDestination = GitHubUserScreens.GitHubUserListScreen.route 19 | ) { 20 | composable(route = GitHubUserScreens.GitHubUserListScreen.route) { 21 | GitHubUserListScreen(navController = navController) 22 | } 23 | composable( 24 | route = GitHubUserScreens.GitHubUserDetailScreen.route + "/{userName}", 25 | arguments = listOf( 26 | navArgument(name = "userName") { 27 | type = NavType.StringType 28 | } 29 | ) 30 | ) { entry -> 31 | GitHubUserDetailScreen(navController, entry.arguments?.getString("userName")) 32 | } 33 | composable(route = GitHubUserScreens.FavouriteGitHubUsersScreen.route) { 34 | FavouriteGitHubUsersScreen(navController = navController) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /androidApp/src/main/java/io/imrekaszab/githubuserfinder/android/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("MagicNumber") 2 | 3 | package io.imrekaszab.githubuserfinder.android.ui.theme 4 | import androidx.compose.ui.graphics.Color 5 | 6 | val BlueGray300 = Color(0xFF90A4AE) 7 | val BlueGray700 = Color(0xFF455A64) 8 | val BlueGray900 = Color(0xFF263238) 9 | -------------------------------------------------------------------------------- /androidApp/src/main/java/io/imrekaszab/githubuserfinder/android/ui/theme/Dimens.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.android.ui.theme 2 | 3 | import androidx.compose.ui.unit.Dp 4 | import androidx.compose.ui.unit.dp 5 | 6 | object Dimens { 7 | val extraTiny: Dp = 4.dp 8 | val tiny: Dp = 8.dp 9 | val default: Dp = 16.dp 10 | val big: Dp = 32.dp 11 | val cardHeight = 100.dp 12 | val imageSize = 50.dp 13 | val bigImageSize = 130.dp 14 | val searchBarHeight = 56.dp 15 | const val favouriteButtonScale = 1.3f 16 | const val labelTitleWeight = 0.4f 17 | const val valueTitleWeight = 0.6f 18 | } 19 | -------------------------------------------------------------------------------- /androidApp/src/main/java/io/imrekaszab/githubuserfinder/android/ui/theme/Shape.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.android.ui.theme 2 | 3 | import androidx.compose.foundation.shape.RoundedCornerShape 4 | import androidx.compose.material.Shapes 5 | 6 | val Shapes = Shapes( 7 | small = RoundedCornerShape(Dimens.extraTiny), 8 | medium = RoundedCornerShape(Dimens.extraTiny), 9 | large = RoundedCornerShape(Dimens.tiny) 10 | ) 11 | -------------------------------------------------------------------------------- /androidApp/src/main/java/io/imrekaszab/githubuserfinder/android/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.android.ui.theme 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.material.MaterialTheme 5 | import androidx.compose.material.darkColors 6 | import androidx.compose.material.lightColors 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.graphics.Color 9 | 10 | private val LightThemeColors = lightColors( 11 | primary = BlueGray700, 12 | primaryVariant = BlueGray900, 13 | onPrimary = Color.White, 14 | secondary = BlueGray700, 15 | secondaryVariant = BlueGray900, 16 | onSecondary = Color.White, 17 | error = Color.Red, 18 | onBackground = Color.Black 19 | ) 20 | 21 | private val DarkThemeColors = darkColors( 22 | primary = BlueGray300, 23 | primaryVariant = BlueGray700, 24 | onPrimary = Color.Black, 25 | secondary = BlueGray300, 26 | onSecondary = Color.Black, 27 | error = Color.Red, 28 | onBackground = Color.White 29 | ) 30 | 31 | @Composable 32 | fun GitHubUserFinderTheme( 33 | darkTheme: Boolean = isSystemInDarkTheme(), 34 | content: @Composable () -> Unit 35 | ) { 36 | val colors = if (darkTheme) { 37 | DarkThemeColors 38 | } else { 39 | LightThemeColors 40 | } 41 | 42 | MaterialTheme( 43 | colors = colors, 44 | typography = Typography, 45 | shapes = Shapes, 46 | content = content 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /androidApp/src/main/java/io/imrekaszab/githubuserfinder/android/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.android.ui.theme 2 | 3 | import androidx.compose.material.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontWeight 6 | import androidx.compose.ui.unit.sp 7 | 8 | val Typography = Typography( 9 | h4 = TextStyle( 10 | fontWeight = FontWeight.SemiBold, 11 | fontSize = 30.sp, 12 | letterSpacing = 0.sp 13 | ), 14 | h5 = TextStyle( 15 | fontWeight = FontWeight.SemiBold, 16 | fontSize = 24.sp, 17 | letterSpacing = 0.sp 18 | ), 19 | h6 = TextStyle( 20 | fontWeight = FontWeight.SemiBold, 21 | fontSize = 20.sp, 22 | letterSpacing = 0.sp 23 | ), 24 | subtitle1 = TextStyle( 25 | fontWeight = FontWeight.SemiBold, 26 | fontSize = 16.sp, 27 | letterSpacing = 0.15.sp 28 | ), 29 | subtitle2 = TextStyle( 30 | fontWeight = FontWeight.Medium, 31 | fontSize = 14.sp, 32 | letterSpacing = 0.1.sp 33 | ), 34 | body1 = TextStyle( 35 | fontWeight = FontWeight.Normal, 36 | fontSize = 16.sp, 37 | letterSpacing = 0.5.sp 38 | ), 39 | body2 = TextStyle( 40 | fontWeight = FontWeight.Medium, 41 | fontSize = 14.sp, 42 | letterSpacing = 0.25.sp 43 | ), 44 | button = TextStyle( 45 | fontWeight = FontWeight.SemiBold, 46 | fontSize = 14.sp, 47 | letterSpacing = 1.25.sp 48 | ), 49 | caption = TextStyle( 50 | fontWeight = FontWeight.Medium, 51 | fontSize = 12.sp, 52 | letterSpacing = 0.4.sp 53 | ) 54 | ) 55 | -------------------------------------------------------------------------------- /androidApp/src/main/java/io/imrekaszab/githubuserfinder/android/ui/view/FavouriteUsersScreen.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.android.ui.view 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.material.Icon 7 | import androidx.compose.material.Scaffold 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.LaunchedEffect 10 | import androidx.compose.runtime.collectAsState 11 | import androidx.compose.runtime.mutableStateOf 12 | import androidx.compose.runtime.remember 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.tooling.preview.Preview 15 | import androidx.navigation.NavController 16 | import dev.icerock.moko.resources.compose.painterResource 17 | import dev.icerock.moko.resources.compose.stringResource 18 | import io.imrekaszab.githubuserfinder.MR 19 | import io.imrekaszab.githubuserfinder.android.ui.navigation.GitHubUserScreens 20 | import io.imrekaszab.githubuserfinder.android.ui.theme.Dimens 21 | import io.imrekaszab.githubuserfinder.android.ui.theme.GitHubUserFinderTheme 22 | import io.imrekaszab.githubuserfinder.android.ui.widget.CommonAppBar 23 | import io.imrekaszab.githubuserfinder.android.ui.widget.EmptyView 24 | import io.imrekaszab.githubuserfinder.android.ui.widget.ErrorView 25 | import io.imrekaszab.githubuserfinder.android.ui.widget.GitHubUserListView 26 | import io.imrekaszab.githubuserfinder.android.ui.widget.RemoveAllUserDialog 27 | import io.imrekaszab.githubuserfinder.viewmodel.favourite.FavouriteUsersScreenState 28 | import io.imrekaszab.githubuserfinder.viewmodel.favourite.FavouriteUsersViewModel 29 | import org.koin.compose.koinInject 30 | 31 | @Composable 32 | fun FavouriteGitHubUsersScreen(navController: NavController) { 33 | val viewModel: FavouriteUsersViewModel = koinInject() 34 | val state = viewModel.state.collectAsState() 35 | val error = viewModel.error.collectAsState() 36 | 37 | LaunchedEffect(Unit) { 38 | viewModel.loadUsers() 39 | } 40 | 41 | FavouriteGitHubUsersContent( 42 | state = state.value, 43 | error = error.value, 44 | onArrowClick = { navController.popBackStack() }, 45 | onRemoveUsers = { viewModel.deleteAllUser() }, 46 | onItemClick = { 47 | navController.navigate(GitHubUserScreens.GitHubUserDetailScreen.route + "/$it") 48 | } 49 | ) 50 | } 51 | 52 | @Composable 53 | fun FavouriteGitHubUsersContent( 54 | state: FavouriteUsersScreenState, 55 | error: String? = null, 56 | onArrowClick: () -> Unit = {}, 57 | onRemoveUsers: () -> Unit = {}, 58 | onItemClick: (String) -> Unit = {}, 59 | ) { 60 | val showRemoveDialog = remember { mutableStateOf(false) } 61 | Scaffold(topBar = { 62 | CommonAppBar( 63 | title = stringResource(MR.strings.favourite_screen_title), 64 | onArrowClick = onArrowClick, 65 | trailing = { 66 | if (state.data.isNotEmpty()) { 67 | Icon( 68 | painter = painterResource(imageResource = MR.images.ic_trash), 69 | contentDescription = stringResource(MR.strings.clear_button_content_description), 70 | modifier = Modifier 71 | .clickable { showRemoveDialog.value = true } 72 | .padding(end = Dimens.tiny) 73 | ) 74 | } 75 | } 76 | ) 77 | }) { 78 | Box(modifier = Modifier.padding(it)) { 79 | when { 80 | !error.isNullOrEmpty() -> ErrorView(error) 81 | state.data.isEmpty() -> EmptyView() 82 | else -> GitHubUserListView( 83 | itemList = state.data, 84 | onItemClick = onItemClick 85 | ) 86 | } 87 | if (showRemoveDialog.value) { 88 | RemoveAllUserDialog( 89 | onPositiveButtonClick = { 90 | onRemoveUsers() 91 | showRemoveDialog.value = false 92 | }, 93 | onDismissRequest = { 94 | showRemoveDialog.value = false 95 | } 96 | ) 97 | } 98 | } 99 | } 100 | } 101 | 102 | @Preview(name = "FavouriteGitHubUsersScreen", group = "Screens") 103 | @Composable 104 | private fun FavouriteGitHubUsersScreenPreview() { 105 | GitHubUserFinderTheme { 106 | FavouriteGitHubUsersContent(FavouriteUsersScreenState.initial()) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /androidApp/src/main/java/io/imrekaszab/githubuserfinder/android/ui/view/GitHubUserDetailScreen.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.android.ui.view 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.foundation.rememberScrollState 7 | import androidx.compose.foundation.verticalScroll 8 | import androidx.compose.material.Scaffold 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.LaunchedEffect 11 | import androidx.compose.runtime.collectAsState 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.tooling.preview.Preview 14 | import androidx.navigation.NavController 15 | import io.imrekaszab.githubuserfinder.android.ui.theme.Dimens 16 | import io.imrekaszab.githubuserfinder.android.ui.theme.GitHubUserFinderTheme 17 | import io.imrekaszab.githubuserfinder.android.ui.widget.CommonAppBar 18 | import io.imrekaszab.githubuserfinder.android.ui.widget.ErrorView 19 | import io.imrekaszab.githubuserfinder.android.ui.widget.FavoriteButton 20 | import io.imrekaszab.githubuserfinder.android.ui.widget.GitHubUserDetailsView 21 | import io.imrekaszab.githubuserfinder.android.ui.widget.LoadingView 22 | import io.imrekaszab.githubuserfinder.viewmodel.details.GitHubUserDetailsViewModel 23 | import io.imrekaszab.githubuserfinder.viewmodel.details.UserDetailsScreenState 24 | import org.koin.compose.koinInject 25 | 26 | @Composable 27 | fun GitHubUserDetailScreen(navController: NavController, userName: String?) { 28 | val viewModel: GitHubUserDetailsViewModel = koinInject() 29 | LaunchedEffect(userName) { 30 | userName ?: return@LaunchedEffect 31 | viewModel.refreshUserDetails(userName) 32 | } 33 | val state = viewModel.state.collectAsState() 34 | val error = viewModel.error.collectAsState() 35 | 36 | GitHubUserDetailContent( 37 | state = state.value, 38 | error = error.value, 39 | onArrowClick = { navController.popBackStack() }, 40 | onFavouriteClick = { 41 | if (it) { 42 | viewModel.saveUser() 43 | } else { 44 | viewModel.deleteUser() 45 | } 46 | } 47 | ) 48 | } 49 | 50 | @Composable 51 | fun GitHubUserDetailContent( 52 | state: UserDetailsScreenState, 53 | error: String? = null, 54 | onArrowClick: () -> Unit = {}, 55 | onFavouriteClick: (Boolean) -> Unit = {}, 56 | ) { 57 | val userDetails = state.userDetails 58 | 59 | Scaffold(topBar = { 60 | CommonAppBar( 61 | title = userDetails?.login ?: "", 62 | onArrowClick = onArrowClick, 63 | trailing = { 64 | FavoriteButton( 65 | Modifier.padding(end = Dimens.tiny), 66 | isFavourite = state.userDetails?.favourite ?: false, 67 | onFavouriteClick = onFavouriteClick 68 | ) 69 | } 70 | ) 71 | }) { 72 | Box(modifier = Modifier.padding(it)) { 73 | when { 74 | !error.isNullOrEmpty() -> ErrorView(error) 75 | userDetails != null -> 76 | Column( 77 | modifier = Modifier 78 | .verticalScroll(rememberScrollState()) 79 | .padding(it) 80 | ) { 81 | GitHubUserDetailsView(userDetails = userDetails) 82 | } 83 | else -> LoadingView() 84 | } 85 | } 86 | } 87 | } 88 | 89 | @Preview(name = "GitHubUserDetailScreen", group = "Screens") 90 | @Composable 91 | private fun GitHubUserDetailScreenPreview() { 92 | GitHubUserFinderTheme { 93 | GitHubUserDetailContent(UserDetailsScreenState.initial()) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /androidApp/src/main/java/io/imrekaszab/githubuserfinder/android/ui/view/GitHubUserListScreen.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.android.ui.view 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.material.Scaffold 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.runtime.LaunchedEffect 8 | import androidx.compose.runtime.collectAsState 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.tooling.preview.Preview 11 | import androidx.navigation.NavController 12 | import io.imrekaszab.githubuserfinder.android.ui.navigation.GitHubUserScreens 13 | import io.imrekaszab.githubuserfinder.android.ui.theme.GitHubUserFinderTheme 14 | import io.imrekaszab.githubuserfinder.android.ui.widget.EmptyView 15 | import io.imrekaszab.githubuserfinder.android.ui.widget.ErrorView 16 | import io.imrekaszab.githubuserfinder.android.ui.widget.GitHubUserListView 17 | import io.imrekaszab.githubuserfinder.android.ui.widget.LoadingView 18 | import io.imrekaszab.githubuserfinder.android.ui.widget.SearchAppBar 19 | import io.imrekaszab.githubuserfinder.viewmodel.list.GitHubUserListViewModel 20 | import io.imrekaszab.githubuserfinder.viewmodel.list.UserListScreenState 21 | import org.koin.compose.koinInject 22 | 23 | @Composable 24 | fun GitHubUserListScreen(navController: NavController) { 25 | val viewModel: GitHubUserListViewModel = koinInject() 26 | val state = viewModel.state.collectAsState() 27 | val error = viewModel.error.collectAsState() 28 | 29 | LaunchedEffect(Unit) { 30 | viewModel.loadUsers() 31 | } 32 | 33 | GitHubUserListContent( 34 | state = state.value, 35 | error = error.value, 36 | onSearchCLick = { viewModel.searchUser(it) }, 37 | onStarClick = { navController.navigate(GitHubUserScreens.FavouriteGitHubUsersScreen.route) }, 38 | loadMore = { viewModel.requestNextPage() }, 39 | onItemClick = { 40 | navController.navigate(GitHubUserScreens.GitHubUserDetailScreen.route + "/$it") 41 | } 42 | ) 43 | } 44 | 45 | @Composable 46 | fun GitHubUserListContent( 47 | state: UserListScreenState, 48 | error: String? = null, 49 | onSearchCLick: (String) -> Unit = {}, 50 | onStarClick: () -> Unit = {}, 51 | loadMore: () -> Unit = {}, 52 | onItemClick: (String) -> Unit = {}, 53 | ) { 54 | Scaffold(topBar = { 55 | SearchAppBar(onSearchCLick = onSearchCLick, onStarClick = onStarClick) 56 | }) { 57 | Box(modifier = Modifier.padding(it)) { 58 | when { 59 | !error.isNullOrEmpty() -> ErrorView(error) 60 | state.isLoading -> LoadingView() 61 | state.data.isEmpty() -> EmptyView() 62 | else -> GitHubUserListView( 63 | itemList = state.data, 64 | showFavouriteIconOnItem = true, 65 | isFetchingFinished = state.isFetchingFinished, 66 | loadMore = loadMore, 67 | onItemClick = onItemClick 68 | ) 69 | } 70 | } 71 | } 72 | } 73 | 74 | @Preview(name = "GitHubUserListScreen", group = "Screens") 75 | @Composable 76 | private fun GitHubUserListScreenPreview() { 77 | GitHubUserFinderTheme { 78 | GitHubUserListContent(UserListScreenState.initial()) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /androidApp/src/main/java/io/imrekaszab/githubuserfinder/android/ui/widget/AlertDialog.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.android.ui.widget 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.material.AlertDialog 8 | import androidx.compose.material.Button 9 | import androidx.compose.material.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.graphics.Color 13 | import dev.icerock.moko.resources.compose.stringResource 14 | import io.imrekaszab.githubuserfinder.MR 15 | import io.imrekaszab.githubuserfinder.android.ui.theme.Dimens 16 | 17 | @Composable 18 | fun RemoveAllUserDialog(onPositiveButtonClick: () -> Unit, onDismissRequest: () -> Unit) { 19 | AlertDialog( 20 | title = { 21 | Text(text = stringResource(MR.strings.remove_all_user_dialog_title)) 22 | }, 23 | text = { 24 | Text(text = stringResource(MR.strings.remove_all_user_dialog_description)) 25 | }, 26 | backgroundColor = Color.Transparent, 27 | buttons = { 28 | Row( 29 | modifier = Modifier 30 | .padding(all = Dimens.tiny) 31 | .fillMaxWidth(), 32 | horizontalArrangement = Arrangement.SpaceEvenly 33 | ) { 34 | Button( 35 | modifier = Modifier.padding(all = Dimens.tiny), 36 | onClick = onDismissRequest 37 | ) { 38 | Text(stringResource(MR.strings.remove_all_user_dialog_dismiss)) 39 | } 40 | Button( 41 | modifier = Modifier.padding(all = Dimens.tiny), 42 | onClick = onPositiveButtonClick 43 | ) { 44 | Text(stringResource(MR.strings.remove_all_user_dialog_remove)) 45 | } 46 | } 47 | }, 48 | onDismissRequest = onDismissRequest 49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /androidApp/src/main/java/io/imrekaszab/githubuserfinder/android/ui/widget/CommonAppBar.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.android.ui.widget 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.Spacer 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.material.Icon 10 | import androidx.compose.material.MaterialTheme 11 | import androidx.compose.material.Text 12 | import androidx.compose.material.TopAppBar 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import dev.icerock.moko.resources.compose.painterResource 17 | import dev.icerock.moko.resources.compose.stringResource 18 | import io.imrekaszab.githubuserfinder.MR 19 | import io.imrekaszab.githubuserfinder.android.ui.theme.Dimens 20 | 21 | @Composable 22 | fun CommonAppBar( 23 | title: String, 24 | onArrowClick: () -> Unit = {}, 25 | trailing: @Composable (() -> Unit) = {}, 26 | ) { 27 | TopAppBar { 28 | Row( 29 | horizontalArrangement = Arrangement.Start, 30 | verticalAlignment = Alignment.CenterVertically, 31 | modifier = Modifier 32 | .padding(start = Dimens.tiny) 33 | .fillMaxWidth() 34 | ) { 35 | Icon( 36 | painter = painterResource(imageResource = MR.images.ic_arrow_left), 37 | contentDescription = stringResource(MR.strings.common_app_bar_back_arrow_content_description), 38 | modifier = Modifier.clickable { onArrowClick() } 39 | ) 40 | Text( 41 | text = title, 42 | style = MaterialTheme.typography.h6, 43 | modifier = Modifier.padding(start = Dimens.default) 44 | ) 45 | Spacer(modifier = Modifier.weight(1.0f)) 46 | trailing() 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /androidApp/src/main/java/io/imrekaszab/githubuserfinder/android/ui/widget/EmptyView.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.android.ui.widget 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.material.Text 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Alignment 8 | import androidx.compose.ui.Modifier 9 | import dev.icerock.moko.resources.compose.stringResource 10 | import io.imrekaszab.githubuserfinder.MR 11 | 12 | @Composable 13 | fun EmptyView() { 14 | Box( 15 | modifier = Modifier.fillMaxSize(), 16 | contentAlignment = Alignment.Center 17 | ) { 18 | Text(text = stringResource(MR.strings.empty_view_title)) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /androidApp/src/main/java/io/imrekaszab/githubuserfinder/android/ui/widget/ErrorView.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.android.ui.widget 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.material.Text 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Alignment 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.text.style.TextAlign 10 | import dev.icerock.moko.resources.compose.stringResource 11 | import io.imrekaszab.githubuserfinder.MR 12 | 13 | @Composable 14 | fun ErrorView(errorText: String) { 15 | Box( 16 | modifier = Modifier.fillMaxSize(), 17 | contentAlignment = Alignment.Center 18 | ) { 19 | Text( 20 | text = stringResource(MR.strings.error_view_title, errorText), 21 | textAlign = TextAlign.Center 22 | ) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /androidApp/src/main/java/io/imrekaszab/githubuserfinder/android/ui/widget/FavouriteButton.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.android.ui.widget 2 | 3 | import androidx.compose.material.Icon 4 | import androidx.compose.material.IconToggleButton 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.graphics.Color 8 | import androidx.compose.ui.graphics.graphicsLayer 9 | import dev.icerock.moko.resources.compose.painterResource 10 | import dev.icerock.moko.resources.compose.stringResource 11 | import io.imrekaszab.githubuserfinder.MR 12 | import io.imrekaszab.githubuserfinder.android.ui.theme.Dimens 13 | 14 | @Composable 15 | fun FavoriteButton( 16 | modifier: Modifier, 17 | isFavourite: Boolean, 18 | onFavouriteClick: (Boolean) -> Unit 19 | ) { 20 | IconToggleButton( 21 | modifier = modifier, 22 | checked = isFavourite, 23 | onCheckedChange = { onFavouriteClick(!isFavourite) } 24 | ) { 25 | Icon( 26 | tint = Color.White, 27 | modifier = Modifier.graphicsLayer { 28 | scaleX = Dimens.favouriteButtonScale 29 | scaleY = Dimens.favouriteButtonScale 30 | }, 31 | painter = painterResource( 32 | imageResource = if (isFavourite) { 33 | MR.images.ic_star_fill 34 | } else { 35 | MR.images.ic_star 36 | } 37 | ), 38 | contentDescription = stringResource(MR.strings.favourite_button_content_description) 39 | ) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /androidApp/src/main/java/io/imrekaszab/githubuserfinder/android/ui/widget/GitHubUserDetailItemView.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.android.ui.widget 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.material.MaterialTheme 8 | import androidx.compose.material.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.text.style.TextAlign 12 | import io.imrekaszab.githubuserfinder.android.ui.theme.Dimens 13 | 14 | @Composable 15 | fun GitHubUserDetailItemView(label: String, value: String?) { 16 | if (!value.isNullOrEmpty()) { 17 | Row( 18 | modifier = Modifier 19 | .fillMaxWidth() 20 | .padding(Dimens.tiny), 21 | horizontalArrangement = Arrangement.SpaceBetween 22 | ) { 23 | Text( 24 | label, 25 | style = MaterialTheme.typography.subtitle1, 26 | modifier = Modifier.weight(Dimens.labelTitleWeight) 27 | ) 28 | Text( 29 | value, 30 | style = MaterialTheme.typography.body1, 31 | modifier = Modifier.weight(Dimens.valueTitleWeight), 32 | textAlign = TextAlign.End 33 | ) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /androidApp/src/main/java/io/imrekaszab/githubuserfinder/android/ui/widget/GitHubUserDetailsView.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.android.ui.widget 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.Spacer 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.height 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.layout.size 9 | import androidx.compose.material.Card 10 | import androidx.compose.material.MaterialTheme 11 | import androidx.compose.material.Surface 12 | import androidx.compose.material.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.text.style.TextAlign 17 | import coil.compose.AsyncImage 18 | import dev.icerock.moko.resources.compose.stringResource 19 | import io.imrekaszab.githubuserfinder.MR 20 | import io.imrekaszab.githubuserfinder.android.ui.theme.Dimens 21 | import io.imrekaszab.githubuserfinder.model.domain.GitHubUser 22 | 23 | @Composable 24 | fun GitHubUserDetailsView(userDetails: GitHubUser) { 25 | Card( 26 | modifier = Modifier 27 | .padding(Dimens.default) 28 | .fillMaxWidth(), 29 | shape = MaterialTheme.shapes.large, 30 | elevation = Dimens.tiny 31 | ) { 32 | Column( 33 | modifier = Modifier 34 | .fillMaxWidth() 35 | .padding(Dimens.default), 36 | horizontalAlignment = Alignment.CenterHorizontally 37 | ) { 38 | Text( 39 | userDetails.name, 40 | style = MaterialTheme.typography.h5 41 | ) 42 | Spacer(modifier = Modifier.height(Dimens.default)) 43 | Surface( 44 | modifier = Modifier.size(Dimens.bigImageSize), 45 | shape = MaterialTheme.shapes.large, 46 | color = MaterialTheme.colors.secondaryVariant.copy(alpha = 0.2f) 47 | ) { 48 | AsyncImage( 49 | model = userDetails.avatarUrl, 50 | modifier = Modifier.size(Dimens.bigImageSize), 51 | contentDescription = userDetails.name 52 | ) 53 | } 54 | Spacer(modifier = Modifier.height(Dimens.default)) 55 | Text( 56 | text = userDetails.bio ?: "", 57 | style = MaterialTheme.typography.body1, 58 | textAlign = TextAlign.Center 59 | ) 60 | Spacer(modifier = Modifier.height(Dimens.default)) 61 | GitHubUserDetailItemViews(userDetails) 62 | } 63 | } 64 | } 65 | 66 | @Composable 67 | fun GitHubUserDetailItemViews(userDetails: GitHubUser) { 68 | GitHubUserDetailItemView( 69 | label = stringResource(MR.strings.details_view_followers), 70 | value = userDetails.followers.toString() 71 | ) 72 | GitHubUserDetailItemView( 73 | label = stringResource(MR.strings.details_view_following), 74 | value = userDetails.following.toString() 75 | ) 76 | GitHubUserDetailItemView( 77 | label = stringResource(MR.strings.details_view_public_repos), 78 | value = userDetails.publicRepos.toString() 79 | ) 80 | GitHubUserDetailItemView( 81 | label = stringResource(MR.strings.details_view_company), 82 | value = userDetails.company 83 | ) 84 | GitHubUserDetailItemView( 85 | label = stringResource(MR.strings.details_view_location), 86 | value = userDetails.location 87 | ) 88 | GitHubUserDetailItemView( 89 | label = stringResource(MR.strings.details_view_email), 90 | value = userDetails.email 91 | ) 92 | GitHubUserDetailItemView( 93 | label = stringResource(MR.strings.details_view_blog), 94 | value = userDetails.blog 95 | ) 96 | GitHubUserDetailItemView( 97 | label = stringResource(MR.strings.details_view_twitter), 98 | value = userDetails.twitterUsername 99 | ) 100 | } 101 | -------------------------------------------------------------------------------- /androidApp/src/main/java/io/imrekaszab/githubuserfinder/android/ui/widget/GitHubUserListView.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.android.ui.widget 2 | 3 | import androidx.compose.runtime.Composable 4 | import io.imrekaszab.githubuserfinder.model.domain.GitHubUser 5 | 6 | @Composable 7 | fun GitHubUserListView( 8 | itemList: List, 9 | showFavouriteIconOnItem: Boolean = false, 10 | isFetchingFinished: Boolean = true, 11 | loadMore: () -> Unit = {}, 12 | onItemClick: (String) -> Unit = {} 13 | ) { 14 | InfiniteLoadingListView( 15 | items = itemList, 16 | isFetchingFinished = isFetchingFinished, 17 | loadMore = { loadMore() } 18 | ) { _, item -> 19 | GitHubUserRow(item = item as GitHubUser, showFavouriteIconOnItem) { userName -> 20 | onItemClick(userName) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /androidApp/src/main/java/io/imrekaszab/githubuserfinder/android/ui/widget/GitHubUserRow.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.android.ui.widget 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.Spacer 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.height 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.layout.size 11 | import androidx.compose.material.Card 12 | import androidx.compose.material.Icon 13 | import androidx.compose.material.MaterialTheme 14 | import androidx.compose.material.Surface 15 | import androidx.compose.material.Text 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.ui.Alignment 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.text.font.FontWeight 20 | import coil.compose.AsyncImage 21 | import dev.icerock.moko.resources.compose.painterResource 22 | import dev.icerock.moko.resources.compose.stringResource 23 | import io.imrekaszab.githubuserfinder.MR 24 | import io.imrekaszab.githubuserfinder.android.ui.theme.Dimens 25 | import io.imrekaszab.githubuserfinder.model.domain.GitHubUser 26 | 27 | @Composable 28 | fun GitHubUserRow( 29 | item: GitHubUser, 30 | showFavouriteIconOnItem: Boolean, 31 | onItemCLick: (String) -> Unit 32 | ) { 33 | Card( 34 | modifier = Modifier 35 | .padding(Dimens.tiny) 36 | .fillMaxWidth() 37 | .height(Dimens.cardHeight) 38 | .clickable { 39 | onItemCLick(item.login) 40 | }, 41 | shape = MaterialTheme.shapes.large, 42 | elevation = Dimens.tiny 43 | ) { 44 | Row( 45 | horizontalArrangement = Arrangement.Start, 46 | modifier = Modifier 47 | .fillMaxWidth() 48 | .padding(Dimens.default), 49 | verticalAlignment = Alignment.CenterVertically 50 | ) { 51 | Surface( 52 | modifier = Modifier.size(Dimens.imageSize), 53 | shape = MaterialTheme.shapes.large, 54 | color = MaterialTheme.colors.secondaryVariant.copy(alpha = 0.2f) 55 | ) { 56 | AsyncImage( 57 | model = item.avatarUrl, 58 | modifier = Modifier.size(Dimens.imageSize), 59 | contentDescription = item.login 60 | ) 61 | } 62 | Text( 63 | item.login, 64 | style = MaterialTheme.typography.h6, 65 | fontWeight = FontWeight.Bold, 66 | modifier = Modifier.padding(Dimens.tiny) 67 | ) 68 | Spacer(modifier = Modifier.weight(1.0f)) 69 | if (showFavouriteIconOnItem && item.favourite) { 70 | Icon( 71 | painter = painterResource(imageResource = MR.images.ic_star_fill), 72 | contentDescription = stringResource(MR.strings.favourite_button_content_description) 73 | ) 74 | } 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /androidApp/src/main/java/io/imrekaszab/githubuserfinder/android/ui/widget/InfiniteLoadingListView.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.android.ui.widget 2 | 3 | import androidx.compose.foundation.lazy.LazyColumn 4 | import androidx.compose.foundation.lazy.LazyListState 5 | import androidx.compose.foundation.lazy.itemsIndexed 6 | import androidx.compose.foundation.lazy.rememberLazyListState 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.MutableState 9 | import androidx.compose.runtime.SideEffect 10 | import androidx.compose.runtime.mutableStateOf 11 | import androidx.compose.runtime.remember 12 | 13 | @Composable 14 | fun InfiniteLoadingListView( 15 | items: List, 16 | loadMore: () -> Unit, 17 | isFetchingFinished: Boolean, 18 | rowContent: @Composable (Int, Any) -> Unit 19 | ) { 20 | val listState = rememberLazyListState() 21 | val firstVisibleIndex = remember { mutableStateOf(listState.firstVisibleItemIndex) } 22 | LazyColumn(state = listState) { 23 | itemsIndexed(items) { index, item -> 24 | if (items.size == index + 1 && !isFetchingFinished) { 25 | rowContent(index, item) 26 | LoadingView() 27 | } else { 28 | rowContent(index, item) 29 | } 30 | } 31 | } 32 | if (listState.shouldLoadMore(firstVisibleIndex) && !isFetchingFinished) { 33 | SideEffect { 34 | loadMore() 35 | } 36 | } 37 | } 38 | 39 | fun LazyListState.shouldLoadMore(rememberedIndex: MutableState): Boolean { 40 | val firstVisibleIndex = this.firstVisibleItemIndex 41 | if (rememberedIndex.value != firstVisibleIndex) { 42 | rememberedIndex.value = firstVisibleIndex 43 | return layoutInfo.visibleItemsInfo.lastOrNull()?.index == layoutInfo.totalItemsCount - 1 44 | } 45 | return false 46 | } 47 | -------------------------------------------------------------------------------- /androidApp/src/main/java/io/imrekaszab/githubuserfinder/android/ui/widget/LoadingView.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.android.ui.widget 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.material.CircularProgressIndicator 7 | import androidx.compose.material.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Alignment 10 | import androidx.compose.ui.Modifier 11 | import dev.icerock.moko.resources.compose.stringResource 12 | import io.imrekaszab.githubuserfinder.MR 13 | 14 | @Composable 15 | fun LoadingView() { 16 | Box(modifier = Modifier.fillMaxSize()) { 17 | Column( 18 | modifier = Modifier.align(Alignment.Center), 19 | horizontalAlignment = Alignment.CenterHorizontally 20 | ) { 21 | Text(text = stringResource(MR.strings.loading_title)) 22 | CircularProgressIndicator() 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /androidApp/src/main/java/io/imrekaszab/githubuserfinder/android/ui/widget/SearchAppBar.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.android.ui.widget 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.height 7 | import androidx.compose.foundation.text.KeyboardActions 8 | import androidx.compose.foundation.text.KeyboardOptions 9 | import androidx.compose.material.AppBarDefaults 10 | import androidx.compose.material.ContentAlpha 11 | import androidx.compose.material.Icon 12 | import androidx.compose.material.IconButton 13 | import androidx.compose.material.MaterialTheme 14 | import androidx.compose.material.Surface 15 | import androidx.compose.material.Text 16 | import androidx.compose.material.TextField 17 | import androidx.compose.material.TextFieldDefaults 18 | import androidx.compose.runtime.Composable 19 | import androidx.compose.runtime.derivedStateOf 20 | import androidx.compose.runtime.getValue 21 | import androidx.compose.runtime.mutableStateOf 22 | import androidx.compose.runtime.remember 23 | import androidx.compose.runtime.saveable.rememberSaveable 24 | import androidx.compose.runtime.setValue 25 | import androidx.compose.ui.Alignment 26 | import androidx.compose.ui.Modifier 27 | import androidx.compose.ui.graphics.Color 28 | import androidx.compose.ui.platform.LocalFocusManager 29 | import androidx.compose.ui.text.input.ImeAction 30 | import androidx.compose.ui.text.input.KeyboardType 31 | import androidx.compose.ui.tooling.preview.Preview 32 | import dev.icerock.moko.resources.compose.painterResource 33 | import dev.icerock.moko.resources.compose.stringResource 34 | import io.imrekaszab.githubuserfinder.MR 35 | import io.imrekaszab.githubuserfinder.android.ui.theme.Dimens 36 | 37 | @Composable 38 | fun SearchAppBar(onSearchCLick: (String) -> Unit, onStarClick: () -> Unit) { 39 | Surface( 40 | modifier = Modifier 41 | .fillMaxWidth() 42 | .height(Dimens.searchBarHeight), 43 | elevation = AppBarDefaults.TopAppBarElevation, 44 | color = MaterialTheme.colors.primary 45 | ) { 46 | SearchAppBarContent(onSearchCLick, onStarClick) 47 | } 48 | } 49 | 50 | @Composable 51 | fun SearchAppBarContent(onSearchCLick: (String) -> Unit, onStarClick: () -> Unit) { 52 | var query: String by rememberSaveable { mutableStateOf("") } 53 | val showClearIcon by remember { derivedStateOf { query.isNotEmpty() } } 54 | val focusManager = LocalFocusManager.current 55 | Row( 56 | horizontalArrangement = Arrangement.SpaceAround, 57 | verticalAlignment = Alignment.CenterVertically, 58 | modifier = Modifier.fillMaxWidth() 59 | ) { 60 | TextField( 61 | value = query, 62 | onValueChange = { onQueryChanged -> 63 | query = onQueryChanged 64 | }, 65 | trailingIcon = { 66 | if (showClearIcon) { 67 | IconButton(onClick = { query = "" }) { 68 | Icon( 69 | painter = painterResource(imageResource = MR.images.ic_clear), 70 | contentDescription = 71 | stringResource(MR.strings.clear_button_content_description) 72 | ) 73 | } 74 | } 75 | }, 76 | colors = TextFieldDefaults.textFieldColors( 77 | backgroundColor = Color.Transparent, 78 | cursorColor = Color.White.copy(alpha = ContentAlpha.medium) 79 | ), 80 | maxLines = 1, 81 | placeholder = { 82 | Text( 83 | text = stringResource(MR.strings.search_app_bar_title), 84 | style = MaterialTheme.typography.h6 85 | ) 86 | }, 87 | textStyle = MaterialTheme.typography.h6, 88 | singleLine = true, 89 | keyboardOptions = KeyboardOptions( 90 | keyboardType = KeyboardType.Text, 91 | imeAction = ImeAction.Search 92 | ), 93 | keyboardActions = KeyboardActions( 94 | onSearch = { 95 | focusManager.clearFocus() 96 | onSearchCLick(query) 97 | } 98 | ) 99 | ) 100 | IconButton(onClick = onStarClick) { 101 | Icon( 102 | painter = painterResource(imageResource = MR.images.ic_star_fill), 103 | contentDescription = stringResource(MR.strings.favourite_button_content_description) 104 | ) 105 | } 106 | } 107 | } 108 | 109 | @Preview(showBackground = true) 110 | @Composable 111 | fun SearchAppBarPreview() { 112 | SearchAppBar({}, {}) 113 | } 114 | -------------------------------------------------------------------------------- /androidApp/src/main/res/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apter-tech/GitHubUserFinder/870c25ab76ba69a5a735ae23b67cb20e41607e98/androidApp/src/main/res/ic_launcher-playstore.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apter-tech/GitHubUserFinder/870c25ab76ba69a5a735ae23b67cb20e41607e98/androidApp/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-hdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apter-tech/GitHubUserFinder/870c25ab76ba69a5a735ae23b67cb20e41607e98/androidApp/src/main/res/mipmap-hdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apter-tech/GitHubUserFinder/870c25ab76ba69a5a735ae23b67cb20e41607e98/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apter-tech/GitHubUserFinder/870c25ab76ba69a5a735ae23b67cb20e41607e98/androidApp/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-mdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apter-tech/GitHubUserFinder/870c25ab76ba69a5a735ae23b67cb20e41607e98/androidApp/src/main/res/mipmap-mdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apter-tech/GitHubUserFinder/870c25ab76ba69a5a735ae23b67cb20e41607e98/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apter-tech/GitHubUserFinder/870c25ab76ba69a5a735ae23b67cb20e41607e98/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-xhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apter-tech/GitHubUserFinder/870c25ab76ba69a5a735ae23b67cb20e41607e98/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apter-tech/GitHubUserFinder/870c25ab76ba69a5a735ae23b67cb20e41607e98/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apter-tech/GitHubUserFinder/870c25ab76ba69a5a735ae23b67cb20e41607e98/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apter-tech/GitHubUserFinder/870c25ab76ba69a5a735ae23b67cb20e41607e98/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apter-tech/GitHubUserFinder/870c25ab76ba69a5a735ae23b67cb20e41607e98/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apter-tech/GitHubUserFinder/870c25ab76ba69a5a735ae23b67cb20e41607e98/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apter-tech/GitHubUserFinder/870c25ab76ba69a5a735ae23b67cb20e41607e98/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apter-tech/GitHubUserFinder/870c25ab76ba69a5a735ae23b67cb20e41607e98/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /androidApp/src/main/res/values-night/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #90A4AE 4 | 5 | -------------------------------------------------------------------------------- /androidApp/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #455A64 4 | #263238 5 | #455A64 6 | -------------------------------------------------------------------------------- /androidApp/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | GitHubUserFinder 4 | -------------------------------------------------------------------------------- /androidApp/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.application) apply false 3 | alias(libs.plugins.android.library) apply false 4 | alias(libs.plugins.kotlin.multiplatform) apply false 5 | alias(libs.plugins.moko.resources) apply false 6 | alias(libs.plugins.detekt) 7 | alias(libs.plugins.kover) 8 | } 9 | 10 | tasks.register("clean", Delete::class) { 11 | delete(rootProject.layout.buildDirectory.asFile) 12 | } 13 | 14 | ext { 15 | set("appJvmTarget", JavaVersion.VERSION_1_8) 16 | } 17 | 18 | dependencies { 19 | kover(project(":androidApp")) 20 | kover(project(":shared")) 21 | } 22 | 23 | allprojects { 24 | apply(plugin = rootProject.libs.plugins.kover.get().pluginId) 25 | apply(plugin = rootProject.libs.plugins.detekt.get().pluginId) 26 | detekt { 27 | source.setFrom( 28 | objects.fileCollection().from( 29 | io.gitlab.arturbosch.detekt.extensions.DetektExtension.DEFAULT_SRC_DIR_JAVA, 30 | io.gitlab.arturbosch.detekt.extensions.DetektExtension.DEFAULT_TEST_SRC_DIR_JAVA, 31 | io.gitlab.arturbosch.detekt.extensions.DetektExtension.DEFAULT_SRC_DIR_KOTLIN, 32 | io.gitlab.arturbosch.detekt.extensions.DetektExtension.DEFAULT_TEST_SRC_DIR_KOTLIN, 33 | "src/androidMain", 34 | "src/commonMain", 35 | "src/iosMain", 36 | "src/commonTest", 37 | "src/iosTest", 38 | "src/androidUnitTest", 39 | "src/test", 40 | "src/testDebug", 41 | "src/testRelease", 42 | "build.gradle.kts", 43 | ) 44 | ) 45 | buildUponDefaultConfig = false 46 | // point to your custom config defining rules to run, overwriting default behavior 47 | config.setFrom(files("$rootDir/config/detekt.yml")) 48 | } 49 | dependencies { 50 | detektPlugins(rootProject.libs.detekt.formatting) 51 | } 52 | 53 | koverReport { 54 | defaults { 55 | plugins.withId("com.android.library") { 56 | mergeWith("release") 57 | } 58 | plugins.withId("com.android.application") { 59 | mergeWith("release") 60 | } 61 | } 62 | filters { 63 | excludes { 64 | classes( 65 | "*.MR*", 66 | "*.BuildConfig", 67 | "*.di.*", 68 | ) 69 | } 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /config/detekt.yml: -------------------------------------------------------------------------------- 1 | build: 2 | maxIssues: 0 3 | excludeCorrectable: false 4 | weights: 5 | # complexity: 2 6 | # LongParameterList: 1 7 | # style: 1 8 | # comments: 1 9 | 10 | config: 11 | validation: true 12 | warningsAsErrors: true 13 | # when writing own rules with new properties, exclude the property path e.g.: 'my_rule_set,.*>.*>[my_property]' 14 | excludes: '' 15 | 16 | processors: 17 | active: true 18 | exclude: 19 | - 'DetektProgressListener' 20 | # - 'KtFileCountProcessor' 21 | # - 'PackageCountProcessor' 22 | # - 'ClassCountProcessor' 23 | # - 'FunctionCountProcessor' 24 | # - 'PropertyCountProcessor' 25 | # - 'ProjectComplexityProcessor' 26 | # - 'ProjectCognitiveComplexityProcessor' 27 | # - 'ProjectLLOCProcessor' 28 | # - 'ProjectCLOCProcessor' 29 | # - 'ProjectLOCProcessor' 30 | # - 'ProjectSLOCProcessor' 31 | # - 'LicenseHeaderLoaderExtension' 32 | 33 | console-reports: 34 | active: true 35 | exclude: 36 | - 'ProjectStatisticsReport' 37 | - 'ComplexityReport' 38 | - 'NotificationReport' 39 | # - 'FindingsReport' 40 | - 'FileBasedFindingsReport' 41 | - 'LiteFindingsReport' 42 | 43 | output-reports: 44 | active: true 45 | exclude: 46 | # - 'TxtOutputReport' 47 | # - 'XmlOutputReport' 48 | # - 'HtmlOutputReport' 49 | 50 | comments: 51 | active: true 52 | AbsentOrWrongFileLicense: 53 | active: false 54 | licenseTemplateFile: 'license.template' 55 | licenseTemplateIsRegex: false 56 | CommentOverPrivateFunction: 57 | active: false 58 | CommentOverPrivateProperty: 59 | active: false 60 | DeprecatedBlockTag: 61 | active: false 62 | EndOfSentenceFormat: 63 | active: false 64 | endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)' 65 | OutdatedDocumentation: 66 | active: false 67 | matchTypeParameters: true 68 | matchDeclarationsOrder: true 69 | UndocumentedPublicClass: 70 | active: false 71 | searchInNestedClass: true 72 | searchInInnerClass: true 73 | searchInInnerObject: true 74 | searchInInnerInterface: true 75 | UndocumentedPublicFunction: 76 | active: false 77 | UndocumentedPublicProperty: 78 | active: false 79 | 80 | complexity: 81 | active: true 82 | ComplexCondition: 83 | active: true 84 | threshold: 4 85 | ComplexInterface: 86 | active: true 87 | threshold: 10 88 | includeStaticDeclarations: false 89 | includePrivateDeclarations: false 90 | CyclomaticComplexMethod: 91 | active: true 92 | threshold: 15 93 | ignoreSingleWhenExpression: false 94 | ignoreSimpleWhenEntries: false 95 | ignoreNestingFunctions: false 96 | nestingFunctions: 97 | - 'also' 98 | - 'apply' 99 | - 'forEach' 100 | - 'isNotNull' 101 | - 'ifNull' 102 | - 'let' 103 | - 'run' 104 | - 'use' 105 | - 'with' 106 | LabeledExpression: 107 | active: false 108 | ignoredLabels: [ ] 109 | LargeClass: 110 | active: true 111 | threshold: 600 112 | LongMethod: 113 | active: true 114 | threshold: 60 115 | LongParameterList: 116 | active: true 117 | functionThreshold: 6 118 | constructorThreshold: 9 119 | ignoreDefaultParameters: true 120 | ignoreDataClasses: true 121 | ignoreAnnotatedParameter: [ ] 122 | ignoreAnnotated: [ 'Composable' ] 123 | MethodOverloading: 124 | active: true 125 | threshold: 6 126 | NamedArguments: 127 | active: false 128 | threshold: 3 129 | NestedBlockDepth: 130 | active: true 131 | threshold: 4 132 | ReplaceSafeCallChainWithRun: 133 | active: true 134 | StringLiteralDuplication: 135 | active: true 136 | threshold: 3 137 | ignoreAnnotation: true 138 | excludeStringsWithLessThan5Characters: true 139 | ignoreStringsRegex: '$^' 140 | excludes: [ '**/commonTest/**' ] 141 | TooManyFunctions: 142 | active: true 143 | thresholdInFiles: 30 144 | thresholdInClasses: 30 145 | thresholdInInterfaces: 30 146 | thresholdInObjects: 30 147 | thresholdInEnums: 30 148 | ignoreDeprecated: false 149 | ignorePrivate: false 150 | ignoreOverridden: false 151 | 152 | coroutines: 153 | active: true 154 | GlobalCoroutineUsage: 155 | active: false 156 | InjectDispatcher: 157 | active: false 158 | dispatcherNames: 159 | - 'IO' 160 | - 'Default' 161 | - 'Unconfined' 162 | RedundantSuspendModifier: 163 | active: true 164 | SleepInsteadOfDelay: 165 | active: true 166 | SuspendFunWithFlowReturnType: 167 | active: true 168 | 169 | empty-blocks: 170 | active: true 171 | EmptyCatchBlock: 172 | active: true 173 | allowedExceptionNameRegex: '_|(ignore|expected).*' 174 | EmptyClassBlock: 175 | active: true 176 | EmptyDefaultConstructor: 177 | active: true 178 | EmptyDoWhileBlock: 179 | active: true 180 | EmptyElseBlock: 181 | active: true 182 | EmptyFinallyBlock: 183 | active: true 184 | EmptyForBlock: 185 | active: true 186 | EmptyFunctionBlock: 187 | active: true 188 | ignoreOverridden: false 189 | EmptyIfBlock: 190 | active: true 191 | EmptyInitBlock: 192 | active: true 193 | EmptyKtFile: 194 | active: true 195 | EmptySecondaryConstructor: 196 | active: true 197 | EmptyTryBlock: 198 | active: true 199 | EmptyWhenBlock: 200 | active: true 201 | EmptyWhileBlock: 202 | active: true 203 | 204 | exceptions: 205 | active: true 206 | ExceptionRaisedInUnexpectedLocation: 207 | active: true 208 | methodNames: 209 | - 'equals' 210 | - 'finalize' 211 | - 'hashCode' 212 | - 'toString' 213 | InstanceOfCheckForException: 214 | active: true 215 | NotImplementedDeclaration: 216 | active: true 217 | ObjectExtendsThrowable: 218 | active: true 219 | PrintStackTrace: 220 | active: true 221 | RethrowCaughtException: 222 | active: true 223 | ReturnFromFinally: 224 | active: true 225 | ignoreLabeled: false 226 | SwallowedException: 227 | active: true 228 | ignoredExceptionTypes: 229 | - 'InterruptedException' 230 | - 'MalformedURLException' 231 | - 'NumberFormatException' 232 | - 'ParseException' 233 | allowedExceptionNameRegex: '_|(ignore|expected).*' 234 | ThrowingExceptionFromFinally: 235 | active: true 236 | ThrowingExceptionInMain: 237 | active: true 238 | ThrowingExceptionsWithoutMessageOrCause: 239 | active: true 240 | exceptions: 241 | - 'ArrayIndexOutOfBoundsException' 242 | - 'Exception' 243 | - 'IllegalArgumentException' 244 | - 'IllegalMonitorStateException' 245 | - 'IllegalStateException' 246 | - 'IndexOutOfBoundsException' 247 | - 'NullPointerException' 248 | - 'RuntimeException' 249 | - 'Throwable' 250 | ThrowingNewInstanceOfSameException: 251 | active: true 252 | TooGenericExceptionCaught: 253 | active: true 254 | exceptionNames: 255 | - 'ArrayIndexOutOfBoundsException' 256 | - 'Error' 257 | - 'Exception' 258 | - 'IllegalMonitorStateException' 259 | - 'IndexOutOfBoundsException' 260 | - 'NullPointerException' 261 | - 'RuntimeException' 262 | - 'Throwable' 263 | allowedExceptionNameRegex: '_|(ignore|expected).*' 264 | excludes: [ '**/commonTest/**' ] 265 | TooGenericExceptionThrown: 266 | active: true 267 | exceptionNames: 268 | - 'Error' 269 | - 'Exception' 270 | - 'RuntimeException' 271 | - 'Throwable' 272 | 273 | naming: 274 | active: true 275 | BooleanPropertyNaming: 276 | active: true 277 | allowedPattern: '^(is|was|has|are|should)' 278 | ClassNaming: 279 | active: true 280 | classPattern: '[A-Z][a-zA-Z0-9]*' 281 | ConstructorParameterNaming: 282 | active: true 283 | parameterPattern: '[a-z][A-Za-z0-9]*' 284 | privateParameterPattern: '[a-z][A-Za-z0-9]*' 285 | excludeClassPattern: '$^' 286 | EnumNaming: 287 | active: true 288 | enumEntryPattern: '[A-Z][_a-zA-Z0-9]*' 289 | ForbiddenClassName: 290 | active: false 291 | forbiddenName: [ ] 292 | FunctionMaxLength: 293 | active: false 294 | maximumFunctionNameLength: 30 295 | FunctionMinLength: 296 | active: false 297 | minimumFunctionNameLength: 3 298 | FunctionNaming: 299 | active: true 300 | functionPattern: '([a-z][a-zA-Z0-9]*)|(`.*`)' 301 | excludeClassPattern: '$^' 302 | ignoreAnnotated: [ 'Composable' ] 303 | FunctionParameterNaming: 304 | active: true 305 | parameterPattern: '[a-z][A-Za-z0-9]*' 306 | excludeClassPattern: '$^' 307 | InvalidPackageDeclaration: 308 | active: true 309 | rootPackage: '' 310 | LambdaParameterNaming: 311 | active: true 312 | parameterPattern: '[a-z][A-Za-z0-9]*|_' 313 | MatchingDeclarationName: 314 | active: true 315 | mustBeFirst: true 316 | MemberNameEqualsClassName: 317 | active: true 318 | ignoreOverridden: true 319 | NoNameShadowing: 320 | active: true 321 | NonBooleanPropertyPrefixedWithIs: 322 | active: true 323 | ObjectPropertyNaming: 324 | active: true 325 | constantPattern: '[A-Za-z][_A-Za-z0-9]*' 326 | propertyPattern: '[A-Za-z][_A-Za-z0-9]*' 327 | privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*' 328 | PackageNaming: 329 | active: true 330 | packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*' 331 | TopLevelPropertyNaming: 332 | active: true 333 | constantPattern: '[A-Z][_A-Za-z0-9]*' 334 | propertyPattern: '[A-Za-z][_A-Za-z0-9]*' 335 | privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*' 336 | VariableMaxLength: 337 | active: true 338 | maximumVariableNameLength: 64 339 | VariableMinLength: 340 | active: true 341 | minimumVariableNameLength: 1 342 | VariableNaming: 343 | active: true 344 | variablePattern: '[a-z][A-Za-z0-9]*' 345 | privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*' 346 | excludeClassPattern: '$^' 347 | 348 | performance: 349 | active: true 350 | ArrayPrimitive: 351 | active: true 352 | ForEachOnRange: 353 | active: true 354 | SpreadOperator: 355 | active: true 356 | UnnecessaryTemporaryInstantiation: 357 | active: true 358 | 359 | potential-bugs: 360 | active: true 361 | AvoidReferentialEquality: 362 | active: true 363 | forbiddenTypePatterns: 364 | - 'kotlin.String' 365 | CastToNullableType: 366 | active: false 367 | Deprecation: 368 | active: true 369 | DontDowncastCollectionTypes: 370 | active: true 371 | DoubleMutabilityForCollection: 372 | active: true 373 | EqualsAlwaysReturnsTrueOrFalse: 374 | active: true 375 | EqualsWithHashCodeExist: 376 | active: true 377 | ExitOutsideMain: 378 | active: true 379 | ExplicitGarbageCollectionCall: 380 | active: true 381 | HasPlatformType: 382 | active: true 383 | IgnoredReturnValue: 384 | active: true 385 | restrictToConfig: true 386 | returnValueAnnotations: 387 | - '*.CheckResult' 388 | - '*.CheckReturnValue' 389 | ignoreReturnValueAnnotations: 390 | - '*.CanIgnoreReturnValue' 391 | ImplicitDefaultLocale: 392 | active: true 393 | ImplicitUnitReturnType: 394 | active: false 395 | allowExplicitReturnType: true 396 | InvalidRange: 397 | active: true 398 | IteratorHasNextCallsNextMethod: 399 | active: true 400 | IteratorNotThrowingNoSuchElementException: 401 | active: true 402 | LateinitUsage: 403 | active: false 404 | ignoreOnClassesPattern: '' 405 | MapGetWithNotNullAssertionOperator: 406 | active: true 407 | MissingPackageDeclaration: 408 | active: true 409 | excludes: [ '**/*.kts' ] 410 | NullableToStringCall: 411 | active: true 412 | UnconditionalJumpStatementInLoop: 413 | active: true 414 | UnnecessaryNotNullOperator: 415 | active: true 416 | UnnecessarySafeCall: 417 | active: true 418 | UnreachableCatchBlock: 419 | active: true 420 | UnreachableCode: 421 | active: true 422 | UnsafeCallOnNullableType: 423 | active: false 424 | UnsafeCast: 425 | active: true 426 | UnusedUnaryOperator: 427 | active: true 428 | UselessPostfixExpression: 429 | active: true 430 | WrongEqualsTypeParameter: 431 | active: true 432 | 433 | style: 434 | active: true 435 | ClassOrdering: 436 | active: true 437 | CollapsibleIfStatements: 438 | active: true 439 | DataClassContainsFunctions: 440 | active: true 441 | conversionFunctionPrefix: [ 'to' ] 442 | DataClassShouldBeImmutable: 443 | active: true 444 | DestructuringDeclarationWithTooManyEntries: 445 | active: true 446 | maxDestructuringEntries: 3 447 | EqualsNullCall: 448 | active: true 449 | EqualsOnSignatureLine: 450 | active: true 451 | ExplicitCollectionElementAccessMethod: 452 | active: true 453 | ExplicitItLambdaParameter: 454 | active: true 455 | ExpressionBodySyntax: 456 | active: true 457 | includeLineWrapping: false 458 | ForbiddenComment: 459 | active: true 460 | comments: 461 | - value: 'FIXME:' 462 | reason: 'Forbidden FIXME todo marker in comment, please fix the problem.' 463 | - value: 'FIXME' 464 | reason: 'Forbidden FIXME todo marker in comment, please fix the problem.' 465 | - value: 'STOPSHIP:' 466 | reason: 'Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code.' 467 | - value: 'STOPSHIP' 468 | reason: 'Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code.' 469 | allowedPatterns: '' 470 | ForbiddenImport: 471 | active: false 472 | imports: [ ] 473 | forbiddenPatterns: '' 474 | ForbiddenMethodCall: 475 | active: false 476 | methods: 477 | - 'kotlin.io.print' 478 | - 'kotlin.io.println' 479 | ForbiddenVoid: 480 | active: true 481 | ignoreOverridden: false 482 | ignoreUsageInGenerics: false 483 | FunctionOnlyReturningConstant: 484 | active: true 485 | ignoreOverridableFunction: true 486 | ignoreActualFunction: true 487 | excludedFunctions: [ 'describeContents' ] 488 | ignoreAnnotated: [ 'Provides' ] 489 | LoopWithTooManyJumpStatements: 490 | active: true 491 | maxJumpCount: 1 492 | MagicNumber: 493 | active: true 494 | ignoreNumbers: 495 | - '-1' 496 | - '0' 497 | - '1' 498 | - '2' 499 | ignoreHashCodeFunction: true 500 | ignorePropertyDeclaration: true 501 | ignoreLocalVariableDeclaration: false 502 | ignoreConstantDeclaration: true 503 | ignoreCompanionObjectPropertyDeclaration: true 504 | ignoreAnnotation: false 505 | ignoreNamedArgument: true 506 | ignoreEnums: false 507 | ignoreRanges: false 508 | ignoreExtensionFunctions: true 509 | BracesOnIfStatements: 510 | active: true 511 | multiLine: always 512 | MandatoryBracesLoops: 513 | active: true 514 | MaxLineLength: 515 | active: true 516 | maxLineLength: 120 517 | excludePackageStatements: true 518 | excludeImportStatements: true 519 | excludeCommentStatements: true 520 | excludes: [ '**/commonTest/**' ] 521 | MayBeConst: 522 | active: true 523 | ModifierOrder: 524 | active: true 525 | MultilineLambdaItParameter: 526 | active: true 527 | NestedClassesVisibility: 528 | active: true 529 | NewLineAtEndOfFile: 530 | active: true 531 | NoTabs: 532 | active: true 533 | ObjectLiteralToLambda: 534 | active: false 535 | OptionalAbstractKeyword: 536 | active: true 537 | OptionalUnit: 538 | active: true 539 | BracesOnWhenStatements: 540 | active: false 541 | PreferToOverPairSyntax: 542 | active: true 543 | ProtectedMemberInFinalClass: 544 | active: true 545 | RedundantExplicitType: 546 | active: true 547 | RedundantHigherOrderMapUsage: 548 | active: true 549 | RedundantVisibilityModifierRule: 550 | active: true 551 | ReturnCount: 552 | active: true 553 | max: 2 554 | excludedFunctions: [ 'equals' ] 555 | excludeLabeled: false 556 | excludeReturnFromLambda: true 557 | excludeGuardClauses: false 558 | SafeCast: 559 | active: true 560 | SerialVersionUIDInSerializableClass: 561 | active: true 562 | SpacingBetweenPackageAndImports: 563 | active: true 564 | ThrowsCount: 565 | active: true 566 | max: 2 567 | excludeGuardClauses: false 568 | TrailingWhitespace: 569 | active: true 570 | UnderscoresInNumericLiterals: 571 | active: false 572 | acceptableLength: 5 573 | UnnecessaryAbstractClass: 574 | active: true 575 | ignoreAnnotated: [ 'Module' ] 576 | UnnecessaryAnnotationUseSiteTarget: 577 | active: true 578 | UnnecessaryApply: 579 | active: true 580 | UnnecessaryFilter: 581 | active: true 582 | UnnecessaryInheritance: 583 | active: true 584 | UnnecessaryLet: 585 | active: true 586 | UnnecessaryParentheses: 587 | active: true 588 | UntilInsteadOfRangeTo: 589 | active: true 590 | UnusedImports: 591 | active: true 592 | UnusedPrivateClass: 593 | active: true 594 | UnusedPrivateMember: 595 | active: true 596 | allowedNames: '(_|ignored|expected|serialVersionUID)' 597 | ignoreAnnotated: [ 'Preview' ] 598 | UseAnyOrNoneInsteadOfFind: 599 | active: true 600 | UseArrayLiteralsInAnnotations: 601 | active: true 602 | UseCheckNotNull: 603 | active: true 604 | UseCheckOrError: 605 | active: true 606 | UseDataClass: 607 | active: true 608 | allowVars: false 609 | UseEmptyCounterpart: 610 | active: true 611 | UseIfEmptyOrIfBlank: 612 | active: true 613 | UseIfInsteadOfWhen: 614 | active: true 615 | UseIsNullOrEmpty: 616 | active: true 617 | UseOrEmpty: 618 | active: true 619 | UseRequire: 620 | active: true 621 | UseRequireNotNull: 622 | active: true 623 | UselessCallOnNotNull: 624 | active: true 625 | UtilityClassWithPublicConstructor: 626 | active: true 627 | VarCouldBeVal: 628 | active: true 629 | WildcardImport: 630 | active: true 631 | excludeImports: 632 | - 'java.util.*' 633 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | #Gradle 2 | org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M" 3 | 4 | #Kotlin 5 | kotlin.code.style=official 6 | 7 | #Android 8 | android.useAndroidX=true 9 | android.nonTransitiveRClass=true 10 | android.enableR8.fullMode=false 11 | #MPP 12 | kotlin.mpp.enableCInteropCommonization=true 13 | kotlin.mpp.androidSourceSetLayoutVersion1.nowarn=true 14 | kotlin.mpp.androidSourceSetLayoutVersion=2 15 | #Compose 16 | org.jetbrains.compose.experimental.uikit.enabled=true 17 | org.jetbrains.compose.experimental.macos.enabled=true 18 | #Kotlin/Native 19 | kotlin.native.cacheKind=none 20 | kotlin.native.useEmbeddableCompilerJar=true 21 | # Enable kotlin/native experimental memory model 22 | kotlin.native.binary.memoryModel=experimental 23 | kotlin.native.ignoreDisabledTargets=true 24 | kotlin.native.binary.mimallocUseCompaction=true 25 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | minSdk = "26" 3 | targetSdk = "34" 4 | javaTargetCompatibility = "17" 5 | javaSourceCompatibility = "11" 6 | 7 | agp = "8.4.1" 8 | kotlin = "1.9.23" 9 | kotlinCoroutines = "1.8.0" 10 | kotlinDate = "0.5.0" 11 | ksp = "1.9.23-1.0.20" 12 | 13 | appCompat = "1.7.0" 14 | lifecycleViewModel = "2.8.1" 15 | sqldelight = "1.5.5" 16 | detekt = "1.23.6" 17 | kover = "0.7.6" 18 | mockmp = "1.17.0" 19 | mokoResources = "0.23.0" 20 | mokoGraphics = "0.9.0" 21 | 22 | compose = "1.6.7" 23 | composeActivity = "1.9.0" 24 | composeCompiler = "1.5.12" 25 | composeUITooling = "1.6.7" 26 | composeCoil = "2.6.0" 27 | composeNavigation = "2.7.7" 28 | 29 | koin = "4.0.0" 30 | koinTest = "4.0.0" 31 | koinAndroid = "4.0.0" 32 | koinAndroidCompose = "4.0.0" 33 | ktor = "2.3.11" 34 | 35 | kermit = "2.0.3" 36 | slf4j = "2.0.13" 37 | junitKtx = "1.1.5" 38 | accompanistNavigation = "0.34.0" 39 | 40 | [libraries] 41 | 42 | kotlin-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinCoroutines" } 43 | kotlin-date = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinDate" } 44 | kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } 45 | kotlin-test-common = { module = "org.jetbrains.kotlin:kotlin-test-common", version.ref = "kotlin" } 46 | kotlin-test-annotations-common = { module = "org.jetbrains.kotlin:kotlin-test-annotations-common", version.ref = "kotlin" } 47 | accompanist-navigation = { module = "com.google.accompanist:accompanist-navigation-animation", version.ref = "accompanistNavigation" } 48 | 49 | androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appCompat" } 50 | androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleViewModel" } 51 | androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleViewModel" } 52 | 53 | compose-compiler = { module = "androidx.compose.compiler:compiler", version.ref = "composeCompiler" } 54 | compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" } 55 | compose-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "composeUITooling" } 56 | compose-runtime = { module = "androidx.compose.runtime:runtime", version.ref = "compose" } 57 | compose-activity = { module = "androidx.activity:activity-compose", version.ref = "composeActivity" } 58 | compose-material = { module = "androidx.compose.material:material", version.ref = "compose" } 59 | compose-navigation = { module = "androidx.navigation:navigation-compose", version.ref = "composeNavigation" } 60 | compose-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } 61 | compose-coil = { module = "io.coil-kt:coil-compose", version.ref = "composeCoil" } 62 | 63 | koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } 64 | koin-android = { module = "io.insert-koin:koin-android", version.ref = "koinAndroid" } 65 | koin-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koinAndroidCompose" } 66 | 67 | ktor-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } 68 | ktor-serialization = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } 69 | ktor-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } 70 | ktor-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } 71 | ktor-ios = { module = "io.ktor:ktor-client-ios", version.ref = "ktor" } 72 | ktor-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } 73 | 74 | sqldelight-native = { module = "com.squareup.sqldelight:native-driver", version.ref = "sqldelight" } 75 | sqldelight-android-driver = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" } 76 | sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" } 77 | sqldelight-sqlite-driver = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "sqldelight" } 78 | 79 | test-ktor = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } 80 | test-koin = { module = "io.insert-koin:koin-test", version.ref = "koinTest" } 81 | test-junitKtx = { module = "androidx.test.ext:junit-ktx", version.ref = "junitKtx" } 82 | test-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinCoroutines" } 83 | 84 | log-kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } 85 | log-slf4j = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" } 86 | detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } 87 | 88 | moko-resources = { module = "dev.icerock.moko:resources", version.ref = "mokoResources" } 89 | moko-resources-test = { module = "dev.icerock.moko:resources-test", version.ref = "mokoResources" } 90 | moko-graphics = { module = "dev.icerock.moko:graphics", version.ref = "mokoGraphics" } 91 | moko-compose = { module = "dev.icerock.moko:resources-compose", version.ref = "mokoResources" } 92 | 93 | [plugins] 94 | 95 | android-application = { id = "com.android.application", version.ref = "agp" } 96 | android-library = { id = "com.android.library", version.ref = "agp" } 97 | detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } 98 | kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } 99 | kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } 100 | kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } 101 | kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } 102 | mockmp = { id = "org.kodein.mock.mockmp", version.ref = "mockmp" } 103 | sqldelight = { id = "com.squareup.sqldelight", version.ref = "sqldelight" } 104 | ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } 105 | moko-resources = { id = "dev.icerock.mobile.multiplatform-resources", version.ref = "mokoResources" } 106 | 107 | [bundles] 108 | compose = [ 109 | "compose-ui", 110 | "compose-compiler", 111 | "compose-runtime", 112 | "compose-material", 113 | "compose-navigation", 114 | "compose-preview", 115 | "compose-coil", 116 | "compose-activity" 117 | ] 118 | 119 | koinAndroid = [ 120 | "koin-core", 121 | "koin-android", 122 | "koin-compose" 123 | ] 124 | 125 | commonMain = [ 126 | "kotlin-coroutines", 127 | "koin-core", 128 | "ktor-core", 129 | "ktor-serialization", 130 | "ktor-content-negotiation", 131 | "ktor-logging", 132 | "sqldelight-coroutines", 133 | "log-kermit", 134 | "log-slf4j" 135 | ] 136 | commonTest = [ 137 | "test-coroutines", 138 | "test-ktor", 139 | "test-koin", 140 | "koin-core", 141 | "ktor-serialization", 142 | "ktor-content-negotiation" 143 | ] 144 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apter-tech/GitHubUserFinder/870c25ab76ba69a5a735ae23b67cb20e41607e98/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 87 | APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit 88 | 89 | # Use the maximum available, or set MAX_FD != -1 to use that value. 90 | MAX_FD=maximum 91 | 92 | warn () { 93 | echo "$*" 94 | } >&2 95 | 96 | die () { 97 | echo 98 | echo "$*" 99 | echo 100 | exit 1 101 | } >&2 102 | 103 | # OS specific support (must be 'true' or 'false'). 104 | cygwin=false 105 | msys=false 106 | darwin=false 107 | nonstop=false 108 | case "$( uname )" in #( 109 | CYGWIN* ) cygwin=true ;; #( 110 | Darwin* ) darwin=true ;; #( 111 | MSYS* | MINGW* ) msys=true ;; #( 112 | NONSTOP* ) nonstop=true ;; 113 | esac 114 | 115 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 116 | 117 | 118 | # Determine the Java command to use to start the JVM. 119 | if [ -n "$JAVA_HOME" ] ; then 120 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 121 | # IBM's JDK on AIX uses strange locations for the executables 122 | JAVACMD=$JAVA_HOME/jre/sh/java 123 | else 124 | JAVACMD=$JAVA_HOME/bin/java 125 | fi 126 | if [ ! -x "$JAVACMD" ] ; then 127 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 128 | 129 | Please set the JAVA_HOME variable in your environment to match the 130 | location of your Java installation." 131 | fi 132 | else 133 | JAVACMD=java 134 | if ! command -v java >/dev/null 2>&1 135 | then 136 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | fi 142 | 143 | # Increase the maximum file descriptors if we can. 144 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 145 | case $MAX_FD in #( 146 | max*) 147 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 148 | # shellcheck disable=SC2039,SC3045 149 | MAX_FD=$( ulimit -H -n ) || 150 | warn "Could not query maximum file descriptor limit" 151 | esac 152 | case $MAX_FD in #( 153 | '' | soft) :;; #( 154 | *) 155 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 156 | # shellcheck disable=SC2039,SC3045 157 | ulimit -n "$MAX_FD" || 158 | warn "Could not set maximum file descriptor limit to $MAX_FD" 159 | esac 160 | fi 161 | 162 | # Collect all arguments for the java command, stacking in reverse order: 163 | # * args from the command line 164 | # * the main class name 165 | # * -classpath 166 | # * -D...appname settings 167 | # * --module-path (only if needed) 168 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 169 | 170 | # For Cygwin or MSYS, switch paths to Windows format before running java 171 | if "$cygwin" || "$msys" ; then 172 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 173 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 174 | 175 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 176 | 177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 178 | for arg do 179 | if 180 | case $arg in #( 181 | -*) false ;; # don't mess with options #( 182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 183 | [ -e "$t" ] ;; #( 184 | *) false ;; 185 | esac 186 | then 187 | arg=$( cygpath --path --ignore --mixed "$arg" ) 188 | fi 189 | # Roll the args list around exactly as many times as the number of 190 | # args, so each arg winds up back in the position where it started, but 191 | # possibly modified. 192 | # 193 | # NB: a `for` loop captures its iteration list before it begins, so 194 | # changing the positional parameters here affects neither the number of 195 | # iterations, nor the values presented in `arg`. 196 | shift # remove old arg 197 | set -- "$@" "$arg" # push replacement arg 198 | done 199 | fi 200 | 201 | 202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 204 | 205 | # Collect all arguments for the java command: 206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 207 | # and any embedded shellness will be escaped. 208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 209 | # treated as '${Hostname}' itself on the command line. 210 | 211 | set -- \ 212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 213 | -classpath "$CLASSPATH" \ 214 | org.gradle.wrapper.GradleWrapperMain \ 215 | "$@" 216 | 217 | # Stop when "xargs" is not available. 218 | if ! command -v xargs >/dev/null 2>&1 219 | then 220 | die "xargs is not available" 221 | fi 222 | 223 | # Use "xargs" to parse quoted args. 224 | # 225 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 226 | # 227 | # In Bash we could simply go: 228 | # 229 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 230 | # set -- "${ARGS[@]}" "$@" 231 | # 232 | # but POSIX shell has neither arrays nor command substitution, so instead we 233 | # post-process each arg (as a line of input to sed) to backslash-escape any 234 | # character that might be a shell metacharacter, then use eval to reverse 235 | # that process (while maintaining the separation between arguments), and wrap 236 | # the whole thing up as a single "set" statement. 237 | # 238 | # This will of course break if any of these variables contains a newline or 239 | # an unmatched quote. 240 | # 241 | 242 | eval "set -- $( 243 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 244 | xargs -n1 | 245 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 246 | tr '\n' ' ' 247 | )" '"$@"' 248 | 249 | exec "$JAVACMD" "$@" 250 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 1>&2 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 48 | echo. 1>&2 49 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 50 | echo location of your Java installation. 1>&2 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 1>&2 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 62 | echo. 1>&2 63 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 64 | echo location of your Java installation. 1>&2 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /iosApp/.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: 2 | - dynamic_inline 3 | - private_unit_test 4 | - type_body_length 5 | - valid_ibinspectable 6 | - function_default_parameter_at_end 7 | - nesting 8 | - switch_case_alignment 9 | opt_in_rules: 10 | - closure_spacing 11 | - convenience_type 12 | - discouraged_object_literal 13 | - empty_string 14 | - fallthrough 15 | - fatal_error_message 16 | - first_where 17 | - multiline_parameters 18 | - overridden_super_call 19 | - override_in_extension 20 | - required_enum_case 21 | - vertical_parameter_alignment_on_call 22 | - yoda_condition 23 | - array_init 24 | - explicit_init 25 | - function_default_parameter_at_end 26 | - redundant_nil_coalescing 27 | - closure_body_length 28 | - collection_alignment 29 | - conditional_returns_on_newline 30 | - contains_over_filter_count 31 | - contains_over_filter_is_empty 32 | - contains_over_first_not_nil 33 | - contains_over_range_nil_comparison 34 | - discouraged_optional_boolean 35 | - empty_collection_literal 36 | - empty_count 37 | - enum_case_associated_values_count 38 | - file_name_no_space 39 | - flatmap_over_map_reduce 40 | - identical_operands 41 | - joined_default_parameter 42 | - last_where 43 | - literal_expression_end_indentation 44 | - no_extension_access_modifier 45 | - nslocalizedstring_key 46 | - operator_usage_whitespace 47 | - private_action 48 | - private_outlet 49 | - prohibited_super_call 50 | - reduce_into 51 | - redundant_type_annotation 52 | - sorted_first_last 53 | - static_operator 54 | - toggle_bool 55 | - unavailable_function 56 | - unneeded_parentheses_in_closure_argument 57 | - unused_declaration 58 | - unused_import 59 | - vertical_whitespace_closing_braces 60 | - closure_end_indentation 61 | - multiline_function_chains 62 | - weak_delegate 63 | 64 | closing_brace: 65 | severity: error 66 | closure_body_length: 67 | - 30 #warning 68 | - 40 #error 69 | closure_end_indentation: 70 | severity: error 71 | colon: 72 | severity: error 73 | flexible_right_spacing: false 74 | apply_to_dictionaries: true 75 | comma: 76 | severity: error 77 | conditional_returns_on_newline: 78 | severity: error 79 | if_only: true 80 | control_statement: 81 | severity: error 82 | cyclomatic_complexity: 83 | - 7 #warning 84 | - 8 #error 85 | empty_count: 86 | severity: error 87 | empty_parameters: 88 | severity: error 89 | explicit_init: 90 | severity: error 91 | file_length: 92 | warning: 300 93 | error: 400 94 | force_cast: 95 | severity: error 96 | force_try: 97 | severity: error 98 | function_body_length: 99 | - 60 #warning 100 | - 80 #error 101 | function_parameter_count: 102 | - 5 #warning 103 | - 6 #error 104 | implicit_getter: 105 | severity: error 106 | large_tuple: 107 | - 3 #warning 108 | - 5 #error 109 | leading_whitespace: 110 | severity: error 111 | legacy_cggeometry_functions: 112 | severity: error 113 | legacy_constant: 114 | severity: error 115 | legacy_constructor: 116 | severity: error 117 | legacy_nsgeometry_functions: 118 | severity: error 119 | line_length: 120 | ignores_comments: true 121 | warning: 110 122 | error: 120 123 | mark: 124 | severity: error 125 | opening_brace: 126 | severity: error 127 | operator_whitespace: 128 | severity: error 129 | overridden_super_call: 130 | severity: error 131 | redundant_nil_coalescing: 132 | severity: error 133 | redundant_optional_initialization: 134 | severity: error 135 | redundant_string_enum_value: 136 | severity: error 137 | redundant_void_return: 138 | severity: error 139 | return_arrow_whitespace: 140 | severity: error 141 | syntactic_sugar: 142 | severity: error 143 | todo: 144 | severity: warning 145 | trailing_comma: 146 | severity: error 147 | trailing_newline: 148 | severity: error 149 | trailing_semicolon: 150 | severity: error 151 | trailing_whitespace: 152 | severity: warning 153 | type_name: 154 | max_length: 45 155 | severity: error 156 | excluded: 157 | - T 158 | - U 159 | unused_closure_parameter: 160 | severity: error 161 | identifier_name: 162 | min_length: 2 163 | severity: error 164 | vertical_parameter_alignment: 165 | severity: error 166 | vertical_whitespace: 167 | severity: error 168 | void_return: 169 | severity: error 170 | weak_delegate: 171 | severity: error 172 | first_where: 173 | severity: error 174 | private_action: 175 | severity: error 176 | private_outlet: 177 | severity: error 178 | closure_spacing: 179 | severity: error 180 | multiline_function_chains: 181 | severity: error 182 | array_init: 183 | severity: error 184 | statement_position: 185 | severity: error 186 | unused_enumerated: 187 | severity: error 188 | vertical_parameter_alignment_on_call: 189 | severity: error 190 | empty_string: 191 | severity: error 192 | unneeded_break_in_switch: 193 | severity: error 194 | fatal_error_message: 195 | severity: error 196 | empty_parentheses_with_trailing_closure: 197 | severity: error 198 | contains_over_first_not_nil: 199 | severity: error 200 | -------------------------------------------------------------------------------- /iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } -------------------------------------------------------------------------------- /iosApp/iosApp/ContentView.swift: -------------------------------------------------------------------------------- 1 | import shared 2 | import SwiftUI 3 | 4 | struct ContentView: View { 5 | var body: some View { 6 | NavigationView { 7 | GitHubUserListScreen() 8 | .navigationBarTitle(Text(MR.strings().app_name.localize())) 9 | } 10 | .navigationViewStyle(.stack) 11 | } 12 | } 13 | 14 | struct ContentView_Previews: PreviewProvider { 15 | static var previews: some View { 16 | ContentView() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /iosApp/iosApp/GitHubUserFinder.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | struct GitHubUserFinder: App { 5 | // Lazy so it doesn't try to initialize before startKoin() is called 6 | lazy var logger = KoinApplication.getLogger(class: GitHubUserFinder.self) 7 | @State private var safeAreaInsets: (top: CGFloat, bottom: CGFloat) = (0, 0) 8 | 9 | init() { 10 | KoinApplication.start() 11 | logger.v(throwable: nil, tag: String(describing: self), message: { "App Started" }) 12 | } 13 | 14 | var body: some Scene { 15 | WindowGroup { 16 | ContentView() 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /iosApp/iosApp/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BASE_URL 6 | https://api.github.com 7 | CFBundleDevelopmentRegion 8 | $(DEVELOPMENT_LANGUAGE) 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleVersion 22 | 1 23 | LSRequiresIPhoneOS 24 | 25 | UIApplicationSceneManifest 26 | 27 | UIApplicationSupportsMultipleScenes 28 | 29 | 30 | UIRequiredDeviceCapabilities 31 | 32 | armv7 33 | 34 | UISupportedInterfaceOrientations 35 | 36 | UIInterfaceOrientationPortrait 37 | UIInterfaceOrientationLandscapeLeft 38 | UIInterfaceOrientationLandscapeRight 39 | 40 | UISupportedInterfaceOrientations~ipad 41 | 42 | UIInterfaceOrientationPortrait 43 | UIInterfaceOrientationPortraitUpsideDown 44 | UIInterfaceOrientationLandscapeLeft 45 | UIInterfaceOrientationLandscapeRight 46 | 47 | UILaunchScreen 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /iosApp/iosApp/Koin/Koin.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Koin.swift 3 | // iosApp 4 | // 5 | // Created by Imre Kaszab on 2022. 10. 24.. 6 | // Copyright © 2022. orgName. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import shared 11 | 12 | public typealias KoinApplication = Koin_coreKoinApplication 13 | public typealias Koin = Koin_coreKoin 14 | 15 | extension KoinApplication { 16 | static let shared = { 17 | let baseUrl = (Bundle.main.object(forInfoDictionaryKey: "BASE_URL") as? String) ?? "" 18 | return KoinIOSKt.doInitKoinIos(baseUrl: baseUrl, doOnStartup: {}) 19 | }() 20 | 21 | @discardableResult 22 | static func start() -> KoinApplication { 23 | shared 24 | } 25 | 26 | static func getLogger(class: T) -> KermitLogger { 27 | shared.koin.loggerWithTag(tag: String(describing: T.self)) 28 | } 29 | } 30 | 31 | extension KoinApplication { 32 | static func inject() -> T { 33 | shared.inject() 34 | } 35 | 36 | func inject() -> T { 37 | guard let kotlinClass = koin.get(objCClass: T.self) as? T else { 38 | fatalError("\(T.self) is not registered with KoinApplication") 39 | } 40 | 41 | return kotlinClass 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /iosApp/iosApp/Koin/LazyKoin.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LazyKoin.swift 3 | // iosApp 4 | // 5 | // Created by Imre Kaszab on 2023. 04. 14.. 6 | // Copyright © 2023. orgName. All rights reserved. 7 | // 8 | 9 | import shared 10 | 11 | @propertyWrapper 12 | struct LazyKoin { 13 | lazy var wrappedValue: T = { KoinApplication.shared.inject() }() 14 | 15 | init() { } 16 | } 17 | -------------------------------------------------------------------------------- /iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } -------------------------------------------------------------------------------- /iosApp/iosApp/UI/Details/GitHubUserDetailBioView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitHubUserDetailBioView.swift 3 | // iosApp 4 | // 5 | // Created by Imre Kaszab on 2022. 04. 11.. 6 | // Copyright © 2022. orgName. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct GitHubUserDetailBioView: View { 12 | var bio: String? 13 | 14 | var body: some View { 15 | if let bio = bio, !bio.isEmpty { 16 | Text(bio) 17 | .padding(8) 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /iosApp/iosApp/UI/Details/GitHubUserDetailItemView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitHubUserDetailItemView.swift 3 | // iosApp 4 | // 5 | // Created by Imre Kaszab on 2022. 04. 10.. 6 | // Copyright © 2022. orgName. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct GitHubUserDetailItemView: View { 12 | var label: String 13 | var value: String? 14 | 15 | var body: some View { 16 | if let value = value, !value.isEmpty { 17 | HStack { 18 | Text(label) 19 | .fixedSize(horizontal: false, vertical: true) 20 | .lineLimit(1) 21 | .font(.system(size: 16, weight: .heavy, design: .default)) 22 | Spacer() 23 | Text(value) 24 | } 25 | .padding(8) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /iosApp/iosApp/UI/Details/GitHubUserDetailItemViews.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitHubUserDetailItemViews.swift 3 | // iosApp 4 | // 5 | // Created by Imre Kaszab on 2023. 06. 19.. 6 | // Copyright © 2023. orgName. All rights reserved. 7 | // 8 | 9 | import shared 10 | import SwiftUI 11 | 12 | struct GitHubUserDetailItemViews: View { 13 | var userDetails: GitHubUser 14 | 15 | var body: some View { 16 | VStack { 17 | GitHubUserDetailItemView(label: MR.strings().details_view_followers.localize(), 18 | value: String(userDetails.followers)) 19 | GitHubUserDetailItemView( 20 | label: MR.strings().details_view_following.localize(), 21 | value: String(userDetails.following) 22 | ) 23 | GitHubUserDetailItemView( 24 | label: MR.strings().details_view_public_repos.localize(), 25 | value: String(userDetails.publicRepos) 26 | ) 27 | GitHubUserDetailItemView(label: MR.strings().details_view_company.localize(), 28 | value: userDetails.company) 29 | GitHubUserDetailItemView(label: MR.strings().details_view_location.localize(), 30 | value: userDetails.location) 31 | GitHubUserDetailItemView(label: MR.strings().details_view_email.localize(), 32 | value: userDetails.email) 33 | GitHubUserDetailItemView(label: MR.strings().details_view_blog.localize(), 34 | value: userDetails.blog) 35 | GitHubUserDetailItemView(label: MR.strings().details_view_twitter.localize(), 36 | value: userDetails.twitterUsername) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /iosApp/iosApp/UI/Details/GitHubUserDetailsScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitHubUserDetailsScreen.swift 3 | // iosApp 4 | // 5 | // Created by Imre Kaszab on 2022. 04. 10.. 6 | // Copyright © 2022. orgName. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import shared 11 | 12 | struct GitHubUserDetailsScreen: View { 13 | @StateObject private var reducer = ReducerViewModel() 14 | var userName: String 15 | 16 | var body: some View { 17 | if let state = reducer.state { 18 | ScrollView { 19 | if let userDetails = state.userDetails { 20 | VStack { 21 | if userDetails.name != userName { 22 | Text(userDetails.name) 23 | .font(.title) 24 | .foregroundColor(.primary) 25 | } 26 | AsyncImage(url: URL(string: userDetails.avatarUrl)!) { image in 27 | image.resizable().scaledToFill() 28 | } placeholder: { Color.gray 29 | } 30 | .frame(width: 100, height: 100) 31 | .cornerRadius(16) 32 | GitHubUserDetailBioView(bio: userDetails.bio) 33 | GitHubUserDetailItemViews(userDetails: userDetails) 34 | } 35 | } 36 | } 37 | .navigationBarTitle(Text(userName)) 38 | .toolbar { 39 | HStack { 40 | Button { 41 | if state.userDetails?.favourite == true { 42 | reducer.viewModel.deleteUser() 43 | } else { 44 | reducer.viewModel.saveUser() 45 | } 46 | } label: { 47 | if state.userDetails?.favourite == true { 48 | MR.images().ic_star_fill.asImage() 49 | } else { 50 | MR.images().ic_star.asImage() 51 | } 52 | } 53 | } 54 | } 55 | .onAppear { 56 | reducer.viewModel.refreshUserDetails(userName: userName) 57 | } 58 | .onDisappear { 59 | reducer.deactivate() 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /iosApp/iosApp/UI/Favourite/FavouriteUsersScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FavouriteUsersScreen.swift 3 | // iosApp 4 | // 5 | // Created by Imre Kaszab on 2022. 09. 17.. 6 | // Copyright © 2022. orgName. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import shared 11 | 12 | struct FavouriteUsersScreen: View { 13 | @StateObject private var reducer = ReducerViewModel() 14 | @State private var showConfirmDialog = false 15 | 16 | var body: some View { 17 | if let state = reducer.state { 18 | VStack(alignment: .center) { 19 | Spacer() 20 | List { 21 | ForEach(state.data, id: \.id) { item in 22 | NavigationLink(destination: GitHubUserDetailsScreen(userName: item.login)) { 23 | GitHubUserRow(item: item) 24 | } 25 | } 26 | } 27 | .listStateModifier(state.data.isEmpty) { 28 | Text(MR.strings().empty_view_title.localize()) 29 | } 30 | .listStateModifier(reducer.error != nil) { 31 | Text(MR.strings().error_view_title.localize(input: reducer.error ?? "" )) 32 | } 33 | Spacer() 34 | } 35 | .task { 36 | reducer.viewModel.loadUsers() 37 | } 38 | .navigationTitle(MR.strings().favourite_screen_title.localize()) 39 | .toolbar { 40 | if !state.data.isEmpty { 41 | HStack { 42 | Button { 43 | showConfirmDialog.toggle() 44 | } label: { 45 | MR.images().ic_trash.asImage() 46 | } 47 | } 48 | } 49 | } 50 | .confirmationDialog(MR.strings().remove_all_user_dialog_title.localize(), 51 | isPresented: $showConfirmDialog, actions: { 52 | HStack { 53 | Button(MR.strings().remove_all_user_dialog_title.localize()) { 54 | reducer.viewModel.deleteAllUser() 55 | } 56 | } 57 | }) 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /iosApp/iosApp/UI/List/GitHubUserListScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitHubUserListScreen.swift 3 | // iosApp 4 | // 5 | // Created by Imre Kaszab on 2022. 04. 10.. 6 | // Copyright © 2022. orgName. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import shared 11 | 12 | struct GitHubUserListScreen: View { 13 | @StateObject private var reducer = ReducerViewModel() 14 | 15 | var body: some View { 16 | if let state = reducer.state { 17 | VStack(alignment: .center) { 18 | SearchBar { query in 19 | reducer.viewModel.searchUser(userName: query) 20 | } 21 | Spacer() 22 | if state.isLoading { 23 | ProgressView() 24 | } else { 25 | List { 26 | ForEach(state.data, id: \.id) { item in 27 | NavigationLink(destination: GitHubUserDetailsScreen(userName: item.login)) { 28 | GitHubUserRow(item: item) 29 | } 30 | } 31 | if !state.isFetchingFinished { 32 | HStack { 33 | Spacer() 34 | ProgressView() 35 | Spacer() 36 | } 37 | .onAppear { 38 | reducer.viewModel.requestNextPage() 39 | } 40 | } 41 | } 42 | .listStateModifier(state.data.isEmpty) { 43 | Text(MR.strings().empty_view_title.localize()) 44 | } 45 | .listStateModifier(reducer.error != nil) { 46 | Text(MR.strings().error_view_title.localize(input: reducer.error ?? "" )) 47 | } 48 | } 49 | Spacer() 50 | } 51 | .toolbar { 52 | HStack { 53 | NavigationLink(destination: FavouriteUsersScreen()) { 54 | MR.images().ic_star_fill.asImage() 55 | } 56 | } 57 | } 58 | .task { 59 | reducer.viewModel.loadUsers() 60 | } 61 | .navigationTitle(MR.strings().app_name.localize()) 62 | .navigationBarTitleDisplayMode(.inline) 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /iosApp/iosApp/UI/List/GitHubUserRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitHubUserRow.swift 3 | // iosApp 4 | // 5 | // Created by Imre Kaszab on 2022. 04. 10.. 6 | // Copyright © 2022. orgName. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import shared 11 | 12 | struct GitHubUserRow: View { 13 | var item: GitHubUser 14 | 15 | var body: some View { 16 | HStack(alignment: .center, spacing: 8) { 17 | AsyncImage(url: URL(string: item.avatarUrl)!) { $0.resizable().scaledToFill() } 18 | placeholder: { 19 | Color.gray 20 | } 21 | .frame(width: 60, height: 60) 22 | .cornerRadius(16) 23 | VStack(alignment: .leading, spacing: 4) { 24 | Text(item.login) 25 | .fixedSize(horizontal: false, vertical: true) 26 | .lineLimit(1) 27 | .font(.system(size: 16, weight: .heavy, design: .default)) 28 | } 29 | Spacer() 30 | if item.favourite { 31 | MR.images().ic_star_fill.asImage() 32 | } 33 | } 34 | .padding(4) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /iosApp/iosApp/UI/Widget/ClearButtonModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClearButtonModifier.swift 3 | // iosApp 4 | // 5 | // Created by Imre Kaszab on 2022. 04. 22.. 6 | // Copyright © 2022. orgName. All rights reserved. 7 | // 8 | 9 | import shared 10 | import SwiftUI 11 | 12 | struct ClearButtonModifier: ViewModifier { 13 | @Binding var text: String 14 | 15 | public func body(content: Content) -> some View { 16 | ZStack(alignment: .trailing) { 17 | content 18 | if !text.isEmpty { 19 | Button { 20 | self.text = "" 21 | } 22 | label: { 23 | MR.images().ic_arrow_left 24 | .asImage() 25 | .foregroundColor(Color(UIColor.opaqueSeparator)) 26 | } 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /iosApp/iosApp/UI/Widget/ListStateViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListStateViewModifier.swift 3 | // iosApp 4 | // 5 | // Created by Imre Kaszab on 2022. 04. 15.. 6 | // Copyright © 2022. orgName. All rights reserved. 7 | // 8 | 9 | import shared 10 | import SwiftUI 11 | 12 | extension View { 13 | func debug() -> Self { 14 | print(Mirror(reflecting: self).subjectType) 15 | return self 16 | } 17 | } 18 | 19 | struct ListStateViewModifier: ViewModifier where ListStateContent: View { 20 | var isNotValid: Bool 21 | let stateContent: () -> ListStateContent 22 | 23 | func body(content: Content) -> some View { 24 | if isNotValid { 25 | stateContent() 26 | } else { 27 | content 28 | } 29 | } 30 | } 31 | 32 | extension View { 33 | func listStateModifier(_ isNotValid: Bool, 34 | emptyContent: @escaping () -> ListStateContent) 35 | -> some View where ListStateContent: View { 36 | modifier(ListStateViewModifier(isNotValid: isNotValid, stateContent: emptyContent)) 37 | } 38 | } 39 | 40 | struct EmptyStateView_Previews: PreviewProvider { 41 | static var previews: some View { 42 | Label("Content", systemImage: "heart") 43 | .listStateModifier(true) { 44 | Text(MR.strings().empty_view_title.localize()) 45 | } 46 | } 47 | } 48 | 49 | struct ErrorStateView_Previews: PreviewProvider { 50 | static var previews: some View { 51 | Label("Content", systemImage: "heart") 52 | .listStateModifier(true) { 53 | Text("Something went wrong 🤯") 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /iosApp/iosApp/UI/Widget/SearchBar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchBar.swift 3 | // iosApp 4 | // 5 | // Created by Imre Kaszab on 2022. 04. 10.. 6 | // Copyright © 2022. orgName. All rights reserved. 7 | // 8 | 9 | import shared 10 | import SwiftUI 11 | 12 | struct SearchBar: View { 13 | @State private var searchQuery = "" 14 | var action: (String) -> Void 15 | 16 | var body: some View { 17 | HStack { 18 | TextField(MR.strings().search_app_bar_title.localize(), 19 | text: $searchQuery) 20 | .modifier(ClearButtonModifier(text: $searchQuery)) 21 | .padding(10) 22 | .background(Color(.systemGray6)) 23 | .cornerRadius(8) 24 | .autocapitalization(.none) 25 | .disableAutocorrection(true) 26 | .padding(.horizontal, 10) 27 | .onSubmit { action(searchQuery) } 28 | .submitLabel(.search) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /iosApp/iosApp/Util/CombineAdapters.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CombineAdapters.swift 3 | // iosApp 4 | // 5 | // Created by Imre Kaszab on 2022. 11. 17.. 6 | // Copyright © 2022. orgName. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import shared 11 | 12 | /// Create a Combine publisher from the supplied `FlowAdapter`. Use this in contexts where more transformation will be 13 | /// done on the Swift side before the value is bound to UI 14 | func createPublisher(_ flowAdapter: FlowAdapter) -> AnyPublisher { 15 | return Deferred>> { 16 | let subject = PassthroughSubject() 17 | let canceller = flowAdapter.subscribe( 18 | onEach: { item in subject.send(item) }, 19 | onComplete: { subject.send(completion: .finished) }, 20 | onThrow: { error in subject.send(completion: .failure(KotlinError(error))) } 21 | ) 22 | return subject.handleEvents(receiveCancel: { canceller.cancel() }) 23 | } 24 | .eraseToAnyPublisher() 25 | } 26 | 27 | /// Prepare the supplied `FlowAdapter` to be bound to UI. The `onEach` callback will be called from `DispatchQueue.main` 28 | /// on every new emission. 29 | /// 30 | /// Note that this calls `assertNoFailure()` internally so you should handle errors upstream to avoid crashes. 31 | func doPublish(_ flowAdapter: FlowAdapter, onEach: @escaping (T?) -> Void) -> Cancellable { 32 | return createPublisher(flowAdapter) 33 | .assertNoFailure() 34 | .receive(on: DispatchQueue.main) 35 | .sink { onEach($0) } 36 | } 37 | 38 | /// Wraps a `KotlinThrowable` in a `LocalizedError` which can be used as a Combine error type 39 | class KotlinError: LocalizedError { 40 | let throwable: KotlinThrowable 41 | 42 | init(_ throwable: KotlinThrowable) { 43 | self.throwable = throwable 44 | } 45 | var errorDescription: String? { 46 | throwable.message 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /iosApp/iosApp/Util/ImageResource+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageResource+Extensions.swift 3 | // iosApp 4 | // 5 | // Created by Imre Kaszab on 2023. 06. 16.. 6 | // Copyright © 2023. orgName. All rights reserved. 7 | // 8 | 9 | import shared 10 | import SwiftUI 11 | import UIKit 12 | 13 | extension ImageResource { 14 | func asUIImage() -> UIImage { 15 | self.toUIImage() ?? UIImage() 16 | } 17 | 18 | func asImage() -> Image { 19 | Image(uiImage: self.asUIImage()) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /iosApp/iosApp/Util/StringResource+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StringResource+Extensions.swift 3 | // iosApp 4 | // 5 | // Created by Imre Kaszab on 2023. 06. 19.. 6 | // Copyright © 2023. orgName. All rights reserved. 7 | // 8 | 9 | import shared 10 | 11 | extension StringResource { 12 | func localize() -> String { 13 | self.desc().localized() 14 | } 15 | func localize(input: String) -> String { 16 | self.format(args_: [input]).localized() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /iosApp/iosApp/ViewModel/ReducerViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitHubUserListViewModelImpl.swift 3 | // iosApp 4 | // 5 | // Created by Imre Kaszab on 2022. 04. 10.. 6 | // Copyright © 2022. orgName. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import shared 11 | import Combine 12 | 13 | @MainActor 14 | final class ReducerViewModel: ObservableObject where S: UiState, V: ViewModel, V: StateFlowAdaptable { 15 | @LazyKoin var viewModel: V 16 | 17 | @Published public var state: S? 18 | @Published public var error: String? 19 | private var cancellables = Set() 20 | 21 | init() { 22 | doPublish(viewModel.stateFlowAdapter) { [weak self] in 23 | self?.state = $0 as? S 24 | } 25 | .store(in: &cancellables) 26 | 27 | doPublish(viewModel.errorFlowAdapter) { [weak self] in 28 | if let error = $0 as? String { 29 | self?.error = String(error) 30 | } else { 31 | self?.error = nil 32 | } 33 | } 34 | .store(in: &cancellables) 35 | } 36 | 37 | func deactivate() { 38 | viewModel.clear() 39 | cancellables.removeAll() 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>apter-tech/renovate-config#v0.7.0" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /screenshots/android.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apter-tech/GitHubUserFinder/870c25ab76ba69a5a735ae23b67cb20e41607e98/screenshots/android.gif -------------------------------------------------------------------------------- /screenshots/ios.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apter-tech/GitHubUserFinder/870c25ab76ba69a5a735ae23b67cb20e41607e98/screenshots/ios.gif -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | gradlePluginPortal() 6 | } 7 | } 8 | 9 | dependencyResolutionManagement { 10 | @Suppress("UnstableApiUsage") 11 | repositories { 12 | google() 13 | mavenCentral() 14 | } 15 | } 16 | 17 | rootProject.name = "GitHubUserFinder" 18 | include(":androidApp") 19 | include(":shared") 20 | -------------------------------------------------------------------------------- /shared/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlin.multiplatform) 3 | alias(libs.plugins.kotlin.serialization) 4 | alias(libs.plugins.android.library) 5 | alias(libs.plugins.mockmp) 6 | alias(libs.plugins.sqldelight) 7 | alias(libs.plugins.ksp) 8 | alias(libs.plugins.moko.resources) 9 | } 10 | 11 | val appJvmTarget: JavaVersion by rootProject.extra 12 | 13 | kotlin { 14 | jvmToolchain(appJvmTarget.majorVersion.toInt()) 15 | 16 | androidTarget { 17 | compilations.all { 18 | kotlinOptions { 19 | jvmTarget = "$appJvmTarget" 20 | } 21 | } 22 | } 23 | 24 | listOf( 25 | iosX64(), 26 | iosArm64(), 27 | iosSimulatorArm64() 28 | ).forEach { 29 | it.binaries.framework { 30 | baseName = "shared" 31 | export(libs.moko.resources) 32 | export(libs.moko.graphics) 33 | } 34 | } 35 | 36 | applyDefaultHierarchyTemplate() 37 | 38 | sourceSets { 39 | all { 40 | languageSettings.apply { 41 | optIn("kotlinx.coroutines.ExperimentalCoroutinesApi") 42 | } 43 | } 44 | } 45 | 46 | @Suppress("UNUSED_VARIABLE") 47 | sourceSets { 48 | commonMain.dependencies { 49 | implementation(libs.bundles.commonMain) 50 | api(libs.moko.resources) 51 | } 52 | commonTest.dependencies { 53 | implementation(kotlin("test-junit")) 54 | implementation(kotlin("test-common")) 55 | implementation(kotlin("test-annotations-common")) 56 | implementation(libs.bundles.commonTest) 57 | } 58 | androidMain.dependencies { 59 | implementation(libs.androidx.lifecycle.runtime) 60 | implementation(libs.androidx.lifecycle.viewmodel) 61 | implementation(libs.sqldelight.android.driver) 62 | implementation(libs.ktor.android) 63 | api(libs.moko.compose) 64 | } 65 | val androidUnitTest by getting { 66 | dependencies { 67 | implementation(libs.test.junitKtx) 68 | implementation(libs.sqldelight.sqlite.driver) 69 | } 70 | } 71 | iosMain.dependencies { 72 | implementation(libs.ktor.ios) 73 | implementation(libs.sqldelight.native) 74 | } 75 | } 76 | } 77 | 78 | android { 79 | namespace = "io.imrekaszab.githubuserfinder" 80 | compileSdk = libs.versions.targetSdk.get().toInt() 81 | sourceSets["main"].res.srcDir(File(buildDir, "generated/moko/androidMain/res")) 82 | sourceSets["main"].java.srcDir(File(buildDir, "generated/moko/androidMain/src")) 83 | defaultConfig { 84 | minSdk = libs.versions.minSdk.get().toInt() 85 | } 86 | } 87 | 88 | sqldelight { 89 | database("GitHubUserFinderDB") { 90 | packageName = "io.imrekaszab.githubuserfinder.db" 91 | } 92 | } 93 | 94 | mockmp { 95 | usesHelper = true 96 | installWorkaround() 97 | } 98 | 99 | multiplatformResources { 100 | multiplatformResourcesPackage = "io.imrekaszab.githubuserfinder" 101 | } 102 | 103 | val org.jetbrains.kotlin.konan.target.KonanTarget.enabledOnCurrentHost 104 | get() = org.jetbrains.kotlin.konan.target.HostManager().isEnabled(this) 105 | 106 | extensions.configure { 107 | targets.matching { target -> 108 | target is org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget && 109 | !target.konanTarget.enabledOnCurrentHost 110 | } 111 | .forEach { target -> 112 | tasks.findByName("${target.name}ProcessResources")?.enabled = false 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/io/imrekaszab/githubuserfinder/di/KoinAndroid.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.di 2 | 3 | import com.squareup.sqldelight.android.AndroidSqliteDriver 4 | import com.squareup.sqldelight.db.SqlDriver 5 | import io.imrekaszab.githubuserfinder.db.GitHubUserFinderDB 6 | import org.koin.core.module.Module 7 | import org.koin.dsl.module 8 | 9 | actual val platformModule: Module = module { 10 | single { 11 | AndroidSqliteDriver( 12 | GitHubUserFinderDB.Schema, 13 | get(), 14 | "GitHubUserFinderDB" 15 | ) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/io/imrekaszab/githubuserfinder/util/ViewModel.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.util 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import androidx.lifecycle.ViewModel as AndroidXViewModel 5 | import androidx.lifecycle.viewModelScope as androidXViewModelScope 6 | 7 | actual abstract class ViewModel actual constructor() : AndroidXViewModel() { 8 | actual val viewModelScope: CoroutineScope = androidXViewModelScope 9 | 10 | actual override fun onCleared() { 11 | super.onCleared() 12 | } 13 | 14 | // unused 15 | actual open fun clear() {} 16 | } 17 | -------------------------------------------------------------------------------- /shared/src/androidUnitTest/kotlin/io/imrekaszab/githubuserfinder/TestUtilAndroid.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder 2 | 3 | import android.app.Application 4 | import androidx.test.core.app.ApplicationProvider 5 | import com.squareup.sqldelight.android.AndroidSqliteDriver 6 | import com.squareup.sqldelight.db.SqlDriver 7 | import com.squareup.sqldelight.sqlite.driver.JdbcSqliteDriver 8 | import io.imrekaszab.githubuserfinder.db.GitHubUserFinderDB 9 | 10 | internal actual fun testDbConnection(): SqlDriver { 11 | // Try to use the android driver (which only works if we're on robolectric). 12 | // Fall back to jdbc if that fails. 13 | return try { 14 | val app = ApplicationProvider.getApplicationContext() 15 | AndroidSqliteDriver( 16 | GitHubUserFinderDB.Schema, 17 | app, 18 | "GitHubUserFinderDB" 19 | ) 20 | } catch (@Suppress("SwallowedException") exception: IllegalStateException) { 21 | JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) 22 | .also { GitHubUserFinderDB.Schema.create(it) } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/io/imrekaszab/githubuserfinder/api/GitHubApi.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.api 2 | 3 | import io.imrekaszab.githubuserfinder.model.api.GitHubUserDetailsApiModel 4 | import io.imrekaszab.githubuserfinder.model.api.SearchResponse 5 | 6 | interface GitHubApi { 7 | suspend fun searchUser(userName: String, page: Int, offset: Int): SearchResponse 8 | suspend fun refreshUserDetails(userName: String): GitHubUserDetailsApiModel 9 | } 10 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/io/imrekaszab/githubuserfinder/api/GitHubApiImpl.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.api 2 | 3 | import io.imrekaszab.githubuserfinder.model.api.GitHubUserDetailsApiModel 4 | import io.imrekaszab.githubuserfinder.model.api.SearchResponse 5 | import io.ktor.client.HttpClient 6 | import io.ktor.client.call.body 7 | import io.ktor.client.request.get 8 | 9 | class GitHubApiImpl( 10 | private val httpClient: HttpClient 11 | ) : GitHubApi { 12 | override suspend fun searchUser(userName: String, page: Int, offset: Int): SearchResponse = 13 | httpClient.get("search/users?q=$userName&page=$page&per_page=$offset").body() 14 | 15 | override suspend fun refreshUserDetails(userName: String): GitHubUserDetailsApiModel = 16 | httpClient.get("users/$userName").body() 17 | } 18 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/io/imrekaszab/githubuserfinder/database/CoroutinesExtensions.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.database 2 | 3 | import com.squareup.sqldelight.Transacter 4 | import com.squareup.sqldelight.TransactionWithoutReturn 5 | import kotlinx.coroutines.withContext 6 | import kotlin.coroutines.CoroutineContext 7 | 8 | suspend fun Transacter.transactionWithContext( 9 | coroutineContext: CoroutineContext, 10 | noEnclosing: Boolean = false, 11 | body: TransactionWithoutReturn.() -> Unit 12 | ) { 13 | withContext(coroutineContext) { 14 | this@transactionWithContext.transaction(noEnclosing) { 15 | body() 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/io/imrekaszab/githubuserfinder/database/DatabaseHelper.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.database 2 | 3 | import com.squareup.sqldelight.db.SqlDriver 4 | import com.squareup.sqldelight.runtime.coroutines.asFlow 5 | import com.squareup.sqldelight.runtime.coroutines.mapToList 6 | import io.imrekaszab.githubuserfinder.db.GitHubUserDataModel 7 | import io.imrekaszab.githubuserfinder.db.GitHubUserFinderDB 8 | import kotlinx.coroutines.CoroutineDispatcher 9 | import kotlinx.coroutines.flow.Flow 10 | import kotlinx.coroutines.flow.flowOn 11 | import org.koin.core.component.KoinComponent 12 | 13 | class DatabaseHelper( 14 | sqlDriver: SqlDriver, 15 | private val backgroundDispatcher: CoroutineDispatcher 16 | ) : KoinComponent { 17 | private val database: GitHubUserFinderDB = GitHubUserFinderDB(sqlDriver) 18 | 19 | fun selectAllItems(): Flow> = 20 | database.gitHubUserDataModelQueries 21 | .selectAll() 22 | .asFlow() 23 | .mapToList() 24 | .flowOn(backgroundDispatcher) 25 | 26 | suspend fun insertUser(user: GitHubUserDataModel) = 27 | database.transactionWithContext(backgroundDispatcher) { 28 | database.gitHubUserDataModelQueries.insertUser( 29 | user.id, 30 | user.login, 31 | user.avatarUrl, 32 | user.score, 33 | user.name, 34 | user.company, 35 | user.blog, 36 | user.location, 37 | user.email, 38 | user.bio, 39 | user.twitterUsername, 40 | user.followers, 41 | user.following, 42 | user.publicRepos 43 | ) 44 | } 45 | 46 | fun selectByUserName(userName: String): Flow> = 47 | database.gitHubUserDataModelQueries 48 | .selectByUserName(userName) 49 | .asFlow() 50 | .mapToList() 51 | .flowOn(backgroundDispatcher) 52 | 53 | suspend fun deleteById(id: Long) = 54 | database.transactionWithContext(backgroundDispatcher) { 55 | database.gitHubUserDataModelQueries.deleteById(id) 56 | } 57 | 58 | suspend fun deleteAll() = 59 | database.transactionWithContext(backgroundDispatcher) { 60 | database.gitHubUserDataModelQueries.deleteAll() 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/io/imrekaszab/githubuserfinder/di/DataModule.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.di 2 | 3 | import io.imrekaszab.githubuserfinder.service.GitHubUserService 4 | import io.imrekaszab.githubuserfinder.service.action.GitHubUserAction 5 | import io.imrekaszab.githubuserfinder.service.store.GitHubUserStore 6 | import org.koin.dsl.binds 7 | import org.koin.dsl.module 8 | 9 | var dataModule = module { 10 | single { GitHubUserService(get()) } binds arrayOf(GitHubUserAction::class, GitHubUserStore::class) 11 | } 12 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/io/imrekaszab/githubuserfinder/di/Koin.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.di 2 | 3 | import co.touchlab.kermit.Logger 4 | import co.touchlab.kermit.StaticConfig 5 | import co.touchlab.kermit.platformLogWriter 6 | import io.imrekaszab.githubuserfinder.api.GitHubApi 7 | import io.imrekaszab.githubuserfinder.api.GitHubApiImpl 8 | import io.imrekaszab.githubuserfinder.database.DatabaseHelper 9 | import io.imrekaszab.githubuserfinder.repository.GitHubUserRepository 10 | import io.imrekaszab.githubuserfinder.repository.GitHubUserRepositoryImpl 11 | import io.imrekaszab.githubuserfinder.viewmodel.details.GitHubUserDetailsViewModel 12 | import io.imrekaszab.githubuserfinder.viewmodel.favourite.FavouriteUsersViewModel 13 | import io.imrekaszab.githubuserfinder.viewmodel.list.GitHubUserListViewModel 14 | import io.ktor.client.HttpClient 15 | import io.ktor.client.plugins.contentnegotiation.ContentNegotiation 16 | import io.ktor.client.plugins.defaultRequest 17 | import io.ktor.client.plugins.logging.LogLevel 18 | import io.ktor.client.plugins.logging.Logging 19 | import io.ktor.http.URLBuilder 20 | import io.ktor.http.encodedPath 21 | import io.ktor.http.takeFrom 22 | import io.ktor.serialization.kotlinx.json.json 23 | import kotlinx.coroutines.Dispatchers 24 | import kotlinx.serialization.json.Json 25 | import org.koin.core.KoinApplication 26 | import org.koin.core.component.KoinComponent 27 | import org.koin.core.component.inject 28 | import org.koin.core.context.startKoin 29 | import org.koin.core.module.Module 30 | import org.koin.core.parameter.parametersOf 31 | import org.koin.dsl.module 32 | 33 | fun initKoin(baseUrl: String, appModule: Module): KoinApplication { 34 | val koinApplication = startKoin { 35 | modules( 36 | appModule, 37 | platformModule, 38 | networkModule(baseUrl), 39 | coreModule, 40 | repositoryModule, 41 | factoryModule, 42 | dataModule, 43 | apiModule, 44 | viewModelModule 45 | ) 46 | } 47 | 48 | // Dummy initialization logic, making use of appModule declarations for demonstration purposes. 49 | val koin = koinApplication.koin 50 | // doOnStartup is a lambda which is implemented in Swift on iOS side 51 | val doOnStartup = koin.get<() -> Unit>() 52 | doOnStartup.invoke() 53 | 54 | return koinApplication 55 | } 56 | 57 | var coreModule = module { 58 | single { 59 | DatabaseHelper( 60 | get(), 61 | Dispatchers.Default 62 | ) 63 | } 64 | } 65 | var apiModule = module { 66 | single { GitHubApiImpl(get()) } 67 | } 68 | 69 | var repositoryModule = module { 70 | single { GitHubUserRepositoryImpl(get(), get()) } 71 | } 72 | 73 | var viewModelModule = module { 74 | factory { GitHubUserListViewModel(get(), get()) } 75 | factory { GitHubUserDetailsViewModel(get(), get()) } 76 | factory { FavouriteUsersViewModel(get(), get()) } 77 | } 78 | 79 | internal val factoryModule = module { 80 | val baseLogger = 81 | Logger( 82 | config = StaticConfig(logWriterList = listOf(platformLogWriter())), 83 | "GitHubUserFinder" 84 | ) 85 | factory { (tag: String?) -> if (tag != null) baseLogger.withTag(tag) else baseLogger } 86 | } 87 | 88 | // Simple function to clean up the syntax a bit 89 | fun KoinComponent.injectLogger(tag: String): Lazy = inject { parametersOf(tag) } 90 | 91 | internal fun networkModule(baseUrl: String) = module { 92 | single { 93 | HttpClient { 94 | defaultRequest { 95 | url.takeFrom( 96 | URLBuilder().takeFrom(baseUrl).apply { 97 | encodedPath += "/${url.encodedPath}" 98 | } 99 | ) 100 | } 101 | 102 | install(ContentNegotiation) { 103 | json( 104 | Json { 105 | ignoreUnknownKeys = true 106 | } 107 | ) 108 | } 109 | 110 | install(Logging) { 111 | level = LogLevel.INFO 112 | } 113 | } 114 | } 115 | } 116 | 117 | expect val platformModule: Module 118 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/io/imrekaszab/githubuserfinder/mapper/Mappers.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.mapper 2 | 3 | import io.imrekaszab.githubuserfinder.db.GitHubUserDataModel 4 | import io.imrekaszab.githubuserfinder.model.api.GitHubUserApiModel 5 | import io.imrekaszab.githubuserfinder.model.api.GitHubUserDetailsApiModel 6 | import io.imrekaszab.githubuserfinder.model.domain.GitHubUser 7 | 8 | fun List.toDomainModels() = 9 | map { it.toDomain() } 10 | 11 | fun GitHubUserApiModel.toDomain() = 12 | GitHubUser( 13 | id = id, 14 | login = login, 15 | avatarUrl = avatar_url, 16 | score = score 17 | ) 18 | 19 | fun GitHubUserDetailsApiModel.toDomain() = 20 | GitHubUser( 21 | id = id, 22 | login = login, 23 | avatarUrl = avatar_url, 24 | name = name ?: login, 25 | company = company, 26 | blog = blog, 27 | location = location, 28 | email = email, 29 | bio = bio, 30 | twitterUsername = twitter_username, 31 | followers = followers, 32 | following = following, 33 | publicRepos = public_repos 34 | ) 35 | 36 | fun GitHubUserDataModel.toDomain() = 37 | GitHubUser( 38 | id = id.toInt(), 39 | login = login, 40 | avatarUrl = avatarUrl, 41 | score = score, 42 | name = name, 43 | company = company, 44 | blog = blog, 45 | location = location, 46 | email = email, 47 | bio = bio, 48 | twitterUsername = twitterUsername, 49 | followers = followers.toInt(), 50 | following = following.toInt(), 51 | publicRepos = publicRepos.toInt(), 52 | favourite = true 53 | ) 54 | 55 | fun GitHubUser.toData() = GitHubUserDataModel( 56 | id = id.toLong(), 57 | login = login, 58 | avatarUrl = avatarUrl, 59 | score = score, 60 | name = name, 61 | company = company, 62 | blog = blog, 63 | location = location, 64 | email = email, 65 | bio = bio, 66 | twitterUsername = twitterUsername, 67 | followers = followers.toLong(), 68 | following = following.toLong(), 69 | publicRepos = publicRepos.toLong() 70 | ) 71 | 72 | fun List.toDomains() = map { it.toDomain() } 73 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/io/imrekaszab/githubuserfinder/model/api/GitHubUserApiModel.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.model.api 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Suppress("ConstructorParameterNaming") 6 | @Serializable 7 | data class GitHubUserApiModel( 8 | val id: Int, 9 | val login: String, 10 | val avatar_url: String, 11 | val followers_url: String, 12 | val following_url: String, 13 | val repos_url: String, 14 | val score: Double 15 | ) 16 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/io/imrekaszab/githubuserfinder/model/api/GitHubUserDetailsApiModel.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.model.api 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Suppress("ConstructorParameterNaming") 6 | @Serializable 7 | data class GitHubUserDetailsApiModel( 8 | val id: Int, 9 | val login: String, 10 | val avatar_url: String, 11 | val followers_url: String, 12 | val following_url: String, 13 | val repos_url: String, 14 | val name: String?, 15 | val company: String?, 16 | val blog: String?, 17 | val location: String?, 18 | val email: String?, 19 | val bio: String?, 20 | val twitter_username: String?, 21 | val followers: Int, 22 | val following: Int, 23 | val public_repos: Int 24 | ) 25 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/io/imrekaszab/githubuserfinder/model/api/SearchResponse.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.model.api 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Suppress("ConstructorParameterNaming") 6 | @Serializable 7 | data class SearchResponse( 8 | val total_count: Int = 0, 9 | val items: List = emptyList() 10 | ) 11 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/io/imrekaszab/githubuserfinder/model/domain/GitHubPagingInfo.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.model.domain 2 | 3 | data class GitHubPagingInfo( 4 | val totalItemCount: Int = 0, 5 | val currentCount: Int = 0, 6 | val offset: Int = 100, 7 | val page: Int = 1, 8 | val userName: String = "" 9 | ) 10 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/io/imrekaszab/githubuserfinder/model/domain/GitHubUser.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.model.domain 2 | 3 | data class GitHubUser( 4 | val id: Int, 5 | val login: String = "", 6 | val avatarUrl: String = "", 7 | val score: Double = 0.0, 8 | val name: String = "", 9 | val company: String? = null, 10 | val blog: String? = null, 11 | val location: String? = null, 12 | val email: String? = null, 13 | val bio: String? = null, 14 | val twitterUsername: String? = null, 15 | val followers: Int = 0, 16 | val following: Int = 0, 17 | val publicRepos: Int = 0, 18 | val favourite: Boolean = false 19 | ) 20 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/io/imrekaszab/githubuserfinder/repository/GitHubUserRepository.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.repository 2 | 3 | import io.imrekaszab.githubuserfinder.model.domain.GitHubUser 4 | import kotlinx.coroutines.flow.Flow 5 | 6 | interface GitHubUserRepository { 7 | suspend fun searchUser(userName: String, page: Int, offset: Int): Pair> 8 | suspend fun refreshUserDetails(userName: String): GitHubUser 9 | suspend fun saveUser(user: GitHubUser) 10 | suspend fun deleteUser(userId: Int) 11 | suspend fun deleteAllUsers() 12 | fun getUserByUserName(userName: String): Flow 13 | fun getSavedUserList(): Flow> 14 | } 15 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/io/imrekaszab/githubuserfinder/repository/GitHubUserRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.repository 2 | 3 | import io.imrekaszab.githubuserfinder.api.GitHubApi 4 | import io.imrekaszab.githubuserfinder.database.DatabaseHelper 5 | import io.imrekaszab.githubuserfinder.mapper.toData 6 | import io.imrekaszab.githubuserfinder.mapper.toDomain 7 | import io.imrekaszab.githubuserfinder.mapper.toDomainModels 8 | import io.imrekaszab.githubuserfinder.mapper.toDomains 9 | import io.imrekaszab.githubuserfinder.model.domain.GitHubUser 10 | import kotlinx.coroutines.Dispatchers 11 | import kotlinx.coroutines.flow.Flow 12 | import kotlinx.coroutines.flow.firstOrNull 13 | import kotlinx.coroutines.flow.flowOn 14 | import kotlinx.coroutines.flow.map 15 | 16 | class GitHubUserRepositoryImpl( 17 | private val databaseHelper: DatabaseHelper, 18 | private val gitHubApi: GitHubApi 19 | ) : GitHubUserRepository { 20 | override suspend fun searchUser(userName: String, page: Int, offset: Int): Pair> { 21 | val result = gitHubApi.searchUser(userName, page, offset) 22 | return result.total_count to result.items.toDomainModels().toMutableList() 23 | } 24 | 25 | override suspend fun refreshUserDetails(userName: String): GitHubUser = 26 | getUserByUserName(userName).firstOrNull() ?: gitHubApi.refreshUserDetails(userName).toDomain() 27 | 28 | override suspend fun saveUser(user: GitHubUser) = databaseHelper.insertUser(user.toData()) 29 | 30 | override suspend fun deleteUser(userId: Int) = databaseHelper.deleteById(userId.toLong()) 31 | 32 | override suspend fun deleteAllUsers() = databaseHelper.deleteAll() 33 | 34 | override fun getUserByUserName(userName: String): Flow = 35 | databaseHelper.selectByUserName(userName) 36 | .map { it.firstOrNull()?.toDomain() } 37 | .flowOn(Dispatchers.Default) 38 | 39 | override fun getSavedUserList(): Flow> = 40 | databaseHelper.selectAllItems() 41 | .map { it.toDomains() } 42 | .flowOn(Dispatchers.Default) 43 | } 44 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/io/imrekaszab/githubuserfinder/service/GitHubUserService.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.service 2 | 3 | import co.touchlab.kermit.Logger 4 | import io.imrekaszab.githubuserfinder.di.injectLogger 5 | import io.imrekaszab.githubuserfinder.model.domain.GitHubPagingInfo 6 | import io.imrekaszab.githubuserfinder.model.domain.GitHubUser 7 | import io.imrekaszab.githubuserfinder.repository.GitHubUserRepository 8 | import io.imrekaszab.githubuserfinder.service.action.GitHubUserAction 9 | import io.imrekaszab.githubuserfinder.service.store.GitHubUserStore 10 | import kotlinx.coroutines.Dispatchers 11 | import kotlinx.coroutines.flow.Flow 12 | import kotlinx.coroutines.flow.MutableStateFlow 13 | import kotlinx.coroutines.flow.combine 14 | import kotlinx.coroutines.flow.first 15 | import kotlinx.coroutines.flow.map 16 | import kotlinx.coroutines.withContext 17 | import org.koin.core.component.KoinComponent 18 | 19 | class GitHubUserService( 20 | private val gitHubUserRepository: GitHubUserRepository 21 | ) : GitHubUserAction, GitHubUserStore, KoinComponent { 22 | private val logger: Logger by injectLogger("GitHubUserService") 23 | 24 | private val gitHubUserDetailsStateFlow = MutableStateFlow(null) 25 | private val gitHubUserListStateFlow = MutableStateFlow>(listOf()) 26 | private val gitHubPagingInfoStateFlow = MutableStateFlow(GitHubPagingInfo()) 27 | private val gitHubUserDetails: Flow = gitHubUserDetailsStateFlow 28 | private var fetchingInProgress = false 29 | 30 | override suspend fun searchUser(userName: String) = withContext(Dispatchers.Default) { 31 | try { 32 | val pagingInfo = GitHubPagingInfo() 33 | val result = gitHubUserRepository.searchUser(userName, pagingInfo.page, pagingInfo.offset) 34 | val userList = result.second 35 | 36 | val newPagingInfo = pagingInfo.copy( 37 | totalItemCount = result.first, 38 | userName = userName, 39 | currentCount = userList.size 40 | ) 41 | gitHubUserListStateFlow.emit(userList) 42 | gitHubPagingInfoStateFlow.emit(newPagingInfo) 43 | } catch (@Suppress("TooGenericExceptionCaught") exception: Exception) { 44 | gitHubUserListStateFlow.emit(mutableListOf()) 45 | logger.e("searchUser, error happened $exception") 46 | throw exception 47 | } 48 | } 49 | 50 | override suspend fun fetchNextPage() = withContext(Dispatchers.Default) { 51 | try { 52 | if (!fetchingInProgress && !isFetchingFinished().first()) { 53 | val pagingInfo = gitHubPagingInfoStateFlow.first() 54 | fetchingInProgress = true 55 | val result = gitHubUserRepository.searchUser( 56 | pagingInfo.userName, 57 | pagingInfo.page + 1, 58 | pagingInfo.offset 59 | ) 60 | val userList = result.second 61 | 62 | val currentResults = getUsers().first().toMutableList() 63 | currentResults.addAll(userList) 64 | gitHubUserListStateFlow.emit(currentResults.distinctBy { it.id }) 65 | gitHubPagingInfoStateFlow.emit( 66 | pagingInfo.copy( 67 | totalItemCount = result.first, 68 | currentCount = currentResults.size, 69 | page = pagingInfo.page + 1 70 | ) 71 | ) 72 | fetchingInProgress = false 73 | } 74 | } catch (@Suppress("TooGenericExceptionCaught") exception: Exception) { 75 | gitHubUserListStateFlow.emit(mutableListOf()) 76 | logger.e("fetchNextPage, error happened $exception") 77 | throw exception 78 | } 79 | } 80 | 81 | override suspend fun refreshUserDetails(userName: String) = withContext(Dispatchers.Default) { 82 | try { 83 | val result = gitHubUserRepository.refreshUserDetails(userName) 84 | logger.i("Result: $result") 85 | gitHubUserDetailsStateFlow.emit(result) 86 | } catch (@Suppress("TooGenericExceptionCaught") exception: Exception) { 87 | logger.e("refreshUserDetails, error happened $exception") 88 | throw exception 89 | } 90 | } 91 | 92 | override suspend fun saveUser() = withContext(Dispatchers.Default) { 93 | val user = getUserDetails().first() ?: return@withContext 94 | gitHubUserRepository.saveUser(user) 95 | gitHubUserDetailsStateFlow.emit(user.copy(favourite = true)) 96 | } 97 | 98 | override suspend fun deleteUser() = withContext(Dispatchers.Default) { 99 | val user = getUserDetails().first() ?: return@withContext 100 | gitHubUserRepository.deleteUser(user.id) 101 | gitHubUserDetailsStateFlow.emit(user.copy(favourite = false)) 102 | } 103 | 104 | override suspend fun deleteAllUsers() = withContext(Dispatchers.Default) { 105 | gitHubUserRepository.deleteAllUsers() 106 | } 107 | 108 | override fun getUsers(): Flow> = gitHubUserListStateFlow 109 | .combine(getSavedUsers()) { currentList, savedList -> 110 | currentList.map { item -> item.copy(favourite = savedList.any { it.id == item.id }) } 111 | } 112 | 113 | override fun getUserDetails(): Flow = 114 | gitHubUserDetails 115 | .combine(gitHubUserRepository.getSavedUserList()) { currentUserDetail, savedUserList -> 116 | logger.i( 117 | "User details params " + 118 | "current: $currentUserDetail " + 119 | "savedUserList: $savedUserList" 120 | ) 121 | savedUserList.firstOrNull { it.id == currentUserDetail?.id } ?: currentUserDetail 122 | } 123 | 124 | override fun isFetchingFinished(): Flow = 125 | gitHubPagingInfoStateFlow 126 | .map { it.currentCount == it.totalItemCount } 127 | 128 | override fun getSavedUsers(): Flow> = 129 | gitHubUserRepository.getSavedUserList() 130 | } 131 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/io/imrekaszab/githubuserfinder/service/action/GitHubUserAction.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.service.action 2 | 3 | interface GitHubUserAction { 4 | suspend fun searchUser(userName: String) 5 | suspend fun fetchNextPage() 6 | suspend fun refreshUserDetails(userName: String) 7 | suspend fun saveUser() 8 | suspend fun deleteUser() 9 | suspend fun deleteAllUsers() 10 | } 11 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/io/imrekaszab/githubuserfinder/service/store/GitHubUserStore.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.service.store 2 | 3 | import io.imrekaszab.githubuserfinder.model.domain.GitHubUser 4 | import kotlinx.coroutines.flow.Flow 5 | 6 | interface GitHubUserStore { 7 | fun getUsers(): Flow> 8 | fun getUserDetails(): Flow 9 | fun isFetchingFinished(): Flow 10 | fun getSavedUsers(): Flow> 11 | } 12 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/io/imrekaszab/githubuserfinder/util/CoroutineAdapters.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.util 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.Job 5 | import kotlinx.coroutines.flow.Flow 6 | import kotlinx.coroutines.flow.catch 7 | import kotlinx.coroutines.flow.launchIn 8 | import kotlinx.coroutines.flow.onCompletion 9 | import kotlinx.coroutines.flow.onEach 10 | 11 | class FlowAdapter( 12 | private val scope: CoroutineScope, 13 | private val flow: Flow 14 | ) { 15 | fun subscribe( 16 | onEach: (item: T) -> Unit, 17 | onComplete: () -> Unit, 18 | onThrow: (error: Throwable) -> Unit 19 | ): Canceller = JobCanceller( 20 | flow.onEach { onEach(it) } 21 | .catch { onThrow(it) } 22 | .onCompletion { onComplete() } 23 | .launchIn(scope) 24 | ) 25 | } 26 | 27 | interface Canceller { 28 | fun cancel() 29 | } 30 | 31 | private class JobCanceller(private val job: Job) : Canceller { 32 | override fun cancel() { 33 | job.cancel() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/io/imrekaszab/githubuserfinder/util/ViewModel.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.util 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | 5 | expect abstract class ViewModel() { 6 | val viewModelScope: CoroutineScope 7 | protected open fun onCleared() 8 | open fun clear() 9 | } 10 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/io/imrekaszab/githubuserfinder/util/reducer/Reducer.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.util.reducer 2 | 3 | import io.imrekaszab.githubuserfinder.util.FlowAdapter 4 | import io.imrekaszab.githubuserfinder.util.ViewModel 5 | import kotlinx.coroutines.flow.MutableStateFlow 6 | import kotlinx.coroutines.flow.StateFlow 7 | import kotlinx.coroutines.launch 8 | 9 | interface StateFlowAdaptable { 10 | val stateFlowAdapter: FlowAdapter<*> 11 | val errorFlowAdapter: FlowAdapter<*> 12 | } 13 | 14 | abstract class Reducer(initialVal: S) : ViewModel(), StateFlowAdaptable { 15 | private val mainScope = viewModelScope 16 | 17 | private val _state: MutableStateFlow = MutableStateFlow(initialVal) 18 | val state: StateFlow 19 | get() = _state 20 | 21 | private val _error: MutableStateFlow = MutableStateFlow(null) 22 | val error: StateFlow 23 | get() = _error 24 | 25 | override val stateFlowAdapter = state.asCallbacks() 26 | override val errorFlowAdapter = error.asCallbacks() 27 | 28 | fun sendEvent(event: E) { 29 | mainScope.launch { 30 | try { 31 | reduce(_state.value, event) 32 | } catch (@Suppress("TooGenericExceptionCaught") ex: Throwable) { 33 | _error.emit(ex.message) 34 | doOnError() 35 | } 36 | } 37 | } 38 | 39 | private suspend fun clearError() { 40 | _error.emit(null) 41 | } 42 | 43 | suspend fun setState(newState: S, withClearError: Boolean = true) { 44 | if (withClearError) { 45 | clearError() 46 | } 47 | _state.emit(newState) 48 | } 49 | 50 | open suspend fun doOnError() {} 51 | 52 | abstract suspend fun reduce(oldState: S, event: E) 53 | 54 | private fun StateFlow.asCallbacks() = FlowAdapter(viewModelScope, this) 55 | } 56 | 57 | interface UiState 58 | 59 | interface UiEvent 60 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/io/imrekaszab/githubuserfinder/viewmodel/details/GitHubUserDetailsModel.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.viewmodel.details 2 | 3 | import io.imrekaszab.githubuserfinder.model.domain.GitHubUser 4 | import io.imrekaszab.githubuserfinder.util.reducer.UiEvent 5 | import io.imrekaszab.githubuserfinder.util.reducer.UiState 6 | 7 | sealed class UserDetailsScreenUiEvent : UiEvent { 8 | data class RefreshUser(val userName: String) : UserDetailsScreenUiEvent() 9 | object DeleteUser : UserDetailsScreenUiEvent() 10 | object SaveUser : UserDetailsScreenUiEvent() 11 | } 12 | 13 | data class UserDetailsScreenState(val userDetails: GitHubUser?) : UiState { 14 | companion object { 15 | fun initial() = UserDetailsScreenState(userDetails = null) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/io/imrekaszab/githubuserfinder/viewmodel/details/GitHubUserDetailsViewModel.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.viewmodel.details 2 | 3 | import io.imrekaszab.githubuserfinder.service.action.GitHubUserAction 4 | import io.imrekaszab.githubuserfinder.service.store.GitHubUserStore 5 | import io.imrekaszab.githubuserfinder.util.reducer.Reducer 6 | import kotlinx.coroutines.flow.first 7 | 8 | class GitHubUserDetailsViewModel( 9 | private val gitHubUserAction: GitHubUserAction, 10 | private val gitHubUserStore: GitHubUserStore 11 | ) : Reducer(UserDetailsScreenState.initial()) { 12 | 13 | fun refreshUserDetails(userName: String) { 14 | sendEvent(UserDetailsScreenUiEvent.RefreshUser(userName)) 15 | } 16 | 17 | fun saveUser() { 18 | sendEvent(UserDetailsScreenUiEvent.SaveUser) 19 | } 20 | 21 | fun deleteUser() { 22 | sendEvent(UserDetailsScreenUiEvent.DeleteUser) 23 | } 24 | 25 | override suspend fun reduce(oldState: UserDetailsScreenState, event: UserDetailsScreenUiEvent) { 26 | when (event) { 27 | UserDetailsScreenUiEvent.DeleteUser -> { 28 | gitHubUserAction.deleteUser() 29 | updateUserDetails(oldState) 30 | } 31 | is UserDetailsScreenUiEvent.RefreshUser -> { 32 | gitHubUserAction.refreshUserDetails(event.userName) 33 | updateUserDetails(oldState) 34 | } 35 | UserDetailsScreenUiEvent.SaveUser -> { 36 | gitHubUserAction.saveUser() 37 | updateUserDetails(oldState) 38 | } 39 | } 40 | } 41 | 42 | private suspend fun updateUserDetails(oldState: UserDetailsScreenState) { 43 | val userDetails = gitHubUserStore.getUserDetails().first() 44 | setState(oldState.copy(userDetails = userDetails)) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/io/imrekaszab/githubuserfinder/viewmodel/favourite/FavouriteUsersModel.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.viewmodel.favourite 2 | 3 | import io.imrekaszab.githubuserfinder.model.domain.GitHubUser 4 | import io.imrekaszab.githubuserfinder.util.reducer.UiEvent 5 | import io.imrekaszab.githubuserfinder.util.reducer.UiState 6 | 7 | sealed class FavouriteUsersScreenUiEvent : UiEvent { 8 | object DeleteUsers : FavouriteUsersScreenUiEvent() 9 | object LoadUsers : FavouriteUsersScreenUiEvent() 10 | } 11 | 12 | data class FavouriteUsersScreenState(val data: List) : UiState { 13 | companion object { 14 | fun initial() = FavouriteUsersScreenState(data = emptyList()) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/io/imrekaszab/githubuserfinder/viewmodel/favourite/FavouriteUsersViewModel.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.viewmodel.favourite 2 | 3 | import io.imrekaszab.githubuserfinder.service.action.GitHubUserAction 4 | import io.imrekaszab.githubuserfinder.service.store.GitHubUserStore 5 | import io.imrekaszab.githubuserfinder.util.reducer.Reducer 6 | import kotlinx.coroutines.flow.first 7 | 8 | class FavouriteUsersViewModel( 9 | private val gitHubUserAction: GitHubUserAction, 10 | private val gitHubUserStore: GitHubUserStore 11 | ) : Reducer( 12 | FavouriteUsersScreenState.initial() 13 | ) { 14 | fun loadUsers() { 15 | sendEvent(FavouriteUsersScreenUiEvent.LoadUsers) 16 | } 17 | 18 | fun deleteAllUser() { 19 | sendEvent(FavouriteUsersScreenUiEvent.DeleteUsers) 20 | } 21 | 22 | override suspend fun reduce( 23 | oldState: FavouriteUsersScreenState, 24 | event: FavouriteUsersScreenUiEvent 25 | ) { 26 | when (event) { 27 | FavouriteUsersScreenUiEvent.DeleteUsers -> { 28 | gitHubUserAction.deleteAllUsers() 29 | loadUsers(oldState) 30 | } 31 | is FavouriteUsersScreenUiEvent.LoadUsers -> loadUsers(oldState) 32 | } 33 | } 34 | 35 | private suspend fun loadUsers(oldState: FavouriteUsersScreenState) { 36 | val users = gitHubUserStore.getSavedUsers().first() 37 | setState(oldState.copy(data = users)) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/io/imrekaszab/githubuserfinder/viewmodel/list/GitHubUserListModel.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.viewmodel.list 2 | 3 | import io.imrekaszab.githubuserfinder.model.domain.GitHubUser 4 | import io.imrekaszab.githubuserfinder.util.reducer.UiEvent 5 | import io.imrekaszab.githubuserfinder.util.reducer.UiState 6 | 7 | sealed class UserListScreenUiEvent : UiEvent { 8 | data class Search(val query: String) : UserListScreenUiEvent() 9 | object RequestNextPage : UserListScreenUiEvent() 10 | object LoadUsers : UserListScreenUiEvent() 11 | } 12 | 13 | data class UserListScreenState( 14 | val isLoading: Boolean, 15 | val data: List, 16 | val isFetchingFinished: Boolean, 17 | val navigateToDetails: Boolean 18 | ) : UiState { 19 | companion object { 20 | fun initial() = UserListScreenState( 21 | isLoading = false, 22 | data = emptyList(), 23 | isFetchingFinished = true, 24 | navigateToDetails = false 25 | ) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/io/imrekaszab/githubuserfinder/viewmodel/list/GitHubUserListViewModel.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.viewmodel.list 2 | 3 | import io.imrekaszab.githubuserfinder.service.action.GitHubUserAction 4 | import io.imrekaszab.githubuserfinder.service.store.GitHubUserStore 5 | import io.imrekaszab.githubuserfinder.util.reducer.Reducer 6 | import kotlinx.coroutines.flow.first 7 | 8 | class GitHubUserListViewModel( 9 | private val gitHubUserAction: GitHubUserAction, 10 | private val gitHubUserStore: GitHubUserStore 11 | ) : Reducer(UserListScreenState.initial()) { 12 | 13 | fun searchUser(userName: String? = null) { 14 | sendEvent(UserListScreenUiEvent.Search(query = userName ?: "")) 15 | } 16 | 17 | fun requestNextPage() { 18 | sendEvent(UserListScreenUiEvent.RequestNextPage) 19 | } 20 | 21 | fun loadUsers() { 22 | sendEvent(UserListScreenUiEvent.LoadUsers) 23 | } 24 | 25 | override suspend fun reduce(oldState: UserListScreenState, event: UserListScreenUiEvent) { 26 | when (event) { 27 | is UserListScreenUiEvent.Search -> { 28 | setState(oldState.copy(isLoading = true)) 29 | gitHubUserAction.searchUser(event.query) 30 | refreshStateByUsers(oldState) 31 | } 32 | 33 | is UserListScreenUiEvent.RequestNextPage -> { 34 | gitHubUserAction.fetchNextPage() 35 | refreshStateByUsers(oldState) 36 | } 37 | 38 | UserListScreenUiEvent.LoadUsers -> refreshStateByUsers(oldState) 39 | } 40 | } 41 | 42 | private suspend fun refreshStateByUsers(oldState: UserListScreenState) { 43 | val users = gitHubUserStore.getUsers().first() 44 | val isFetchingFinished = gitHubUserStore.isFetchingFinished().first() 45 | setState( 46 | oldState.copy( 47 | isLoading = false, 48 | data = users, 49 | isFetchingFinished = isFetchingFinished 50 | ) 51 | ) 52 | } 53 | 54 | override suspend fun doOnError() { 55 | setState(state.value.copy(isLoading = false), withClearError = false) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /shared/src/commonMain/resources/MR/base/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Favourite users 4 | GitHubUserFinder 5 | Search… 6 | Remove all user 7 | Are you sure? 8 | Dismiss 9 | Remove 10 | We don\'t have any content, sorry 😔 11 | Something went wrong 🤯 \n\n %1$s 12 | Followers 13 | Following 14 | Public repos 15 | Company 16 | Location 17 | Blog 18 | Twitter 19 | Email 20 | Loading 21 | 22 | Clear icon 23 | Arrow back 24 | Favourite button 25 | 26 | -------------------------------------------------------------------------------- /shared/src/commonMain/resources/MR/images/ic_arrow_left.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /shared/src/commonMain/resources/MR/images/ic_clear.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /shared/src/commonMain/resources/MR/images/ic_star.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /shared/src/commonMain/resources/MR/images/ic_star_fill.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /shared/src/commonMain/resources/MR/images/ic_trash.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /shared/src/commonMain/sqldelight/io/imrekaszab/githubuserfinder/db/GitHubUserDataModel.sq: -------------------------------------------------------------------------------- 1 | CREATE TABLE GitHubUserDataModel ( 2 | id INTEGER NOT NULL PRIMARY KEY, 3 | login TEXT NOT NULL, 4 | avatarUrl TEXT NOT NULL, 5 | score REAL NOT NULL, 6 | name TEXT NOT NULL, 7 | company TEXT, 8 | blog TEXT, 9 | location TEXT, 10 | email TEXT, 11 | bio TEXT, 12 | twitterUsername TEXT, 13 | followers INTEGER NOT NULL, 14 | following INTEGER NOT NULL, 15 | publicRepos INTEGER NOT NULL 16 | ); 17 | 18 | selectAll: 19 | SELECT * FROM GitHubUserDataModel; 20 | 21 | selectByUserName: 22 | SELECT * FROM GitHubUserDataModel WHERE login = ?; 23 | 24 | insertUser: 25 | INSERT OR IGNORE INTO GitHubUserDataModel(id, login, avatarUrl, score, name, company, blog, location, email, bio, twitterUsername,followers, following, publicRepos) 26 | VALUES (?,?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); 27 | 28 | deleteById: 29 | DELETE FROM GitHubUserDataModel WHERE id = ?; 30 | 31 | deleteAll: 32 | DELETE FROM GitHubUserDataModel; 33 | -------------------------------------------------------------------------------- /shared/src/commonTest/kotlin/io/imrekaszab/githubuserfinder/FavouriteUsersViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder 2 | 3 | import io.imrekaszab.githubuserfinder.model.domain.GitHubUser 4 | import io.imrekaszab.githubuserfinder.service.action.GitHubUserAction 5 | import io.imrekaszab.githubuserfinder.service.store.GitHubUserStore 6 | import io.imrekaszab.githubuserfinder.viewmodel.favourite.FavouriteUsersViewModel 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.flow.first 9 | import kotlinx.coroutines.flow.flowOf 10 | import kotlinx.coroutines.test.resetMain 11 | import kotlinx.coroutines.test.runTest 12 | import kotlinx.coroutines.test.setMain 13 | import org.kodein.mock.Mock 14 | import org.kodein.mock.tests.TestsWithMocks 15 | import kotlin.test.AfterTest 16 | import kotlin.test.Test 17 | import kotlin.test.assertEquals 18 | 19 | class FavouriteUsersViewModelTest : TestsWithMocks() { 20 | @Mock 21 | lateinit var action: GitHubUserAction 22 | 23 | @Mock 24 | lateinit var store: GitHubUserStore 25 | 26 | private val viewModel by withMocks { FavouriteUsersViewModel(action, store) } 27 | 28 | override fun setUpMocks() { 29 | Dispatchers.setMain(Dispatchers.Unconfined) 30 | injectMocks(mocker) 31 | } 32 | 33 | @AfterTest 34 | fun clear() { 35 | Dispatchers.resetMain() 36 | viewModel.clear() 37 | } 38 | 39 | @Test 40 | fun `given non-emptyList when loadUsers called then returns non-emptyList`() = runTest { 41 | // Given 42 | val userList = listOf(MockData.user) 43 | every { store.getSavedUsers() } returns flowOf(userList) 44 | 45 | // When 46 | viewModel.loadUsers() 47 | 48 | // Then 49 | val result = viewModel.state.first() 50 | 51 | assertEquals(userList, result.data) 52 | 53 | verifyWithSuspend { 54 | viewModel.loadUsers() 55 | viewModel.setState(isAny()) 56 | } 57 | } 58 | 59 | @Test 60 | fun `given emptyList when deleteAllUser called then returns emptyList`() = runTest { 61 | // Given 62 | val userList = emptyList() 63 | everySuspending { action.deleteAllUsers() } returns Unit 64 | every { store.getSavedUsers() } returns flowOf(userList) 65 | 66 | // When 67 | viewModel.deleteAllUser() 68 | 69 | // Then 70 | val result = viewModel.state.first() 71 | 72 | assertEquals(userList, result.data) 73 | 74 | verifyWithSuspend { 75 | viewModel.deleteAllUser() 76 | viewModel.setState(isAny()) 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /shared/src/commonTest/kotlin/io/imrekaszab/githubuserfinder/GitHubUserDetailsViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder 2 | 3 | import io.imrekaszab.githubuserfinder.service.action.GitHubUserAction 4 | import io.imrekaszab.githubuserfinder.service.store.GitHubUserStore 5 | import io.imrekaszab.githubuserfinder.viewmodel.details.GitHubUserDetailsViewModel 6 | import kotlinx.coroutines.Dispatchers 7 | import kotlinx.coroutines.flow.first 8 | import kotlinx.coroutines.flow.flowOf 9 | import kotlinx.coroutines.test.resetMain 10 | import kotlinx.coroutines.test.runTest 11 | import kotlinx.coroutines.test.setMain 12 | import org.kodein.mock.Mock 13 | import org.kodein.mock.tests.TestsWithMocks 14 | import kotlin.test.AfterTest 15 | import kotlin.test.Test 16 | import kotlin.test.assertEquals 17 | import kotlin.test.assertFalse 18 | import kotlin.test.assertTrue 19 | 20 | class GitHubUserDetailsViewModelTest : TestsWithMocks() { 21 | 22 | @Mock 23 | lateinit var store: GitHubUserStore 24 | 25 | @Mock 26 | lateinit var action: GitHubUserAction 27 | 28 | private val viewModel by withMocks { GitHubUserDetailsViewModel(action, store) } 29 | 30 | override fun setUpMocks() { 31 | Dispatchers.setMain(Dispatchers.Unconfined) 32 | injectMocks(mocker) 33 | } 34 | 35 | @AfterTest 36 | fun clear() { 37 | Dispatchers.resetMain() 38 | viewModel.clear() 39 | } 40 | 41 | @Test 42 | fun `given mock user when saveUser called then returns the user with favourite true`() = runTest { 43 | // Given 44 | val user = MockData.user.copy(favourite = true) 45 | every { store.getUserDetails() } returns flowOf(user) 46 | everySuspending { action.saveUser() } returns Unit 47 | 48 | // When 49 | viewModel.saveUser() 50 | 51 | // Then 52 | val result = viewModel.state.first() 53 | 54 | assertTrue(result.userDetails?.favourite == true) 55 | 56 | verifyWithSuspend { 57 | viewModel.saveUser() 58 | viewModel.setState(isAny()) 59 | } 60 | } 61 | 62 | @Test 63 | fun `given mock user when refreshUserDetails called then returns the user`() = runTest { 64 | // Given 65 | val user = MockData.user 66 | every { store.getUserDetails() } returns flowOf(user) 67 | everySuspending { action.refreshUserDetails(isAny()) } returns Unit 68 | 69 | // When 70 | viewModel.refreshUserDetails(user.name) 71 | 72 | // Then 73 | val result = viewModel.state.first() 74 | 75 | assertEquals(user, result.userDetails) 76 | 77 | verifyWithSuspend { 78 | viewModel.refreshUserDetails(isAny()) 79 | viewModel.setState(isAny()) 80 | } 81 | } 82 | 83 | @Test 84 | fun `given mock user when deleteUser called then returns the user with favourite false`() = runTest { 85 | // Given 86 | val user = MockData.user.copy(favourite = false) 87 | every { store.getUserDetails() } returns flowOf(user) 88 | everySuspending { action.deleteUser() } returns Unit 89 | 90 | // When 91 | viewModel.deleteUser() 92 | 93 | // Then 94 | val result = viewModel.state.first() 95 | 96 | assertFalse(result.userDetails?.favourite == true) 97 | 98 | verifyWithSuspend { 99 | viewModel.deleteUser() 100 | viewModel.setState(isAny()) 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /shared/src/commonTest/kotlin/io/imrekaszab/githubuserfinder/GitHubUserListViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder 2 | 3 | import io.imrekaszab.githubuserfinder.model.domain.GitHubUser 4 | import io.imrekaszab.githubuserfinder.service.action.GitHubUserAction 5 | import io.imrekaszab.githubuserfinder.service.store.GitHubUserStore 6 | import io.imrekaszab.githubuserfinder.viewmodel.list.GitHubUserListViewModel 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.flow.first 9 | import kotlinx.coroutines.flow.flowOf 10 | import kotlinx.coroutines.test.resetMain 11 | import kotlinx.coroutines.test.runTest 12 | import kotlinx.coroutines.test.setMain 13 | import org.kodein.mock.Mock 14 | import org.kodein.mock.tests.TestsWithMocks 15 | import kotlin.test.AfterTest 16 | import kotlin.test.Test 17 | import kotlin.test.assertEquals 18 | import kotlin.test.assertTrue 19 | 20 | class GitHubUserListViewModelTest : TestsWithMocks() { 21 | @Mock 22 | lateinit var action: GitHubUserAction 23 | 24 | @Mock 25 | lateinit var store: GitHubUserStore 26 | 27 | private val viewModel by withMocks { GitHubUserListViewModel(action, store) } 28 | 29 | override fun setUpMocks() { 30 | Dispatchers.setMain(Dispatchers.Unconfined) 31 | injectMocks(mocker) 32 | } 33 | 34 | @AfterTest 35 | fun clear() { 36 | Dispatchers.resetMain() 37 | viewModel.clear() 38 | } 39 | 40 | @Test 41 | fun `given emptyList when loadUsers called then returns emptyList`() = runTest { 42 | // Given 43 | val emptyList = emptyList() 44 | every { store.getUsers() } returns flowOf(emptyList) 45 | every { store.isFetchingFinished() } returns flowOf(true) 46 | 47 | // When 48 | viewModel.loadUsers() 49 | 50 | // Then 51 | val result = viewModel.state.first() 52 | 53 | assertEquals(emptyList, result.data) 54 | 55 | verifyWithSuspend { 56 | viewModel.loadUsers() 57 | viewModel.setState(isAny()) 58 | } 59 | } 60 | @Test 61 | fun `given null userName when searchUser called then returns emptyList`() = runTest { 62 | // Given 63 | val userName = null 64 | everySuspending { action.searchUser(isAny()) } returns Unit 65 | every { store.getUsers() } returns flowOf(emptyList()) 66 | every { store.isFetchingFinished() } returns flowOf(true) 67 | 68 | // When 69 | viewModel.searchUser(userName) 70 | 71 | // Then 72 | val result = viewModel.state.first() 73 | 74 | assertTrue(result.data.isEmpty()) 75 | 76 | verifyWithSuspend { 77 | viewModel.searchUser(isAny()) 78 | viewModel.setState(isAny()) 79 | } 80 | } 81 | 82 | @Test 83 | fun `given mock username when searchUser called then returns non-emptyList with the user`() = runTest { 84 | // Given 85 | val userName = MockData.userName 86 | everySuspending { action.searchUser(isAny()) } returns Unit 87 | every { store.getUsers() } returns flowOf(listOf(MockData.user)) 88 | every { store.isFetchingFinished() } returns flowOf(true) 89 | 90 | // When 91 | viewModel.searchUser(userName) 92 | 93 | // Then 94 | val result = viewModel.state.first() 95 | 96 | assertEquals(userName, result.data.first().login) 97 | 98 | verifyWithSuspend { 99 | viewModel.searchUser(isAny()) 100 | viewModel.setState(isAny()) 101 | } 102 | } 103 | 104 | @Test 105 | fun `given mock user when requestNextPage called then returns non-emptyList with the user`() = runTest { 106 | // Given 107 | val userName = MockData.userName 108 | everySuspending { action.fetchNextPage() } returns Unit 109 | every { store.getUsers() } returns flowOf(listOf(MockData.user)) 110 | every { store.isFetchingFinished() } returns flowOf(true) 111 | 112 | // When 113 | viewModel.requestNextPage() 114 | 115 | // Then 116 | val result = viewModel.state.first() 117 | 118 | assertEquals(userName, result.data.first().login) 119 | 120 | verifyWithSuspend { 121 | viewModel.requestNextPage() 122 | viewModel.setState(isAny()) 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /shared/src/commonTest/kotlin/io/imrekaszab/githubuserfinder/GitHubUserServiceTest.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder 2 | 3 | import io.imrekaszab.githubuserfinder.di.apiModule 4 | import io.imrekaszab.githubuserfinder.di.coreModule 5 | import io.imrekaszab.githubuserfinder.di.dataModule 6 | import io.imrekaszab.githubuserfinder.di.factoryModule 7 | import io.imrekaszab.githubuserfinder.di.platformModule 8 | import io.imrekaszab.githubuserfinder.di.repositoryModule 9 | import io.imrekaszab.githubuserfinder.model.domain.GitHubUser 10 | import io.imrekaszab.githubuserfinder.service.action.GitHubUserAction 11 | import io.imrekaszab.githubuserfinder.service.store.GitHubUserStore 12 | import kotlinx.coroutines.flow.first 13 | import kotlinx.coroutines.runBlocking 14 | import org.koin.core.context.startKoin 15 | import org.koin.core.context.stopKoin 16 | import org.koin.test.KoinTest 17 | import org.koin.test.inject 18 | import kotlin.test.AfterTest 19 | import kotlin.test.BeforeTest 20 | import kotlin.test.Test 21 | import kotlin.test.assertEquals 22 | 23 | class GitHubUserServiceTest : KoinTest { 24 | 25 | private val action: GitHubUserAction by inject() 26 | private val store: GitHubUserStore by inject() 27 | 28 | @BeforeTest 29 | fun setUp() { 30 | startKoin { 31 | modules( 32 | apiModule, 33 | repositoryModule, 34 | coreModule, 35 | platformModule, 36 | factoryModule, 37 | dataModule, 38 | mockModule 39 | ) 40 | } 41 | } 42 | 43 | @AfterTest 44 | fun clear() { 45 | stopKoin() 46 | } 47 | 48 | @Test 49 | fun `store gives back an empty list after a search with empty string`() = runBlocking { 50 | // Given 51 | val emptyList = emptyList() 52 | 53 | // When 54 | action.searchUser("") 55 | val result = store.getUsers().first() 56 | 57 | // Then 58 | assertEquals(emptyList, result) 59 | } 60 | 61 | @Test 62 | fun `isFetchingFinished is true by default because the search has not been started yet`() = 63 | runBlocking { 64 | // Given 65 | val isFetchingFinished = true 66 | 67 | // When 68 | val result = store.isFetchingFinished().first() 69 | 70 | // Then 71 | assertEquals(isFetchingFinished, result) 72 | } 73 | 74 | @Test 75 | fun `store gives back a non empty list after search`() = runBlocking { 76 | // Given 77 | val listIsNotEmpty = true 78 | val userName = MockData.userName 79 | 80 | // When 81 | action.searchUser(userName) 82 | val result = store.getUsers().first() 83 | 84 | // Then 85 | assertEquals(listIsNotEmpty, result.isNotEmpty()) 86 | } 87 | 88 | @Test 89 | fun `isFetchingFinished is true after search`() = runBlocking { 90 | // Given 91 | val isFetchingFinished = true 92 | val userName = MockData.userName 93 | 94 | // When 95 | action.searchUser(userName) 96 | val result = store.isFetchingFinished().first() 97 | 98 | // Then 99 | assertEquals(isFetchingFinished, result) 100 | } 101 | 102 | @Test 103 | fun `store gives back user detail after refresh`() = runBlocking { 104 | // Given 105 | val userName = MockData.userName 106 | 107 | // When 108 | action.refreshUserDetails(userName) 109 | 110 | // Then 111 | val result = store.getUserDetails().first() 112 | assertEquals(userName, result?.login) 113 | } 114 | 115 | @Test 116 | fun `store gives back user after refresh and save`() = runBlocking { 117 | // Given 118 | val userName = MockData.userName 119 | 120 | // When 121 | action.refreshUserDetails(userName) 122 | action.saveUser() 123 | 124 | // Then 125 | 126 | val result = store.getUserDetails().first() 127 | assertEquals(userName, result?.login) 128 | } 129 | 130 | @Test 131 | fun `store gives back emptyList after deleteAll`() = runBlocking { 132 | // Given 133 | val isEmpty = true 134 | 135 | // When 136 | action.deleteAllUsers() 137 | val result = store.getSavedUsers().first() 138 | 139 | // Then 140 | assertEquals(isEmpty, result.isEmpty()) 141 | } 142 | 143 | @Test 144 | fun `store gives back emptyList after save and delete`() = runBlocking { 145 | // Given 146 | val isEmpty = true 147 | val userName = "test" 148 | 149 | // When 150 | action.refreshUserDetails(userName) 151 | action.saveUser() 152 | action.deleteUser() 153 | val result = store.getSavedUsers().first() 154 | 155 | // Then 156 | assertEquals(isEmpty, result.isEmpty()) 157 | } 158 | 159 | @Test 160 | fun `store gives back non-emptyList after save and refresh`() = runBlocking { 161 | // Given 162 | val isNonEmpty = true 163 | val userName = "test" 164 | 165 | // When 166 | action.refreshUserDetails(userName) 167 | action.saveUser() 168 | 169 | // Then 170 | val result = store.getSavedUsers().first() 171 | assertEquals(isNonEmpty, result.isNotEmpty()) 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /shared/src/commonTest/kotlin/io/imrekaszab/githubuserfinder/MockData.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder 2 | 3 | import io.imrekaszab.githubuserfinder.model.domain.GitHubUser 4 | 5 | object MockData { 6 | const val baseUrl = "https://api.github.com" 7 | const val userName = "example" 8 | const val emptyResponse = "{\"items\":[] }" 9 | const val userDetailsResponse = "{\n" + 10 | "\"login\": \"$userName\",\n" + 11 | "\"id\": 1,\n" + 12 | "\"avatar_url\": \"avatar_link\",\n" + 13 | "\"followers_url\": \"followers_link\",\n" + 14 | "\"repos_url\": \"repos_link\",\n" + 15 | "\"name\": \"name\",\n" + 16 | "\"company\": \"company\",\n" + 17 | "\"blog\": \"blog\",\n" + 18 | "\"location\": \"location\",\n" + 19 | "\"email\": \"email\",\n" + 20 | "\"bio\": \"bio\",\n" + 21 | "\"twitter_username\": \"twitter_username\",\n" + 22 | "\"score\": 1,\n" + 23 | "\"public_repos\": 1,\n" + 24 | "\"following\": 1,\n" + 25 | "\"followers\": 1,\n" + 26 | "\"following_url\": \"following_url\"\n" + 27 | "}\n" 28 | const val nonEmptyResponse = "{\n" + 29 | "\"total_count\": 1,\n" + 30 | "\"items\": [\n" + userDetailsResponse + "]\n" + "}" 31 | 32 | val user = GitHubUser( 33 | id = 1, 34 | login = userName, 35 | avatarUrl = "avatar_link", 36 | name = "name", 37 | company = "company", 38 | blog = "blog", 39 | location = "location", 40 | email = "email", 41 | bio = "bio", 42 | twitterUsername = "twitter_username", 43 | publicRepos = 1, 44 | following = 1, 45 | followers = 1 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /shared/src/commonTest/kotlin/io/imrekaszab/githubuserfinder/MockHttpClient.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder 2 | 3 | import io.ktor.client.HttpClient 4 | import io.ktor.client.engine.mock.MockEngine 5 | import io.ktor.client.engine.mock.respond 6 | import io.ktor.client.engine.mock.respondError 7 | import io.ktor.client.plugins.contentnegotiation.ContentNegotiation 8 | import io.ktor.client.plugins.defaultRequest 9 | import io.ktor.http.HttpMethod 10 | import io.ktor.http.HttpStatusCode 11 | import io.ktor.http.URLBuilder 12 | import io.ktor.http.Url 13 | import io.ktor.http.encodedPath 14 | import io.ktor.http.fullPath 15 | import io.ktor.http.headersOf 16 | import io.ktor.http.hostWithPort 17 | import io.ktor.http.takeFrom 18 | import io.ktor.serialization.kotlinx.json.json 19 | import kotlinx.serialization.json.Json 20 | 21 | private val Url.hostWithPortIfRequired: String 22 | get() = if (port == protocol.defaultPort) { 23 | host 24 | } else { 25 | hostWithPort 26 | } 27 | private val Url.urlWithoutPath: String 28 | get() = "${protocol.name}://$hostWithPortIfRequired" 29 | private val Url.fullUrl: String 30 | get() = "${protocol.name}://$hostWithPortIfRequired$fullPath" 31 | 32 | internal val mockHttpClient = 33 | HttpClient(MockEngine) { 34 | defaultRequest { 35 | url.takeFrom( 36 | URLBuilder().takeFrom(MockData.baseUrl).apply { 37 | encodedPath += url.encodedPath 38 | } 39 | ) 40 | } 41 | 42 | install(ContentNegotiation) { 43 | json( 44 | Json { 45 | ignoreUnknownKeys = true 46 | } 47 | ) 48 | } 49 | 50 | engine { 51 | addHandler { request -> 52 | if (request.url.urlWithoutPath == MockData.baseUrl) { 53 | if (request.method == HttpMethod.Get) { 54 | when { 55 | request.url.fullUrl.contains("search/users") -> respond( 56 | content = getSearchResponseContentByUrl(request.url.fullUrl), 57 | status = HttpStatusCode.OK, 58 | headers = headersOf("Content-Type", "application/json") 59 | ) 60 | 61 | request.url.fullUrl.contains("users/") -> respond( 62 | content = MockData.userDetailsResponse, 63 | status = HttpStatusCode.OK, 64 | headers = headersOf("Content-Type", "application/json") 65 | ) 66 | 67 | else -> respondError(HttpStatusCode.BadRequest) 68 | } 69 | } else { 70 | respondError(HttpStatusCode.Unauthorized) 71 | } 72 | } else { 73 | respondError(HttpStatusCode.NotFound) 74 | } 75 | } 76 | } 77 | } 78 | 79 | private fun getSearchResponseContentByUrl(fullUrl: String) = 80 | if (fullUrl.contains("?q=&")) { 81 | MockData.emptyResponse 82 | } else { 83 | MockData.nonEmptyResponse 84 | } 85 | -------------------------------------------------------------------------------- /shared/src/commonTest/kotlin/io/imrekaszab/githubuserfinder/MockModule.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder 2 | 3 | import co.touchlab.kermit.Logger 4 | import co.touchlab.kermit.StaticConfig 5 | import org.koin.dsl.module 6 | 7 | internal val mockModule = module { 8 | single { 9 | mockHttpClient 10 | } 11 | single { 12 | testDbConnection() 13 | } 14 | single { 15 | Logger(StaticConfig()) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /shared/src/commonTest/kotlin/io/imrekaszab/githubuserfinder/TestUtil.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder 2 | 3 | import com.squareup.sqldelight.db.SqlDriver 4 | 5 | internal expect fun testDbConnection(): SqlDriver 6 | -------------------------------------------------------------------------------- /shared/src/iosMain/kotlin/io/imrekaszab/githubuserfinder/di/KoinIOS.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.di 2 | 3 | import co.touchlab.kermit.Logger 4 | import com.squareup.sqldelight.db.SqlDriver 5 | import com.squareup.sqldelight.drivers.native.NativeSqliteDriver 6 | import io.imrekaszab.githubuserfinder.db.GitHubUserFinderDB 7 | import kotlinx.cinterop.BetaInteropApi 8 | import kotlinx.cinterop.ObjCClass 9 | import kotlinx.cinterop.ObjCProtocol 10 | import kotlinx.cinterop.getOriginalKotlinClass 11 | import org.koin.core.Koin 12 | import org.koin.core.KoinApplication 13 | import org.koin.core.parameter.parametersOf 14 | import org.koin.dsl.module 15 | 16 | fun initKoinIos( 17 | baseUrl: String, 18 | doOnStartup: () -> Unit 19 | ): KoinApplication = initKoin( 20 | baseUrl, 21 | module { 22 | single { doOnStartup } 23 | } 24 | ) 25 | 26 | actual val platformModule = module { 27 | single { NativeSqliteDriver(GitHubUserFinderDB.Schema, "GitHubUserFinderDB") } 28 | } 29 | 30 | // Access from Swift to create a logger 31 | @Suppress("unused") 32 | fun Koin.loggerWithTag(tag: String) = get(qualifier = null) { parametersOf(tag) } 33 | 34 | @OptIn(BetaInteropApi::class) 35 | fun Koin.get(objCClass: ObjCClass): Any? { 36 | val kClazz = getOriginalKotlinClass(objCClass) ?: return null 37 | return get(kClazz) 38 | } 39 | 40 | @OptIn(BetaInteropApi::class) 41 | fun Koin.get(objCProtocol: ObjCProtocol): Any? { 42 | val kClazz = getOriginalKotlinClass(objCProtocol) ?: return null 43 | return get(kClazz) 44 | } 45 | -------------------------------------------------------------------------------- /shared/src/iosMain/kotlin/io/imrekaszab/githubuserfinder/util/ViewModel.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder.util 2 | 3 | import kotlinx.coroutines.MainScope 4 | import kotlinx.coroutines.cancel 5 | 6 | actual abstract class ViewModel { 7 | 8 | actual val viewModelScope = MainScope() 9 | 10 | protected actual open fun onCleared() {} 11 | 12 | actual open fun clear() { 13 | onCleared() 14 | viewModelScope.cancel() 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /shared/src/iosTest/kotlin/io/imrekaszab/githubuserfinder/TestUtilIOS.kt: -------------------------------------------------------------------------------- 1 | package io.imrekaszab.githubuserfinder 2 | 3 | import co.touchlab.sqliter.DatabaseConfiguration 4 | import com.squareup.sqldelight.db.SqlDriver 5 | import com.squareup.sqldelight.drivers.native.NativeSqliteDriver 6 | import com.squareup.sqldelight.drivers.native.wrapConnection 7 | import io.imrekaszab.githubuserfinder.db.GitHubUserFinderDB 8 | 9 | internal actual fun testDbConnection(): SqlDriver { 10 | val schema = GitHubUserFinderDB.Schema 11 | return NativeSqliteDriver( 12 | DatabaseConfiguration( 13 | name = "GitHubUserFinderDB", 14 | version = schema.version, 15 | create = { connection -> 16 | wrapConnection(connection) { schema.create(it) } 17 | }, 18 | upgrade = { connection, oldVersion, newVersion -> 19 | wrapConnection(connection) { schema.migrate(it, oldVersion, newVersion) } 20 | }, 21 | inMemory = true 22 | ) 23 | ) 24 | } 25 | --------------------------------------------------------------------------------