├── .github
└── workflows
│ ├── android-ci-test.yml
│ ├── android-ci.yml
│ ├── release.yml
│ └── test.yml
├── .gitignore
├── .idea
├── .gitignore
├── compiler.xml
├── deploymentTargetSelector.xml
├── gradle.xml
├── inspectionProfiles
│ └── Project_Default.xml
├── kotlinc.xml
├── migrations.xml
├── misc.xml
├── runConfigurations.xml
└── vcs.xml
├── .kotlin
└── errors
│ ├── errors-1728569097183.log
│ ├── errors-1728569168864.log
│ ├── errors-1728569240614.log
│ ├── errors-1728569359431.log
│ ├── errors-1728569511078.log
│ ├── errors-1728569545241.log
│ ├── errors-1728569587439.log
│ ├── errors-1728569603404.log
│ ├── errors-1728569897006.log
│ ├── errors-1728603696143.log
│ └── errors-1728824767720.log
├── README.md
├── README_cn.md
├── app
├── .gitignore
├── build.gradle.kts
├── proguard-rules.pro
└── src
│ ├── androidTest
│ └── java
│ │ └── com
│ │ └── github
│ │ └── app
│ │ └── ExampleInstrumentedTest.kt
│ ├── main
│ ├── AndroidManifest.xml
│ ├── java
│ │ └── com
│ │ │ └── github
│ │ │ └── app
│ │ │ ├── App.kt
│ │ │ ├── navigation
│ │ │ ├── AppNavHost.kt
│ │ │ └── Screen.kt
│ │ │ └── ui
│ │ │ ├── MainActivity.kt
│ │ │ └── theme
│ │ │ ├── Color.kt
│ │ │ ├── Theme.kt
│ │ │ └── Type.kt
│ └── res
│ │ ├── drawable
│ │ ├── ic_launcher_background.xml
│ │ └── ic_launcher_foreground.xml
│ │ ├── mipmap-anydpi-v26
│ │ ├── ic_launcher.xml
│ │ └── ic_launcher_round.xml
│ │ ├── mipmap-hdpi
│ │ ├── ic_launcher.webp
│ │ └── ic_launcher_round.webp
│ │ ├── mipmap-mdpi
│ │ ├── ic_launcher.webp
│ │ └── ic_launcher_round.webp
│ │ ├── mipmap-xhdpi
│ │ ├── ic_launcher.webp
│ │ └── ic_launcher_round.webp
│ │ ├── mipmap-xxhdpi
│ │ ├── ic_launcher.webp
│ │ └── ic_launcher_round.webp
│ │ ├── mipmap-xxxhdpi
│ │ ├── ic_launcher.webp
│ │ └── ic_launcher_round.webp
│ │ ├── values
│ │ ├── colors.xml
│ │ ├── strings.xml
│ │ └── themes.xml
│ │ └── xml
│ │ ├── backup_rules.xml
│ │ └── data_extraction_rules.xml
│ └── test
│ └── java
│ └── com
│ └── github
│ └── app
│ └── ExampleUnitTest.kt
├── build.gradle.kts
├── feature
├── details
│ ├── .gitignore
│ ├── build.gradle.kts
│ ├── consumer-rules.pro
│ ├── proguard-rules.pro
│ └── src
│ │ ├── androidTest
│ │ └── java
│ │ │ └── com
│ │ │ └── andy
│ │ │ └── github
│ │ │ └── details
│ │ │ └── ExampleInstrumentedTest.kt
│ │ ├── main
│ │ ├── AndroidManifest.xml
│ │ └── java
│ │ │ └── com
│ │ │ └── andy
│ │ │ └── github
│ │ │ └── details
│ │ │ ├── data
│ │ │ ├── model
│ │ │ │ ├── RepositoryItemModel.kt
│ │ │ │ └── UserResultModel.kt
│ │ │ ├── remote
│ │ │ │ ├── ApiRepositoriesResponse.kt
│ │ │ │ └── DetailsApiService.kt
│ │ │ └── repository
│ │ │ │ └── GitHubUserDetailsRepository.kt
│ │ │ ├── di
│ │ │ ├── DetailsModule.kt
│ │ │ └── DetailsRepositoryModule.kt
│ │ │ ├── domain
│ │ │ ├── model
│ │ │ │ ├── Repository.kt
│ │ │ │ └── User.kt
│ │ │ └── repository
│ │ │ │ └── UserDetailsRepository.kt
│ │ │ └── ui
│ │ │ ├── DetailAppBar.kt
│ │ │ ├── DetailContent.kt
│ │ │ ├── DetailScreen.kt
│ │ │ ├── DetailViewModel.kt
│ │ │ ├── ErrorContent.kt
│ │ │ ├── Loading.kt
│ │ │ ├── RepositoryCard.kt
│ │ │ ├── UserConnections.kt
│ │ │ └── UserHeader.kt
│ │ └── test
│ │ └── java
│ │ └── com
│ │ └── andy
│ │ └── github
│ │ └── details
│ │ └── ExampleUnitTest.kt
└── home
│ ├── .gitignore
│ ├── build.gradle.kts
│ ├── consumer-rules.pro
│ ├── proguard-rules.pro
│ └── src
│ ├── androidTest
│ └── java
│ │ └── com
│ │ └── andy
│ │ └── github
│ │ └── home
│ │ ├── data
│ │ └── local
│ │ │ └── SearchHistoryDaoTest.kt
│ │ ├── di
│ │ └── FakeDatabaseModule.kt
│ │ └── ui
│ │ ├── HomeContentTest.kt
│ │ ├── HomeSearchBarTest.kt
│ │ └── SearchHistoryTest.kt
│ ├── main
│ ├── AndroidManifest.xml
│ ├── java
│ │ └── com
│ │ │ └── andy
│ │ │ └── github
│ │ │ └── home
│ │ │ ├── data
│ │ │ ├── local
│ │ │ │ ├── SearchHistoryDao.kt
│ │ │ │ ├── SearchHistoryDatabase.kt
│ │ │ │ └── entity
│ │ │ │ │ └── SearchEntity.kt
│ │ │ ├── model
│ │ │ │ └── SimpleUserModel.kt
│ │ │ ├── paging
│ │ │ │ └── SearchPagingSource.kt
│ │ │ ├── remote
│ │ │ │ ├── ApiSimpleUserResponse.kt
│ │ │ │ └── HomeApiService.kt
│ │ │ └── repository
│ │ │ │ ├── DatabaseSearchHistoryRepository.kt
│ │ │ │ └── SimpleGitHubUserRepository.kt
│ │ │ ├── di
│ │ │ ├── DatabaseModule.kt
│ │ │ ├── HomeModule.kt
│ │ │ └── HomeRepositoryModule.kt
│ │ │ ├── domain
│ │ │ ├── model
│ │ │ │ ├── Search.kt
│ │ │ │ └── SimpleUser.kt
│ │ │ └── repository
│ │ │ │ ├── SearchHistoryRepository.kt
│ │ │ │ └── SimpleUserRepository.kt
│ │ │ └── ui
│ │ │ ├── HomeContent.kt
│ │ │ ├── HomeScreen.kt
│ │ │ ├── HomeSearchBar.kt
│ │ │ ├── HomeViewModel.kt
│ │ │ ├── SearchHistory.kt
│ │ │ ├── SearchHistoryList.kt
│ │ │ ├── SearchHistoryViewModel.kt
│ │ │ ├── UserItem.kt
│ │ │ └── components
│ │ │ ├── ErrorContent.kt
│ │ │ ├── LoadingItem.kt
│ │ │ ├── NoData.kt
│ │ │ └── NoMoreDataItem.kt
│ └── res
│ │ └── drawable
│ │ ├── baseline_person_50.xml
│ │ └── baseline_person_off_50.xml
│ └── test
│ └── java
│ └── com
│ └── andy
│ └── github
│ └── home
│ ├── ExampleUnitTest.kt
│ ├── data
│ └── FakeSearchHistoryRepository.kt
│ └── ui
│ └── SearchHistoryViewModelTest.kt
├── gradle.properties
├── gradle
├── libs.versions.toml
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── libs
├── common
│ ├── .gitignore
│ ├── build.gradle.kts
│ ├── consumer-rules.pro
│ ├── proguard-rules.pro
│ └── src
│ │ ├── androidTest
│ │ └── java
│ │ │ └── com
│ │ │ └── andy
│ │ │ └── common
│ │ │ └── ExampleInstrumentedTest.kt
│ │ ├── main
│ │ ├── AndroidManifest.xml
│ │ └── java
│ │ │ └── com
│ │ │ └── andy
│ │ │ └── common
│ │ │ ├── JsonKt.kt
│ │ │ ├── JsonManager.kt
│ │ │ ├── NumberFormatter.kt
│ │ │ └── di
│ │ │ └── DispatchersModule.kt
│ │ └── test
│ │ └── java
│ │ └── com
│ │ └── andy
│ │ └── common
│ │ └── ExampleUnitTest.kt
├── framework
│ ├── .gitignore
│ ├── build.gradle.kts
│ ├── consumer-rules.pro
│ ├── proguard-rules.pro
│ └── src
│ │ ├── androidTest
│ │ └── java
│ │ │ └── com
│ │ │ └── andy
│ │ │ └── framework
│ │ │ └── ExampleInstrumentedTest.kt
│ │ ├── main
│ │ ├── AndroidManifest.xml
│ │ ├── java
│ │ │ └── com
│ │ │ │ └── andy
│ │ │ │ └── framework
│ │ │ │ ├── BaseActivity.kt
│ │ │ │ ├── BaseFragment.kt
│ │ │ │ └── LazyFragment.kt
│ │ └── res
│ │ │ └── anim
│ │ │ ├── slid_in_from_bottom.xml
│ │ │ ├── slid_in_from_left.xml
│ │ │ ├── slid_in_from_right.xml
│ │ │ ├── slid_in_from_top.xml
│ │ │ ├── slid_out_to_bottom.xml
│ │ │ ├── slid_out_to_left.xml
│ │ │ ├── slid_out_to_right.xml
│ │ │ └── slid_out_to_top.xml
│ │ └── test
│ │ └── java
│ │ └── com
│ │ └── andy
│ │ └── framework
│ │ └── ExampleUnitTest.kt
├── network
│ ├── .gitignore
│ ├── build.gradle.kts
│ ├── consumer-rules.pro
│ ├── proguard-rules.pro
│ └── src
│ │ ├── androidTest
│ │ └── java
│ │ │ └── com
│ │ │ └── andy
│ │ │ └── network
│ │ │ └── ExampleInstrumentedTest.kt
│ │ ├── main
│ │ ├── AndroidManifest.xml
│ │ └── java
│ │ │ └── com
│ │ │ └── andy
│ │ │ └── network
│ │ │ ├── ApiResultAdapterFactory.kt
│ │ │ ├── ApiResultCall.kt
│ │ │ ├── ApiResultCallAdapter.kt
│ │ │ ├── AuthorizationHeaderInterceptor.kt
│ │ │ ├── common
│ │ │ ├── ApiResult.kt
│ │ │ └── Constants.kt
│ │ │ ├── data
│ │ │ └── ApiResult.kt
│ │ │ ├── di
│ │ │ └── NetworkModule.kt
│ │ │ └── domain
│ │ │ ├── Result.kt
│ │ │ └── ResultKt.kt
│ │ └── test
│ │ └── java
│ │ └── com
│ │ └── andy
│ │ └── network
│ │ └── ExampleUnitTest.kt
└── testing
│ ├── .gitignore
│ ├── build.gradle.kts
│ ├── consumer-rules.pro
│ ├── proguard-rules.pro
│ └── src
│ ├── androidTest
│ └── java
│ │ └── com
│ │ └── andy
│ │ └── testing
│ │ ├── AppTestRunner.kt
│ │ └── ExampleInstrumentedTest.kt
│ ├── main
│ ├── AndroidManifest.xml
│ └── java
│ │ └── com
│ │ └── andy
│ │ └── testing
│ │ └── AppTestRunner.kt
│ └── test
│ └── java
│ └── com
│ └── andy
│ └── testing
│ └── ExampleUnitTest.kt
└── settings.gradle.kts
/.github/workflows/android-ci-test.yml:
--------------------------------------------------------------------------------
1 | name: Generated APK and AAB (Upload Artifact to Github Action)
2 |
3 | env:
4 | # The name of the main module repository
5 | main_project_module: app
6 | # The name of the Play Store
7 | playstore_name: GitHubApp
8 |
9 | on:
10 | pull_request:
11 | branches:
12 | - 'main'
13 |
14 | push:
15 | branches:
16 | - 'main'
17 | - 'dev-testing'
18 | - 'dev-modulization'
19 | - 'feature/*'
20 | # Allows you to run this workflow manually from the Actions tab
21 | workflow_dispatch:
22 | jobs:
23 | build:
24 | runs-on: ubuntu-latest
25 |
26 | steps:
27 | - uses: actions/checkout@v4
28 |
29 | # Set Current date as env Variable
30 | - name: Set current date as env variavle
31 | run: |
32 | date_today=$(date +%Y-%m-%d_%H-%M-%S)
33 | echo "date_today=$date_today" >> $GITHUB_ENV
34 |
35 | # Set Repository Name as Env Variable
36 | - name: Set repository name as env variable
37 | run: echo "repository_name=$(echo '${{ github.repository }}' | awk -F '/' '{print $2}')" >> $GITHUB_ENV
38 |
39 | - name: Set Up JDK
40 | uses: actions/setup-java@v3
41 | with:
42 | distribution: 'zulu' #See 'Supported distributions' for avaible options
43 | java-version: '17'
44 | cache: 'gradle'
45 |
46 | - name: Change wrapper permission
47 | run: chmod +x ./gradlew
48 |
49 | # Run Tests Build
50 | - name: Run gradle tests
51 | run: ./gradlew test
52 |
53 | # Run Build Project
54 | - name: Build gradle project
55 | run: ./gradlew build
56 |
57 | # Create APK Debug
58 | - name: Build apk debug project (APK) - ${{ env.main_project_module }} module
59 | run: ./gradlew assembleDebug
60 |
61 | # Create APK Release
62 | - name: Build apk release roject (APK) - ${{ env.main_project_module }} module
63 | run: ./gradlew assemble
64 |
65 | # Create Bundle AAB Release
66 | # Noted for main module build [main_project_module]:bundleRelease
67 | - name: Build app bundle relase (AAB) - ${{ env.main_project_module }} module
68 | run: ./gradlew ${{ env.main_project_module }}:bundleRelease
69 |
70 | # Upload Artifact Build
71 | # Noted For Output [main_project_main]/build.outputs/apk/debug/
72 | - name: Upload APK Debug - ${{env.repository_name }}
73 | uses: actions/upload-artifact@v3
74 | with:
75 | name: ${{ env.date_today }} - ${{ env.playstore_name }} - ${{ env.repository_name }} - APK(s) debug generated
76 | path: ${{ env.main_project_module }}/build/outputs/apk/debug/
77 |
78 | # Noted For Output [main_project_module]/build/outputs/apk/release/
79 | - name: Upload APK Release - ${{ env.repository_name }}
80 | uses: actions/upload-artifact@v3
81 | with:
82 | name: ${{ env.date_today }} - ${{ env.playstore_name }} - ${{ env.repository_name }} - APK(s) release generated
83 | path: ${{ env.main_project_module }}/build/outputs/apk/release/
84 |
85 | # Noted For Output [main_project_module]/build/outputs/bundle/release/
86 | - name: Upload AAB (App Bundle) Release - ${{ env.repository_name }}
87 | uses: actions/upload-artifact@v3
88 | with:
89 | name: ${{ env.date_today }} - ${{ env.playstore_name }} - ${{ env.repository_name }} - App bundle(s) AAB release generated
90 | path: ${{ env.main_project_module }}/build/outputs/bundle/release/
91 |
--------------------------------------------------------------------------------
/.github/workflows/android-ci.yml:
--------------------------------------------------------------------------------
1 | name: Generated APK AAB (Upload - Create Artifact To Github Action Test)
2 |
3 | env:
4 | # The name of the main module repository
5 | main_project_module: app
6 |
7 | # The name of the Play Store
8 | playstore_name: Test ID
9 |
10 | on:
11 |
12 | pull_request:
13 | branches:
14 | - 'main'
15 |
16 | # Allows you to run this workflow manually from the Actions tab
17 | workflow_dispatch:
18 |
19 | jobs:
20 | build:
21 |
22 | runs-on: ubuntu-latest
23 |
24 | steps:
25 | - uses: actions/checkout@v3
26 |
27 | # Set Current Date As Env Variable
28 | - name: Set current date as env variable
29 | run: echo "date_today=$(date +'%Y-%m-%d')" >> $GITHUB_ENV
30 |
31 | # Set Repository Name As Env Variable
32 | - name: Set repository name as env variable
33 | run: echo "repository_name=$(echo '${{ github.repository }}' | awk -F '/' '{print $2}')" >> $GITHUB_ENV
34 |
35 | - name: Set Up JDK
36 | uses: actions/setup-java@v3
37 | with:
38 | distribution: 'zulu' # See 'Supported distributions' for available options
39 | java-version: '17'
40 | cache: 'gradle'
41 |
42 | - name: Change wrapper permissions
43 | run: chmod +x ./gradlew
44 |
45 | # Run Tests Build
46 | - name: Run gradle tests
47 | run: ./gradlew test
48 |
49 | # Run Build Project
50 | - name: Build gradle project
51 | run: ./gradlew build
52 |
53 | # Create APK Debug
54 | - name: Build apk debug project (APK) - ${{ env.main_project_module }} module
55 | run: ./gradlew assembleDebug
56 |
57 | # Create APK Release
58 | - name: Build apk release project (APK) - ${{ env.main_project_module }} module
59 | run: ./gradlew assemble
60 |
61 | # Create Bundle AAB Release
62 | # Noted for main module build [main_project_module]:bundleRelease
63 | - name: Build app bundle release (AAB) - ${{ env.main_project_module }} module
64 | run: ./gradlew ${{ env.main_project_module }}:bundleRelease
65 |
66 | # Upload Artifact Build
67 | # Noted For Output [main_project_module]/build/outputs/apk/debug/
68 | - name: Upload APK Debug - ${{ env.repository_name }}
69 | uses: actions/upload-artifact@v3
70 | with:
71 | name: ${{ env.date_today }} - ${{ env.playstore_name }} - ${{ env.repository_name }} - APK(s) debug generated
72 | path: ${{ env.main_project_module }}/build/outputs/apk/debug/
73 |
74 | # Noted For Output [main_project_module]/build/outputs/apk/release/
75 | - name: Upload APK Release - ${{ env.repository_name }}
76 | uses: actions/upload-artifact@v3
77 | with:
78 | name: ${{ env.date_today }} - ${{ env.playstore_name }} - ${{ env.repository_name }} - APK(s) release generated
79 | path: ${{ env.main_project_module }}/build/outputs/apk/release/
80 |
81 | # Noted For Output [main_project_module]/build/outputs/bundle/release/
82 | - name: Upload AAB (App Bundle) Release - ${{ env.repository_name }}
83 | uses: actions/upload-artifact@v3
84 | with:
85 | name: ${{ env.date_today }} - ${{ env.playstore_name }} - ${{ env.repository_name }} - App bundle(s) AAB release generated
86 | path: ${{ env.main_project_module }}/build/outputs/bundle/release/
87 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Deploy To Google Play
2 |
3 | on:
4 | # push:
5 | pull_request:
6 | branches: [main]
7 |
8 | jobs:
9 | test:
10 | name: Unit Test
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - uses: actions/checkout@v4
15 |
16 | - name: Setup JDK 17
17 | uses: actions/setup-java@v4
18 | with:
19 | distribution: 'temurin'
20 | java-version: 17
21 | cache: 'gradle'
22 |
23 | - name: Grant excecute permissions for gradlew
24 | run: chmod +x gradlew
25 |
26 | - name: Run unit tests
27 | run: ./gradlew clean testDebug
28 |
29 | distribute:
30 | name: Distribute bundle to Google Play
31 | needs: test
32 | runs-on: ubuntu-latest
33 |
34 | steps:
35 | - uses: actions/checkout@v4
36 |
37 | - name: Setup JDK 17
38 | uses: actions/setup-java@v4
39 | with:
40 | distribution: 'temurin'
41 | java-version: 17
42 | cache: 'gradle'
43 |
44 | - name: Version Bump
45 | uses: chkfung/android-version-action@v1.2.3
46 | with:
47 | gradlePath: app/build.gradle.kts
48 | versionCode: ${{ github.run_number }}
49 |
50 | - name: Assemble Release Bundle
51 | run: ./gradlew bundleRelease
52 |
53 | - name: Sign Release
54 | uses: r0adkll/sign-android-release@v1
55 | with:
56 | releaseDirectory: app/build/outputs/bundle/release
57 | signingKeyBase64: ${{ secrets.ANDROID_KEYSTORE }} # generate and setting the keyStore
58 | keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
59 | alias: ${{ secrets.ANDROID_DEVS_ALIAS }}
60 | keyPassword: ${{ secrets.ANDROID_DEVS_ALIAS_PASSWORD }}
61 |
62 | - name: Setup Authorization with Google Play Store
63 | run: echo '${{ secrets.PLAY_AUTH_JSON }}' > service_account.json
64 |
65 | - name: Deploy bundle to Google Play
66 | uses: r0adkll/upload-google-play@v1.1.3
67 | with:
68 | serviceAccountJson: service_account.json
69 | pakageName: com.github.app
70 | releaseFiles: app/build/outputs/bundle/release/app-release.aab
71 | track: internal
72 | status: 'completed'
73 | whatsNewDirectory: whatsNew/ # TODO establist whatsNew directorys under project directory
74 |
75 |
76 |
77 |
78 |
79 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Run test
2 |
3 | on:
4 | pull_request:
5 | branches: [main]
6 |
7 | jobs:
8 | build:
9 | runs-on: ubuntu-latest
10 |
11 | steps:
12 | - uses: actions/checkout@v4
13 |
14 | - name: Setup JDK 17
15 | uses: actions/setup-java@v4
16 | with:
17 | distribution: 'temurin'
18 | java-version: 17
19 | cache: 'gradle'
20 |
21 | - name: Grant excecute permissions for gradlew
22 | run: chmod +x gradlew
23 |
24 | - name: Run unit tests
25 | run: ./gradlew clean testDebug
26 |
27 |
28 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | /local.properties
4 | /.idea/caches
5 | /.idea/libraries
6 | /.idea/modules.xml
7 | /.idea/workspace.xml
8 | /.idea/navEditor.xml
9 | /.idea/assetWizardSettings.xml
10 | .DS_Store
11 | /build
12 | /captures
13 | .externalNativeBuild
14 | .cxx
15 | local.properties
16 | /.kotlin
17 | /.idea
18 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 |
--------------------------------------------------------------------------------
/.idea/compiler.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/deploymentTargetSelector.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/.idea/gradle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
27 |
28 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
--------------------------------------------------------------------------------
/.idea/kotlinc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/migrations.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/.idea/runConfigurations.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.kotlin/errors/errors-1728603696143.log:
--------------------------------------------------------------------------------
1 | kotlin version: 2.0.20
2 | error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
3 | 1. Kotlin compile daemon is ready
4 |
5 |
--------------------------------------------------------------------------------
/.kotlin/errors/errors-1728824767720.log:
--------------------------------------------------------------------------------
1 | kotlin version: 2.0.20
2 | error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
3 | 1. Kotlin compile daemon is ready
4 |
5 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # CleanArchitectureGitHubApp
2 |
3 | 
4 |
5 | [简体中文版说明 >>>](https://github.com/andyhaha/CleanArchitectureGitHubApp/blob/main/README_cn.md)
6 |
7 | **🔥Special Note:** Add a variable in the local.properties file: API_TOKEN=xxx Replace xxx with your own GitHub API token.
8 | ## Project Introduction
9 |
10 | This is a GitHub user search application that allows users to search for GitHub users on the homepage and display a list of users. Users can click on a user in the list to enter their detailed information page, showcasing the user's basic information as well as the repositories they have created.
11 |
12 | ## Architecture Introduction
13 |
14 | This project follows the **Clean Architecture** principles and adopts the **MVVM** (Model-View-ViewModel) architectural pattern to achieve better modularization and separation of concerns. By decoupling different business logic from UI logic, the project is easier to test and maintain.
15 |
16 | Here’s an overview of your project structure, following the style of your example:
17 |
18 | ## Modules Overview
19 |
20 | The project is divided into several modules:
21 |
22 | - **:app** - Main Android app module that coordinates feature and library modules.
23 | - **:feature:home** - Displays a list of GitHub users with the ability to search and store results locally using Room.
24 | - **:feature:details** - Shows detailed information about selected users, including their profile and repositories.
25 | - **:libs:network** - Manages network requests using Retrofit and Moshi for data serialization.
26 | - **:libs:common** - Kotlin-only module providing utility functions and common classes used throughout the app.
27 |
28 | ## Technologies Used
29 |
30 | This project utilizes the following technology stack:
31 |
32 | - **Clean Architecture**: Implements clean architecture for better separation of concerns, enhancing code maintainability and testability..
33 | - **Multi-Module**: Divides the codebase into smaller, reusable, and testable modules, improving scalability, reducing dependencies, and simplifying the management and maintenance of large projects.
34 | - **Jetpack Compose**: For building the user interface, simplifying the declarative programming approach for UI.
35 | - **MVVM**: Implements the MVVM architectural pattern to separate view logic from business logic.
36 | - **Flow**: Used for handling asynchronous data streams, providing a reactive approach to manage and emit data updates in a lifecycle-aware manner.
37 | - **Dagger Hilt**: Dagger Hilt simplifies dependency injection across modules and enhances testability by enabling easy mocking for unit and UI tests.
38 | - **Retrofit**: Used for network requests, simplifying the process of API calls.
39 | - **Moshi**: Used for JSON parsing, simplifying the conversion between JSON and Kotlin/Java objects.
40 | - **Paging3**: Used to implement efficient pagination for user search, enabling smooth loading of data in chunks as the user scrolls.
41 | - **Room Database**: Provides local database support for easy data storage and querying.
42 | - **Moshi**: Used for parsing JSON data, enhancing the efficiency of data serialization and deserialization.
43 | - **GitHub Authorize**: Uses GitHub OAuth for authorization, ensuring users can safely access and search GitHub data.
44 | - **Room Database Testing**: Ensures reliable data storage by testing CRUD operations and data integrity in the local SQLite database through unit and instrumented tests.
45 | - **UI Testing**: Verifies the functionality and user experience of Compose-based components, including HomeContent, SearchHistory, and HomeSearchBar, through thorough Compose UI tests to ensure smooth interactions and correct rendering.
46 |
47 | I welcome suggestions and contributions to this project! Please create a Pull Request or submit an issue.
48 |
49 | ## License
50 |
51 | Copyright (c) [2024] [Andy]
52 |
53 | Permission is hereby granted, free of charge, to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of this software, and to permit others to do so, subject to the following conditions:
54 |
55 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the software.
56 |
57 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
58 |
--------------------------------------------------------------------------------
/README_cn.md:
--------------------------------------------------------------------------------
1 | # 项目名称
2 |
3 | 
4 |
5 | **🔥注意事项:** 在local.properties文件中加一个变量,API_TOKEN=xxx, 把“xxx”替换成自己的GitHub API token
6 |
7 | ## 项目简介
8 |
9 | 这是一个 GitHub 用户搜索应用程序,用户可以在首页搜索 GitHub 用户并显示用户列表。用户可以点击列表中的某个用户,进入该用户的详细信息页面,展示用户的基本信息以及他们创建的仓库。
10 |
11 | ## 架构简介
12 |
13 | 该项目遵循 **Clean Architecture** 原则,并采用 **MVVM**(Model-View-ViewModel)架构模式,以实现更好的模块化和关注点分离。通过将不同的业务逻辑与 UI 逻辑解耦,项目更容易进行测试和维护。
14 |
15 | 以下是项目结构的概述,遵循您的示例风格:
16 |
17 | ## 模块概述
18 |
19 | 该项目分为多个模块:
20 |
21 | - **:app** - 主 Android 应用模块,协调特性模块和库模块。
22 | - **:feature:home** - 显示 GitHub 用户列表,支持搜索并使用 Room 存储搜索结果。
23 | - **:feature:details** - 显示选定用户的详细信息,包括其个人资料和仓库。
24 | - **:libs:network** - 使用 Retrofit 和 Moshi 进行网络请求和数据序列化。
25 | - **:libs:common** - 仅 Kotlin 模块,提供应用中常用的工具函数和公共类。
26 |
27 | ## 使用的技术
28 |
29 | 该项目使用了以下技术栈:
30 |
31 | - **Clean Architecture**: 实现了“清洁”架构,提供更好的关注点分离,提高了代码的可维护性和可测试性。
32 | - **Multi-Modules**:将代码库划分为更小、可重用且可测试的模块,提高可扩展性,减少依赖,并简化大型项目的管理和维护。
33 | - **MVVM**: 实现了 MVVM 架构模式,将视图逻辑与业务逻辑分离。
34 | - **Flow & ViewModel**: 用于以生命周期感知的方式管理 UI 相关数据。
35 | - **Jetpack Compose**: 用于构建用户界面,简化了声明式编程方式的 UI 开发。
36 | - **Dagger Hilt**: Dagger Hilt 简化了跨模块的依赖注入,通过便捷的单元和 UI 测试的 mock 支持,增强了可测试性。
37 | - **Retrofit**: 用于网络请求,简化了 API 调用的过程。
38 | - **Room Database**: 提供本地数据库支持,方便数据存储和查询。
39 | - **Moshi**: 用于解析 JSON 数据,提高了数据序列化和反序列化的效率。
40 | - **Coil**: 用于图像加载,提供平滑的图像处理和缓存功能。
41 | - **GitHub Authorize**: 使用 GitHub OAuth 进行授权,确保用户可以安全地访问和搜索 GitHub 数据。
42 | - **Room Database Testing**: 通过单元测试和仪器测试,确保本地 SQLite 数据库中的数据存储的可靠性,测试 CRUD 操作和数据完整性。
43 | - **UI Testing**:通过 Compose UI 测试验证 HomeContent、SearchHistory 和 HomeSearchBar 等基于 Compose 的组件的功能和用户体验,确保交互流畅且渲染正确。
44 |
45 | ## 贡献
46 |
47 | 欢迎对这个项目提出建议和贡献!请创建一个 Pull Request 或提交问题。
48 |
49 | ## 许可证
50 |
51 | 版权所有 (c) [2024] [Andy]
52 |
53 | 特此免费授予使用、复制、修改、合并、发布、分发、再许可和/或出售本软件的副本,以及允许他人这样做的权限,但须遵守以下条件:
54 |
55 | 上述版权声明和本许可声明应包含在本软件的所有副本或重要部分中。
56 |
57 | 本软件按“原样”提供,不附任何明示或暗示的担保,包括但不限于适销性、特定用途的适用性和非侵权的担保。在任何情况下,作者或版权持有者均不对因使用本软件或与本软件有关的行为所引起的任何索赔、损害或其他责任负责。
58 |
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import java.util.Properties
2 |
3 | plugins {
4 | alias(libs.plugins.android.application)
5 | alias(libs.plugins.jetbrains.kotlin.android)
6 | alias(libs.plugins.google.dagger.hilt.android)
7 | alias(libs.plugins.ksp)
8 | alias(libs.plugins.compose.compiler)
9 | // kotlin("plugin.serialization") version "2.0.21"
10 | alias(libs.plugins.kotlin.serialization)
11 | }
12 |
13 | android {
14 | namespace = "com.github.app"
15 | compileSdk = 35
16 |
17 | defaultConfig {
18 | applicationId = "com.github.app"
19 | minSdk = 25
20 | targetSdk = 35
21 | versionCode = 1
22 | versionName = "1.0"
23 |
24 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
25 |
26 | // Custom test runner to set up Hilt dependency graph
27 | // testInstrumentationRunner = "com.andy.testing.AppTestRunner"
28 | vectorDrawables {
29 | useSupportLibrary = true
30 | }
31 |
32 | vectorDrawables {
33 | useSupportLibrary = true
34 | }
35 |
36 | // get API_TOKEN from local.properties
37 | // val localProperties = Properties()
38 | // val localPropertiesFile = rootProject.file("local.properties")
39 | // if (localPropertiesFile.exists()) {
40 | // localProperties.load(localPropertiesFile.inputStream())
41 | // }
42 | // val apiToken = localProperties.getProperty("API_TOKEN", "default_token")
43 | // buildConfigField("String", "API_TOKEN", "\"$apiToken\"")
44 | }
45 |
46 | buildTypes {
47 | release {
48 | isMinifyEnabled = true
49 | isShrinkResources = true
50 | proguardFiles(
51 | getDefaultProguardFile("proguard-android-optimize.txt"),
52 | "proguard-rules.pro"
53 | )
54 | }
55 | }
56 | compileOptions {
57 | sourceCompatibility = JavaVersion.VERSION_1_8
58 | targetCompatibility = JavaVersion.VERSION_1_8
59 | }
60 | kotlinOptions {
61 | jvmTarget = "1.8"
62 | }
63 | buildFeatures {
64 | compose = true
65 | // buildConfig = true
66 | }
67 | composeOptions {
68 | kotlinCompilerExtensionVersion = "1.5.1"
69 | }
70 | packaging {
71 | resources {
72 | excludes += "/META-INF/{AL2.0,LGPL2.1}"
73 | }
74 | }
75 | }
76 |
77 | dependencies {
78 | testImplementation(libs.junit)
79 | androidTestImplementation(libs.androidx.junit)
80 | androidTestImplementation(libs.androidx.espresso.core)
81 | androidTestImplementation(platform(libs.androidx.compose.bom))
82 | androidTestImplementation(libs.androidx.ui.test.junit4)
83 | androidTestImplementation(libs.google.dagger.hilt.android.testing)
84 | debugImplementation(libs.androidx.ui.tooling)
85 | debugImplementation(libs.androidx.ui.test.manifest)
86 | kspTest(libs.google.dagger.hilt.compiler)
87 |
88 | implementation(libs.androidx.core.ktx)
89 | implementation(libs.androidx.lifecycle.runtime.ktx)
90 | implementation(libs.androidx.activity.compose)
91 | implementation(platform(libs.androidx.compose.bom))
92 | implementation(libs.androidx.ui)
93 | implementation(libs.androidx.ui.graphics)
94 | implementation(libs.androidx.ui.tooling.preview)
95 | implementation(libs.androidx.compose.material3.android)
96 |
97 | ksp(libs.google.dagger.hilt.compiler)
98 | // only ksp(libs.hilt.compiler) is needed!
99 | // implementation(libs.dagger.hilt.compiler)
100 | implementation(libs.google.dagger.hilt.android)
101 | // navigation
102 | implementation(libs.androidx.hilt.navigation.compose)
103 | // navigation new version
104 | implementation (libs.androidx.navigation.compose)
105 | implementation(libs.kotlinx.serialization.json)
106 |
107 | // network
108 | // implementation(project(":libs:network"))
109 |
110 | // Home Page
111 | implementation(project(":feature:home"))
112 | // Details Page
113 | implementation(project(":feature:details"))
114 | implementation(project(":libs:testing"))
115 | }
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/app/src/androidTest/java/com/github/app/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package com.github.app
2 |
3 | import androidx.test.platform.app.InstrumentationRegistry
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 |
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | import org.junit.Assert.*
10 |
11 | /**
12 | * Instrumented test, which will execute on an Android device.
13 | *
14 | * See [testing documentation](http://d.android.com/tools/testing).
15 | */
16 | @RunWith(AndroidJUnit4::class)
17 | class ExampleInstrumentedTest {
18 | @Test
19 | fun useAppContext() {
20 | // Context of the app under test.
21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
22 | assertEquals("com.github.app", appContext.packageName)
23 | }
24 | }
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
17 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/app/App.kt:
--------------------------------------------------------------------------------
1 | package com.github.app
2 |
3 | import android.app.Application
4 | import dagger.hilt.android.HiltAndroidApp
5 |
6 | @HiltAndroidApp
7 | class App : Application()
--------------------------------------------------------------------------------
/app/src/main/java/com/github/app/navigation/AppNavHost.kt:
--------------------------------------------------------------------------------
1 | package com.github.app.navigation
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.navigation.NavHostController
5 | import androidx.navigation.compose.NavHost
6 | import androidx.navigation.compose.composable
7 | import androidx.navigation.toRoute
8 | import com.andy.github.details.ui.DetailScreen
9 | import com.andy.github.home.ui.HomeScreen
10 |
11 | @Composable
12 | fun AppNavHost(navController: NavHostController) {
13 | NavHost(
14 | navController = navController,
15 | startDestination = Screen.Home
16 | ) {
17 | composable {
18 | HomeScreen {
19 | navController.navigate(Screen.Detail(it.name))
20 | }
21 | }
22 |
23 | composable { backStackEntry ->
24 | val detail = backStackEntry.toRoute()
25 | DetailScreen(
26 | navController = navController,
27 | username = detail.username
28 | )
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/app/src/main/java/com/github/app/navigation/Screen.kt:
--------------------------------------------------------------------------------
1 | package com.github.app.navigation
2 |
3 | import kotlinx.serialization.Serializable
4 |
5 | @Serializable
6 | sealed class Screen {
7 | @Serializable
8 | data object Home : Screen()
9 |
10 | @Serializable
11 | data class Detail(val username: String): Screen()
12 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/github/app/ui/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package com.github.app.ui
2 |
3 | import android.os.Bundle
4 | import androidx.activity.ComponentActivity
5 | import androidx.activity.compose.setContent
6 | import androidx.navigation.compose.rememberNavController
7 | import com.github.app.navigation.AppNavHost
8 | import com.github.app.ui.theme.GitHubAppTheme
9 | import dagger.hilt.android.AndroidEntryPoint
10 |
11 | @AndroidEntryPoint
12 | class MainActivity : ComponentActivity() {
13 |
14 | override fun onCreate(savedInstanceState: Bundle?) {
15 | super.onCreate(savedInstanceState)
16 | setContent {
17 | val navController = rememberNavController()
18 | GitHubAppTheme {
19 | AppNavHost(navController = navController)
20 | }
21 | }
22 | }
23 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/github/app/ui/theme/Color.kt:
--------------------------------------------------------------------------------
1 | package com.github.app.ui.theme
2 |
3 | import androidx.compose.ui.graphics.Color
4 |
5 | val Purple80 = Color(0xFFD0BCFF)
6 | val PurpleGrey80 = Color(0xFFCCC2DC)
7 | val Pink80 = Color(0xFFEFB8C8)
8 |
9 | val Purple40 = Color(0xFF6650a4)
10 | val PurpleGrey40 = Color(0xFF625b71)
11 | val Pink40 = Color(0xFF7D5260)
--------------------------------------------------------------------------------
/app/src/main/java/com/github/app/ui/theme/Theme.kt:
--------------------------------------------------------------------------------
1 | package com.github.app.ui.theme
2 |
3 | import android.app.Activity
4 | import android.os.Build
5 | import androidx.compose.foundation.isSystemInDarkTheme
6 | import androidx.compose.material3.MaterialTheme
7 | import androidx.compose.material3.darkColorScheme
8 | import androidx.compose.material3.dynamicDarkColorScheme
9 | import androidx.compose.material3.dynamicLightColorScheme
10 | import androidx.compose.material3.lightColorScheme
11 | import androidx.compose.runtime.Composable
12 | import androidx.compose.ui.platform.LocalContext
13 |
14 | private val DarkColorScheme = darkColorScheme(
15 | primary = Purple80,
16 | secondary = PurpleGrey80,
17 | tertiary = Pink80
18 | )
19 |
20 | private val LightColorScheme = lightColorScheme(
21 | primary = Purple40,
22 | secondary = PurpleGrey40,
23 | tertiary = Pink40
24 |
25 | /* Other default colors to override
26 | background = Color(0xFFFFFBFE),
27 | surface = Color(0xFFFFFBFE),
28 | onPrimary = Color.White,
29 | onSecondary = Color.White,
30 | onTertiary = Color.White,
31 | onBackground = Color(0xFF1C1B1F),
32 | onSurface = Color(0xFF1C1B1F),
33 | */
34 | )
35 |
36 | @Composable
37 | fun GitHubAppTheme(
38 | darkTheme: Boolean = isSystemInDarkTheme(),
39 | // Dynamic color is available on Android 12+
40 | dynamicColor: Boolean = true,
41 | content: @Composable () -> Unit
42 | ) {
43 | val colorScheme = when {
44 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
45 | val context = LocalContext.current
46 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
47 | }
48 |
49 | darkTheme -> DarkColorScheme
50 | else -> LightColorScheme
51 | }
52 |
53 | MaterialTheme(
54 | colorScheme = colorScheme,
55 | typography = Typography,
56 | content = content
57 | )
58 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/github/app/ui/theme/Type.kt:
--------------------------------------------------------------------------------
1 | package com.github.app.ui.theme
2 |
3 | import androidx.compose.material3.Typography
4 | import androidx.compose.ui.text.TextStyle
5 | import androidx.compose.ui.text.font.FontFamily
6 | import androidx.compose.ui.text.font.FontWeight
7 | import androidx.compose.ui.unit.sp
8 |
9 | // Set of Material typography styles to start with
10 | val Typography = Typography(
11 | bodyLarge = TextStyle(
12 | fontFamily = FontFamily.Default,
13 | fontWeight = FontWeight.Normal,
14 | fontSize = 16.sp,
15 | lineHeight = 24.sp,
16 | letterSpacing = 0.5.sp
17 | )
18 | /* Other default text styles to override
19 | titleLarge = TextStyle(
20 | fontFamily = FontFamily.Default,
21 | fontWeight = FontWeight.Normal,
22 | fontSize = 22.sp,
23 | lineHeight = 28.sp,
24 | letterSpacing = 0.sp
25 | ),
26 | labelSmall = TextStyle(
27 | fontFamily = FontFamily.Default,
28 | fontWeight = FontWeight.Medium,
29 | fontSize = 11.sp,
30 | lineHeight = 16.sp,
31 | letterSpacing = 0.5.sp
32 | )
33 | */
34 | )
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
15 |
20 |
25 |
30 |
35 |
40 |
45 |
50 |
55 |
60 |
65 |
70 |
75 |
80 |
85 |
90 |
95 |
100 |
105 |
110 |
115 |
120 |
125 |
130 |
135 |
140 |
145 |
150 |
155 |
160 |
165 |
170 |
171 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
15 |
18 |
21 |
22 |
23 |
24 |
30 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andyhaha/CleanArchitectureGitHubApp/a93cd02941385de45a8d160d1b2f8d3ae0088e65/app/src/main/res/mipmap-hdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andyhaha/CleanArchitectureGitHubApp/a93cd02941385de45a8d160d1b2f8d3ae0088e65/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andyhaha/CleanArchitectureGitHubApp/a93cd02941385de45a8d160d1b2f8d3ae0088e65/app/src/main/res/mipmap-mdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andyhaha/CleanArchitectureGitHubApp/a93cd02941385de45a8d160d1b2f8d3ae0088e65/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andyhaha/CleanArchitectureGitHubApp/a93cd02941385de45a8d160d1b2f8d3ae0088e65/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andyhaha/CleanArchitectureGitHubApp/a93cd02941385de45a8d160d1b2f8d3ae0088e65/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andyhaha/CleanArchitectureGitHubApp/a93cd02941385de45a8d160d1b2f8d3ae0088e65/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andyhaha/CleanArchitectureGitHubApp/a93cd02941385de45a8d160d1b2f8d3ae0088e65/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andyhaha/CleanArchitectureGitHubApp/a93cd02941385de45a8d160d1b2f8d3ae0088e65/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andyhaha/CleanArchitectureGitHubApp/a93cd02941385de45a8d160d1b2f8d3ae0088e65/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFBB86FC
4 | #FF6200EE
5 | #FF3700B3
6 | #FF03DAC5
7 | #FF018786
8 | #FF000000
9 | #FFFFFFFF
10 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | GitHubApp
3 |
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
12 |
13 |
19 |
--------------------------------------------------------------------------------
/app/src/test/java/com/github/app/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package com.github.app
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
--------------------------------------------------------------------------------
/build.gradle.kts:
--------------------------------------------------------------------------------
1 | // Top-level build file where you can add configuration options common to all sub-projects/modules.
2 | plugins {
3 | alias(libs.plugins.android.application) apply false
4 | alias(libs.plugins.jetbrains.kotlin.android) apply false
5 | alias(libs.plugins.android.library) apply false
6 | alias(libs.plugins.compose.compiler) apply false
7 | alias(libs.plugins.google.dagger.hilt.android) apply false
8 | alias(libs.plugins.ksp) apply false
9 | alias(libs.plugins.androidx.room) apply false
10 | }
--------------------------------------------------------------------------------
/feature/details/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/feature/details/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.library)
3 | alias(libs.plugins.jetbrains.kotlin.android)
4 | alias(libs.plugins.google.dagger.hilt.android)
5 | alias(libs.plugins.ksp)
6 | alias(libs.plugins.compose.compiler)
7 | // kotlin("plugin.serialization") version "2.0.21"
8 | alias(libs.plugins.kotlin.serialization)
9 | }
10 |
11 | android {
12 | namespace = "com.andy.github.details"
13 | compileSdk = 35
14 |
15 | defaultConfig {
16 | minSdk = 25
17 |
18 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
19 | consumerProguardFiles("consumer-rules.pro")
20 | }
21 |
22 | buildTypes {
23 | release {
24 | isMinifyEnabled = false
25 | proguardFiles(
26 | getDefaultProguardFile("proguard-android-optimize.txt"),
27 | "proguard-rules.pro"
28 | )
29 | }
30 | }
31 | compileOptions {
32 | sourceCompatibility = JavaVersion.VERSION_1_8
33 | targetCompatibility = JavaVersion.VERSION_1_8
34 | }
35 | kotlinOptions {
36 | jvmTarget = "1.8"
37 | }
38 | }
39 |
40 | dependencies {
41 | testImplementation(libs.junit)
42 | androidTestImplementation(libs.androidx.junit)
43 | androidTestImplementation(libs.androidx.espresso.core)
44 |
45 | // Compose versions control
46 | implementation(platform(libs.androidx.compose.bom))
47 | implementation(libs.androidx.ui.tooling.preview)
48 | // Moshi
49 | ksp(libs.squareup.moshi.codegen)
50 | ksp(libs.google.dagger.hilt.compiler)
51 | // DaggerHilt
52 | implementation(libs.google.dagger.hilt.android)
53 | // room
54 | implementation(libs.androidx.room.common)
55 | implementation(libs.androidx.room.ktx)
56 | implementation(libs.androidx.room.runtime)
57 | ksp(libs.androidx.room.compiler)
58 | // navigation
59 | implementation(libs.androidx.hilt.navigation.compose)
60 | implementation(libs.androidx.core.ktx)
61 | implementation(libs.androidx.appcompat)
62 | // Material
63 | implementation(libs.material)
64 | implementation(libs.androidx.compose.material3.android)
65 | // Material3
66 | implementation(libs.androidx.material3)
67 | // material icons
68 | implementation(libs.androidx.compose.material.iconsExtended)
69 | // paging
70 | implementation(libs.androidx.paging.compose)
71 | implementation(libs.androidx.paging.runtime)
72 | implementation(libs.androidx.paging.common.android)
73 | implementation(libs.coil)
74 | implementation(libs.coil.compose)
75 | // navigation new version
76 | implementation (libs.androidx.navigation.compose)
77 | implementation(libs.kotlinx.serialization.json)
78 |
79 | // network
80 | implementation(project(":libs:network"))
81 | }
--------------------------------------------------------------------------------
/feature/details/consumer-rules.pro:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andyhaha/CleanArchitectureGitHubApp/a93cd02941385de45a8d160d1b2f8d3ae0088e65/feature/details/consumer-rules.pro
--------------------------------------------------------------------------------
/feature/details/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/feature/details/src/androidTest/java/com/andy/github/details/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.details
2 |
3 | import androidx.test.platform.app.InstrumentationRegistry
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 |
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | import org.junit.Assert.*
10 |
11 | /**
12 | * Instrumented test, which will execute on an Android device.
13 | *
14 | * See [testing documentation](http://d.android.com/tools/testing).
15 | */
16 | @RunWith(AndroidJUnit4::class)
17 | class ExampleInstrumentedTest {
18 | @Test
19 | fun useAppContext() {
20 | // Context of the app under test.
21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
22 | assertEquals("com.andy.github.details.test", appContext.packageName)
23 | }
24 | }
--------------------------------------------------------------------------------
/feature/details/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/feature/details/src/main/java/com/andy/github/details/data/model/RepositoryItemModel.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.details.data.model
2 |
3 | import com.andy.github.details.domain.model.Repository
4 | import com.squareup.moshi.Json
5 | import com.squareup.moshi.JsonClass
6 |
7 | @JsonClass(generateAdapter = true)
8 | data class RepositoryItemModel(
9 | val name: String?,
10 | val language: String?,
11 | @Json(name = "stargazers_count")
12 | val starsCount: Int?,
13 | val description: String?,
14 | @Json(name = "html_url")
15 | val githubUrl: String?,
16 | @Json(name = "forks_count")
17 | val forksCount: Int?,
18 | @Json(name = "private")
19 | val isPrivate: Boolean?
20 | )
21 |
22 | fun RepositoryItemModel.toDomainModel(): Repository {
23 | return Repository(
24 | name = name,
25 | language = language,
26 | starsCount = starsCount,
27 | description = description,
28 | githubUrl = githubUrl,
29 | forksCount = forksCount,
30 | isPrivate = isPrivate
31 | )
32 | }
--------------------------------------------------------------------------------
/feature/details/src/main/java/com/andy/github/details/data/model/UserResultModel.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.details.data.model
2 |
3 | import com.andy.github.details.domain.model.User
4 | import com.squareup.moshi.Json
5 | import com.squareup.moshi.JsonClass
6 |
7 | @JsonClass(generateAdapter = true)
8 | data class ApiUserResult(
9 | @Json(name = "login")
10 | val name: String?,
11 | @Json(name = "avatar_url")
12 | val avatarUrl: String?,
13 | @Json(name = "name")
14 | val fullName: String?,
15 | val followers: Int?,
16 | val following: Int?
17 | )
18 |
19 | fun ApiUserResult.toDomainUser(): User {
20 | return User(
21 | name = name,
22 | avatarUrl = avatarUrl,
23 | fullName = fullName,
24 | followers = followers,
25 | following = following
26 | )
27 | }
--------------------------------------------------------------------------------
/feature/details/src/main/java/com/andy/github/details/data/remote/ApiRepositoriesResponse.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.details.data.remote
2 |
3 | import com.andy.github.details.data.model.RepositoryItemModel
4 | import com.squareup.moshi.Json
5 | import com.squareup.moshi.JsonClass
6 |
7 | @JsonClass(generateAdapter = true)
8 | data class ApiRepositoriesResponse(
9 | @Json(name = "total_count")
10 | val totalCount: Int,
11 | val items: List
12 | )
--------------------------------------------------------------------------------
/feature/details/src/main/java/com/andy/github/details/data/remote/DetailsApiService.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.details.data.remote
2 |
3 | import com.andy.github.details.data.model.ApiUserResult
4 | import com.andy.network.data.ApiResult
5 |
6 | interface DetailsApiService {
7 |
8 | @retrofit2.http.GET("users/{username}")
9 | suspend fun getUser(@retrofit2.http.Path("username") username: String): ApiResult
10 |
11 | /**
12 | * q=user:{username} for example q=user:andy
13 | */
14 | @retrofit2.http.GET("search/repositories")
15 | suspend fun getUserRepositories(
16 | @retrofit2.http.Query("q") query: String
17 | ): ApiResult
18 | }
--------------------------------------------------------------------------------
/feature/details/src/main/java/com/andy/github/details/data/repository/GitHubUserDetailsRepository.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.details.data.repository
2 |
3 | import com.andy.github.details.data.model.toDomainModel
4 | import com.andy.github.details.data.model.toDomainUser
5 | import com.andy.github.details.data.remote.DetailsApiService
6 | import com.andy.github.details.domain.model.Repository
7 | import com.andy.github.details.domain.model.User
8 | import com.andy.github.details.domain.repository.UserDetailsRepository
9 | import com.andy.network.data.ApiResult
10 | import com.andy.network.domain.Result
11 | import kotlinx.coroutines.CoroutineDispatcher
12 | import kotlinx.coroutines.flow.Flow
13 | import kotlinx.coroutines.flow.flow
14 | import kotlinx.coroutines.flow.flowOn
15 | import javax.inject.Inject
16 | import javax.inject.Named
17 |
18 | class GitHubUserDetailsRepository @Inject constructor(
19 | private val apiService: DetailsApiService,
20 | @Named("io")
21 | private val dispatcher: CoroutineDispatcher,
22 | ) : UserDetailsRepository {
23 |
24 | override fun getUser(username: String): Flow> {
25 | return flow {
26 | when (val result = apiService.getUser(username)) {
27 | is ApiResult.Success -> {
28 | emit(Result.Success(result.data.toDomainUser()))
29 | }
30 | is ApiResult.Error -> {
31 | emit(Result.Error(result.code, result.message))
32 | }
33 | is ApiResult.Exception -> {
34 | emit(Result.Failure(result.throwable))
35 | }
36 | }
37 | }.flowOn(dispatcher)
38 | }
39 |
40 | override fun getUserRepositories(username: String): Flow>> {
41 | return flow {
42 | val query = "user:$username"
43 | when (val result = apiService.getUserRepositories(query)) {
44 | is ApiResult.Success -> {
45 | val list = result.data.items.map {
46 | it.toDomainModel()
47 | }
48 | emit(Result.Success(list))
49 | }
50 | is ApiResult.Error -> {
51 | emit(Result.Error(result.code, result.message))
52 | }
53 | is ApiResult.Exception -> {
54 | emit(Result.Failure(result.throwable))
55 | }
56 | }
57 | }.flowOn(dispatcher)
58 | }
59 | }
--------------------------------------------------------------------------------
/feature/details/src/main/java/com/andy/github/details/di/DetailsModule.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.details.di
2 |
3 | import com.andy.github.details.data.remote.DetailsApiService
4 | import dagger.Module
5 | import dagger.Provides
6 | import dagger.hilt.InstallIn
7 | import dagger.hilt.components.SingletonComponent
8 | import retrofit2.Retrofit
9 | import javax.inject.Singleton
10 |
11 | @Module
12 | @InstallIn(SingletonComponent::class)
13 | object DetailsModule {
14 |
15 | @Provides
16 | @Singleton
17 | fun provideDetailsApiService(retrofit: Retrofit): DetailsApiService {
18 | return retrofit.create(DetailsApiService::class.java)
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/feature/details/src/main/java/com/andy/github/details/di/DetailsRepositoryModule.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.details.di
2 |
3 | import com.andy.github.details.data.repository.GitHubUserDetailsRepository
4 | import com.andy.github.details.domain.repository.UserDetailsRepository
5 | import dagger.Binds
6 | import dagger.Module
7 | import dagger.hilt.InstallIn
8 | import dagger.hilt.components.SingletonComponent
9 |
10 | @Module
11 | @InstallIn(SingletonComponent::class)
12 | abstract class DetailsRepositoryModule {
13 |
14 | @Binds
15 | abstract fun bindUserDetailsRepository(
16 | repository: GitHubUserDetailsRepository,
17 | ): UserDetailsRepository
18 | }
--------------------------------------------------------------------------------
/feature/details/src/main/java/com/andy/github/details/domain/model/Repository.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.details.domain.model
2 |
3 | import com.andy.common.formatToK
4 |
5 |
6 | data class Repository(
7 | val name: String?,
8 | val language: String?,
9 | val starsCount: Int?,
10 | val description: String?,
11 | val githubUrl: String?,
12 | val forksCount: Int?,
13 | val isPrivate: Boolean?
14 | ) {
15 |
16 | fun formattedName(): String {
17 | val result = if (name.isNullOrBlank()) {
18 | "No description provided"
19 | } else {
20 | name
21 | }
22 | return result
23 | }
24 |
25 | fun formattedStarsCount(): String {
26 | val count = starsCount ?: 0
27 | return count.formatToK()
28 | }
29 |
30 | fun formattedForksCount(): String {
31 | val count = forksCount ?: 0
32 | return count.formatToK()
33 | }
34 |
35 | fun formattedDescription(): String {
36 | val result = if (description.isNullOrBlank()) {
37 | "No description provided"
38 | } else {
39 | description
40 | }
41 | return result
42 | }
43 |
44 | fun formattedRepoType(): String {
45 | val result = if (isPrivate?.not() == true) {
46 | "Public"
47 | } else {
48 | "Private"
49 | }
50 | return result
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/feature/details/src/main/java/com/andy/github/details/domain/model/User.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.details.domain.model
2 |
3 | import com.andy.common.formatToK
4 |
5 |
6 | data class User(
7 | val name: String?,
8 | val avatarUrl: String?,
9 | val fullName: String?,
10 | val followers: Int?,
11 | val following: Int?
12 | )
13 |
14 | fun User.formatFullName(): String {
15 | return fullName ?: "Full Name: N/A"
16 | }
17 |
18 | fun User.formatName(): String {
19 | return name ?: "Name: N/A"
20 | }
21 |
22 | fun User.formatFollowers(): String {
23 | val count = followers ?: 0
24 | return count.formatToK()
25 | }
26 |
27 | fun User.formatFollowing(): String {
28 | val count = following ?: 0
29 | return count.formatToK()
30 | }
--------------------------------------------------------------------------------
/feature/details/src/main/java/com/andy/github/details/domain/repository/UserDetailsRepository.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.details.domain.repository
2 |
3 | import com.andy.github.details.domain.model.Repository
4 | import com.andy.github.details.domain.model.User
5 | import kotlinx.coroutines.flow.Flow
6 | import com.andy.network.domain.Result
7 |
8 | interface UserDetailsRepository {
9 | fun getUser(username: String): Flow>
10 |
11 | fun getUserRepositories(username: String): Flow>>
12 | }
--------------------------------------------------------------------------------
/feature/details/src/main/java/com/andy/github/details/ui/DetailAppBar.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.details.ui
2 |
3 | import androidx.compose.material.icons.Icons
4 | import androidx.compose.material.icons.automirrored.filled.ArrowBack
5 | import androidx.compose.material3.ExperimentalMaterial3Api
6 | import androidx.compose.material3.Icon
7 | import androidx.compose.material3.IconButton
8 | import androidx.compose.material3.MaterialTheme
9 | import androidx.compose.material3.Text
10 | import androidx.compose.material3.TopAppBar
11 | import androidx.compose.material3.TopAppBarDefaults
12 | import androidx.compose.material3.surfaceColorAtElevation
13 | import androidx.compose.runtime.Composable
14 | import androidx.compose.ui.graphics.Color
15 | import androidx.compose.ui.unit.dp
16 | import androidx.navigation.NavController
17 |
18 | @OptIn(ExperimentalMaterial3Api::class)
19 | @Composable
20 | fun DetailAppBar(navController: NavController) {
21 | TopAppBar(
22 | title = {
23 | Text(text = "User Details", color = Color.Black)
24 | },
25 | navigationIcon = {
26 | IconButton(onClick = {
27 | navController.popBackStack()
28 | }) {
29 | Icon(
30 | Icons.AutoMirrored.Filled.ArrowBack,
31 | contentDescription = "Back"
32 | )
33 | }
34 | },
35 | colors = TopAppBarDefaults.topAppBarColors(
36 | containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp),
37 | )
38 | )
39 | }
--------------------------------------------------------------------------------
/feature/details/src/main/java/com/andy/github/details/ui/DetailContent.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.details.ui
2 |
3 | import android.content.Intent
4 | import android.net.Uri
5 | import androidx.compose.foundation.layout.PaddingValues
6 | import androidx.compose.foundation.layout.Spacer
7 | import androidx.compose.foundation.layout.fillMaxSize
8 | import androidx.compose.foundation.layout.height
9 | import androidx.compose.foundation.layout.padding
10 | import androidx.compose.foundation.lazy.LazyColumn
11 | import androidx.compose.foundation.lazy.items
12 | import androidx.compose.runtime.Composable
13 | import androidx.compose.runtime.LaunchedEffect
14 | import androidx.compose.runtime.collectAsState
15 | import androidx.compose.runtime.getValue
16 | import androidx.compose.ui.Alignment
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.platform.LocalContext
19 | import androidx.compose.ui.unit.dp
20 | import androidx.hilt.navigation.compose.hiltViewModel
21 |
22 | @Composable
23 | fun DetailContent(
24 | username: String?,
25 | innerPadding: PaddingValues,
26 | detailViewModel: DetailViewModel = hiltViewModel()
27 | ) {
28 | LaunchedEffect(username) {
29 | getUserWithRepositories(detailViewModel, username)
30 | }
31 |
32 | val uiState by detailViewModel.combinedUiState.collectAsState()
33 | if (uiState is DetailUiState.Error) {
34 | ErrorContent(onRetry = {
35 | getUserWithRepositories(detailViewModel, username)
36 | })
37 | return
38 | }
39 |
40 | LazyColumn(
41 | modifier = Modifier
42 | .fillMaxSize()
43 | .padding(innerPadding),
44 | horizontalAlignment = Alignment.CenterHorizontally,
45 | ) {
46 | when (uiState) {
47 | is DetailUiState.Loading -> {
48 | item {
49 | LoadingContent()
50 | }
51 | }
52 |
53 | is DetailUiState.Success -> {
54 | val userWithRepositories = (uiState as DetailUiState.Success).userWithRepositories
55 | item {
56 | UserHeader(user = userWithRepositories.user)
57 | Spacer(modifier = Modifier.height(16.dp))
58 | }
59 | items(userWithRepositories.repositories) { repo ->
60 | val context = LocalContext.current
61 | RepositoryCard(repo) {
62 | if (repo.githubUrl.isNullOrBlank()) {
63 | return@RepositoryCard
64 | }
65 | if (repo.isPrivate == true) {
66 | return@RepositoryCard
67 | }
68 | val intent = Intent(Intent.ACTION_VIEW, Uri.parse(repo.githubUrl))
69 | context.startActivity(intent)
70 | }
71 | }
72 | }
73 |
74 | else -> {}
75 | }
76 | }
77 | }
78 |
79 | private fun getUserWithRepositories(
80 | detailViewModel: DetailViewModel,
81 | username: String?
82 | ) {
83 | username?.let {
84 | detailViewModel.getUserWithRepositories(it)
85 | }
86 | }
--------------------------------------------------------------------------------
/feature/details/src/main/java/com/andy/github/details/ui/DetailScreen.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.details.ui
2 |
3 | import androidx.compose.foundation.layout.padding
4 | import androidx.compose.material3.Scaffold
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.ui.Modifier
7 | import androidx.compose.ui.unit.dp
8 | import androidx.navigation.NavController
9 |
10 | @Composable
11 | fun DetailScreen(navController: NavController, username: String?) {
12 | Scaffold(
13 | modifier = Modifier.padding(bottom = 16.dp),
14 | topBar = {
15 | DetailAppBar(navController)
16 | }
17 | ) { innerPadding ->
18 | DetailContent(username, innerPadding)
19 | }
20 | }
--------------------------------------------------------------------------------
/feature/details/src/main/java/com/andy/github/details/ui/DetailViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.details.ui
2 |
3 | import android.util.Log
4 | import androidx.lifecycle.ViewModel
5 | import androidx.lifecycle.viewModelScope
6 | import com.andy.github.details.domain.model.Repository
7 | import com.andy.github.details.domain.model.User
8 | import com.andy.github.details.domain.repository.UserDetailsRepository
9 | import com.andy.network.domain.Result
10 | import dagger.hilt.android.lifecycle.HiltViewModel
11 | import kotlinx.coroutines.flow.MutableStateFlow
12 | import kotlinx.coroutines.flow.SharingStarted
13 | import kotlinx.coroutines.flow.StateFlow
14 | import kotlinx.coroutines.flow.combine
15 | import kotlinx.coroutines.flow.launchIn
16 | import kotlinx.coroutines.flow.onEach
17 | import kotlinx.coroutines.flow.stateIn
18 | import javax.inject.Inject
19 |
20 | @HiltViewModel
21 | class DetailViewModel @Inject constructor(
22 | private val userRepository: UserDetailsRepository,
23 | ) : ViewModel() {
24 | private val _userUiState = MutableStateFlow(UserUiState.Loading)
25 | private val _repositoriesUiState =
26 | MutableStateFlow(RepositoriesUiState.Loading)
27 |
28 | val combinedUiState: StateFlow = combine(
29 | _userUiState,
30 | _repositoriesUiState
31 | ) { userState, repositoriesState ->
32 | Log.d(
33 | "DetailViewModel", "userState = $userState, " +
34 | "repositoriesState = $repositoriesState"
35 | )
36 | when {
37 | userState is UserUiState.Success && repositoriesState is RepositoriesUiState.Success -> {
38 | val userWithRepositories = UserWithRepositories(
39 | user = userState.user,
40 | repositories = repositoriesState.repositories
41 | )
42 | DetailUiState.Success(userWithRepositories)
43 | }
44 | userState is UserUiState.Error || repositoriesState is RepositoriesUiState.Error -> {
45 | DetailUiState.Error
46 | }
47 | else -> DetailUiState.Loading
48 | }
49 | }.stateIn(
50 | scope = viewModelScope,
51 | started = SharingStarted.WhileSubscribed(5000),
52 | initialValue = DetailUiState.Loading
53 | )
54 |
55 | fun getUserWithRepositories(
56 | username: String
57 | ) {
58 | getUser(username)
59 | getUserRepositories(username)
60 | }
61 |
62 | private fun getUser(username: String) {
63 | userRepository.getUser(username).onEach {
64 | _userUiState.value = when (it) {
65 | is Result.Success -> UserUiState.Success(it.value)
66 | is Result.Error,
67 | is Result.Failure -> UserUiState.Error
68 | }
69 | }.launchIn(viewModelScope)
70 | }
71 |
72 | private fun getUserRepositories(username: String) {
73 | userRepository.getUserRepositories(username).onEach {
74 | _repositoriesUiState.value = when (it) {
75 | is Result.Success -> RepositoriesUiState.Success(it.value)
76 | is Result.Error,
77 | is Result.Failure -> RepositoriesUiState.Error
78 | }
79 | }.launchIn(viewModelScope)
80 | }
81 | }
82 |
83 | sealed interface UserUiState {
84 | data class Success(val user: User) : UserUiState
85 | data object Error : UserUiState
86 | data object Loading : UserUiState
87 | }
88 |
89 | sealed interface RepositoriesUiState {
90 | data class Success(val repositories: List) : RepositoriesUiState
91 | data object Error : RepositoriesUiState
92 | data object Loading : RepositoriesUiState
93 | }
94 |
95 | sealed interface DetailUiState {
96 | data class Success(val userWithRepositories: UserWithRepositories) : DetailUiState
97 | data object Error : DetailUiState
98 | data object Loading : DetailUiState
99 | }
100 |
101 | data class UserWithRepositories(
102 | val user: User,
103 | val repositories: List
104 | )
--------------------------------------------------------------------------------
/feature/details/src/main/java/com/andy/github/details/ui/ErrorContent.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.details.ui
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.layout.Box
5 | import androidx.compose.foundation.layout.PaddingValues
6 | import androidx.compose.foundation.layout.fillMaxSize
7 | import androidx.compose.foundation.layout.padding
8 | import androidx.compose.material3.MaterialTheme
9 | import androidx.compose.material3.Text
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.ui.Alignment
12 | import androidx.compose.ui.Modifier
13 | import androidx.compose.ui.graphics.Color
14 | import androidx.compose.ui.text.SpanStyle
15 | import androidx.compose.ui.text.buildAnnotatedString
16 | import androidx.compose.ui.text.withStyle
17 | import androidx.compose.ui.tooling.preview.Preview
18 | import androidx.compose.ui.unit.dp
19 |
20 | @Composable
21 | fun ErrorContent(
22 | innerPadding: PaddingValues = PaddingValues(),
23 | onRetry: () -> Unit = {}
24 | ) {
25 | Box(
26 | modifier = Modifier
27 | .fillMaxSize()
28 | .padding(innerPadding)
29 | .clickable {
30 | onRetry()
31 | },
32 | contentAlignment = Alignment.Center
33 | ) {
34 | Box(
35 | modifier = Modifier
36 | .fillMaxSize()
37 | .padding(20.dp),
38 | contentAlignment = Alignment.Center
39 | ) {
40 | ErrorText()
41 | }
42 | }
43 | }
44 |
45 | @Composable
46 | fun ErrorText() {
47 | val annotatedText = buildAnnotatedString {
48 | withStyle(
49 | style = SpanStyle(
50 | color = Color.DarkGray,
51 | fontStyle = MaterialTheme.typography.displayMedium.fontStyle
52 | )
53 | ) {
54 | append("Whoops... Something went wrong.\nPlease")
55 | }
56 | withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.error)) {
57 | append(" Click the screen ")
58 | }
59 | withStyle(
60 | style = SpanStyle(
61 | color = Color.DarkGray,
62 | fontStyle = MaterialTheme.typography.displayMedium.fontStyle
63 | )
64 | ) {
65 | append("to try again.")
66 | }
67 | }
68 | Text(text = annotatedText)
69 | }
70 |
71 | @Preview(showBackground = true)
72 | @Composable
73 | fun PreviewErrorContent() {
74 | ErrorContent()
75 | }
76 |
77 | @Preview(showBackground = true)
78 | @Composable
79 | fun PreviewErrorText() {
80 | ErrorText()
81 | }
--------------------------------------------------------------------------------
/feature/details/src/main/java/com/andy/github/details/ui/Loading.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.details.ui
2 |
3 | import androidx.compose.foundation.layout.Arrangement
4 | import androidx.compose.foundation.layout.Box
5 | import androidx.compose.foundation.layout.Row
6 | import androidx.compose.foundation.layout.Spacer
7 | import androidx.compose.foundation.layout.fillMaxSize
8 | import androidx.compose.foundation.layout.fillMaxWidth
9 | import androidx.compose.foundation.layout.padding
10 | import androidx.compose.foundation.layout.width
11 | import androidx.compose.material3.CircularProgressIndicator
12 | import androidx.compose.material3.Text
13 | import androidx.compose.runtime.Composable
14 | import androidx.compose.ui.Alignment
15 | import androidx.compose.ui.Modifier
16 | import androidx.compose.ui.graphics.Color
17 | import androidx.compose.ui.unit.dp
18 |
19 | @Composable
20 | fun LoadingContent() {
21 | Box(modifier = Modifier.fillMaxSize()) {
22 | Row(
23 | modifier = Modifier.fillMaxWidth()
24 | .padding(8.dp),
25 | horizontalArrangement = Arrangement.Center,
26 | verticalAlignment = Alignment.CenterVertically
27 | ) {
28 | CircularProgressIndicator()
29 | Spacer(modifier = Modifier.width(16.dp))
30 | Text(text = "Loading...", color = Color.Gray)
31 | }
32 | }
33 | }
--------------------------------------------------------------------------------
/feature/details/src/main/java/com/andy/github/details/ui/RepositoryCard.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.details.ui
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.clickable
5 | import androidx.compose.foundation.layout.Arrangement
6 | import androidx.compose.foundation.layout.Box
7 | import androidx.compose.foundation.layout.Column
8 | import androidx.compose.foundation.layout.Row
9 | import androidx.compose.foundation.layout.Spacer
10 | import androidx.compose.foundation.layout.fillMaxWidth
11 | import androidx.compose.foundation.layout.height
12 | import androidx.compose.foundation.layout.padding
13 | import androidx.compose.foundation.layout.size
14 | import androidx.compose.foundation.layout.width
15 | import androidx.compose.foundation.shape.CircleShape
16 | import androidx.compose.material.icons.Icons
17 | import androidx.compose.material.icons.filled.CallSplit
18 | import androidx.compose.material.icons.filled.Star
19 | import androidx.compose.material3.Card
20 | import androidx.compose.material3.Icon
21 | import androidx.compose.material3.MaterialTheme
22 | import androidx.compose.material3.Text
23 | import androidx.compose.runtime.Composable
24 | import androidx.compose.ui.Alignment
25 | import androidx.compose.ui.Modifier
26 | import androidx.compose.ui.graphics.Color
27 | import androidx.compose.ui.text.style.TextOverflow
28 | import androidx.compose.ui.tooling.preview.Preview
29 | import androidx.compose.ui.unit.dp
30 | import com.andy.github.details.domain.model.Repository
31 |
32 | @Composable
33 | fun RepositoryCard(repo: Repository, onClick: () -> Unit = {}) {
34 | Card(
35 | modifier = Modifier.fillMaxWidth()
36 | .padding(horizontal = 16.dp, vertical = 8.dp)
37 | .clickable {
38 | onClick()
39 | },
40 | ) {
41 | Column(modifier = Modifier.padding(16.dp)) {
42 | Row(
43 | modifier = Modifier.fillMaxWidth(),
44 | verticalAlignment = Alignment.CenterVertically,
45 | horizontalArrangement = Arrangement.SpaceBetween
46 | ) {
47 | // Repo name
48 | Text(
49 | modifier = Modifier.weight(1f),
50 | maxLines = 1,
51 | overflow = TextOverflow.Ellipsis,
52 | text = repo.formattedName(),
53 | color = Color.Blue,
54 | style = MaterialTheme.typography.titleMedium
55 | )
56 | Spacer(modifier = Modifier.width(8.dp))
57 | // Repo type (private or public)
58 | Box(
59 | modifier = Modifier
60 | .background(Color.White, shape = CircleShape)
61 | .padding(4.dp)
62 | ) {
63 | Text(
64 | text = repo.formattedRepoType(),
65 | color = Color.Gray,
66 | style = MaterialTheme.typography.bodySmall
67 | )
68 | }
69 | }
70 | Spacer(modifier = Modifier.height(8.dp))
71 |
72 | // Repo description
73 | Text(
74 | maxLines = 2,
75 | overflow = TextOverflow.Ellipsis,
76 | text = repo.formattedDescription(),
77 | style = MaterialTheme.typography.bodyMedium,
78 | color = Color.Gray
79 | )
80 |
81 | Spacer(modifier = Modifier.height(16.dp))
82 |
83 | // Stats: Language, Stars, Forks
84 | Row(
85 | verticalAlignment = Alignment.CenterVertically,
86 | horizontalArrangement = Arrangement.spacedBy(16.dp)
87 | ) {
88 | // Language
89 | LanguageBadge(repo.language ?: "Unknown")
90 |
91 | // Stars
92 | Row(verticalAlignment = Alignment.CenterVertically) {
93 | Icon(
94 | imageVector = Icons.Default.Star,
95 | contentDescription = "Star",
96 | tint = Color.Gray
97 | )
98 | Spacer(modifier = Modifier.width(4.dp))
99 | Text(text = repo.formattedStarsCount(), color = Color.Gray)
100 | }
101 |
102 | // Forks
103 | Row(verticalAlignment = Alignment.CenterVertically) {
104 | Icon(
105 | imageVector = Icons.Default.CallSplit, // Fork icon
106 | contentDescription = "Fork",
107 | tint = Color.Gray
108 | )
109 | Spacer(modifier = Modifier.width(4.dp))
110 | Text(text = repo.formattedForksCount(), color = Color.Gray)
111 | }
112 | }
113 | }
114 | }
115 | }
116 |
117 | @Preview(showBackground = true)
118 | @Composable
119 | fun PreviewRepoCard() {
120 | RepositoryCard(
121 | repo = Repository(
122 | name = "Spoon-Knife",
123 | language = "Java",
124 | starsCount = 12600,
125 | description = "This repo is for demonstration purposes only.",
126 | githubUrl = "https://github.com/octocat/Spoon-Knife",
127 | forksCount = 14600,
128 | isPrivate = false
129 | )
130 | )
131 | }
132 |
133 | @Composable
134 | fun LanguageBadge(language: String) {
135 | val languageColor = getLanguageColor(language)
136 |
137 | Row(verticalAlignment = Alignment.CenterVertically) {
138 | Box(
139 | modifier = Modifier
140 | .size(12.dp)
141 | .background(languageColor, shape = CircleShape)
142 | )
143 | Spacer(modifier = Modifier.width(4.dp))
144 | Text(text = language, color = Color.Gray)
145 | }
146 | }
147 |
148 | @Composable
149 | fun getLanguageColor(language: String): Color {
150 | return when (language) {
151 | "HTML" -> Color(0xFFE34C26)
152 | "CSS" -> Color(0xFF563D7C)
153 | "Ruby" -> Color(0xFF701516)
154 | "JavaScript" -> Color(0xFFF1E05A)
155 | "Python" -> Color(0xFF3572A5)
156 | "Java" -> Color(0xFFB07219)
157 | "Go" -> Color(0xFF00ADD8)
158 | "C++" -> Color(0xFFF34B7D)
159 | "Kotlin" -> Color(0xFFA97BFF)
160 | "Swift" -> Color(0xFFFFAC45)
161 | "PHP" -> Color(0xFF4F5D95)
162 | "TypeScript" -> Color(0xFF2B7489)
163 | "C#" -> Color(0xFF178600)
164 | "Shell" -> Color(0xFF89E051)
165 | else -> Color.Gray // Default color for unlisted languages
166 | }
167 | }
--------------------------------------------------------------------------------
/feature/details/src/main/java/com/andy/github/details/ui/UserConnections.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.details.ui
2 |
3 | import androidx.compose.foundation.layout.Arrangement
4 | import androidx.compose.foundation.layout.Row
5 | import androidx.compose.foundation.layout.Spacer
6 | import androidx.compose.foundation.layout.fillMaxWidth
7 | import androidx.compose.foundation.layout.width
8 | import androidx.compose.material.icons.Icons
9 | import androidx.compose.material.icons.outlined.Group
10 | import androidx.compose.material3.Icon
11 | import androidx.compose.material3.MaterialTheme
12 | import androidx.compose.material3.Text
13 | import androidx.compose.runtime.Composable
14 | import androidx.compose.ui.Alignment
15 | import androidx.compose.ui.Modifier
16 | import androidx.compose.ui.graphics.Color
17 | import androidx.compose.ui.text.SpanStyle
18 | import androidx.compose.ui.text.buildAnnotatedString
19 | import androidx.compose.ui.text.font.FontWeight
20 | import androidx.compose.ui.text.style.TextAlign
21 | import androidx.compose.ui.text.withStyle
22 | import androidx.compose.ui.tooling.preview.Preview
23 | import androidx.compose.ui.unit.dp
24 | import com.andy.github.details.domain.model.User
25 | import com.andy.github.details.domain.model.formatFollowers
26 | import com.andy.github.details.domain.model.formatFollowing
27 |
28 | @Composable
29 | fun UserConnections(user: User) {
30 | Row(
31 | modifier = Modifier.fillMaxWidth(),
32 | horizontalArrangement = Arrangement.Center,
33 | verticalAlignment = Alignment.CenterVertically
34 | ) {
35 | Icon(
36 | imageVector = Icons.Outlined.Group,
37 | contentDescription = "Favorite"
38 | )
39 | Spacer(modifier = Modifier.width(3.dp))
40 | ColorfulText(user.formatFollowers(), "followers")
41 | Text(
42 | modifier = Modifier
43 | .width(12.dp)
44 | .align(Alignment.CenterVertically),
45 | text = "·",
46 | textAlign = TextAlign.Center,
47 | style = MaterialTheme.typography.titleLarge
48 | )
49 | ColorfulText(user.formatFollowing(), "following")
50 | }
51 | }
52 |
53 | @Composable
54 | fun ColorfulText(
55 | startText: String = "Hello",
56 | endText: String = "World"
57 | ) {
58 | val annotatedText = buildAnnotatedString {
59 | withStyle(
60 | style = SpanStyle(
61 | fontStyle = MaterialTheme.typography.titleSmall.fontStyle,
62 | fontWeight = FontWeight.SemiBold
63 | )
64 | ) {
65 | append("$startText ")
66 | }
67 | withStyle(style = SpanStyle(color = Color.Black.copy(0.5f))) {
68 | append(endText)
69 | }
70 | }
71 | Text(text = annotatedText)
72 | }
73 |
74 | @Preview(showBackground = true)
75 | @Composable
76 | fun PreviewUserConnections() {
77 | val user = User(
78 | name = "JohnDoe",
79 | avatarUrl = "https://example.com/avatar.jpg",
80 | fullName = "John Doe",
81 | followers = 9000,
82 | following = 19
83 | )
84 | UserConnections(user)
85 | }
86 |
87 | @Preview(showBackground = true)
88 | @Composable
89 | fun PreviewColorfulText() {
90 | ColorfulText()
91 | }
--------------------------------------------------------------------------------
/feature/details/src/main/java/com/andy/github/details/ui/UserHeader.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.details.ui
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.foundation.layout.fillMaxWidth
6 | import androidx.compose.foundation.layout.padding
7 | import androidx.compose.foundation.layout.size
8 | import androidx.compose.foundation.shape.CircleShape
9 | import androidx.compose.material3.MaterialTheme
10 | import androidx.compose.material3.Text
11 | import androidx.compose.runtime.Composable
12 | import androidx.compose.ui.Alignment
13 | import androidx.compose.ui.Modifier
14 | import androidx.compose.ui.draw.clip
15 | import androidx.compose.ui.graphics.Color
16 | import androidx.compose.ui.tooling.preview.Preview
17 | import androidx.compose.ui.unit.dp
18 | import coil.compose.AsyncImage
19 | import com.andy.github.details.domain.model.User
20 | import com.andy.github.details.domain.model.formatFullName
21 | import com.andy.github.details.domain.model.formatName
22 |
23 | @Composable
24 | fun UserHeader(user: User) {
25 | Column(
26 | modifier = Modifier
27 | .fillMaxWidth()
28 | .background(MaterialTheme.colorScheme.primaryContainer)
29 | .padding(16.dp),
30 | horizontalAlignment = Alignment.CenterHorizontally
31 | ) {
32 | AsyncImage(
33 | model = user.avatarUrl,
34 | contentDescription = "User Avatar",
35 | modifier = Modifier
36 | .size(100.dp)
37 | .clip(CircleShape)
38 | )
39 | Text(
40 | text = user.formatFullName(),
41 | modifier = Modifier.padding(top = 8.dp),
42 | fontSize = MaterialTheme.typography.titleMedium.fontSize,
43 | fontWeight = MaterialTheme.typography.titleMedium.fontWeight
44 | )
45 | Text(
46 | text = user.formatName(),
47 | color = Color.Gray,
48 | style = MaterialTheme.typography.titleSmall,
49 | )
50 | UserConnections(user)
51 | }
52 | }
53 |
54 | @Preview(showBackground = true)
55 | @Composable
56 | fun PreviewUserHeader() {
57 | val user = User(
58 | name = "JohnDoe",
59 | avatarUrl = "https://example.com/avatar.jpg",
60 | fullName = "John Doe",
61 | followers = 9000,
62 | following = 1500
63 | )
64 | UserHeader(user)
65 | }
66 |
--------------------------------------------------------------------------------
/feature/details/src/test/java/com/andy/github/details/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.details
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
--------------------------------------------------------------------------------
/feature/home/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/feature/home/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.library)
3 | alias(libs.plugins.jetbrains.kotlin.android)
4 | alias(libs.plugins.google.dagger.hilt.android)
5 | alias(libs.plugins.ksp)
6 | alias(libs.plugins.compose.compiler)
7 | // kotlin("plugin.serialization") version "2.0.21"
8 | alias(libs.plugins.kotlin.serialization)
9 | }
10 |
11 | android {
12 | namespace = "com.andy.github.home"
13 | compileSdk = 35
14 |
15 | defaultConfig {
16 | minSdk = 25
17 |
18 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
19 | consumerProguardFiles("consumer-rules.pro")
20 | }
21 |
22 | buildTypes {
23 | release {
24 | isMinifyEnabled = false
25 | proguardFiles(
26 | getDefaultProguardFile("proguard-android-optimize.txt"),
27 | "proguard-rules.pro"
28 | )
29 | }
30 | }
31 | compileOptions {
32 | sourceCompatibility = JavaVersion.VERSION_1_8
33 | targetCompatibility = JavaVersion.VERSION_1_8
34 | }
35 | kotlinOptions {
36 | jvmTarget = "1.8"
37 | }
38 | }
39 |
40 | dependencies {
41 | val composeBom = platform(libs.androidx.compose.bom)
42 | implementation(composeBom)
43 | androidTestImplementation(composeBom)
44 |
45 | implementation(libs.core)
46 | implementation(libs.androidx.compose.material)
47 |
48 | implementation(libs.androidx.core.ktx)
49 | implementation(libs.androidx.lifecycle.runtime.ktx)
50 | implementation(libs.androidx.activity.compose)
51 | // implementation(platform(libs.androidx.compose.bom))
52 | implementation(libs.androidx.ui)
53 | implementation(libs.androidx.ui.graphics)
54 | implementation(libs.androidx.ui.tooling.preview)
55 | implementation(libs.androidx.material3)
56 |
57 | ksp(libs.squareup.moshi.codegen)
58 | ksp(libs.google.dagger.hilt.compiler)
59 | // only ksp(libs.hilt.compiler) is needed!
60 | // implementation(libs.dagger.hilt.compiler)
61 | implementation(libs.google.dagger.hilt.android)
62 | // room
63 | implementation(libs.androidx.room.common)
64 | implementation(libs.androidx.room.ktx)
65 | implementation(libs.androidx.room.runtime)
66 | ksp(libs.androidx.room.compiler)
67 | // navigation
68 | implementation(libs.androidx.hilt.navigation.compose)
69 | // material icons
70 | implementation(libs.androidx.compose.material.iconsExtended)
71 | // paging
72 | implementation(libs.androidx.paging.compose)
73 | implementation(libs.androidx.paging.runtime)
74 | implementation(libs.androidx.paging.common.android)
75 | implementation(libs.coil)
76 | implementation(libs.coil.compose)
77 | // navigation new version
78 | implementation (libs.androidx.navigation.compose)
79 | implementation(libs.kotlinx.serialization.json)
80 |
81 | // network
82 | implementation(project(":libs:network"))
83 |
84 | testImplementation(libs.junit)
85 | androidTestImplementation(libs.androidx.ui.test.junit4.android)
86 | androidTestImplementation(libs.androidx.junit)
87 | androidTestImplementation(libs.androidx.espresso.core)
88 | androidTestImplementation(libs.google.dagger.hilt.android.testing)
89 | // coroutines for test
90 | androidTestImplementation(libs.kotlinx.coroutines.test)
91 | androidTestImplementation(libs.kotlin.test)
92 | androidTestImplementation (libs.google.truth)
93 | // androidTestImplementation(libs.androidx.compose.ui.test)
94 | // compose ui test
95 | androidTestImplementation(libs.androidx.compose.ui.test.junit4)
96 | // compose ui test
97 | debugImplementation(libs.androidx.compose.ui.test.manifest)
98 | // coroutines for test
99 | testImplementation (libs.kotlinx.coroutines.test)
100 | // truth libs for test
101 | testImplementation (libs.google.truth)
102 | }
--------------------------------------------------------------------------------
/feature/home/consumer-rules.pro:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andyhaha/CleanArchitectureGitHubApp/a93cd02941385de45a8d160d1b2f8d3ae0088e65/feature/home/consumer-rules.pro
--------------------------------------------------------------------------------
/feature/home/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/feature/home/src/androidTest/java/com/andy/github/home/data/local/SearchHistoryDaoTest.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.data.local
2 |
3 | import androidx.room.Room
4 | import androidx.test.core.app.ApplicationProvider
5 | import com.andy.github.home.data.local.entity.SearchEntity
6 | import com.google.common.truth.Truth.assertThat
7 | import kotlinx.coroutines.flow.first
8 | import kotlinx.coroutines.test.runTest
9 | import org.junit.After
10 | import org.junit.Before
11 | import org.junit.Test
12 |
13 | class SearchHistoryDaoTest {
14 | private lateinit var database: SearchHistoryDatabase
15 |
16 | private lateinit var searchHistoryDao: SearchHistoryDao
17 |
18 | @Before
19 | fun setUp() {
20 | database = Room.inMemoryDatabaseBuilder(
21 | ApplicationProvider.getApplicationContext(),
22 | SearchHistoryDatabase::class.java
23 | ).build()
24 | searchHistoryDao = database.searchHistoryDao()
25 | }
26 |
27 | @After
28 | fun tearDown() {
29 | database.close()
30 | }
31 |
32 | @Test
33 | fun insertSingleSearchRecord() = runTest {
34 | // Insert a search record
35 | val searchEntity = SearchEntity(
36 | id = 1,
37 | content = "test insert"
38 | )
39 | searchHistoryDao.insertSearch(searchEntity)
40 |
41 | // Then check that the record is in the database
42 | val searchRecords = searchHistoryDao.getAllSearchRecords()
43 | .first()
44 |
45 | // Assert that the search records contains the inserted record
46 | assertThat(searchRecords).contains(searchEntity)
47 | }
48 |
49 | @Test
50 | fun deleteSingleSearchRecord() = runTest {
51 | // Insert a search record
52 | val searchEntity = SearchEntity(
53 | id = 1,
54 | content = "test delete one record"
55 | )
56 | searchHistoryDao.insertSearch(searchEntity)
57 |
58 | // Then delete the record
59 | searchHistoryDao.deleteSearch(searchEntity)
60 | val searchRecords = searchHistoryDao.getAllSearchRecords()
61 | .first()
62 |
63 | assertThat(searchRecords).doesNotContain(searchEntity)
64 | }
65 |
66 | @Test
67 | fun deleteAllSearchRecords() = runTest {
68 | // Insert multiple search records
69 | val insertList = mutableListOf()
70 | repeat(10) {
71 | val searchEntity = SearchEntity(
72 | id = it + 1,
73 | content = "test delete all record $it"
74 | )
75 | insertList.add(searchEntity)
76 | }
77 |
78 | // Insert all records
79 | insertList.forEach {
80 | searchHistoryDao.insertSearch(it)
81 | }
82 |
83 | // Then delete all records
84 | searchHistoryDao.deleteAllSearchRecords()
85 |
86 | val searchRecords = searchHistoryDao.getAllSearchRecords()
87 | .first()
88 |
89 | assertThat(searchRecords).isEmpty()
90 | }
91 |
92 | @Test
93 | fun insertAndRetrieveMultipleRecords() = runTest {
94 | val searchEntities = (1..3).map {
95 | SearchEntity(
96 | id = it,
97 | content = "test multiple $it"
98 | )
99 | }
100 | searchEntities.forEach { searchHistoryDao.insertSearch(it) }
101 |
102 | val searchRecords = searchHistoryDao.getAllSearchRecords().first()
103 | assertThat(searchRecords).hasSize(3)
104 | assertThat(searchRecords).containsAtLeastElementsIn(searchEntities)
105 | }
106 |
107 | @Test
108 | fun retrieveEmptyDatabase() = runTest {
109 | val searchRecords = searchHistoryDao.getAllSearchRecords().first()
110 | assertThat(searchRecords).isEmpty()
111 | }
112 | }
--------------------------------------------------------------------------------
/feature/home/src/androidTest/java/com/andy/github/home/di/FakeDatabaseModule.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.di
2 |
3 | import android.content.Context
4 | import androidx.room.Room
5 | import com.andy.github.home.data.local.SearchHistoryDao
6 | import com.andy.github.home.data.local.SearchHistoryDatabase
7 | import dagger.Module
8 | import dagger.Provides
9 | import dagger.hilt.android.qualifiers.ApplicationContext
10 | import dagger.hilt.components.SingletonComponent
11 | import dagger.hilt.testing.TestInstallIn
12 | import javax.inject.Singleton
13 |
14 | @Module
15 | @TestInstallIn(
16 | components = [SingletonComponent::class],
17 | replaces = [DatabaseModule::class]
18 | )
19 | object FakeDatabaseModule {
20 |
21 | @Provides
22 | @Singleton
23 | fun provideInMemoryDb(@ApplicationContext context: Context): SearchHistoryDatabase {
24 | return Room.inMemoryDatabaseBuilder(
25 | context, SearchHistoryDatabase::class.java
26 | ).allowMainThreadQueries().build()
27 | }
28 |
29 | @Singleton
30 | @Provides
31 | fun provideSearchHistoryDao(database: SearchHistoryDatabase): SearchHistoryDao {
32 | return database.searchHistoryDao()
33 | }
34 | }
--------------------------------------------------------------------------------
/feature/home/src/androidTest/java/com/andy/github/home/ui/HomeContentTest.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.ui
2 |
3 | import androidx.compose.foundation.layout.PaddingValues
4 | import androidx.compose.material3.SnackbarHostState
5 | import androidx.compose.ui.test.assertIsDisplayed
6 | import androidx.compose.ui.test.junit4.createComposeRule
7 | import androidx.compose.ui.test.onNodeWithText
8 | import androidx.paging.PagingData
9 | import androidx.paging.compose.collectAsLazyPagingItems
10 | import androidx.test.ext.junit.runners.AndroidJUnit4
11 | import com.andy.github.home.domain.model.SimpleUser
12 | import kotlinx.coroutines.flow.flowOf
13 | import kotlinx.coroutines.test.runTest
14 | import org.junit.Rule
15 | import org.junit.Test
16 | import org.junit.runner.RunWith
17 |
18 | @RunWith(AndroidJUnit4::class)
19 | class HomeContentTest {
20 |
21 | @get:Rule
22 | val composeTestRule = createComposeRule()
23 |
24 | @Test
25 | fun homeContent_displaysUserItemsCorrectly() = runTest {
26 | // Fake data
27 | // avatarUrl must be null otherwise the test will fail
28 | // because real avatarUrl is a URL will cause a network request
29 | val users = listOf(
30 | SimpleUser(name = "User1", avatarUrl = ""),
31 | SimpleUser(name = "User2", avatarUrl = ""),
32 | SimpleUser(name = "User3", avatarUrl = ""),
33 | )
34 |
35 | // Create a fake PagingData
36 | val fakePagingData = flowOf(PagingData.from(users))
37 |
38 | composeTestRule.setContent {
39 | val lazyPagingItems = fakePagingData.collectAsLazyPagingItems()
40 | HomeContent(
41 | snackbarHostState = SnackbarHostState(),
42 | innerPadding = PaddingValues(),
43 | items = lazyPagingItems,
44 | onSearchListItemClick = {}
45 | )
46 | }
47 |
48 | // Verify that all user items are displayed
49 | users.forEach { user ->
50 | composeTestRule.onNodeWithText(user.name).assertIsDisplayed()
51 | }
52 | }
53 |
54 | @Test
55 | fun homeContent_displaysNoUsersWhenEmpty() = runTest {
56 | // Create an empty PagingData
57 | val fakePagingData = flowOf(PagingData.empty())
58 |
59 | composeTestRule.setContent {
60 | val lazyPagingItems = fakePagingData.collectAsLazyPagingItems()
61 | HomeContent(
62 | snackbarHostState = SnackbarHostState(),
63 | innerPadding = PaddingValues(),
64 | items = lazyPagingItems,
65 | onSearchListItemClick = {}
66 | )
67 | }
68 |
69 | // Verify "No users" UI is displayed
70 | composeTestRule.onNodeWithText("No users").assertIsDisplayed()
71 | }
72 | }
--------------------------------------------------------------------------------
/feature/home/src/androidTest/java/com/andy/github/home/ui/HomeSearchBarTest.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.ui
2 |
3 | import androidx.compose.ui.test.assertIsDisplayed
4 | import androidx.compose.ui.test.assertTextEquals
5 | import androidx.compose.ui.test.hasSetTextAction
6 | import androidx.compose.ui.test.junit4.createComposeRule
7 | import androidx.compose.ui.test.onNodeWithContentDescription
8 | import androidx.compose.ui.test.onNodeWithText
9 | import androidx.compose.ui.test.performClick
10 | import androidx.compose.ui.test.performTextInput
11 | import com.andy.github.home.domain.model.SearchItem
12 | import com.andy.github.home.domain.model.toUiSearchItem
13 | import com.google.common.truth.Truth.assertThat
14 | import org.junit.Rule
15 | import org.junit.Test
16 |
17 | class HomeSearchBarTest {
18 |
19 | @get:Rule
20 | val composeTestRule = createComposeRule()
21 |
22 | @Test
23 | fun searchBar_initialState_displaysPlaceholder() {
24 | composeTestRule.setContent {
25 | HomeSearchBar()
26 | }
27 |
28 | // Verify that the placeholder text is displayed in the initial state
29 | composeTestRule.onNodeWithText("Search users").assertIsDisplayed()
30 | }
31 |
32 | @Test
33 | fun searchBar_enterTextAndSearch_triggersCallback() {
34 | var searchTriggered = false
35 | val testSearchItem = SearchItem(content = "TestUser")
36 |
37 | composeTestRule.setContent {
38 | HomeSearchBar(onSearch = { item ->
39 | searchTriggered = (item == testSearchItem.toUiSearchItem())
40 | })
41 | }
42 |
43 | // Simulate text input
44 | composeTestRule.onNode(hasSetTextAction()).performTextInput(testSearchItem.content)
45 |
46 | // Click the search icon
47 | composeTestRule.onNodeWithContentDescription("search icon").performClick()
48 |
49 | // Verify that the search callback was triggered
50 | assertThat(searchTriggered).isTrue()
51 | }
52 |
53 | @Test
54 | fun searchBar_expandAndClickHistoryItem_updatesQueryAndSearch() {
55 | var searchTriggered = false
56 | val testSearchItem = SearchItem(content = "HistoryItem")
57 |
58 | composeTestRule.setContent {
59 | HomeSearchBar(
60 | searchUiState = SearchUiState.Success(listOf(testSearchItem.toUiSearchItem())),
61 | onSearch = { item ->
62 | searchTriggered = (item == testSearchItem.toUiSearchItem())
63 | }
64 | )
65 | }
66 |
67 | // Expand the search history
68 | composeTestRule.onNode(hasSetTextAction()).performTextInput("Any")
69 | composeTestRule.onNodeWithContentDescription("search icon").performClick()
70 |
71 | // Click a history item
72 | composeTestRule.onNodeWithText(testSearchItem.content).performClick()
73 |
74 | // Verify that the search callback was triggered
75 | assertThat(searchTriggered).isTrue()
76 | }
77 |
78 | @Test
79 | fun searchBar_inputUpdatesText() {
80 | composeTestRule.setContent {
81 | HomeSearchBar()
82 | }
83 |
84 | // Simulate text input into the search field
85 | composeTestRule.onNode(hasSetTextAction()).performTextInput("Test Query")
86 |
87 | // Verify that the input field now has the expected text
88 | composeTestRule.onNode(hasSetTextAction()).assertTextEquals("Test Query")
89 | }
90 |
91 | @Test
92 | fun searchBar_clearText_resetsText() {
93 | composeTestRule.setContent {
94 | HomeSearchBar()
95 | }
96 |
97 | val placeholder = "Search users"
98 | // Simulate typing
99 | composeTestRule.onNodeWithText(placeholder).performTextInput("SomeText")
100 |
101 | // Assert that the text field now contains "SomeText"
102 | composeTestRule.onNode(hasSetTextAction()).assertTextEquals("SomeText")
103 |
104 | // Simulate clicking the close icon
105 | composeTestRule.onNodeWithContentDescription("close icon").performClick()
106 |
107 | // Assert that the text field is now showing the placeholder
108 | composeTestRule.onNodeWithText(placeholder).assertIsDisplayed()
109 | }
110 |
111 | @Test
112 | fun searchBar_expandedStateToggle() {
113 | composeTestRule.setContent {
114 | HomeSearchBar()
115 | }
116 |
117 | // Initially, the expanded state should be false
118 | composeTestRule.onNodeWithContentDescription("search icon").assertExists()
119 |
120 | // Simulate opening the search bar
121 | composeTestRule.onNode(hasSetTextAction()).performTextInput("Open Search")
122 |
123 | // The expanded state should be true now
124 | composeTestRule.onNodeWithContentDescription("search icon").assertExists()
125 |
126 | // Simulate closing the search bar
127 | composeTestRule.onNodeWithContentDescription("close icon").performClick()
128 |
129 | // The expanded state should be false
130 | composeTestRule.onNodeWithContentDescription("search icon").assertExists()
131 | }
132 | }
--------------------------------------------------------------------------------
/feature/home/src/androidTest/java/com/andy/github/home/ui/SearchHistoryTest.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.ui
2 |
3 | import androidx.compose.ui.test.*
4 | import androidx.compose.ui.test.junit4.createComposeRule
5 | import com.google.common.truth.Truth.assertThat
6 | import org.junit.Rule
7 | import org.junit.Test
8 |
9 | class SearchHistoryTest {
10 |
11 | @get:Rule
12 | val composeTestRule = createComposeRule()
13 |
14 | @Test
15 | fun testLoadingState() {
16 | composeTestRule.setContent {
17 | SearchHistory(state = SearchUiState.Loading)
18 | }
19 |
20 | // Verify that the "Loading..." text is shown when in loading state
21 | composeTestRule.onNodeWithText("Loading...").assertExists()
22 | }
23 |
24 | @Test
25 | fun testSuccessState() {
26 | val sampleHistoryItems = listOf(
27 | UiSearchItem(content = "Item 1"),
28 | UiSearchItem(content = "Item 2")
29 | )
30 |
31 | composeTestRule.setContent {
32 | SearchHistory(state = SearchUiState.Success(sampleHistoryItems))
33 | }
34 |
35 | // Verify that items "Item 1" and "Item 2" are displayed
36 | composeTestRule.onNodeWithText("Item 1").assertExists()
37 | composeTestRule.onNodeWithText("Item 2").assertExists()
38 | }
39 |
40 | @Test
41 | fun testErrorState() {
42 | // Assuming the error state is handled to display an error message or UI
43 | composeTestRule.setContent {
44 | SearchHistory(state = SearchUiState.Error("An error occurred"))
45 | }
46 |
47 | // Verify if the error message appears in the UI
48 | composeTestRule.onNodeWithText("An error occurred").assertExists()
49 | }
50 |
51 | @Test
52 | fun testItemClick() {
53 | val sampleHistoryItems = listOf(
54 | UiSearchItem(content = "Item 1")
55 | )
56 |
57 | var clickedItem: UiSearchItem? = null
58 | val onItemClick: (UiSearchItem) -> Unit = { clickedItem = it }
59 |
60 | composeTestRule.setContent {
61 | SearchHistory(state = SearchUiState.Success(sampleHistoryItems), onItemClick = onItemClick)
62 | }
63 |
64 | composeTestRule.onNodeWithText("Item 1").performClick()
65 |
66 | // Verify that the clicked item is passed to the onItemClick callback
67 | assertThat(clickedItem).isNotNull()
68 | assertThat(clickedItem?.content).isEqualTo("Item 1")
69 | }
70 |
71 | @Test
72 | fun testItemDelete() {
73 | val sampleHistoryItems = listOf(
74 | UiSearchItem(content = "Item 1")
75 | )
76 |
77 | var deletedItem: UiSearchItem? = null
78 | val delete: (UiSearchItem) -> Unit = { deletedItem = it }
79 |
80 | composeTestRule.setContent {
81 | SearchHistory(state = SearchUiState.Success(sampleHistoryItems), delete = delete)
82 | }
83 |
84 | composeTestRule.onNodeWithContentDescription("Right Icon").performClick()
85 |
86 | // Verify that the delete callback was triggered with the correct item
87 | assertThat(deletedItem).isNotNull()
88 | assertThat(deletedItem?.content).isEqualTo("Item 1")
89 | }
90 | }
--------------------------------------------------------------------------------
/feature/home/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/feature/home/src/main/java/com/andy/github/home/data/local/SearchHistoryDao.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.data.local
2 |
3 | import androidx.room.Dao
4 | import androidx.room.Delete
5 | import androidx.room.Insert
6 | import androidx.room.OnConflictStrategy
7 | import androidx.room.Query
8 | import com.andy.github.home.data.local.entity.SearchEntity
9 | import kotlinx.coroutines.flow.Flow
10 | import javax.inject.Named
11 |
12 | @Dao
13 | //@Named("test_db")
14 | interface SearchHistoryDao {
15 |
16 | @Query("SELECT * FROM search_history ORDER BY id DESC")
17 | fun getAllSearchRecords(): Flow>
18 |
19 | @Insert(onConflict = OnConflictStrategy.REPLACE)
20 | suspend fun insertSearch(record: SearchEntity)
21 |
22 | @Delete
23 | suspend fun deleteSearch(content: SearchEntity)
24 |
25 | @Query("DELETE FROM search_history")
26 | suspend fun deleteAllSearchRecords()
27 | }
--------------------------------------------------------------------------------
/feature/home/src/main/java/com/andy/github/home/data/local/SearchHistoryDatabase.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.data.local
2 |
3 | import androidx.room.Database
4 | import androidx.room.RoomDatabase
5 | import com.andy.github.home.data.local.entity.SearchEntity
6 | import javax.inject.Named
7 |
8 | @Database(
9 | entities = [SearchEntity::class],
10 | version = 1,
11 | exportSchema = false
12 | )
13 | //@Named("test_db")
14 | abstract class SearchHistoryDatabase : RoomDatabase() {
15 |
16 | abstract fun searchHistoryDao(): SearchHistoryDao
17 | }
--------------------------------------------------------------------------------
/feature/home/src/main/java/com/andy/github/home/data/local/entity/SearchEntity.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.data.local.entity
2 |
3 | import androidx.room.Entity
4 | import androidx.room.Index
5 | import androidx.room.PrimaryKey
6 | import com.andy.github.home.domain.model.SearchItem
7 |
8 | @Entity(
9 | tableName = "search_history",
10 | indices = [Index(value = ["content"], unique = true)]
11 | )
12 | data class SearchEntity(
13 | @PrimaryKey(autoGenerate = true)
14 | val id: Int = 0,
15 | val content: String
16 | )
17 |
18 | fun SearchEntity.asDomainModel(): SearchItem {
19 | return SearchItem(
20 | id = id,
21 | content = content
22 | )
23 | }
--------------------------------------------------------------------------------
/feature/home/src/main/java/com/andy/github/home/data/model/SimpleUserModel.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.data.model
2 |
3 | import com.andy.github.home.domain.model.SimpleUser
4 | import com.squareup.moshi.Json
5 | import com.squareup.moshi.JsonClass
6 |
7 | @JsonClass(generateAdapter = true)
8 | data class ApiSimpleUserModel(
9 | @Json(name = "login")
10 | val name: String,
11 | @Json(name = "avatar_url")
12 | val avatarUrl: String
13 | )
14 |
15 | fun ApiSimpleUserModel.toDomainUser(): SimpleUser {
16 | return SimpleUser(name = name, avatarUrl = avatarUrl)
17 | }
--------------------------------------------------------------------------------
/feature/home/src/main/java/com/andy/github/home/data/paging/SearchPagingSource.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.data.paging
2 |
3 | import android.util.Log
4 | import androidx.paging.PagingSource
5 | import androidx.paging.PagingState
6 | import com.andy.common.toJson
7 | import com.andy.github.home.data.model.toDomainUser
8 | import com.andy.github.home.data.remote.HomeApiService
9 | import com.andy.github.home.domain.model.SimpleUser
10 | import com.andy.network.common.Constants
11 | import com.andy.network.common.errorMessage
12 | import com.andy.network.data.ApiResult
13 | import com.andy.network.domain.Result
14 | import retrofit2.HttpException
15 | import java.io.IOException
16 |
17 | class SearchPagingSource(
18 | private val apiService: HomeApiService,
19 | private val query: String
20 | ) : PagingSource() {
21 |
22 | override suspend fun load(params: LoadParams): LoadResult {
23 | val page = params.key ?: 1
24 | return try {
25 | val result = apiService.searchUserRepositories(
26 | query = query,
27 | perPage = Constants.PAGE_SIZE,
28 | page = page
29 | )
30 | when (result) {
31 | is ApiResult.Success -> {
32 | val list = result.data.items.map {
33 | it.toDomainUser()
34 | }
35 | Log.d(
36 | "SearchPagingSource",
37 | "list = ${result.data.items.toJson()}"
38 | )
39 | Result.Success(list)
40 | return LoadResult.Page(
41 | data = list,
42 | prevKey = if (page == 1) null else page - 1,
43 | nextKey = if (list.isEmpty()) null else page + 1
44 | )
45 | }
46 |
47 | is ApiResult.Error -> {
48 | Log.e(
49 | "SearchPagingSource", "error code = ${result.code}, " +
50 | "message = ${result.message}"
51 | )
52 | return LoadResult.Error(Throwable(result.errorMessage()))
53 | }
54 |
55 | is ApiResult.Exception -> {
56 | return LoadResult.Error(Throwable(result.throwable))
57 | }
58 | }
59 | } catch (exception: IOException) {
60 | LoadResult.Error(exception)
61 | } catch (exception: HttpException) {
62 | LoadResult.Error(exception)
63 | }
64 | }
65 |
66 | override fun getRefreshKey(state: PagingState): Int? {
67 | return state.anchorPosition?.let { position ->
68 | state.closestPageToPosition(position)?.prevKey?.plus(1)
69 | ?: state.closestPageToPosition(position)?.nextKey?.minus(1)
70 | }
71 | }
72 | }
--------------------------------------------------------------------------------
/feature/home/src/main/java/com/andy/github/home/data/remote/ApiSimpleUserResponse.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.data.remote
2 |
3 | import com.andy.github.home.data.model.ApiSimpleUserModel
4 | import com.squareup.moshi.Json
5 | import com.squareup.moshi.JsonClass
6 |
7 | @JsonClass(generateAdapter = true)
8 | data class ApiSimpleUserResponse(
9 | @Json(name = "total_count")
10 | val totalCount: Int,
11 | val items: List
12 | )
--------------------------------------------------------------------------------
/feature/home/src/main/java/com/andy/github/home/data/remote/HomeApiService.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.data.remote
2 |
3 | import com.andy.network.data.ApiResult
4 | import retrofit2.http.GET
5 | import retrofit2.http.Query
6 |
7 | interface HomeApiService {
8 |
9 | @GET("search/users")
10 | suspend fun searchUserRepositories(
11 | @Query("q") query: String,
12 | @Query("per_page") perPage: Int,
13 | @Query("page") page: Int
14 | ): ApiResult
15 | }
--------------------------------------------------------------------------------
/feature/home/src/main/java/com/andy/github/home/data/repository/DatabaseSearchHistoryRepository.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.data.repository
2 |
3 | import com.andy.github.home.data.local.SearchHistoryDao
4 | import com.andy.github.home.data.local.entity.asDomainModel
5 | import com.andy.github.home.domain.model.SearchItem
6 | import com.andy.github.home.domain.model.asEntity
7 | import com.andy.github.home.domain.repository.SearchHistoryRepository
8 | import kotlinx.coroutines.flow.Flow
9 | import kotlinx.coroutines.flow.map
10 | import javax.inject.Inject
11 |
12 | class DatabaseSearchHistoryRepository @Inject constructor(
13 | private val searchHistoryDao: SearchHistoryDao,
14 | ) : SearchHistoryRepository {
15 | override fun getAllSearchRecords(): Flow> {
16 | return searchHistoryDao.getAllSearchRecords().map { list ->
17 | list.map { it.asDomainModel() }
18 | }
19 | }
20 |
21 | override suspend fun insertSearch(record: SearchItem) {
22 | searchHistoryDao.insertSearch(record.asEntity())
23 | }
24 |
25 | override suspend fun deleteSearch(content: SearchItem) {
26 | searchHistoryDao.deleteSearch(content.asEntity())
27 | }
28 |
29 | override suspend fun deleteAllSearchRecords() {
30 | searchHistoryDao.deleteAllSearchRecords()
31 | }
32 | }
--------------------------------------------------------------------------------
/feature/home/src/main/java/com/andy/github/home/data/repository/SimpleGitHubUserRepository.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.data.repository
2 |
3 | import androidx.paging.Pager
4 | import androidx.paging.PagingConfig
5 | import androidx.paging.PagingData
6 | import com.andy.github.home.data.paging.SearchPagingSource
7 | import com.andy.github.home.data.remote.HomeApiService
8 | import com.andy.github.home.domain.model.SimpleUser
9 | import com.andy.github.home.domain.repository.SimpleUserRepository
10 | import com.andy.network.common.Constants
11 | import kotlinx.coroutines.flow.Flow
12 | import javax.inject.Inject
13 |
14 | class SimpleGitHubUserRepository @Inject constructor(
15 | private val apiService: HomeApiService
16 | ) : SimpleUserRepository {
17 |
18 | override fun searchUserRepositories(
19 | query: String
20 | ): Flow> {
21 | return Pager(
22 | config = PagingConfig(pageSize = Constants.PAGE_SIZE),
23 | pagingSourceFactory = {
24 | SearchPagingSource(
25 | apiService = apiService,
26 | query = query
27 | )
28 | }
29 | ).flow
30 | }
31 | }
--------------------------------------------------------------------------------
/feature/home/src/main/java/com/andy/github/home/di/DatabaseModule.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.di
2 |
3 | import android.content.Context
4 | import androidx.room.Room
5 | import com.andy.github.home.data.local.SearchHistoryDao
6 | import com.andy.github.home.data.local.SearchHistoryDatabase
7 | import dagger.Module
8 | import dagger.Provides
9 | import dagger.hilt.InstallIn
10 | import dagger.hilt.android.qualifiers.ApplicationContext
11 | import dagger.hilt.components.SingletonComponent
12 | import javax.inject.Singleton
13 |
14 | @Module
15 | @InstallIn(SingletonComponent::class)
16 | object DatabaseModule {
17 |
18 | @Singleton
19 | @Provides
20 | fun provideSearchHistoryDatabase(
21 | @ApplicationContext appContext: Context
22 | ): SearchHistoryDatabase {
23 | return Room.databaseBuilder(
24 | appContext,
25 | SearchHistoryDatabase::class.java,
26 | "search_history"
27 | ).build()
28 | }
29 |
30 | @Singleton
31 | @Provides
32 | fun provideSearchHistoryDao(database: SearchHistoryDatabase): SearchHistoryDao {
33 | return database.searchHistoryDao()
34 | }
35 | }
--------------------------------------------------------------------------------
/feature/home/src/main/java/com/andy/github/home/di/HomeModule.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.di
2 |
3 | import com.andy.github.home.data.remote.HomeApiService
4 | import dagger.Module
5 | import dagger.Provides
6 | import dagger.hilt.InstallIn
7 | import dagger.hilt.components.SingletonComponent
8 | import retrofit2.Retrofit
9 | import javax.inject.Singleton
10 |
11 | @Module
12 | @InstallIn(SingletonComponent::class)
13 | object HomeModule {
14 |
15 | @Provides
16 | @Singleton
17 | fun provideHomeApiService(retrofit: Retrofit): HomeApiService {
18 | return retrofit.create(HomeApiService::class.java)
19 | }
20 | }
--------------------------------------------------------------------------------
/feature/home/src/main/java/com/andy/github/home/di/HomeRepositoryModule.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.di
2 |
3 | import com.andy.github.home.data.repository.DatabaseSearchHistoryRepository
4 | import com.andy.github.home.data.repository.SimpleGitHubUserRepository
5 | import com.andy.github.home.domain.repository.SearchHistoryRepository
6 | import com.andy.github.home.domain.repository.SimpleUserRepository
7 | import dagger.Binds
8 | import dagger.Module
9 | import dagger.hilt.InstallIn
10 | import dagger.hilt.components.SingletonComponent
11 |
12 | @Module
13 | @InstallIn(SingletonComponent::class)
14 | abstract class HomeRepositoryModule {
15 |
16 | @Binds
17 | abstract fun bindSimpleRepository(
18 | repository: SimpleGitHubUserRepository,
19 | ): SimpleUserRepository
20 |
21 | @Binds
22 | abstract fun bindSearchHistoryRepository(
23 | repository: DatabaseSearchHistoryRepository,
24 | ): SearchHistoryRepository
25 | }
--------------------------------------------------------------------------------
/feature/home/src/main/java/com/andy/github/home/domain/model/Search.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.domain.model
2 |
3 | import com.andy.github.home.data.local.entity.SearchEntity
4 | import com.andy.github.home.ui.UiSearchItem
5 |
6 | data class SearchItem(
7 | val id: Int = 0,
8 | val content: String
9 | )
10 |
11 | fun SearchItem.asEntity(): SearchEntity {
12 | return SearchEntity(
13 | id = id,
14 | content = content
15 | )
16 | }
17 |
18 | fun SearchItem.toUiSearchItem(): UiSearchItem {
19 | return UiSearchItem(
20 | id = id,
21 | content = content
22 | )
23 | }
--------------------------------------------------------------------------------
/feature/home/src/main/java/com/andy/github/home/domain/model/SimpleUser.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.domain.model
2 |
3 | data class SimpleUser(
4 | val name: String,
5 | val avatarUrl: String
6 | )
--------------------------------------------------------------------------------
/feature/home/src/main/java/com/andy/github/home/domain/repository/SearchHistoryRepository.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.domain.repository
2 |
3 | import com.andy.github.home.domain.model.SearchItem
4 | import kotlinx.coroutines.flow.Flow
5 |
6 | interface SearchHistoryRepository {
7 | fun getAllSearchRecords(): Flow>
8 |
9 | suspend fun insertSearch(record: SearchItem)
10 |
11 | suspend fun deleteSearch(content: SearchItem)
12 |
13 | suspend fun deleteAllSearchRecords()
14 | }
--------------------------------------------------------------------------------
/feature/home/src/main/java/com/andy/github/home/domain/repository/SimpleUserRepository.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.domain.repository
2 |
3 | import androidx.paging.PagingData
4 | import com.andy.github.home.domain.model.SimpleUser
5 | import kotlinx.coroutines.flow.Flow
6 |
7 | interface SimpleUserRepository {
8 |
9 | fun searchUserRepositories(
10 | query: String
11 | ): Flow>
12 | }
--------------------------------------------------------------------------------
/feature/home/src/main/java/com/andy/github/home/ui/HomeContent.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.ui
2 |
3 | import android.util.Log
4 | import androidx.compose.foundation.layout.PaddingValues
5 | import androidx.compose.foundation.layout.fillMaxWidth
6 | import androidx.compose.foundation.layout.padding
7 | import androidx.compose.foundation.lazy.LazyColumn
8 | import androidx.compose.material3.SnackbarDuration
9 | import androidx.compose.material3.SnackbarHostState
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.runtime.LaunchedEffect
12 | import androidx.compose.ui.Modifier
13 | import androidx.compose.ui.tooling.preview.Preview
14 | import androidx.paging.LoadState
15 | import androidx.paging.compose.LazyPagingItems
16 | import com.andy.github.home.domain.model.SimpleUser
17 | import com.andy.github.home.ui.components.LoadingItem
18 | import com.andy.github.home.ui.components.NoData
19 | import com.andy.github.home.ui.components.NoMoreDataItem
20 |
21 | @Composable
22 | fun HomeContent(
23 | snackbarHostState: SnackbarHostState,
24 | innerPadding: PaddingValues = PaddingValues(),
25 | items: LazyPagingItems,
26 | onSearchListItemClick: (SimpleUser) -> Unit = {}
27 | ) {
28 | Log.d("HomeContent", "items.loadState = ${items.loadState}")
29 | Log.d("HomeContent", "items.loadState.refresh = ${items.loadState.refresh}")
30 | when {
31 | items.loadState.refresh is LoadState.Error -> {
32 | val error = (items.loadState.refresh as LoadState.Error).error
33 | LaunchedEffect(snackbarHostState) {
34 | snackbarHostState.showSnackbar(
35 | message = error.message ?: "Unknown error!",
36 | actionLabel = "Cancel",
37 | duration = SnackbarDuration.Short
38 | )
39 | }
40 | }
41 |
42 | items.loadState.append is LoadState.Error -> {
43 | Log.d("HomeContent", "items.loadState.append is LoadState.Error: ${items.loadState.append}")
44 | val error = (items.loadState.append as LoadState.Error).error
45 | LaunchedEffect(snackbarHostState) {
46 | snackbarHostState.showSnackbar(
47 | message = error.message ?: "Unknown error!",
48 | actionLabel = "Cancel",
49 | duration = SnackbarDuration.Short
50 | )
51 | }
52 | }
53 | }
54 |
55 | if (items.itemCount == 0) {
56 | NoData()
57 | return
58 | }
59 |
60 | LazyColumn(
61 | modifier = Modifier
62 | .fillMaxWidth()
63 | .padding(innerPadding),
64 | ) {
65 | items(count = items.itemCount) { index ->
66 | val item = items[index]
67 | item?.let {
68 | UserItem(it, onSearchListItemClick)
69 | }
70 | }
71 | item {
72 | when {
73 | items.loadState.append is LoadState.Loading -> {
74 | LoadingItem()
75 | }
76 |
77 | items.loadState.append.endOfPaginationReached -> {
78 | NoMoreDataItem()
79 | }
80 |
81 | items.loadState.append is LoadState.Error -> {
82 | Log.d("HomeContent", "LazyColumn items.loadState.append " +
83 | "is LoadState.Error: ${items.loadState.append}")
84 | }
85 | }
86 | }
87 | }
88 | }
89 |
90 | @Preview(showBackground = true)
91 | @Composable
92 | fun PreviewHomeContent() {
93 | // HomeContent(PaddingValues())
94 | }
--------------------------------------------------------------------------------
/feature/home/src/main/java/com/andy/github/home/ui/HomeScreen.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.ui
2 |
3 | import androidx.compose.material3.Scaffold
4 | import androidx.compose.material3.SnackbarHost
5 | import androidx.compose.material3.SnackbarHostState
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.runtime.getValue
8 | import androidx.compose.runtime.mutableStateOf
9 | import androidx.compose.runtime.remember
10 | import androidx.compose.runtime.setValue
11 | import androidx.compose.ui.tooling.preview.Preview
12 | import androidx.hilt.navigation.compose.hiltViewModel
13 | import androidx.lifecycle.compose.collectAsStateWithLifecycle
14 | import androidx.paging.compose.LazyPagingItems
15 | import androidx.paging.compose.collectAsLazyPagingItems
16 | import com.andy.github.home.domain.model.SimpleUser
17 |
18 | @Composable
19 | fun HomeScreen(
20 | searchHistoryViewModel: SearchHistoryViewModel = hiltViewModel(),
21 | homeViewModel: HomeViewModel = hiltViewModel(),
22 | onSearchListItemClick: (SimpleUser) -> Unit = {}
23 | ) {
24 | var searchText by remember { mutableStateOf("") }
25 | val historyItemsState: SearchUiState by searchHistoryViewModel.viewState.collectAsStateWithLifecycle()
26 | val snackbarHostState = remember { SnackbarHostState() }
27 | Scaffold(
28 | snackbarHost = { SnackbarHost(snackbarHostState) },
29 | topBar = {
30 | HomeSearchBar(
31 | searchUiState = historyItemsState,
32 | onSearch = {
33 | searchText = it.content
34 | if (searchText.isNotEmpty()) {
35 | searchHistoryViewModel.addHistoryItem(searchText)
36 | homeViewModel.searchUserRepositories(searchText)
37 | }
38 | },
39 | delete = {
40 | searchHistoryViewModel.deleteHistoryItem(it)
41 | }
42 | )
43 | }
44 | ) { innerPadding ->
45 | val items: LazyPagingItems = homeViewModel.searchedUsers.collectAsLazyPagingItems()
46 | HomeContent(
47 | snackbarHostState = snackbarHostState,
48 | innerPadding = innerPadding,
49 | items = items,
50 | onSearchListItemClick = onSearchListItemClick
51 | )
52 | }
53 | }
54 |
55 | @Preview
56 | @Composable
57 | fun PreviewHomeScreen() {
58 | HomeScreen()
59 | }
--------------------------------------------------------------------------------
/feature/home/src/main/java/com/andy/github/home/ui/HomeSearchBar.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.ui
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.layout.Box
5 | import androidx.compose.foundation.layout.fillMaxWidth
6 | import androidx.compose.material.icons.Icons
7 | import androidx.compose.material.icons.filled.Close
8 | import androidx.compose.material.icons.filled.Search
9 | import androidx.compose.material3.ExperimentalMaterial3Api
10 | import androidx.compose.material3.Icon
11 | import androidx.compose.material3.MaterialTheme
12 | import androidx.compose.material3.SearchBar
13 | import androidx.compose.material3.SearchBarDefaults
14 | import androidx.compose.material3.Text
15 | import androidx.compose.runtime.Composable
16 | import androidx.compose.runtime.derivedStateOf
17 | import androidx.compose.runtime.getValue
18 | import androidx.compose.runtime.mutableStateOf
19 | import androidx.compose.runtime.remember
20 | import androidx.compose.runtime.saveable.rememberSaveable
21 | import androidx.compose.runtime.setValue
22 | import androidx.compose.ui.Alignment
23 | import androidx.compose.ui.Modifier
24 | import androidx.compose.ui.semantics.isTraversalGroup
25 | import androidx.compose.ui.semantics.semantics
26 | import androidx.compose.ui.semantics.traversalIndex
27 | import androidx.compose.ui.tooling.preview.Preview
28 | import androidx.wear.compose.material.ContentAlpha
29 |
30 | @OptIn(ExperimentalMaterial3Api::class)
31 | @Composable
32 | fun HomeSearchBar(
33 | searchUiState: SearchUiState = SearchUiState.Loading,
34 | onSearch: (UiSearchItem) -> Unit = {},
35 | delete: (UiSearchItem) -> Unit = {}
36 | ) {
37 | var text by rememberSaveable { mutableStateOf("") }
38 | var expanded by rememberSaveable { mutableStateOf(false) }
39 |
40 | val isSearchEnabled by remember(text) {
41 | derivedStateOf { text.isNotBlank() }
42 | }
43 | val searchIconTint = if (isSearchEnabled) {
44 | MaterialTheme.colorScheme.onSurface
45 | } else {
46 | MaterialTheme.colorScheme.onSurface.copy(alpha = ContentAlpha.disabled)
47 | }
48 |
49 | Box(
50 | Modifier
51 | .fillMaxWidth()
52 | .semantics { isTraversalGroup = true }) {
53 | SearchBar(
54 | modifier = Modifier
55 | .align(Alignment.TopCenter)
56 | .semantics { traversalIndex = 0f },
57 | inputField = {
58 | SearchBarDefaults.InputField(
59 | query = text,
60 | onQueryChange = { text = it },
61 | onSearch = {
62 | if (isSearchEnabled) {
63 | expanded = false
64 | onSearch(UiSearchItem(content = it))
65 | }
66 | },
67 | expanded = expanded,
68 | onExpandedChange = { expanded = it },
69 | placeholder = { Text("Search users") },
70 | leadingIcon = {
71 | Icon(
72 | Icons.Default.Search,
73 | tint = searchIconTint,
74 | contentDescription = "search icon",
75 | modifier = Modifier.clickable(
76 | enabled = isSearchEnabled
77 | ) {
78 | expanded = false
79 | onSearch(UiSearchItem(content = text))
80 | }
81 | )
82 | },
83 | trailingIcon = {
84 | if (expanded) {
85 | Icon(
86 | imageVector = Icons.Default.Close,
87 | contentDescription = "close icon",
88 | modifier = Modifier.clickable {
89 | if (isSearchEnabled) {
90 | text = ""
91 | return@clickable
92 | }
93 | expanded = false
94 | }
95 | )
96 | }
97 | }
98 | )
99 | },
100 | expanded = expanded,
101 | onExpandedChange = { expanded = it },
102 | ) {
103 | SearchHistory(
104 | onItemClick = {
105 | text = it.content
106 | expanded = false
107 | onSearch(it)
108 | },
109 | delete = delete,
110 | state = searchUiState
111 | )
112 | }
113 | }
114 | }
115 |
116 | @Preview
117 | @Composable
118 | fun PreviewHomeScreenBar() {
119 | HomeSearchBar()
120 | }
--------------------------------------------------------------------------------
/feature/home/src/main/java/com/andy/github/home/ui/HomeViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.ui
2 |
3 | import android.util.Log
4 | import androidx.lifecycle.ViewModel
5 | import androidx.lifecycle.viewModelScope
6 | import androidx.paging.PagingData
7 | import androidx.paging.cachedIn
8 | import com.andy.github.home.domain.model.SimpleUser
9 | import com.andy.github.home.domain.repository.SimpleUserRepository
10 | import dagger.hilt.android.lifecycle.HiltViewModel
11 | import kotlinx.coroutines.flow.MutableStateFlow
12 | import kotlinx.coroutines.flow.asStateFlow
13 | import kotlinx.coroutines.flow.catch
14 | import kotlinx.coroutines.launch
15 | import javax.inject.Inject
16 |
17 | @HiltViewModel
18 | class HomeViewModel @Inject constructor(
19 | private val userRepository: SimpleUserRepository
20 | ): ViewModel() {
21 | private val _searchedUsers = MutableStateFlow>(PagingData.empty())
22 | val searchedUsers = _searchedUsers.asStateFlow()
23 |
24 | fun searchUserRepositories(query: String) {
25 | viewModelScope.launch {
26 | userRepository.searchUserRepositories(query)
27 | .cachedIn(viewModelScope)
28 | .catch {
29 | Log.d("HomeViewModel", "exception: $it")
30 | }
31 | .collect {
32 | Log.d("HomeViewModel", "collect: $it")
33 | _searchedUsers.value = it
34 | }
35 | }
36 | }
37 | }
--------------------------------------------------------------------------------
/feature/home/src/main/java/com/andy/github/home/ui/SearchHistory.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.ui
2 |
3 | import androidx.compose.material3.Text
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.ui.tooling.preview.Preview
6 |
7 | @Composable
8 | fun SearchHistory(
9 | onItemClick: (UiSearchItem) -> Unit = {},
10 | delete: (UiSearchItem) -> Unit = {},
11 | state: SearchUiState = SearchUiState.Loading
12 | ) {
13 | when (state) {
14 | is SearchUiState.Success -> {
15 | SearchHistoryList(
16 | onItemClick = onItemClick,
17 | delete = delete,
18 | historyItems = state.historyItems
19 | )
20 | }
21 |
22 | is SearchUiState.Error -> {
23 | Text(state.message)
24 | }
25 |
26 | is SearchUiState.Loading -> {
27 | Text("Loading...")
28 | }
29 | }
30 | }
31 |
32 | @Preview
33 | @Composable
34 | fun PreviewSearchHistory() {
35 | SearchHistory()
36 | }
--------------------------------------------------------------------------------
/feature/home/src/main/java/com/andy/github/home/ui/SearchHistoryList.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.ui
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.foundation.layout.Row
6 | import androidx.compose.foundation.layout.Spacer
7 | import androidx.compose.foundation.layout.fillMaxSize
8 | import androidx.compose.foundation.layout.fillMaxWidth
9 | import androidx.compose.foundation.layout.height
10 | import androidx.compose.foundation.layout.padding
11 | import androidx.compose.foundation.layout.width
12 | import androidx.compose.foundation.rememberScrollState
13 | import androidx.compose.foundation.verticalScroll
14 | import androidx.compose.material.icons.Icons
15 | import androidx.compose.material.icons.filled.DeleteOutline
16 | import androidx.compose.material.icons.filled.History
17 | import androidx.compose.material3.Icon
18 | import androidx.compose.material3.Text
19 | import androidx.compose.runtime.Composable
20 | import androidx.compose.ui.Alignment
21 | import androidx.compose.ui.Modifier
22 | import androidx.compose.ui.tooling.preview.Preview
23 | import androidx.compose.ui.unit.dp
24 |
25 | @Composable
26 | fun SearchHistoryList(
27 | historyItems: List = emptyList(),
28 | onItemClick: (UiSearchItem) -> Unit = {},
29 | delete: (UiSearchItem) -> Unit = {}
30 | ) {
31 | Column(
32 | modifier = Modifier.fillMaxSize()
33 | .verticalScroll(rememberScrollState()),
34 | ) {
35 | historyItems.forEach { item ->
36 | Row(
37 | modifier = Modifier.fillMaxWidth()
38 | .height(40.dp)
39 | .clickable {
40 | onItemClick(item)
41 | },
42 | verticalAlignment = Alignment.CenterVertically
43 | ) {
44 | Icon(
45 | modifier = Modifier.padding(start = 10.dp),
46 | imageVector = Icons.Default.History,
47 | contentDescription = "History Icon",
48 | )
49 | Spacer(modifier = Modifier.width(16.dp))
50 | Text(text = item.content, modifier = Modifier.weight(1f))
51 | Icon(
52 | imageVector = Icons.Default.DeleteOutline,
53 | contentDescription = "Right Icon",
54 | modifier = Modifier.padding(end = 16.dp)
55 | .clickable {
56 | delete(item)
57 | }
58 | )
59 | }
60 | }
61 | }
62 | }
63 |
64 | @Preview(showBackground = true)
65 | @Composable
66 | fun PreviewSearchHistoryList() {
67 | SearchHistoryList(
68 | historyItems = listOf(UiSearchItem(content = "Example Item")),
69 | onItemClick = {},
70 | delete = {}
71 | )
72 | }
--------------------------------------------------------------------------------
/feature/home/src/main/java/com/andy/github/home/ui/SearchHistoryViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.ui
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import com.andy.github.home.domain.model.SearchItem
6 | import com.andy.github.home.domain.model.toUiSearchItem
7 | import com.andy.github.home.domain.repository.SearchHistoryRepository
8 | import dagger.hilt.android.lifecycle.HiltViewModel
9 | import kotlinx.coroutines.flow.SharingStarted
10 | import kotlinx.coroutines.flow.StateFlow
11 | import kotlinx.coroutines.flow.catch
12 | import kotlinx.coroutines.flow.map
13 | import kotlinx.coroutines.flow.stateIn
14 | import kotlinx.coroutines.launch
15 | import javax.inject.Inject
16 |
17 | @HiltViewModel
18 | class SearchHistoryViewModel @Inject constructor(
19 | private val searchHistoryRepository: SearchHistoryRepository
20 | ) : ViewModel() {
21 |
22 | val viewState: StateFlow = searchHistoryRepository.getAllSearchRecords()
23 | .map { list ->
24 | SearchUiState.Success(
25 | historyItems = list.map {
26 | it.toUiSearchItem()
27 | }
28 | )
29 | }
30 | .catch {
31 | SearchUiState.Error(it.message ?: "Unknown Error")
32 | }
33 | .stateIn(
34 | viewModelScope,
35 | SharingStarted.WhileSubscribed(),
36 | SearchUiState.Loading
37 | )
38 |
39 | fun addHistoryItem(content: String) {
40 | viewModelScope.launch {
41 | searchHistoryRepository.insertSearch(
42 | SearchItem(content = content)
43 | )
44 | }
45 | }
46 |
47 | fun deleteHistoryItem(content: UiSearchItem) {
48 | viewModelScope.launch {
49 | searchHistoryRepository.deleteSearch(
50 | content.toDomainSearchItem()
51 | )
52 | }
53 | }
54 |
55 | fun clearHistory() {
56 | viewModelScope.launch {
57 | searchHistoryRepository.deleteAllSearchRecords()
58 | }
59 | }
60 | }
61 |
62 | sealed interface SearchUiState {
63 | data class Success(val historyItems: List) : SearchUiState
64 | data class Error(val message: String) : SearchUiState
65 | data object Loading : SearchUiState
66 | }
67 |
68 | data class UiSearchItem(
69 | val id: Int = 0,
70 | val content: String
71 | ) {
72 | fun toDomainSearchItem() = SearchItem(
73 | id = id,
74 | content = content
75 | )
76 | }
--------------------------------------------------------------------------------
/feature/home/src/main/java/com/andy/github/home/ui/UserItem.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.ui
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.layout.fillMaxWidth
5 | import androidx.compose.foundation.layout.size
6 | import androidx.compose.foundation.shape.CircleShape
7 | import androidx.compose.material3.HorizontalDivider
8 | import androidx.compose.material3.ListItem
9 | import androidx.compose.material3.Text
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.ui.Modifier
12 | import androidx.compose.ui.draw.clip
13 | import androidx.compose.ui.platform.LocalContext
14 | import androidx.compose.ui.res.painterResource
15 | import androidx.compose.ui.unit.dp
16 | import coil.compose.AsyncImage
17 | import coil.request.ImageRequest
18 | import com.andy.github.home.R
19 | import com.andy.github.home.domain.model.SimpleUser
20 |
21 | @Composable
22 | fun UserItem(
23 | simpleUser: SimpleUser,
24 | onSearchListItemClick: (SimpleUser) -> Unit = {}
25 | ) {
26 | ListItem(
27 | modifier = Modifier
28 | .fillMaxWidth()
29 | .clickable {
30 | onSearchListItemClick(simpleUser)
31 | },
32 | headlineContent = { Text(text = simpleUser.name) },
33 | leadingContent = {
34 | AsyncImage(
35 | model = ImageRequest.Builder(LocalContext.current)
36 | .data(simpleUser.avatarUrl)
37 | .crossfade(true)
38 | .build(),
39 | contentDescription = "Net image",
40 | placeholder = painterResource(
41 | id = R.drawable.baseline_person_50
42 | ),
43 | error = painterResource(
44 | R.drawable.baseline_person_off_50
45 | ),
46 | modifier = Modifier
47 | .size(50.dp)
48 | .clip(CircleShape)
49 | )
50 | }
51 | )
52 | HorizontalDivider(thickness = 0.5.dp)
53 | }
--------------------------------------------------------------------------------
/feature/home/src/main/java/com/andy/github/home/ui/components/ErrorContent.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.ui.components
2 |
3 | import androidx.compose.foundation.layout.Box
4 | import androidx.compose.foundation.layout.PaddingValues
5 | import androidx.compose.foundation.layout.fillMaxSize
6 | import androidx.compose.foundation.layout.padding
7 | import androidx.compose.material3.Text
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.ui.Alignment
10 | import androidx.compose.ui.Modifier
11 |
12 | @Composable
13 | fun LoadError(innerPadding: PaddingValues = PaddingValues()) {
14 | Box(
15 | modifier = Modifier
16 | .fillMaxSize()
17 | .padding(innerPadding.calculateTopPadding()),
18 | contentAlignment = Alignment.Center
19 | ) {
20 | Text(text = "Load error")
21 | }
22 | }
--------------------------------------------------------------------------------
/feature/home/src/main/java/com/andy/github/home/ui/components/LoadingItem.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.ui.components
2 |
3 | import androidx.compose.foundation.layout.Arrangement
4 | import androidx.compose.foundation.layout.Row
5 | import androidx.compose.foundation.layout.Spacer
6 | import androidx.compose.foundation.layout.fillMaxWidth
7 | import androidx.compose.foundation.layout.padding
8 | import androidx.compose.foundation.layout.width
9 | import androidx.compose.material3.CircularProgressIndicator
10 | import androidx.compose.material3.HorizontalDivider
11 | import androidx.compose.material3.Text
12 | import androidx.compose.runtime.Composable
13 | import androidx.compose.ui.Alignment
14 | import androidx.compose.ui.Modifier
15 | import androidx.compose.ui.graphics.Color
16 | import androidx.compose.ui.tooling.preview.Preview
17 | import androidx.compose.ui.unit.dp
18 |
19 | @Composable
20 | fun LoadingItem() {
21 | Row(
22 | modifier = Modifier.fillMaxWidth()
23 | .padding(8.dp),
24 | horizontalArrangement = Arrangement.Center,
25 | verticalAlignment = Alignment.CenterVertically
26 | ) {
27 | CircularProgressIndicator()
28 | Spacer(modifier = Modifier.width(16.dp))
29 | Text(text = "Loading...", color = Color.Gray)
30 | }
31 | HorizontalDivider(thickness = 0.5.dp)
32 | }
33 |
34 | @Preview(showBackground = true)
35 | @Composable
36 | fun PreViewLoadingItem() {
37 | LoadingItem()
38 | }
--------------------------------------------------------------------------------
/feature/home/src/main/java/com/andy/github/home/ui/components/NoData.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.ui.components
2 |
3 | import androidx.compose.foundation.layout.Box
4 | import androidx.compose.foundation.layout.PaddingValues
5 | import androidx.compose.foundation.layout.fillMaxSize
6 | import androidx.compose.foundation.layout.padding
7 | import androidx.compose.material3.Text
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.ui.Alignment
10 | import androidx.compose.ui.Modifier
11 |
12 | @Composable
13 | fun NoData(innerPadding: PaddingValues = PaddingValues()) {
14 | Box(
15 | modifier = Modifier
16 | .fillMaxSize()
17 | .padding(innerPadding.calculateTopPadding()),
18 | contentAlignment = Alignment.Center
19 | ) {
20 | Text(text = "No users")
21 | }
22 | }
--------------------------------------------------------------------------------
/feature/home/src/main/java/com/andy/github/home/ui/components/NoMoreDataItem.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.ui.components
2 |
3 | import androidx.compose.foundation.layout.Box
4 | import androidx.compose.foundation.layout.fillMaxSize
5 | import androidx.compose.foundation.layout.padding
6 | import androidx.compose.material3.Text
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.ui.Alignment
9 | import androidx.compose.ui.Modifier
10 | import androidx.compose.ui.unit.dp
11 |
12 | @Composable
13 | fun NoMoreDataItem() {
14 | Box(
15 | modifier = Modifier
16 | .padding(vertical = 12.dp)
17 | .fillMaxSize(),
18 | contentAlignment = Alignment.Center
19 | ) {
20 | Text(text = "No more data")
21 | }
22 | }
--------------------------------------------------------------------------------
/feature/home/src/main/res/drawable/baseline_person_50.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/feature/home/src/main/res/drawable/baseline_person_off_50.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/feature/home/src/test/java/com/andy/github/home/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
--------------------------------------------------------------------------------
/feature/home/src/test/java/com/andy/github/home/data/FakeSearchHistoryRepository.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.data
2 |
3 | import com.andy.github.home.domain.model.SearchItem
4 | import com.andy.github.home.domain.repository.SearchHistoryRepository
5 | import kotlinx.coroutines.flow.Flow
6 | import kotlinx.coroutines.flow.MutableStateFlow
7 |
8 | class FakeSearchHistoryRepository : SearchHistoryRepository {
9 |
10 | // Use a MutableStateFlow to simulate changes in the data
11 | private val searchItems = MutableStateFlow>(emptyList())
12 |
13 | override fun getAllSearchRecords(): Flow> {
14 | return searchItems
15 | }
16 |
17 | override suspend fun insertSearch(record: SearchItem) {
18 | // Simulate adding the search record to the list
19 | val currentItems = searchItems.value.toMutableList()
20 | currentItems.add(record)
21 | searchItems.emit(currentItems)
22 | }
23 |
24 | override suspend fun deleteSearch(content: SearchItem) {
25 | // Simulate deleting the search record from the list
26 | val currentItems = searchItems.value.toMutableList()
27 | currentItems.remove(content)
28 | searchItems.emit(currentItems)
29 | }
30 |
31 | override suspend fun deleteAllSearchRecords() {
32 | // Simulate clearing all search records
33 | searchItems.emit(emptyList())
34 | }
35 | }
--------------------------------------------------------------------------------
/feature/home/src/test/java/com/andy/github/home/ui/SearchHistoryViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package com.andy.github.home.ui
2 |
3 | import com.andy.github.home.data.FakeSearchHistoryRepository
4 | import com.andy.github.home.domain.model.SearchItem
5 | import com.google.common.truth.Truth.assertThat
6 | import kotlinx.coroutines.Dispatchers
7 | import kotlinx.coroutines.ExperimentalCoroutinesApi
8 | import kotlinx.coroutines.flow.first
9 | import kotlinx.coroutines.test.StandardTestDispatcher
10 | import kotlinx.coroutines.test.TestDispatcher
11 | import kotlinx.coroutines.test.resetMain
12 | import kotlinx.coroutines.test.runTest
13 | import kotlinx.coroutines.test.setMain
14 | import org.junit.After
15 | import org.junit.Before
16 | import org.junit.Test
17 |
18 | class SearchHistoryViewModelTest {
19 | private lateinit var viewModel: SearchHistoryViewModel
20 | private val testDispatcher: TestDispatcher = StandardTestDispatcher()
21 |
22 | @OptIn(ExperimentalCoroutinesApi::class)
23 | @Before
24 | fun setup() {
25 | Dispatchers.setMain(testDispatcher)
26 | viewModel = SearchHistoryViewModel(FakeSearchHistoryRepository())
27 | }
28 |
29 | @OptIn(ExperimentalCoroutinesApi::class)
30 | @After
31 | fun tearDown() {
32 | Dispatchers.resetMain()
33 | }
34 |
35 | // Helper to assert the Loading state
36 | private suspend fun assertLoadingState() {
37 | val loadingState = viewModel.viewState.first { it is SearchUiState.Loading }
38 | assertThat(loadingState)
39 | .isEqualTo(SearchUiState.Loading)
40 | }
41 |
42 | // Helper to assert the Success state with expected items
43 | private suspend fun assertSuccessState(expectedItems: List) {
44 | val successState =
45 | viewModel.viewState.first { it is SearchUiState.Success } as SearchUiState.Success
46 | assertThat(
47 | successState.historyItems.map { it.toDomainSearchItem() })
48 | .isEqualTo(expectedItems)
49 | }
50 |
51 | @Test
52 | fun testAddHistoryItem() = runTest {
53 | val content = "New History"
54 | val expectedItem = SearchItem(content = content)
55 |
56 | // Add item via ViewModel
57 | viewModel.addHistoryItem(content)
58 |
59 | // Assert Loading and Success states
60 | assertLoadingState()
61 | assertSuccessState(listOf(expectedItem))
62 | }
63 |
64 | @Test
65 | fun testDeleteHistoryItem() = runTest {
66 | val content = "Delete History"
67 | val uiItem = UiSearchItem(content = content)
68 |
69 | // Add an item first
70 | viewModel.addHistoryItem(content)
71 |
72 | // Assert Loading state before deletion
73 | assertLoadingState()
74 |
75 | // Delete the item
76 | viewModel.deleteHistoryItem(uiItem)
77 |
78 | // Assert Success state reflects an empty list
79 | assertSuccessState(emptyList())
80 | }
81 |
82 | @Test
83 | fun testClearHistory() = runTest {
84 | val content = "Clear History"
85 |
86 | // Add an item first
87 | viewModel.addHistoryItem(content)
88 |
89 | // Assert Loading state before clearing
90 | assertLoadingState()
91 |
92 | // Clear all history
93 | viewModel.clearHistory()
94 |
95 | // Assert Success state reflects an empty list
96 | assertSuccessState(emptyList())
97 | }
98 |
99 | @Test
100 | fun testClearHistoryWithMultipleItems() = runTest {
101 | // Add some items to the history
102 | val items = listOf(
103 | SearchItem(content = "History 1"),
104 | SearchItem(content = "History 2"),
105 | SearchItem(content = "History 3")
106 | )
107 |
108 | items.forEach { viewModel.addHistoryItem(it.content) }
109 |
110 | // Trigger clear history operation
111 | viewModel.clearHistory()
112 |
113 | // Assert that the history is now empty
114 | assertSuccessState(emptyList())
115 | }
116 | }
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. For more details, visit
12 | # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app's APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andyhaha/CleanArchitectureGitHubApp/a93cd02941385de45a8d160d1b2f8d3ae0088e65/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Wed Oct 09 20:19:51 JST 2024
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or 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 UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/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 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/libs/common/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/libs/common/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.library)
3 | alias(libs.plugins.jetbrains.kotlin.android)
4 | alias(libs.plugins.ksp)
5 | alias(libs.plugins.google.dagger.hilt.android)
6 | }
7 |
8 | android {
9 | namespace = "com.andy.common"
10 | compileSdk = 35
11 |
12 | defaultConfig {
13 | minSdk = 25
14 |
15 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
16 | consumerProguardFiles("consumer-rules.pro")
17 | }
18 |
19 | buildTypes {
20 | release {
21 | isMinifyEnabled = false
22 | proguardFiles(
23 | getDefaultProguardFile("proguard-android-optimize.txt"),
24 | "proguard-rules.pro"
25 | )
26 | }
27 | }
28 | compileOptions {
29 | sourceCompatibility = JavaVersion.VERSION_1_8
30 | targetCompatibility = JavaVersion.VERSION_1_8
31 | }
32 | kotlinOptions {
33 | jvmTarget = "1.8"
34 | }
35 | }
36 |
37 | dependencies {
38 |
39 | implementation(libs.androidx.core.ktx)
40 | implementation(libs.androidx.appcompat)
41 | implementation(libs.material)
42 | implementation(libs.androidx.espresso.core)
43 | testImplementation(libs.junit)
44 | androidTestImplementation(libs.androidx.junit)
45 | androidTestImplementation(libs.androidx.espresso.core)
46 |
47 | api(libs.squareup.moshi)
48 | ksp(libs.squareup.moshi.codegen)
49 | ksp(libs.google.dagger.hilt.compiler)
50 | implementation(libs.google.dagger.hilt.android)
51 | }
--------------------------------------------------------------------------------
/libs/common/consumer-rules.pro:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andyhaha/CleanArchitectureGitHubApp/a93cd02941385de45a8d160d1b2f8d3ae0088e65/libs/common/consumer-rules.pro
--------------------------------------------------------------------------------
/libs/common/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/libs/common/src/androidTest/java/com/andy/common/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package com.andy.common
2 |
3 | import androidx.test.platform.app.InstrumentationRegistry
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 |
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | import org.junit.Assert.*
10 |
11 | /**
12 | * Instrumented test, which will execute on an Android device.
13 | *
14 | * See [testing documentation](http://d.android.com/tools/testing).
15 | */
16 | @RunWith(AndroidJUnit4::class)
17 | class ExampleInstrumentedTest {
18 | @Test
19 | fun useAppContext() {
20 | // Context of the app under test.
21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
22 | assertEquals("com.andy.common.test", appContext.packageName)
23 | }
24 | }
--------------------------------------------------------------------------------
/libs/common/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/libs/common/src/main/java/com/andy/common/JsonKt.kt:
--------------------------------------------------------------------------------
1 | package com.andy.common
2 |
3 | import com.andy.common.JsonManager.moshi
4 |
5 | inline fun T.toJson(): String {
6 | return try {
7 | val adapter = moshi.adapter(T::class.java)
8 | return adapter.toJson(this)
9 | } catch (e: Throwable) {
10 | ""
11 | }
12 | }
--------------------------------------------------------------------------------
/libs/common/src/main/java/com/andy/common/JsonManager.kt:
--------------------------------------------------------------------------------
1 | package com.andy.common
2 |
3 | import com.squareup.moshi.Moshi
4 |
5 | object JsonManager {
6 | val moshi = Moshi.Builder()
7 | .build()
8 |
9 | inline fun parse(json: String): T? {
10 | val adapter = moshi.adapter(T::class.java)
11 | return adapter.fromJson(json)
12 | }
13 | }
--------------------------------------------------------------------------------
/libs/common/src/main/java/com/andy/common/NumberFormatter.kt:
--------------------------------------------------------------------------------
1 | package com.andy.common
2 |
3 | import android.annotation.SuppressLint
4 | import android.util.Log
5 |
6 | @SuppressLint("DefaultLocale")
7 | fun Int.formatToK(): String {
8 | val value = this
9 | return if (value >= 1000) {
10 | val formattedValue = value / 1000
11 | Log.d("FormattedValue", "this = $value, " +
12 | "formattedValue = ${value % 1000}")
13 | val modules = value % 1000
14 | if (modules < 50) {
15 | // No decimal part needed, return integer (e.g., 4k)
16 | "${formattedValue}k"
17 | } else {
18 | // Decimal part exists, return float (e.g., 4.5k)
19 | var format = String.format("%.1fk", value / 1000.0)
20 | if (format.endsWith(".0k")) {
21 | format = format.replace(".0k", "k")
22 | }
23 | format
24 | }
25 | } else {
26 | value.toString()
27 | }
28 | }
--------------------------------------------------------------------------------
/libs/common/src/main/java/com/andy/common/di/DispatchersModule.kt:
--------------------------------------------------------------------------------
1 | package com.andy.common.di
2 |
3 | import dagger.Module
4 | import dagger.Provides
5 | import dagger.hilt.InstallIn
6 | import dagger.hilt.components.SingletonComponent
7 | import kotlinx.coroutines.CoroutineDispatcher
8 | import kotlinx.coroutines.Dispatchers
9 | import javax.inject.Named
10 | import javax.inject.Singleton
11 |
12 | @Module
13 | @InstallIn(SingletonComponent::class)
14 | object DispatchersModule {
15 |
16 | @Provides
17 | @Singleton
18 | @Named("io")
19 | fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
20 |
21 | @Provides
22 | @Singleton
23 | @Named("main")
24 | fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main
25 | }
--------------------------------------------------------------------------------
/libs/common/src/test/java/com/andy/common/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package com.andy.common
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
--------------------------------------------------------------------------------
/libs/framework/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/libs/framework/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.library)
3 | alias(libs.plugins.jetbrains.kotlin.android)
4 | }
5 |
6 | android {
7 | namespace = "com.andy.framework"
8 | compileSdk = 35
9 |
10 | defaultConfig {
11 | minSdk = 25
12 |
13 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
14 | consumerProguardFiles("consumer-rules.pro")
15 | }
16 |
17 | buildTypes {
18 | release {
19 | isMinifyEnabled = false
20 | proguardFiles(
21 | getDefaultProguardFile("proguard-android-optimize.txt"),
22 | "proguard-rules.pro"
23 | )
24 | }
25 | }
26 | compileOptions {
27 | sourceCompatibility = JavaVersion.VERSION_1_8
28 | targetCompatibility = JavaVersion.VERSION_1_8
29 | }
30 | kotlinOptions {
31 | jvmTarget = "1.8"
32 | }
33 | }
34 |
35 | dependencies {
36 |
37 | implementation(libs.androidx.core.ktx)
38 | implementation(libs.androidx.appcompat)
39 | implementation(libs.material)
40 | testImplementation(libs.junit)
41 | androidTestImplementation(libs.androidx.junit)
42 | androidTestImplementation(libs.androidx.espresso.core)
43 | }
--------------------------------------------------------------------------------
/libs/framework/consumer-rules.pro:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andyhaha/CleanArchitectureGitHubApp/a93cd02941385de45a8d160d1b2f8d3ae0088e65/libs/framework/consumer-rules.pro
--------------------------------------------------------------------------------
/libs/framework/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/libs/framework/src/androidTest/java/com/andy/framework/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package com.andy.framework
2 |
3 | import androidx.test.platform.app.InstrumentationRegistry
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 |
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | import org.junit.Assert.*
10 |
11 | /**
12 | * Instrumented test, which will execute on an Android device.
13 | *
14 | * See [testing documentation](http://d.android.com/tools/testing).
15 | */
16 | @RunWith(AndroidJUnit4::class)
17 | class ExampleInstrumentedTest {
18 | @Test
19 | fun useAppContext() {
20 | // Context of the app under test.
21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
22 | assertEquals("com.andy.framework.test", appContext.packageName)
23 | }
24 | }
--------------------------------------------------------------------------------
/libs/framework/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/libs/framework/src/main/java/com/andy/framework/BaseActivity.kt:
--------------------------------------------------------------------------------
1 | package com.andy.framework
2 |
3 | import android.os.Bundle
4 | import androidx.activity.ComponentActivity
5 |
6 | open class BaseActivity : ComponentActivity() {
7 | private var firstResume = true
8 |
9 | override fun onCreate(savedInstanceState: Bundle?) {
10 | super.onCreate(savedInstanceState)
11 | initData()
12 | initView()
13 | afterInitView()
14 | }
15 |
16 | override fun onResume() {
17 | super.onResume()
18 | if (firstResume) {
19 | firstResume = false
20 | firstResume()
21 | } else {
22 | afterFirstResume()
23 | }
24 | }
25 |
26 | open fun initData() {
27 |
28 | }
29 |
30 | open fun initView() {
31 |
32 | }
33 |
34 | open fun afterInitView() {
35 |
36 | }
37 |
38 | open fun firstResume() {
39 |
40 | }
41 |
42 | open fun afterFirstResume() {
43 |
44 | }
45 | }
--------------------------------------------------------------------------------
/libs/framework/src/main/java/com/andy/framework/BaseFragment.kt:
--------------------------------------------------------------------------------
1 | package com.andy.framework
2 |
3 | import android.os.Bundle
4 | import android.view.View
5 | import androidx.fragment.app.Fragment
6 |
7 | open class BaseFragment : Fragment() {
8 | private var firstResume = true
9 |
10 | override fun onCreate(savedInstanceState: Bundle?) {
11 | super.onCreate(savedInstanceState)
12 | initData()
13 | }
14 |
15 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
16 | super.onViewCreated(view, savedInstanceState)
17 | initView()
18 | afterInitView()
19 | }
20 |
21 | override fun onResume() {
22 | super.onResume()
23 | if (firstResume) {
24 | firstResume = false
25 | firstResume()
26 | } else {
27 | afterFirstResume()
28 | }
29 | }
30 |
31 | open fun initData() {
32 |
33 | }
34 |
35 | open fun initView() {
36 |
37 | }
38 |
39 | open fun afterInitView() {
40 |
41 | }
42 |
43 | open fun firstResume() {
44 |
45 | }
46 |
47 | open fun afterFirstResume() {
48 |
49 | }
50 | }
--------------------------------------------------------------------------------
/libs/framework/src/main/java/com/andy/framework/LazyFragment.kt:
--------------------------------------------------------------------------------
1 | package com.andy.framework
2 |
3 | abstract class LazyFragment : BaseFragment() {
4 | private var isLoaded = false
5 |
6 | override fun onResume() {
7 | super.onResume()
8 | if (!isLoaded && !isHidden) {
9 | lazyInit()
10 | isLoaded = true
11 | }
12 | }
13 |
14 | override fun onDestroyView() {
15 | super.onDestroyView()
16 | isLoaded = false
17 | }
18 |
19 | abstract fun lazyInit()
20 | }
--------------------------------------------------------------------------------
/libs/framework/src/main/res/anim/slid_in_from_bottom.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
8 |
9 |
--------------------------------------------------------------------------------
/libs/framework/src/main/res/anim/slid_in_from_left.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
8 |
9 |
--------------------------------------------------------------------------------
/libs/framework/src/main/res/anim/slid_in_from_right.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
8 |
9 |
--------------------------------------------------------------------------------
/libs/framework/src/main/res/anim/slid_in_from_top.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
10 |
11 |
--------------------------------------------------------------------------------
/libs/framework/src/main/res/anim/slid_out_to_bottom.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
8 |
9 |
--------------------------------------------------------------------------------
/libs/framework/src/main/res/anim/slid_out_to_left.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
8 |
9 |
--------------------------------------------------------------------------------
/libs/framework/src/main/res/anim/slid_out_to_right.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
8 |
9 |
--------------------------------------------------------------------------------
/libs/framework/src/main/res/anim/slid_out_to_top.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
10 |
11 |
--------------------------------------------------------------------------------
/libs/framework/src/test/java/com/andy/framework/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package com.andy.framework
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
--------------------------------------------------------------------------------
/libs/network/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/libs/network/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import java.util.Properties
2 |
3 | plugins {
4 | alias(libs.plugins.android.library)
5 | alias(libs.plugins.jetbrains.kotlin.android)
6 | alias(libs.plugins.google.dagger.hilt.android)
7 | alias(libs.plugins.ksp)
8 | }
9 |
10 | android {
11 | namespace = "com.andy.network"
12 | compileSdk = 35
13 |
14 | defaultConfig {
15 | minSdk = 25
16 |
17 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
18 | consumerProguardFiles("consumer-rules.pro")
19 |
20 | // get API_TOKEN from local.properties
21 | val localProperties = Properties()
22 | val localPropertiesFile = rootProject.file("local.properties")
23 | if (localPropertiesFile.exists()) {
24 | localProperties.load(localPropertiesFile.inputStream())
25 | }
26 | val apiToken = localProperties.getProperty("API_TOKEN", "default_token")
27 | buildConfigField("String", "API_TOKEN", "\"$apiToken\"")
28 | }
29 |
30 | buildTypes {
31 | release {
32 | isMinifyEnabled = false
33 | proguardFiles(
34 | getDefaultProguardFile("proguard-android-optimize.txt"),
35 | "proguard-rules.pro"
36 | )
37 | }
38 | }
39 | compileOptions {
40 | sourceCompatibility = JavaVersion.VERSION_1_8
41 | targetCompatibility = JavaVersion.VERSION_1_8
42 | }
43 | kotlinOptions {
44 | jvmTarget = "1.8"
45 | }
46 | buildFeatures {
47 | buildConfig = true
48 | }
49 | }
50 |
51 | dependencies {
52 |
53 | implementation(libs.androidx.core.ktx)
54 | implementation(libs.androidx.appcompat)
55 | implementation(libs.material)
56 | testImplementation(libs.junit)
57 | androidTestImplementation(libs.androidx.junit)
58 | androidTestImplementation(libs.androidx.espresso.core)
59 |
60 | ksp(libs.google.dagger.hilt.compiler)
61 | ksp(libs.squareup.moshi.codegen)
62 | api(libs.squareup.retrofit)
63 | implementation(libs.squareup.retrofit.converter.moshi)
64 | implementation(libs.squareup.okhttp3)
65 | implementation(libs.squareup.okhttp3.logger)
66 |
67 | implementation(libs.google.dagger.hilt.android)
68 |
69 | api(project(":libs:common"))
70 | }
--------------------------------------------------------------------------------
/libs/network/consumer-rules.pro:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andyhaha/CleanArchitectureGitHubApp/a93cd02941385de45a8d160d1b2f8d3ae0088e65/libs/network/consumer-rules.pro
--------------------------------------------------------------------------------
/libs/network/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/libs/network/src/androidTest/java/com/andy/network/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package com.andy.network
2 |
3 | import androidx.test.platform.app.InstrumentationRegistry
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 |
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | import org.junit.Assert.*
10 |
11 | /**
12 | * Instrumented test, which will execute on an Android device.
13 | *
14 | * See [testing documentation](http://d.android.com/tools/testing).
15 | */
16 | @RunWith(AndroidJUnit4::class)
17 | class ExampleInstrumentedTest {
18 | @Test
19 | fun useAppContext() {
20 | // Context of the app under test.
21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
22 | assertEquals("com.andy.network.test", appContext.packageName)
23 | }
24 | }
--------------------------------------------------------------------------------
/libs/network/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/libs/network/src/main/java/com/andy/network/ApiResultAdapterFactory.kt:
--------------------------------------------------------------------------------
1 | package com.andy.network
2 |
3 | import com.andy.network.data.ApiResult
4 | import retrofit2.Call
5 | import retrofit2.CallAdapter
6 | import retrofit2.Retrofit
7 | import java.lang.reflect.ParameterizedType
8 | import java.lang.reflect.Type
9 |
10 | class ApiResultAdapterFactory : CallAdapter.Factory() {
11 | override fun get(returnType: Type, annotations: Array, retrofit: Retrofit): CallAdapter<*, *>? {
12 | if (Call::class.java != getRawType(returnType)) return null
13 | check(returnType is ParameterizedType)
14 |
15 | val responseType = getParameterUpperBound(0, returnType)
16 | if (getRawType(responseType) != ApiResult::class.java) return null
17 | check(responseType is ParameterizedType)
18 |
19 | val successType = getParameterUpperBound(0, responseType)
20 |
21 | return ApiResultCallAdapter(successType)
22 | }
23 | }
--------------------------------------------------------------------------------
/libs/network/src/main/java/com/andy/network/ApiResultCall.kt:
--------------------------------------------------------------------------------
1 | package com.andy.network
2 |
3 | import android.util.Log
4 | import com.andy.common.toJson
5 | import com.andy.network.data.ApiResult
6 | import okhttp3.Request
7 | import okio.Timeout
8 | import retrofit2.Call
9 | import retrofit2.Callback
10 | import retrofit2.Response
11 |
12 | internal class ApiResultCall(
13 | private val callDelegate: Call,
14 | ) : Call> {
15 |
16 | override fun enqueue(callback: Callback>) = callDelegate.enqueue(
17 | object : Callback {
18 | override fun onResponse(call: Call, response: Response) {
19 | Log.d("ApiResultCall", "onResponse() response: $response}")
20 | response.body()?.let {
21 | when {
22 | response.isSuccessful -> {
23 | callback.onResponse(
24 | this@ApiResultCall,
25 | Response.success(ApiResult.Success(it))
26 | )
27 | }
28 |
29 | else -> {
30 | Log.e(
31 | "ApiResultCall", "error code1111111 " +
32 | "= ${response.code()}, message = response"
33 | )
34 | callback.onResponse(
35 | this@ApiResultCall,
36 | Response.success(
37 | ApiResult.Error(
38 | response.code(),
39 | response.message()
40 | )
41 | ),
42 | )
43 | }
44 | }
45 | } ?: callback.onResponse(
46 | this@ApiResultCall,
47 | Response.success(
48 | ApiResult.Error(
49 | response.code(),
50 | response.message()
51 | )
52 | )
53 | )
54 | }
55 |
56 | override fun onFailure(call: Call, throwable: Throwable) {
57 | Log.d("ApiResultCall", "onFailure() throwable: $throwable}")
58 | callback.onResponse(
59 | this@ApiResultCall,
60 | Response.success(ApiResult.Exception(throwable))
61 | )
62 | call.cancel()
63 | }
64 | },
65 | )
66 |
67 | override fun clone(): Call> = ApiResultCall(callDelegate.clone())
68 |
69 | override fun execute(): Response> =
70 | throw UnsupportedOperationException("ResponseCall does not support execute.")
71 |
72 | override fun isExecuted(): Boolean = callDelegate.isExecuted
73 |
74 | override fun cancel() = callDelegate.cancel()
75 |
76 | override fun isCanceled(): Boolean = callDelegate.isCanceled
77 |
78 | override fun request(): Request = callDelegate.request()
79 |
80 | override fun timeout(): Timeout = callDelegate.timeout()
81 | }
--------------------------------------------------------------------------------
/libs/network/src/main/java/com/andy/network/ApiResultCallAdapter.kt:
--------------------------------------------------------------------------------
1 | package com.andy.network
2 |
3 | import com.andy.network.data.ApiResult
4 | import retrofit2.Call
5 | import retrofit2.CallAdapter
6 | import java.lang.reflect.Type
7 |
8 | internal class ApiResultCallAdapter(
9 | private val successType: Type,
10 | ) : CallAdapter>> {
11 | override fun responseType(): Type = successType
12 |
13 | override fun adapt(call: Call): Call> = ApiResultCall(call)
14 | }
--------------------------------------------------------------------------------
/libs/network/src/main/java/com/andy/network/AuthorizationHeaderInterceptor.kt:
--------------------------------------------------------------------------------
1 | package com.andy.network
2 |
3 | import okhttp3.Interceptor
4 | import okhttp3.Request
5 | import okhttp3.Response
6 |
7 | class AuthorizationHeaderInterceptor : Interceptor {
8 | companion object {
9 | private const val GITHUB_TOKEN = BuildConfig.API_TOKEN
10 | }
11 |
12 | override fun intercept(chain: Interceptor.Chain): Response {
13 | val originalRequest = chain.request()
14 |
15 | val newRequest: Request = originalRequest.newBuilder()
16 | .header("authorization", "Bearer $GITHUB_TOKEN")
17 | .build()
18 |
19 | return chain.proceed(newRequest)
20 | }
21 | }
--------------------------------------------------------------------------------
/libs/network/src/main/java/com/andy/network/common/ApiResult.kt:
--------------------------------------------------------------------------------
1 | package com.andy.network.common
2 |
3 | import com.andy.network.data.ApiResult
4 |
5 | private const val ERROR_CODE_UNAUTHORIZED_CODE = 401
6 | private const val ERROR_CODE_RATE_LIMIT_EXCEEDED = 403
7 | private const val ERROR_CODE_NOT_FOUND = 404
8 | private const val ERROR_CODE_REACHED_API_LIMIT = 422
9 | private const val ERROR_CODE_TOO_MANY_REQUESTS = 429
10 |
11 |
12 | fun ApiResult.Error.errorMessage(): String {
13 | val result = if (message.isNullOrEmpty()) {
14 | "Unknown error!"
15 | } else {
16 | message
17 | }
18 | return when(code) {
19 | ERROR_CODE_UNAUTHORIZED_CODE -> {
20 | "Unauthorized!"
21 | }
22 |
23 | ERROR_CODE_RATE_LIMIT_EXCEEDED -> {
24 | "Rate limit exceeded!"
25 | }
26 |
27 | ERROR_CODE_NOT_FOUND -> {
28 | "Not found!"
29 | }
30 |
31 | ERROR_CODE_REACHED_API_LIMIT -> {
32 | "Only the first 1000 search results are available!"
33 | }
34 |
35 | ERROR_CODE_TOO_MANY_REQUESTS -> {
36 | "Too many requests!"
37 | }
38 |
39 | // TODO still much more error code to implement here!
40 |
41 | else-> {
42 | result
43 | }
44 | }
45 | }
--------------------------------------------------------------------------------
/libs/network/src/main/java/com/andy/network/common/Constants.kt:
--------------------------------------------------------------------------------
1 | package com.andy.network.common
2 |
3 | object Constants {
4 | const val PAGE_SIZE = 20
5 | }
--------------------------------------------------------------------------------
/libs/network/src/main/java/com/andy/network/data/ApiResult.kt:
--------------------------------------------------------------------------------
1 | package com.andy.network.data
2 |
3 | sealed interface ApiResult {
4 |
5 | /**
6 | * Represents a network result that successfully received a response containing body data.
7 | */
8 | class Success(val data: T) : ApiResult
9 |
10 | /**
11 | * Represents a network result that successfully received a response containing an error message.
12 | */
13 | class Error(val code: Int, val message: String?) : ApiResult
14 |
15 | /**
16 | * Represents a network result that faced an unexpected exception before getting a response
17 | * from the network such as IOException and UnKnownHostException.
18 | */
19 | class Exception(val throwable: Throwable) : ApiResult
20 | }
--------------------------------------------------------------------------------
/libs/network/src/main/java/com/andy/network/di/NetworkModule.kt:
--------------------------------------------------------------------------------
1 | package com.andy.network.di
2 |
3 | import com.andy.network.ApiResultAdapterFactory
4 | import com.andy.network.AuthorizationHeaderInterceptor
5 | import com.squareup.moshi.Moshi
6 | import dagger.Module
7 | import dagger.Provides
8 | import dagger.hilt.InstallIn
9 | import dagger.hilt.components.SingletonComponent
10 | import okhttp3.OkHttpClient
11 | import okhttp3.logging.HttpLoggingInterceptor
12 | import retrofit2.Retrofit
13 | import retrofit2.converter.moshi.MoshiConverterFactory
14 | import javax.inject.Singleton
15 |
16 | @Module
17 | @InstallIn(SingletonComponent::class)
18 | object NetworkModule {
19 |
20 | @Provides
21 | @Singleton
22 | fun provideOkHttpClient(): OkHttpClient {
23 | val loggingInterceptor = HttpLoggingInterceptor().apply {
24 | level = HttpLoggingInterceptor.Level.BODY
25 | }
26 | return OkHttpClient.Builder()
27 | .addInterceptor(loggingInterceptor)
28 | .addInterceptor(AuthorizationHeaderInterceptor())
29 | .build()
30 | }
31 |
32 | @Provides
33 | @Singleton
34 | fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
35 | val moshi = Moshi.Builder().build()
36 | return Retrofit.Builder()
37 | .baseUrl("https://api.github.com/")
38 | .client(okHttpClient)
39 | .addCallAdapterFactory(ApiResultAdapterFactory())
40 | .addConverterFactory(MoshiConverterFactory.create(moshi))
41 | .build()
42 | }
43 | }
--------------------------------------------------------------------------------
/libs/network/src/main/java/com/andy/network/domain/Result.kt:
--------------------------------------------------------------------------------
1 | package com.andy.network.domain
2 |
3 | sealed interface Result {
4 | data class Success(val value: T) : Result
5 | data class Failure(val throwable: Throwable? = null) : Result
6 | class Error(val code: Int, val message: String?) : Result
7 | }
--------------------------------------------------------------------------------
/libs/network/src/main/java/com/andy/network/domain/ResultKt.kt:
--------------------------------------------------------------------------------
1 | package com.andy.network.domain
2 |
3 | inline fun Result.mapSuccess(
4 | crossinline onResult: Result.Success.() -> Result,
5 | ): Result {
6 | if (this is Result.Success) {
7 | return onResult(this)
8 | }
9 | return this
10 | }
--------------------------------------------------------------------------------
/libs/network/src/test/java/com/andy/network/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package com.andy.network
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
--------------------------------------------------------------------------------
/libs/testing/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/libs/testing/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.library)
3 | alias(libs.plugins.jetbrains.kotlin.android)
4 | }
5 |
6 | android {
7 | namespace = "com.andy.testing"
8 | compileSdk = 35
9 |
10 | defaultConfig {
11 | minSdk = 25
12 |
13 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
14 | consumerProguardFiles("consumer-rules.pro")
15 | }
16 |
17 | buildTypes {
18 | release {
19 | isMinifyEnabled = false
20 | proguardFiles(
21 | getDefaultProguardFile("proguard-android-optimize.txt"),
22 | "proguard-rules.pro"
23 | )
24 | }
25 | }
26 | compileOptions {
27 | sourceCompatibility = JavaVersion.VERSION_1_8
28 | targetCompatibility = JavaVersion.VERSION_1_8
29 | }
30 | kotlinOptions {
31 | jvmTarget = "1.8"
32 | }
33 | }
34 |
35 | dependencies {
36 |
37 | implementation(libs.androidx.core.ktx)
38 | implementation(libs.androidx.appcompat)
39 | implementation(libs.material)
40 | implementation(libs.androidx.test.runner)
41 | implementation(libs.google.dagger.hilt.android.testing)
42 | testImplementation(libs.junit)
43 | androidTestImplementation(libs.androidx.junit)
44 | androidTestImplementation(libs.androidx.espresso.core)
45 | }
--------------------------------------------------------------------------------
/libs/testing/consumer-rules.pro:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andyhaha/CleanArchitectureGitHubApp/a93cd02941385de45a8d160d1b2f8d3ae0088e65/libs/testing/consumer-rules.pro
--------------------------------------------------------------------------------
/libs/testing/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/libs/testing/src/androidTest/java/com/andy/testing/AppTestRunner.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 The Android Open Source Project
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * https://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.andy.testing
18 |
19 | import android.app.Application
20 | import android.content.Context
21 | import androidx.test.runner.AndroidJUnitRunner
22 | import dagger.hilt.android.testing.HiltTestApplication
23 |
24 | /**
25 | * A custom runner to set up the instrumented application class for tests.
26 | */
27 | class AppTestRunner : AndroidJUnitRunner() {
28 | override fun newApplication(cl: ClassLoader, name: String, context: Context): Application =
29 | super.newApplication(cl, HiltTestApplication::class.java.name, context)
30 | }
31 |
--------------------------------------------------------------------------------
/libs/testing/src/androidTest/java/com/andy/testing/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package com.andy.testing
2 |
3 | import androidx.test.platform.app.InstrumentationRegistry
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 |
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | import org.junit.Assert.*
10 |
11 | /**
12 | * Instrumented test, which will execute on an Android device.
13 | *
14 | * See [testing documentation](http://d.android.com/tools/testing).
15 | */
16 | @RunWith(AndroidJUnit4::class)
17 | class ExampleInstrumentedTest {
18 | @Test
19 | fun useAppContext() {
20 | // Context of the app under test.
21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
22 | assertEquals("com.andy.testing.test", appContext.packageName)
23 | }
24 | }
--------------------------------------------------------------------------------
/libs/testing/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/libs/testing/src/main/java/com/andy/testing/AppTestRunner.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 The Android Open Source Project
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * https://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.andy.testing
18 |
19 | import android.app.Application
20 | import android.content.Context
21 | import androidx.test.runner.AndroidJUnitRunner
22 | import dagger.hilt.android.testing.HiltTestApplication
23 |
24 | /**
25 | * A custom runner to set up the instrumented application class for tests.
26 | */
27 | class AppTestRunner : AndroidJUnitRunner() {
28 | override fun newApplication(cl: ClassLoader, name: String, context: Context): Application =
29 | super.newApplication(cl, HiltTestApplication::class.java.name, context)
30 | }
31 |
--------------------------------------------------------------------------------
/libs/testing/src/test/java/com/andy/testing/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package com.andy.testing
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | google {
4 | content {
5 | includeGroupByRegex("com\\.android.*")
6 | includeGroupByRegex("com\\.google.*")
7 | includeGroupByRegex("androidx.*")
8 | }
9 | }
10 | mavenCentral()
11 | gradlePluginPortal()
12 | }
13 | }
14 | dependencyResolutionManagement {
15 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
16 | repositories {
17 | google()
18 | mavenCentral()
19 | }
20 | }
21 |
22 | rootProject.name = "CleanArchitectureGitHubApp"
23 | include(":app")
24 | include(":libs:framework")
25 | include(":libs:common")
26 | include(":libs:network")
27 | include(":feature:home")
28 | include(":feature:details")
29 | include(":libs:testing")
30 |
--------------------------------------------------------------------------------