├── .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 | 
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 | 
9 | 
10 | 
11 | 
12 | 
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 | [](https://github.com/ktorio/ktor)
40 | - 🔒 [SQLDelight](https://github.com/cashapp/sqldelight) - LocalDB
41 | [](https://github.com/cashapp/sqldelight)
42 | - 💉 [Koin](https://github.com/InsertKoinIO/koin) - DI framework
43 | [](https://github.com/InsertKoinIO/koin)
44 | - 📋 [Kermit](https://github.com/touchlab/Kermit) - Logger
45 | [](https://github.com/touchlab/Kermit)
46 | - 🎨 [moko resources](https://github.com/icerockdev/moko-resources) - Shared resources
47 | [](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 | 
52 | ```
53 | ./gradlew detekt
54 | ```
55 | - [Swiftlint](https://github.com/realm/SwiftLint) - `iOS`
56 | 
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 | [](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 |
--------------------------------------------------------------------------------