├── .github └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── SigningKey.jks ├── app ├── .gitignore ├── build.gradle.kts ├── google-services.json ├── proguard-rules.pro └── src │ ├── androidTest │ └── kotlin │ │ └── ir │ │ └── fallahpoor │ │ └── releasetracker │ │ ├── fakes │ │ ├── FakeLibraryRepository.kt │ │ └── FakeStorageRepository.kt │ │ └── features │ │ ├── addlibrary │ │ └── ui │ │ │ ├── AddLibraryContentTest.kt │ │ │ └── AddLibraryScreenTest.kt │ │ └── libraries │ │ └── ui │ │ ├── LibrariesListContentTest.kt │ │ ├── LibrariesListScreenTest.kt │ │ ├── LibrariesListTest.kt │ │ ├── LibraryItemTest.kt │ │ ├── SearchBarTest.kt │ │ ├── SingleSelectionDialogTest.kt │ │ └── ToolbarTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── assets │ │ └── database │ │ │ └── libraries.db │ ├── kotlin │ │ └── ir │ │ │ └── fallahpoor │ │ │ └── releasetracker │ │ │ ├── NightModeViewModel.kt │ │ │ ├── UpdateVersionsWorker.kt │ │ │ ├── common │ │ │ ├── Consts.kt │ │ │ └── NotificationManager.kt │ │ │ ├── di │ │ │ ├── ContextModule.kt │ │ │ ├── DatabaseModule.kt │ │ │ ├── LibraryRepositoryModule.kt │ │ │ ├── NetworkModule.kt │ │ │ ├── NightModeRepositoryModule.kt │ │ │ ├── StorageModule.kt │ │ │ └── StorageRepositoryModule.kt │ │ │ ├── entrypoint │ │ │ ├── MainActivity.kt │ │ │ ├── ReleaseTracker.kt │ │ │ └── ReleaseTrackerApp.kt │ │ │ ├── features │ │ │ ├── addlibrary │ │ │ │ ├── AddLibraryViewModel.kt │ │ │ │ ├── Event.kt │ │ │ │ ├── UiState.kt │ │ │ │ └── ui │ │ │ │ │ ├── AddLibraryContent.kt │ │ │ │ │ ├── AddLibraryScreen.kt │ │ │ │ │ └── OutlinedTextFieldWithPrefix.kt │ │ │ └── libraries │ │ │ │ ├── Event.kt │ │ │ │ ├── LibrariesViewModel.kt │ │ │ │ ├── UiState.kt │ │ │ │ └── ui │ │ │ │ ├── LibrariesList.kt │ │ │ │ ├── LibrariesListContent.kt │ │ │ │ ├── LibrariesListScreen.kt │ │ │ │ ├── LibraryItem.kt │ │ │ │ ├── SearchBar.kt │ │ │ │ ├── SingleSelectionDialog.kt │ │ │ │ └── Toolbar.kt │ │ │ └── theme │ │ │ ├── Colors.kt │ │ │ ├── Spacing.kt │ │ │ └── Theme.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ ├── ic_launcher_background.xml │ │ ├── ic_pin_filled.xml │ │ ├── ic_pin_outline.xml │ │ └── ic_sort.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ └── values │ │ ├── strings.xml │ │ └── styles.xml │ └── test │ └── kotlin │ └── ir │ └── fallahpoor │ └── releasetracker │ ├── NightModeViewModelTest.kt │ ├── fakes │ ├── FakeLibraryRepository.kt │ ├── FakeNightModeRepository.kt │ ├── FakeStorage.kt │ └── FakeStorageRepository.kt │ └── features │ ├── addlibrary │ └── AddLibraryViewModelTest.kt │ └── libraries │ └── viewmodel │ └── LibrariesViewModelTest.kt ├── build.gradle.kts ├── data ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ ├── main │ ├── AndroidManifest.xml │ ├── kotlin │ │ └── ir │ │ │ └── fallahpoor │ │ │ └── releasetracker │ │ │ └── data │ │ │ ├── Enums.kt │ │ │ ├── Extensions.kt │ │ │ ├── database │ │ │ ├── LibraryDao.kt │ │ │ └── LibraryDaoImpl.kt │ │ │ ├── exceptions │ │ │ ├── ExceptionParser.kt │ │ │ └── Exceptions.kt │ │ │ ├── network │ │ │ ├── ConnectionChecker.kt │ │ │ ├── GithubApi.kt │ │ │ ├── GithubApiImpl.kt │ │ │ └── LibraryVersion.kt │ │ │ ├── repository │ │ │ ├── library │ │ │ │ ├── Library.kt │ │ │ │ ├── LibraryRepository.kt │ │ │ │ └── LibraryRepositoryImpl.kt │ │ │ ├── nightmode │ │ │ │ ├── NightModeRepository.kt │ │ │ │ └── NightModeRepositoryImpl.kt │ │ │ └── storage │ │ │ │ ├── StorageRepository.kt │ │ │ │ └── StorageRepositoryImpl.kt │ │ │ └── storage │ │ │ ├── LocalStorage.kt │ │ │ └── Storage.kt │ ├── res │ │ └── values │ │ │ └── strings.xml │ └── sqldelight │ │ └── ir │ │ └── fallahpoor │ │ └── releasetracker │ │ └── data │ │ └── database │ │ └── entity │ │ └── Library.sq │ └── test │ └── kotlin │ └── ir │ └── fallahpoor │ └── releasetracker │ └── data │ ├── TestData.kt │ ├── fakes │ ├── FakeGithubApi.kt │ ├── FakeLibraryDao.kt │ └── FakeStorage.kt │ ├── repository │ ├── library │ │ └── LibraryRepositoryImplTest.kt │ └── storage │ │ └── StorageRepositoryImplTest.kt │ ├── storage │ └── LocalStorageTest.kt │ └── utils │ ├── ConnectionCheckerTest.kt │ └── ExceptionParserTest.kt ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── screenshots ├── screenshots_1.png └── screenshots_2.png └── settings.gradle.kts /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - dev 7 | pull_request: 8 | branches: 9 | - dev 10 | 11 | jobs: 12 | 13 | build: 14 | 15 | runs-on: macos-latest 16 | 17 | steps: 18 | - name: Checkout source code 19 | uses: actions/checkout@v2 20 | 21 | - name: Set up JDK 11 22 | uses: actions/setup-java@v1 23 | with: 24 | java-version: '11' 25 | 26 | - name: Make gradlew executable 27 | run: chmod +x gradlew 28 | 29 | - name: Create local.properties file 30 | env: 31 | accessToken: ${{ secrets.ACCESS_TOKEN }} 32 | storePassword: ${{ secrets.STORE_PASSWORD }} 33 | keyPassword: ${{ secrets.KEY_PASSWORD }} 34 | run: | 35 | echo accessToken=$accessToken > ./local.properties 36 | echo storePassword=$storePassword >> ./local.properties 37 | echo keyPassword=$keyPassword >> ./local.properties 38 | 39 | - name: Run unit tests 40 | run: ./gradlew test 41 | 42 | - name: Run instrumentation tests 43 | uses: reactivecircus/android-emulator-runner@v2 44 | with: 45 | api-level: 29 46 | arch: x86 47 | profile: Nexus 6 48 | avd-name: test 49 | emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -camera-back none 50 | disable-animations: true 51 | script: ./gradlew connectedCheck 52 | 53 | - name: Build with Gradle 54 | run: ./gradlew build -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release APK on GitHub 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | 10 | release: 11 | 12 | name: Release APK on GitHub 13 | runs-on: macos-latest 14 | 15 | steps: 16 | - name: Checkout source code 17 | uses: actions/checkout@v2 18 | 19 | - name: Setup JDK 20 | uses: actions/setup-java@v1 21 | with: 22 | java-version: '11' 23 | 24 | - name: Make gradlew executable 25 | run: chmod +x gradlew 26 | 27 | - name: Create local.properties file 28 | env: 29 | accessToken: ${{ secrets.ACCESS_TOKEN }} 30 | storePassword: ${{ secrets.STORE_PASSWORD }} 31 | keyPassword: ${{ secrets.KEY_PASSWORD }} 32 | run: | 33 | echo accessToken=$accessToken > ./local.properties 34 | echo storePassword=$storePassword >> ./local.properties 35 | echo keyPassword=$keyPassword >> ./local.properties 36 | 37 | - name: Run unit tests 38 | run: ./gradlew test 39 | 40 | - name: Run instrumentation tests 41 | uses: reactivecircus/android-emulator-runner@v2 42 | with: 43 | api-level: 29 44 | profile: Nexus 6 45 | emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -camera-back none 46 | disable-animations: true 47 | script: ./gradlew connectedCheck 48 | 49 | - name: Generate APK 50 | run: ./gradlew assembleRelease 51 | 52 | - name: Create GitHub release 53 | id: create_release 54 | uses: ncipollo/release-action@v1 55 | with: 56 | token: ${{ secrets.GITHUB_TOKEN }} 57 | artifact: "app/build/outputs/apk/release/ReleaseTracker-release.apk" 58 | artifactContentType: "application/vnd.android.package-archive" 59 | bodyFile: "CHANGELOG.md" 60 | artifactErrorsFailBuild: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea 5 | .DS_Store 6 | /build 7 | /captures 8 | .externalNativeBuild 9 | .cxx 10 | /buildSrc/build 11 | *.exec -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Version 1.1 2 | 3 | - The architecture of the app is migrated from MVVM to MVI. 4 | - The "delete library" dialog is replaced with a more UX-friendly "Swipe to delete" gesture. 5 | - Text changes now happen with a nice fade through animation 6 | - Retrofit is replaced with Ktor 7 | - The dependency management mechanism is migrated from "buildSrc" to Gradle's "version catalogs" 8 | 9 | # Version 1.0 10 | 11 | - Added tests for LibrariesViewModel 12 | - Added tests for AddLibraryScreen, LibrariesListScreen, Toolbar, SearchBar, SortOrderDialog, 13 | DeleteLibraryDialog, and NightModeDialog composables 14 | - Replaced SharedPreferences with DataStore 15 | 16 | # Version 0.3 17 | 18 | - The UI is implemented from scratch using Compose! 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Release Tracker 2 | ![Build status](https://github.com/masoodfallahpoor/ReleaseTracker/actions/workflows/build.yml/badge.svg?branch=dev) 3 | ![GitHub release (latest by date)](https://img.shields.io/github/v/release/masoodfallahpoor/ReleaseTracker?label=Latest%20version) 4 | 5 | Release Tracker is an Android app that shows and tracks the latest version of any Github-hosted 6 | library, tool, framework, or whatever. 7 | 8 | By default it's pre-populated with a list of some popular Android libraries. Of course you can add 9 | your favorite libraries too! 10 | 11 | # Screenshots 12 | 13 | ![Screenshots](/screenshots/screenshots_1.png?raw=true "Screenshots") 14 | ![Screenshots](/screenshots/screenshots_2.png?raw=true "Screenshots") 15 | 16 | # How it works 17 | There is a [worker](https://developer.android.com/topic/libraries/architecture/workmanager) that 18 | runs once a day. In each run, for each library, it connects to 19 | the [Github REST API](https://docs.github.com/en/free-pro-team@latest/rest) and fetches the latest 20 | version of the library. 21 | 22 | # How to build 23 | 24 | To build the app, clone the repository and import it into Android Studio. Then add the following 25 | line to `local.properties` with your 26 | own [Github personal access token](https://github.com/settings/tokens): 27 | 28 | `accessToken = YOUR_ACCESS_TOKEN` 29 | 30 | Now you're good to go. Good luck! 31 | 32 | If you want to track the latest version of AndroidX libraries then take a look 33 | at [Jetpack Release Tracker](https://github.com/lmj0011/jetpack-release-tracker). 34 | 35 | # Technology Stack 36 | 37 | Release Tracker uses (almost) the latest and greatest technologies of Android development. The 38 | following list highlights its tech stack: 39 | 40 | - Kotlin 41 | - MVI 42 | - Compose 43 | - ViewModel 44 | - Coroutines 45 | - Room 46 | - Navigation 47 | - Dagger Hilt 48 | - WorkManager 49 | - Ktor 50 | 51 | # Contributing 52 | Pull requests are welcome! Please make your pull requests against the `dev` branch. If you have a 53 | feature request or bug report, please open a new issue so it could be tracked. 54 | 55 | License 56 | ======= 57 | 58 | Copyright 2022 Masood Fallahpoor. 59 | 60 | Licensed under the Apache License, Version 2.0 (the "License"); 61 | you may not use this file except in compliance with the License. 62 | You may obtain a copy of the License at 63 | 64 | http://www.apache.org/licenses/LICENSE-2.0 65 | 66 | Unless required by applicable law or agreed to in writing, software 67 | distributed under the License is distributed on an "AS IS" BASIS, 68 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 69 | See the License for the specific language governing permissions and 70 | limitations under the License. 71 | -------------------------------------------------------------------------------- /SigningKey.jks: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MasoudFallahpour/ReleaseTracker/65f8c958f8bae1dee7fe96ed4113b8fd4fbfe4e4/SigningKey.jks -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties 2 | 3 | plugins { 4 | id("com.android.application") 5 | id("dagger.hilt.android.plugin") 6 | // TODO re-enable Crashlytics after resolving the build error 7 | // id("com.google.gms.google-services") 8 | // id("com.google.firebase.crashlytics") 9 | kotlin("android") 10 | kotlin("kapt") 11 | } 12 | 13 | val properties: java.util.Properties = gradleLocalProperties(rootDir) 14 | val sp: String = properties.getProperty("storePassword") 15 | val kp: String = properties.getProperty("keyPassword") 16 | 17 | android { 18 | namespace = "ir.fallahpoor.releasetracker" 19 | compileSdk = libs.versions.compileSdk.get().toInt() 20 | 21 | defaultConfig { 22 | applicationId = "ir.fallahpoor.releasetracker" 23 | minSdk = libs.versions.minSdk.get().toInt() 24 | targetSdk = libs.versions.targetSdk.get().toInt() 25 | versionCode = 5 26 | versionName = "1.1" 27 | setProperty("archivesBaseName", "ReleaseTracker") 28 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 29 | } 30 | 31 | signingConfigs { 32 | create("release") { 33 | storeFile = file("../SigningKey.jks") 34 | storePassword = sp 35 | keyAlias = "android app signing certificate" 36 | keyPassword = kp 37 | } 38 | } 39 | 40 | buildTypes { 41 | release { 42 | isMinifyEnabled = true 43 | isShrinkResources = true 44 | proguardFiles( 45 | getDefaultProguardFile( 46 | "proguard-android.txt" 47 | ), 48 | "proguard-rules.pro" 49 | ) 50 | signingConfig = signingConfigs["release"] 51 | } 52 | } 53 | 54 | compileOptions { 55 | sourceCompatibility = JavaVersion.VERSION_1_8 56 | targetCompatibility = JavaVersion.VERSION_1_8 57 | } 58 | 59 | buildFeatures { 60 | compose = true 61 | } 62 | 63 | composeOptions { 64 | kotlinCompilerExtensionVersion = libs.versions.compose.get() 65 | } 66 | 67 | kotlinOptions { 68 | jvmTarget = "1.8" 69 | } 70 | 71 | packagingOptions { 72 | resources.excludes.add("**/attach_hotspot_windows.dll") 73 | resources.excludes.add("META-INF/licenses/ASM") 74 | resources.excludes.add("META-INF/AL2.0") 75 | resources.excludes.add("META-INF/LGPL2.1") 76 | } 77 | 78 | testOptions { 79 | animationsDisabled = true 80 | 81 | unitTests { 82 | isIncludeAndroidResources = true 83 | } 84 | } 85 | } 86 | 87 | kapt { 88 | correctErrorTypes = true 89 | } 90 | 91 | tasks.withType().configureEach { 92 | kotlinOptions.freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn" 93 | } 94 | 95 | dependencies { 96 | implementation(libs.kotlin.stdlib) 97 | implementation(libs.appcompat) 98 | implementation(libs.core) 99 | implementation(libs.datastore.preferences) 100 | implementation(libs.activityCompose) 101 | implementation(libs.navigationCompose) 102 | implementation(libs.material) 103 | implementation(libs.lifecycle.viewModel) 104 | implementation(libs.lifecycle.liveData) 105 | implementation(libs.workManagar.runtime) 106 | implementation(libs.timber) 107 | implementation(libs.sqlDelight.driver) 108 | 109 | implementation(platform(libs.firebase.bom)) 110 | implementation(libs.firebase.crashlytics) 111 | 112 | implementation(libs.hilt.android) 113 | implementation(libs.hilt.workManager) 114 | implementation(libs.hilt.navigationCompose) 115 | kapt(libs.hilt.androidCompiler) 116 | kapt(libs.hilt.compiler) 117 | 118 | implementation(libs.compose.ui) 119 | debugImplementation(libs.compose.tooling) 120 | implementation(libs.compose.uiToolingPreview) 121 | implementation(libs.compose.material) 122 | implementation(libs.compose.runtime) 123 | 124 | implementation(libs.bundles.ktor) 125 | 126 | testImplementation(libs.junit) 127 | testImplementation(libs.truth) 128 | testImplementation(libs.coreTesting) 129 | testImplementation(libs.coroutines.test) 130 | testImplementation(libs.androidxTest.core) 131 | testImplementation(libs.robolectric) 132 | kaptTest(libs.hilt.androidCompiler) 133 | testImplementation(libs.mockito.inline) 134 | 135 | androidTestImplementation(libs.androidxTest.runner) 136 | androidTestImplementation(libs.androidxTest.rules) 137 | androidTestImplementation(libs.truth) 138 | androidTestImplementation(libs.compose.uiTestJunit) 139 | debugImplementation(libs.compose.uiTestManifest) 140 | androidTestImplementation(libs.dexMaker) 141 | androidTestImplementation(libs.coreTesting) 142 | androidTestImplementation(libs.bundles.espresso) 143 | kaptAndroidTest(libs.hilt.androidCompiler) 144 | androidTestImplementation(libs.coroutines.test) 145 | 146 | implementation(project(":data")) 147 | } -------------------------------------------------------------------------------- /app/google-services.json: -------------------------------------------------------------------------------- 1 | { 2 | "project_info": { 3 | "project_number": "34086092595", 4 | "firebase_url": "https://release-tracker-3b360.firebaseio.com", 5 | "project_id": "release-tracker-3b360", 6 | "storage_bucket": "release-tracker-3b360.appspot.com" 7 | }, 8 | "client": [ 9 | { 10 | "client_info": { 11 | "mobilesdk_app_id": "1:34086092595:android:99582a45f4df5b0a0f0846", 12 | "android_client_info": { 13 | "package_name": "ir.fallahpoor.releasetracker" 14 | } 15 | }, 16 | "oauth_client": [ 17 | { 18 | "client_id": "34086092595-34toonmjbdqlbgi6g57dr412c6gg17dn.apps.googleusercontent.com", 19 | "client_type": 3 20 | } 21 | ], 22 | "api_key": [ 23 | { 24 | "current_key": "AIzaSyD8P5GWMwuHajpONqILBDIfnNuxr9_X35Y" 25 | } 26 | ], 27 | "services": { 28 | "appinvite_service": { 29 | "other_platform_oauth_client": [ 30 | { 31 | "client_id": "34086092595-34toonmjbdqlbgi6g57dr412c6gg17dn.apps.googleusercontent.com", 32 | "client_type": 3 33 | } 34 | ] 35 | } 36 | } 37 | } 38 | ], 39 | "configuration_version": "1" 40 | } -------------------------------------------------------------------------------- /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.kts. 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 22 | 23 | # Keep `Companion` object fields of serializable classes. 24 | # This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects. 25 | -if @kotlinx.serialization.Serializable class ** 26 | -keepclassmembers class <1> { 27 | static <1>$Companion Companion; 28 | } 29 | 30 | # Keep `serializer()` on companion objects (both default and named) of serializable classes. 31 | -if @kotlinx.serialization.Serializable class ** { 32 | static **$* *; 33 | } 34 | -keepclassmembers class <2>$<3> { 35 | kotlinx.serialization.KSerializer serializer(...); 36 | } 37 | 38 | # Keep `INSTANCE.serializer()` of serializable objects. 39 | -if @kotlinx.serialization.Serializable class ** { 40 | public static ** INSTANCE; 41 | } 42 | -keepclassmembers class <1> { 43 | public static <1> INSTANCE; 44 | kotlinx.serialization.KSerializer serializer(...); 45 | } 46 | 47 | # @Serializable and @Polymorphic are used at runtime for polymorphic serialization. 48 | -keepattributes RuntimeVisibleAnnotations,AnnotationDefault 49 | 50 | # Serializer for classes with named companion objects are retrieved using `getDeclaredClasses`. 51 | # If you have any, uncomment and replace classes with those containing named companion objects. 52 | #-keepattributes InnerClasses # Needed for `getDeclaredClasses`. 53 | #-if @kotlinx.serialization.Serializable class 54 | #com.example.myapplication.HasNamedCompanion, # <-- List serializable classes with named companions. 55 | #com.example.myapplication.HasNamedCompanion2 56 | #{ 57 | # static **$* *; 58 | #} 59 | #-keepnames class <1>$$serializer { # -keepnames suffices; class is kept when serializer() is kept. 60 | # static <1>$$serializer INSTANCE; 61 | #} -------------------------------------------------------------------------------- /app/src/androidTest/kotlin/ir/fallahpoor/releasetracker/fakes/FakeLibraryRepository.kt: -------------------------------------------------------------------------------- 1 | package ir.fallahpoor.releasetracker.fakes 2 | 3 | import androidx.lifecycle.MutableLiveData 4 | import androidx.lifecycle.asFlow 5 | import androidx.lifecycle.map 6 | import ir.fallahpoor.releasetracker.common.GITHUB_BASE_URL 7 | import ir.fallahpoor.releasetracker.data.exceptions.ExceptionParser 8 | import ir.fallahpoor.releasetracker.data.repository.library.Library 9 | import ir.fallahpoor.releasetracker.data.repository.library.LibraryRepository 10 | import kotlinx.coroutines.flow.Flow 11 | import java.io.IOException 12 | 13 | class FakeLibraryRepository : LibraryRepository { 14 | 15 | object Coil { 16 | const val name = "Coil" 17 | const val url = GITHUB_BASE_URL + "coil-kt/coil" 18 | const val version = "1.3.1" 19 | val library = Library(name = name, url = url, version = version, isPinned = false) 20 | } 21 | 22 | object Kotlin { 23 | const val name = "Kotlin" 24 | const val url = GITHUB_BASE_URL + "JetBrains/kotlin" 25 | const val version = "1.5.21" 26 | val library = Library(name = name, url = url, version = version, isPinned = false) 27 | } 28 | 29 | object Koin { 30 | const val name = "Koin" 31 | const val url = GITHUB_BASE_URL + "InsertKoinIO/koin" 32 | const val version = "3.1.2" 33 | val library = Library(name = name, url = url, version = version, isPinned = true) 34 | } 35 | 36 | companion object { 37 | const val LAST_UPDATE_CHECK = "N/A" 38 | const val LIBRARY_NAME_TO_CAUSE_ERROR_WHEN_ADDING = "Coroutines" 39 | const val LIBRARY_NAME_TO_CAUSE_ERROR_WHEN_DELETING = Kotlin.name 40 | const val ERROR_MESSAGE = ExceptionParser.SOMETHING_WENT_WRONG 41 | const val LIBRARY_VERSION = "0.2" 42 | } 43 | 44 | val libraries = mutableListOf( 45 | Coil.library, 46 | Kotlin.library, 47 | Koin.library 48 | ) 49 | private val librariesLiveData = MutableLiveData>(libraries) 50 | 51 | override suspend fun addLibrary( 52 | libraryName: String, 53 | libraryUrl: String, 54 | libraryVersion: String 55 | ) { 56 | if (libraryName.trim() == LIBRARY_NAME_TO_CAUSE_ERROR_WHEN_ADDING) { 57 | throw IOException(ERROR_MESSAGE) 58 | } else { 59 | val library: Library? = libraries.find { 60 | it.name.equals(libraryName.trim(), ignoreCase = true) 61 | } 62 | if (library != null) { 63 | throw RuntimeException(ExceptionParser.SOMETHING_WENT_WRONG) 64 | } else { 65 | libraries += Library( 66 | libraryName.trim(), 67 | libraryUrl.trim(), 68 | libraryVersion, 69 | isPinned = false 70 | ) 71 | updateLibrariesLiveData(libraries) 72 | } 73 | } 74 | } 75 | 76 | override suspend fun updateLibrary(library: Library) { 77 | val removed: Boolean = libraries.removeIf { 78 | it.name.equals(library.name, ignoreCase = true) 79 | } 80 | if (removed) { 81 | libraries += library 82 | updateLibrariesLiveData(libraries) 83 | } 84 | } 85 | 86 | override suspend fun getLibrary(libraryName: String): Library? = 87 | libraries.firstOrNull { it.name.equals(libraryName.trim(), ignoreCase = true) } 88 | 89 | override fun getLibrariesAsFlow(): Flow> = 90 | librariesLiveData.map { libraries: List -> 91 | libraries.sortedBy { it.name } 92 | }.asFlow() 93 | 94 | override suspend fun getLibraries(): List = libraries 95 | 96 | override suspend fun deleteLibrary(library: Library) { 97 | if (library.name == LIBRARY_NAME_TO_CAUSE_ERROR_WHEN_DELETING) { 98 | throw RuntimeException(ERROR_MESSAGE) 99 | } else { 100 | val removed = libraries.removeIf { it.name.equals(library.name, ignoreCase = true) } 101 | if (removed) { 102 | updateLibrariesLiveData(libraries) 103 | } 104 | } 105 | } 106 | 107 | override suspend fun getLibraryVersion(libraryName: String, libraryUrl: String): String { 108 | // TODO Correct the implementation 109 | return LIBRARY_VERSION 110 | } 111 | 112 | override suspend fun pinLibrary(library: Library, pinned: Boolean) { 113 | updateLibrary(library.copy(isPinned = pinned)) 114 | } 115 | 116 | private fun updateLibrariesLiveData(libraries: List) { 117 | librariesLiveData.value = libraries 118 | } 119 | 120 | } -------------------------------------------------------------------------------- /app/src/androidTest/kotlin/ir/fallahpoor/releasetracker/fakes/FakeStorageRepository.kt: -------------------------------------------------------------------------------- 1 | package ir.fallahpoor.releasetracker.fakes 2 | 3 | import androidx.lifecycle.MutableLiveData 4 | import androidx.lifecycle.asFlow 5 | import ir.fallahpoor.releasetracker.data.SortOrder 6 | import ir.fallahpoor.releasetracker.data.repository.storage.StorageRepository 7 | import kotlinx.coroutines.flow.Flow 8 | import kotlinx.coroutines.flow.flow 9 | 10 | class FakeStorageRepository : StorageRepository { 11 | 12 | private var sortOrderLiveData = MutableLiveData(SortOrder.A_TO_Z) 13 | 14 | override fun getLastUpdateCheck(): Flow = flow { 15 | emit(FakeLibraryRepository.LAST_UPDATE_CHECK) 16 | } 17 | 18 | override suspend fun setLastUpdateCheck(date: String) { 19 | TODO("Not yet implemented") 20 | } 21 | 22 | override suspend fun setSortOrder(sortOrder: SortOrder) { 23 | sortOrderLiveData.value = sortOrder 24 | } 25 | 26 | override fun getSortOrder() = sortOrderLiveData.value!! 27 | 28 | override fun getSortOrderAsFlow(): Flow = sortOrderLiveData.asFlow() 29 | 30 | } -------------------------------------------------------------------------------- /app/src/androidTest/kotlin/ir/fallahpoor/releasetracker/features/addlibrary/ui/AddLibraryScreenTest.kt: -------------------------------------------------------------------------------- 1 | package ir.fallahpoor.releasetracker.features.addlibrary.ui 2 | 3 | import android.content.Context 4 | import androidx.compose.ui.test.* 5 | import androidx.compose.ui.test.junit4.createComposeRule 6 | import androidx.test.core.app.ApplicationProvider 7 | import ir.fallahpoor.releasetracker.R 8 | import ir.fallahpoor.releasetracker.features.addlibrary.AddLibraryScreenUiState 9 | import ir.fallahpoor.releasetracker.features.addlibrary.AddLibraryState 10 | import ir.fallahpoor.releasetracker.features.addlibrary.AddLibraryViewModel 11 | import ir.fallahpoor.releasetracker.features.addlibrary.Event 12 | import kotlinx.coroutines.flow.MutableStateFlow 13 | import org.junit.Rule 14 | import org.junit.Test 15 | import org.junit.runner.RunWith 16 | import org.mockito.Mock 17 | import org.mockito.Mockito 18 | import org.mockito.junit.MockitoJUnitRunner 19 | 20 | @RunWith(MockitoJUnitRunner::class) 21 | class AddLibraryScreenTest { 22 | 23 | @get:Rule 24 | val composeTestRule = createComposeRule() 25 | 26 | @Mock 27 | private lateinit var addLibraryViewModel: AddLibraryViewModel 28 | 29 | @Test 30 | fun screen_is_initialized_correctly() { 31 | 32 | // Given 33 | composeAddLibraryScreen() 34 | 35 | // When the screen is initially displayed 36 | 37 | // Then 38 | with(composeTestRule) { 39 | onNodeWithTag(AddLibraryScreenTags.TITLE) 40 | .assertIsDisplayed() 41 | onNodeWithTag(AddLibraryScreenTags.BACK_BUTTON) 42 | .assertIsDisplayed() 43 | onNodeWithTag(AddLibraryScreenTags.CONTENT) 44 | .assertIsDisplayed() 45 | } 46 | 47 | } 48 | 49 | @Test 50 | fun correct_event_is_called_when_library_name_is_entered() { 51 | 52 | // Given 53 | composeAddLibraryScreen() 54 | 55 | // When 56 | composeTestRule.onNodeWithTag(AddLibraryContentTags.LIBRARY_NAME_TEXT_FIELD) 57 | .performTextInput("Coil") 58 | 59 | // Then 60 | Mockito.verify(addLibraryViewModel).handleEvent(Event.UpdateLibraryName("Coil")) 61 | 62 | } 63 | 64 | @Test 65 | fun correct_event_is_called_when_library_URL_path_is_entered() { 66 | 67 | // Given 68 | composeAddLibraryScreen() 69 | 70 | // When 71 | composeTestRule.onNodeWithTag(AddLibraryContentTags.LIBRARY_URL_TEXT_FIELD) 72 | .performTextInput("coil-kt/coil") 73 | 74 | // Then 75 | Mockito.verify(addLibraryViewModel) 76 | .handleEvent(Event.UpdateLibraryUrlPath("coil-kt/coil")) 77 | 78 | } 79 | 80 | @Test 81 | fun correct_event_is_called_when_adding_a_new_library() { 82 | 83 | // Given 84 | composeAddLibraryScreen(libraryName = "Coil", libraryUrl = "coil-kt/coil") 85 | 86 | // When 87 | composeTestRule.onNodeWithTag(AddLibraryContentTags.ADD_LIBRARY_BUTTON) 88 | .performClick() 89 | 90 | // Then 91 | Mockito.verify(addLibraryViewModel).handleEvent(Event.AddLibrary("Coil", "coil-kt/coil")) 92 | 93 | } 94 | 95 | // TODO Add a test to assert that AddLibraryViewModel.resetState() is called when dismissing an error 96 | 97 | @Test 98 | fun correct_callback_is_called_when_pressing_the_back_button() { 99 | 100 | // Given 101 | val context: Context = ApplicationProvider.getApplicationContext() 102 | val onBackClick: () -> Unit = mock() 103 | composeAddLibraryScreen(onBackClick = onBackClick) 104 | 105 | // When 106 | composeTestRule.onNodeWithContentDescription(context.getString(R.string.back)) 107 | .performClick() 108 | 109 | // Then 110 | Mockito.verify(onBackClick).invoke() 111 | 112 | } 113 | 114 | private fun composeAddLibraryScreen( 115 | state: AddLibraryState = AddLibraryState.Initial, 116 | libraryName: String = "", 117 | libraryUrl: String = "", 118 | onBackClick: () -> Unit = {} 119 | ) { 120 | Mockito.`when`(addLibraryViewModel.uiState).thenReturn( 121 | MutableStateFlow( 122 | AddLibraryScreenUiState( 123 | libraryName = libraryName, 124 | libraryUrlPath = libraryUrl, 125 | addLibraryState = state 126 | ) 127 | ) 128 | ) 129 | composeTestRule.setContent { 130 | AddLibraryScreen( 131 | addLibraryViewModel = addLibraryViewModel, 132 | isDarkTheme = false, 133 | onBackClick = onBackClick 134 | ) 135 | } 136 | } 137 | 138 | private inline fun mock(): T = Mockito.mock(T::class.java) 139 | 140 | } -------------------------------------------------------------------------------- /app/src/androidTest/kotlin/ir/fallahpoor/releasetracker/features/libraries/ui/LibrariesListContentTest.kt: -------------------------------------------------------------------------------- 1 | package ir.fallahpoor.releasetracker.features.libraries.ui 2 | 3 | import androidx.compose.ui.test.* 4 | import androidx.compose.ui.test.junit4.createComposeRule 5 | import ir.fallahpoor.releasetracker.data.repository.library.Library 6 | import ir.fallahpoor.releasetracker.fakes.FakeLibraryRepository 7 | import ir.fallahpoor.releasetracker.features.libraries.LibrariesListState 8 | import org.junit.Rule 9 | import org.junit.Test 10 | import org.mockito.Mockito 11 | 12 | class LibrariesListContentTest { 13 | 14 | @get:Rule 15 | val composeRule = createComposeRule() 16 | 17 | private val libraries = listOf( 18 | FakeLibraryRepository.Coil.library, 19 | FakeLibraryRepository.Koin.library, 20 | FakeLibraryRepository.Kotlin.library 21 | ) 22 | 23 | @Test 24 | fun test_loading_state() { 25 | 26 | // Given 27 | composeLibrariesListContent(librariesListState = LibrariesListState.Loading) 28 | 29 | // Then 30 | with(composeRule) { 31 | composeRule.onNodeWithTag(LibrariesListContentTags.LAST_UPDATE_CHECK_TEXT) 32 | .assertIsDisplayed() 33 | onNodeWithTag(LibrariesListContentTags.PROGRESS_INDICATOR) 34 | .assertIsDisplayed() 35 | onNodeWithTag(LibrariesListTags.LIBRARIES_LIST) 36 | .assertDoesNotExist() 37 | } 38 | 39 | } 40 | 41 | @Test 42 | fun list_of_libraries_is_displayed() { 43 | 44 | // Given 45 | composeLibrariesListContent( 46 | librariesListState = LibrariesListState.LibrariesLoaded( 47 | libraries 48 | ) 49 | ) 50 | 51 | // Then 52 | with(composeRule) { 53 | onNodeWithTag(LibrariesListContentTags.LAST_UPDATE_CHECK_TEXT) 54 | .assertIsDisplayed() 55 | onNodeWithTag(LibrariesListTags.LIBRARIES_LIST) 56 | .assertIsDisplayed() 57 | onNodeWithTag(LibrariesListContentTags.PROGRESS_INDICATOR) 58 | .assertDoesNotExist() 59 | } 60 | 61 | } 62 | 63 | @Test 64 | fun correct_callback_is_called_when_a_library_is_clicked() { 65 | 66 | // Given 67 | val library: Library = FakeLibraryRepository.Kotlin.library 68 | val onLibraryClick: (Library) -> Unit = mock() 69 | composeLibrariesListContent( 70 | librariesListState = LibrariesListState.LibrariesLoaded(libraries), 71 | onLibraryClick = onLibraryClick 72 | ) 73 | 74 | // When 75 | composeRule.onNodeWithTag(LibraryItemTags.LIBRARY_ITEM + library.name) 76 | .performClick() 77 | 78 | // Then 79 | Mockito.verify(onLibraryClick).invoke(library) 80 | 81 | } 82 | 83 | @Test 84 | fun correct_callback_is_called_when_a_library_is_dismissed() { 85 | 86 | // Given 87 | val library: Library = FakeLibraryRepository.Coil.library 88 | val onLibraryDismissed: (Library) -> Unit = mock() 89 | composeLibrariesListContent( 90 | librariesListState = LibrariesListState.LibrariesLoaded(libraries), 91 | onLibraryDismissed = onLibraryDismissed 92 | ) 93 | 94 | // When 95 | composeRule.onNodeWithTag(LibraryItemTags.LIBRARY_ITEM + library.name) 96 | .performTouchInput { 97 | swipeRight() 98 | } 99 | 100 | // Then 101 | Mockito.verify(onLibraryDismissed).invoke(library) 102 | 103 | } 104 | 105 | @Test 106 | fun correct_callback_is_called_when_a_library_is_pinned() { 107 | 108 | // Given 109 | val onPinLibrary: (Library, Boolean) -> Unit = mock() 110 | composeLibrariesListContent( 111 | librariesListState = LibrariesListState.LibrariesLoaded(libraries), 112 | onPinLibraryClick = onPinLibrary 113 | ) 114 | 115 | // When 116 | composeRule.onNodeWithTag( 117 | LibraryItemTags.PIN_BUTTON + FakeLibraryRepository.Coil.name 118 | ).performClick() 119 | 120 | // Then 121 | Mockito.verify(onPinLibrary).invoke(FakeLibraryRepository.Coil.library, true) 122 | 123 | } 124 | 125 | @Test 126 | fun correct_callback_is_called_when_a_library_is_unpinned() { 127 | 128 | // Given 129 | val onPinLibrary: (Library, Boolean) -> Unit = mock() 130 | composeLibrariesListContent( 131 | librariesListState = LibrariesListState.LibrariesLoaded(libraries), 132 | onPinLibraryClick = onPinLibrary 133 | ) 134 | 135 | // When 136 | composeRule.onNodeWithTag( 137 | LibraryItemTags.PIN_BUTTON + FakeLibraryRepository.Koin.name 138 | ).performClick() 139 | 140 | // Then 141 | Mockito.verify(onPinLibrary).invoke(FakeLibraryRepository.Koin.library, false) 142 | 143 | } 144 | 145 | private fun composeLibrariesListContent( 146 | librariesListState: LibrariesListState = LibrariesListState.Loading, 147 | lastUpdateCheck: String = "N/A", 148 | onLibraryClick: (Library) -> Unit = {}, 149 | onLibraryDismissed: (Library) -> Unit = {}, 150 | onPinLibraryClick: (Library, Boolean) -> Unit = { _, _ -> }, 151 | onAddLibraryClick: () -> Unit = {} 152 | ) { 153 | composeRule.setContent { 154 | LibrariesListContent( 155 | librariesListState = librariesListState, 156 | lastUpdateCheck = lastUpdateCheck, 157 | onLibraryClick = onLibraryClick, 158 | onLibraryDismissed = onLibraryDismissed, 159 | onPinLibraryClick = onPinLibraryClick, 160 | onAddLibraryClick = onAddLibraryClick 161 | ) 162 | } 163 | } 164 | 165 | private inline fun mock(): T = Mockito.mock(T::class.java) 166 | 167 | } -------------------------------------------------------------------------------- /app/src/androidTest/kotlin/ir/fallahpoor/releasetracker/features/libraries/ui/LibrariesListTest.kt: -------------------------------------------------------------------------------- 1 | package ir.fallahpoor.releasetracker.features.libraries.ui 2 | 3 | import android.content.Context 4 | import androidx.compose.ui.test.* 5 | import androidx.compose.ui.test.junit4.createComposeRule 6 | import androidx.test.core.app.ApplicationProvider 7 | import ir.fallahpoor.releasetracker.R 8 | import ir.fallahpoor.releasetracker.data.repository.library.Library 9 | import ir.fallahpoor.releasetracker.fakes.FakeLibraryRepository 10 | import org.junit.Rule 11 | import org.junit.Test 12 | import org.mockito.Mockito 13 | 14 | class LibrariesListTest { 15 | 16 | @get:Rule 17 | val composeRule = createComposeRule() 18 | 19 | private val libraries = listOf( 20 | FakeLibraryRepository.Coil.library, 21 | FakeLibraryRepository.Koin.library, 22 | FakeLibraryRepository.Kotlin.library 23 | ) 24 | 25 | private val context: Context = ApplicationProvider.getApplicationContext() 26 | 27 | @Test 28 | fun list_of_libraries_is_displayed() { 29 | 30 | // Given 31 | val libraries = listOf( 32 | FakeLibraryRepository.Coil.library, 33 | FakeLibraryRepository.Koin.library, 34 | FakeLibraryRepository.Kotlin.library 35 | ) 36 | composeLibrariesList(libraries = libraries) 37 | 38 | // Then 39 | with(composeRule) { 40 | libraries.forEach { 41 | onNodeWithTag( 42 | LibraryItemTags.LIBRARY_ITEM + it.name, 43 | useUnmergedTree = true 44 | ).assertIsDisplayed() 45 | } 46 | onNodeWithTag(LibrariesListTags.ADD_LIBRARY_BUTTON) 47 | .assertIsDisplayed() 48 | onNodeWithText(context.getString(R.string.no_libraries), useUnmergedTree = true) 49 | .assertDoesNotExist() 50 | } 51 | 52 | } 53 | 54 | @Test 55 | fun a_proper_message_is_displayed_when_list_of_libraries_is_empty() { 56 | 57 | // Given 58 | composeLibrariesList(libraries = emptyList()) 59 | 60 | // Then 61 | with(composeRule) { 62 | onNodeWithTag(LibrariesListTags.LIBRARIES_LIST) 63 | .onChildren() 64 | .assertCountEquals(2) 65 | onNodeWithText(context.getString(R.string.no_libraries), useUnmergedTree = true) 66 | .assertIsDisplayed() 67 | onNodeWithTag(LibrariesListTags.ADD_LIBRARY_BUTTON) 68 | .assertIsDisplayed() 69 | } 70 | 71 | } 72 | 73 | @Test 74 | fun correct_callback_is_called_when_a_library_is_clicked() { 75 | 76 | // Given 77 | val library: Library = FakeLibraryRepository.Kotlin.library 78 | val onLibraryClick: (Library) -> Unit = mock() 79 | composeLibrariesList( 80 | libraries = libraries, 81 | onLibraryClick = onLibraryClick 82 | ) 83 | 84 | // When 85 | composeRule.onNodeWithTag(LibraryItemTags.LIBRARY_ITEM + library.name) 86 | .performClick() 87 | 88 | // Then 89 | Mockito.verify(onLibraryClick).invoke(library) 90 | 91 | } 92 | 93 | @Test 94 | fun correct_callback_is_called_when_a_library_is_dismissed() { 95 | 96 | // Given 97 | val library: Library = FakeLibraryRepository.Coil.library 98 | val onLibraryDismissed: (Library) -> Unit = mock() 99 | composeLibrariesList( 100 | libraries = libraries, 101 | onLibraryDismissed = onLibraryDismissed 102 | ) 103 | 104 | // When 105 | composeRule.onNodeWithTag(LibraryItemTags.LIBRARY_ITEM + library.name) 106 | .performTouchInput { 107 | swipeRight() 108 | } 109 | 110 | // Then 111 | Mockito.verify(onLibraryDismissed).invoke(library) 112 | 113 | } 114 | 115 | @Test 116 | fun correct_callback_is_called_when_a_library_is_pinned() { 117 | 118 | // Given 119 | val library: Library = FakeLibraryRepository.Coil.library 120 | val onPinLibrary: (Library, Boolean) -> Unit = mock() 121 | composeLibrariesList( 122 | libraries = libraries, 123 | onPinLibraryClick = onPinLibrary 124 | ) 125 | 126 | // When 127 | composeRule.onNodeWithTag(LibraryItemTags.PIN_BUTTON + library.name) 128 | .performClick() 129 | 130 | // Then 131 | Mockito.verify(onPinLibrary).invoke(library, true) 132 | 133 | } 134 | 135 | @Test 136 | fun correct_callback_is_called_when_a_library_is_unpinned() { 137 | 138 | // Given 139 | val library: Library = FakeLibraryRepository.Koin.library 140 | val onPinLibrary: (Library, Boolean) -> Unit = mock() 141 | composeLibrariesList( 142 | libraries = libraries, 143 | onPinLibraryClick = onPinLibrary 144 | ) 145 | 146 | // When 147 | composeRule.onNodeWithTag( 148 | LibraryItemTags.PIN_BUTTON + library.name 149 | ).performClick() 150 | 151 | // Then 152 | Mockito.verify(onPinLibrary).invoke(library, false) 153 | 154 | } 155 | 156 | @Test 157 | fun correct_callback_is_called_when_add_library_button_is_clicked() { 158 | 159 | // Given 160 | val onAddLibraryLick: () -> Unit = mock() 161 | composeLibrariesList( 162 | libraries = libraries, 163 | onAddLibraryClick = onAddLibraryLick 164 | ) 165 | 166 | // When 167 | composeRule.onNodeWithTag(LibrariesListTags.ADD_LIBRARY_BUTTON) 168 | .performClick() 169 | 170 | // Then 171 | Mockito.verify(onAddLibraryLick).invoke() 172 | 173 | } 174 | 175 | private fun composeLibrariesList( 176 | libraries: List, 177 | onLibraryClick: (Library) -> Unit = {}, 178 | onLibraryDismissed: (Library) -> Unit = {}, 179 | onPinLibraryClick: (Library, Boolean) -> Unit = { _, _ -> }, 180 | onAddLibraryClick: () -> Unit = {} 181 | ) { 182 | composeRule.setContent { 183 | LibrariesList( 184 | libraries = libraries, 185 | onLibraryClick = onLibraryClick, 186 | onLibraryDismissed = onLibraryDismissed, 187 | onPinLibraryClick = onPinLibraryClick, 188 | onAddLibraryClick = onAddLibraryClick 189 | ) 190 | } 191 | } 192 | 193 | private inline fun mock(): T = Mockito.mock(T::class.java) 194 | 195 | } -------------------------------------------------------------------------------- /app/src/androidTest/kotlin/ir/fallahpoor/releasetracker/features/libraries/ui/LibraryItemTest.kt: -------------------------------------------------------------------------------- 1 | package ir.fallahpoor.releasetracker.features.libraries.ui 2 | 3 | import androidx.compose.ui.test.* 4 | import androidx.compose.ui.test.junit4.createComposeRule 5 | import ir.fallahpoor.releasetracker.data.repository.library.Library 6 | import ir.fallahpoor.releasetracker.fakes.FakeLibraryRepository 7 | import org.junit.Rule 8 | import org.junit.Test 9 | import org.mockito.Mockito 10 | 11 | class LibraryItemTest { 12 | 13 | @get:Rule 14 | val composeRule = createComposeRule() 15 | 16 | @Test 17 | fun library_is_displayed() { 18 | 19 | // Given 20 | val library: Library = FakeLibraryRepository.Koin.library 21 | composeLibraryItem(library = library) 22 | 23 | // Then 24 | with(composeRule) { 25 | onNodeWithTag(LibraryItemTags.LIBRARY_NAME, useUnmergedTree = true) 26 | .assertTextEquals(library.name) 27 | onNodeWithTag(LibraryItemTags.LIBRARY_URL, useUnmergedTree = true) 28 | .assertTextEquals(library.url) 29 | onNodeWithTag(LibraryItemTags.LIBRARY_VERSION, useUnmergedTree = true) 30 | .assertTextEquals(library.version) 31 | onNodeWithTag(LibraryItemTags.PIN_BUTTON + library.name) 32 | .assertIsOn() 33 | } 34 | 35 | } 36 | 37 | @Test 38 | fun correct_callback_is_called_when_library_is_clicked() { 39 | 40 | // Given 41 | val onLibraryClick: (Library) -> Unit = mock() 42 | val library: Library = FakeLibraryRepository.Coil.library 43 | composeLibraryItem( 44 | library = library, 45 | onLibraryClick = onLibraryClick 46 | ) 47 | 48 | // When 49 | composeRule.onNodeWithTag(LibraryItemTags.LIBRARY_ITEM + library.name) 50 | .performClick() 51 | 52 | // Then 53 | Mockito.verify(onLibraryClick).invoke(library) 54 | 55 | } 56 | 57 | @Test 58 | fun correct_callback_is_called_when_library_is_pinned() { 59 | 60 | // Given 61 | val onPinLibrary: (Library, Boolean) -> Unit = mock() 62 | composeLibraryItem( 63 | library = FakeLibraryRepository.Coil.library, 64 | onPinLibraryClick = onPinLibrary 65 | ) 66 | 67 | // When 68 | composeRule.onNodeWithTag( 69 | LibraryItemTags.PIN_BUTTON + FakeLibraryRepository.Coil.name 70 | ).performClick() 71 | 72 | // Then 73 | Mockito.verify(onPinLibrary).invoke(FakeLibraryRepository.Coil.library, true) 74 | 75 | } 76 | 77 | @Test 78 | fun correct_callback_is_called_when_library_is_unpinned() { 79 | 80 | // Given 81 | val onPinLibrary: (Library, Boolean) -> Unit = mock() 82 | val library: Library = FakeLibraryRepository.Koin.library 83 | composeLibraryItem( 84 | library = library, 85 | onPinLibraryClick = onPinLibrary 86 | ) 87 | 88 | // When 89 | composeRule.onNodeWithTag( 90 | LibraryItemTags.PIN_BUTTON + library.name 91 | ).performClick() 92 | 93 | // Then 94 | Mockito.verify(onPinLibrary).invoke(library, false) 95 | 96 | } 97 | 98 | @Test 99 | fun correct_callback_is_called_when_library_is_dismissed() { 100 | 101 | // Given 102 | val onLibraryDismissed: (Library) -> Unit = mock() 103 | val library: Library = FakeLibraryRepository.Coil.library 104 | composeLibraryItem( 105 | library = library, 106 | onLibraryDismissed = onLibraryDismissed 107 | ) 108 | 109 | // When 110 | composeRule.onNodeWithTag(LibraryItemTags.LIBRARY_ITEM + library.name) 111 | .performTouchInput { 112 | swipeRight() 113 | } 114 | 115 | // Then 116 | Mockito.verify(onLibraryDismissed).invoke(library) 117 | 118 | } 119 | 120 | private fun composeLibraryItem( 121 | library: Library, 122 | onLibraryClick: (Library) -> Unit = {}, 123 | onPinLibraryClick: (Library, Boolean) -> Unit = { _, _ -> }, 124 | onLibraryDismissed: (Library) -> Unit = {} 125 | ) { 126 | composeRule.setContent { 127 | LibraryItem( 128 | library = library, 129 | onLibraryClick = onLibraryClick, 130 | onPinLibraryClick = onPinLibraryClick, 131 | onLibraryDismissed = onLibraryDismissed 132 | ) 133 | } 134 | } 135 | 136 | private inline fun mock(): T = Mockito.mock(T::class.java) 137 | 138 | } -------------------------------------------------------------------------------- /app/src/androidTest/kotlin/ir/fallahpoor/releasetracker/features/libraries/ui/SearchBarTest.kt: -------------------------------------------------------------------------------- 1 | package ir.fallahpoor.releasetracker.features.libraries.ui 2 | 3 | import androidx.compose.ui.test.* 4 | import androidx.compose.ui.test.junit4.createComposeRule 5 | import org.junit.Rule 6 | import org.junit.Test 7 | import org.mockito.Mockito 8 | 9 | class SearchBarTest { 10 | 11 | @get:Rule 12 | val composeTestRule = createComposeRule() 13 | 14 | @Test 15 | fun search_bar_is_initialized_correctly() { 16 | 17 | // Given 18 | val hint = "hint" 19 | composeSearchBar(hint = "hint") 20 | 21 | // When the composable is freshly composed 22 | 23 | // Then 24 | with(composeTestRule) { 25 | onNodeWithText(hint).assertIsDisplayed() 26 | onNodeWithTag(SearchBarTags.QUERY_TEXT_FIELD) 27 | .assertTextEquals("") 28 | onNodeWithTag(SearchBarTags.CLOSE_BUTTON) 29 | .assertIsDisplayed() 30 | onNodeWithTag(SearchBarTags.CLEAR_BUTTON) 31 | .assertIsDisplayed() 32 | } 33 | 34 | } 35 | 36 | @Test 37 | fun hint_is_not_displayed_when_search_query_is_not_empty() { 38 | 39 | // Given 40 | val hint = "Enter library name" 41 | val query = "Coil" 42 | composeSearchBar(hint = hint, query = query) 43 | 44 | // When 45 | 46 | // Then 47 | with(composeTestRule) { 48 | onNodeWithText(hint) 49 | .assertDoesNotExist() 50 | onNodeWithTag(SearchBarTags.QUERY_TEXT_FIELD) 51 | .assertTextEquals(query) 52 | } 53 | 54 | } 55 | 56 | @Test 57 | fun correct_callback_is_called_when_clear_button_is_clicked() { 58 | 59 | // Given 60 | val onClearClick: () -> Unit = mock() 61 | composeSearchBar(onClearClick = onClearClick) 62 | 63 | // When 64 | composeTestRule.onNodeWithTag(SearchBarTags.CLEAR_BUTTON) 65 | .performClick() 66 | 67 | // Then 68 | Mockito.verify(onClearClick).invoke() 69 | 70 | } 71 | 72 | @Test 73 | fun correct_callback_is_called_when_close_button_is_clicked() { 74 | 75 | // Given 76 | val onCloseClick: () -> Unit = mock() 77 | composeSearchBar(onCloseClick = onCloseClick) 78 | 79 | // When 80 | composeTestRule.onNodeWithTag(SearchBarTags.CLOSE_BUTTON) 81 | .performClick() 82 | 83 | // Then 84 | Mockito.verify(onCloseClick).invoke() 85 | 86 | } 87 | 88 | @Test 89 | fun correct_callback_is_called_when_query_is_changed() { 90 | 91 | // Given 92 | val onQueryChange: (String) -> Unit = mock() 93 | composeSearchBar(onQueryChange = onQueryChange) 94 | 95 | // When 96 | composeTestRule.onNodeWithTag(SearchBarTags.QUERY_TEXT_FIELD) 97 | .performTextInput("Coroutines") 98 | 99 | // Then 100 | Mockito.verify(onQueryChange).invoke("Coroutines") 101 | 102 | } 103 | 104 | // TODO Test if the correct callback is called when query is submitted 105 | 106 | private inline fun mock(): T = Mockito.mock(T::class.java) 107 | 108 | private fun composeSearchBar( 109 | hint: String = "", 110 | query: String = "", 111 | onQueryChange: (String) -> Unit = {}, 112 | onQuerySubmit: (String) -> Unit = {}, 113 | onClearClick: () -> Unit = {}, 114 | onCloseClick: () -> Unit = {} 115 | ) { 116 | composeTestRule.setContent { 117 | SearchBar( 118 | hint = hint, 119 | query = query, 120 | onQueryChange = onQueryChange, 121 | onQuerySubmit = onQuerySubmit, 122 | onClearClick = onClearClick, 123 | onCloseClick = onCloseClick 124 | ) 125 | } 126 | } 127 | 128 | } -------------------------------------------------------------------------------- /app/src/androidTest/kotlin/ir/fallahpoor/releasetracker/features/libraries/ui/SingleSelectionDialogTest.kt: -------------------------------------------------------------------------------- 1 | package ir.fallahpoor.releasetracker.features.libraries.ui 2 | 3 | import android.content.Context 4 | import androidx.compose.ui.res.stringResource 5 | import androidx.compose.ui.test.* 6 | import androidx.compose.ui.test.junit4.createComposeRule 7 | import androidx.test.core.app.ApplicationProvider 8 | import androidx.test.espresso.Espresso 9 | import ir.fallahpoor.releasetracker.data.NightMode 10 | import org.junit.Rule 11 | import org.junit.Test 12 | import org.mockito.Mockito 13 | 14 | class SingleSelectionDialogTest { 15 | 16 | @get:Rule 17 | val composeTestRule = createComposeRule() 18 | 19 | private val dialogTitle = "Awesome Dialog" 20 | private val context: Context = ApplicationProvider.getApplicationContext() 21 | 22 | @Test 23 | fun dialog_is_initialized_correctly() { 24 | 25 | // Given 26 | composeSingleSelectionDialog(currentlySelectedItem = NightMode.OFF) 27 | 28 | // Then 29 | with(composeTestRule) { 30 | onNodeWithText(dialogTitle) 31 | .assertIsDisplayed() 32 | NightMode.values().forEach { item: NightMode -> 33 | onNodeWithText( 34 | context.getString(item.label), 35 | useUnmergedTree = true 36 | ).assertIsDisplayed() 37 | if (item == NightMode.OFF) { 38 | onNodeWithTag(context.getString(item.label)) 39 | .assertIsSelected() 40 | } else { 41 | onNodeWithTag(context.getString(item.label)) 42 | .assertIsNotSelected() 43 | } 44 | } 45 | } 46 | 47 | } 48 | 49 | @Test 50 | fun correct_callback_is_called_when_an_item_is_selected() { 51 | 52 | // Given 53 | val onItemSelect: (NightMode) -> Unit = mock() 54 | composeSingleSelectionDialog( 55 | currentlySelectedItem = NightMode.ON, 56 | onItemSelect = onItemSelect 57 | ) 58 | 59 | // When 60 | composeTestRule.onNodeWithText( 61 | context.getString(NightMode.AUTO.label), 62 | useUnmergedTree = true 63 | ).performClick() 64 | 65 | // Then 66 | Mockito.verify(onItemSelect).invoke(NightMode.AUTO) 67 | 68 | } 69 | 70 | @Test 71 | fun correct_callback_is_called_when_dialog_is_dismissed() { 72 | 73 | // Given 74 | val onDismiss: () -> Unit = mock() 75 | composeSingleSelectionDialog(onDismiss = onDismiss) 76 | 77 | // When 78 | Espresso.pressBack() 79 | 80 | // Then 81 | Mockito.verify(onDismiss).invoke() 82 | 83 | } 84 | 85 | private fun composeSingleSelectionDialog( 86 | currentlySelectedItem: NightMode = NightMode.OFF, 87 | onItemSelect: (NightMode) -> Unit = {}, 88 | onDismiss: () -> Unit = {} 89 | ) { 90 | composeTestRule.setContent { 91 | SingleSelectionDialog( 92 | title = dialogTitle, 93 | items = NightMode.values().toList(), 94 | labels = NightMode.values().toList().map { stringResource(it.label) }, 95 | selectedItem = currentlySelectedItem, 96 | onItemSelect = onItemSelect, 97 | onDismiss = onDismiss 98 | ) 99 | } 100 | } 101 | 102 | private inline fun mock(): T = Mockito.mock(T::class.java) 103 | 104 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 15 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /app/src/main/assets/database/libraries.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MasoudFallahpour/ReleaseTracker/65f8c958f8bae1dee7fe96ed4113b8fd4fbfe4e4/app/src/main/assets/database/libraries.db -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/fallahpoor/releasetracker/NightModeViewModel.kt: -------------------------------------------------------------------------------- 1 | package ir.fallahpoor.releasetracker 2 | 3 | import android.os.Build 4 | import androidx.appcompat.app.AppCompatDelegate 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.viewModelScope 7 | import dagger.hilt.android.lifecycle.HiltViewModel 8 | import ir.fallahpoor.releasetracker.data.NightMode 9 | import ir.fallahpoor.releasetracker.data.repository.nightmode.NightModeRepository 10 | import kotlinx.coroutines.flow.SharingStarted 11 | import kotlinx.coroutines.flow.StateFlow 12 | import kotlinx.coroutines.flow.stateIn 13 | import kotlinx.coroutines.launch 14 | import timber.log.Timber 15 | import javax.inject.Inject 16 | 17 | sealed class Event { 18 | data class ChangeNightMode(val nightMode: NightMode) : Event() 19 | } 20 | 21 | @HiltViewModel 22 | class NightModeViewModel @Inject constructor( 23 | private val nightModeRepository: NightModeRepository 24 | ) : ViewModel() { 25 | 26 | val state: StateFlow = nightModeRepository.getNightModeAsFlow() 27 | .stateIn(viewModelScope, SharingStarted.Eagerly, nightModeRepository.getNightMode()) 28 | val isNightModeSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q 29 | 30 | fun handleEvent(event: Event) { 31 | if (event is Event.ChangeNightMode) { 32 | setNightMode(event.nightMode) 33 | } 34 | } 35 | 36 | private fun setNightMode(nightMode: NightMode) { 37 | if (nightMode == state.value) { 38 | return 39 | } 40 | viewModelScope.launch { 41 | try { 42 | nightModeRepository.setNightMode(nightMode) 43 | when (nightMode) { 44 | NightMode.ON -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) 45 | NightMode.OFF -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) 46 | NightMode.AUTO -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) 47 | } 48 | } catch (t: Throwable) { 49 | Timber.d(t, "Failed to set the night mode") 50 | } 51 | } 52 | } 53 | 54 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/fallahpoor/releasetracker/UpdateVersionsWorker.kt: -------------------------------------------------------------------------------- 1 | package ir.fallahpoor.releasetracker 2 | 3 | import android.content.Context 4 | import androidx.hilt.work.HiltWorker 5 | import androidx.work.CoroutineWorker 6 | import androidx.work.WorkerParameters 7 | import dagger.assisted.Assisted 8 | import dagger.assisted.AssistedInject 9 | import ir.fallahpoor.releasetracker.common.NotificationManager 10 | import ir.fallahpoor.releasetracker.data.network.ConnectionChecker 11 | import ir.fallahpoor.releasetracker.data.repository.library.Library 12 | import ir.fallahpoor.releasetracker.data.repository.library.LibraryRepository 13 | import ir.fallahpoor.releasetracker.data.repository.storage.StorageRepository 14 | import kotlinx.coroutines.launch 15 | import kotlinx.coroutines.supervisorScope 16 | import timber.log.Timber 17 | import java.text.SimpleDateFormat 18 | import java.util.* 19 | 20 | @HiltWorker 21 | class UpdateVersionsWorker 22 | @AssistedInject constructor( 23 | @Assisted private val context: Context, 24 | @Assisted workerParams: WorkerParameters, 25 | private val libraryRepository: LibraryRepository, 26 | private val storageRepository: StorageRepository, 27 | private val connectionChecker: ConnectionChecker, 28 | private val notificationManager: NotificationManager 29 | ) : CoroutineWorker(context, workerParams) { 30 | 31 | override suspend fun doWork(): Result = if (!connectionChecker.isInternetConnected()) { 32 | Result.retry() 33 | } else { 34 | val updatedLibraries: List = updateLibraries() 35 | saveUpdateDate() 36 | showNotification(updatedLibraries) 37 | Result.success() 38 | } 39 | 40 | private suspend fun updateLibraries(): List { 41 | 42 | val updatedLibraries = mutableListOf() 43 | val libraries: List = libraryRepository.getLibraries() 44 | 45 | supervisorScope { 46 | libraries.forEach { library: Library -> 47 | launch { 48 | val latestVersion: String? = getLatestVersion(library) 49 | latestVersion?.let { 50 | val libraryCopy = library.copy(version = it) 51 | libraryRepository.updateLibrary(libraryCopy) 52 | if (newVersionAvailable(it, library.version)) { 53 | updatedLibraries += "${library.name}: ${library.version} -> $it" 54 | } 55 | } 56 | } 57 | } 58 | } 59 | 60 | return updatedLibraries 61 | 62 | } 63 | 64 | private suspend fun getLatestVersion(library: Library): String? = try { 65 | val libraryVersion: String = libraryRepository.getLibraryVersion(library.name, library.url) 66 | Timber.d("Update SUCCESS (%s): %s", library.name, libraryVersion) 67 | libraryVersion 68 | } catch (t: Throwable) { 69 | Timber.d("Update FAILURE (%s): %s", library.name, t.message) 70 | null 71 | } 72 | 73 | private fun newVersionAvailable(latestVersion: String?, currentVersion: String): Boolean = 74 | latestVersion != null && currentVersion != "N/A" && latestVersion != currentVersion 75 | 76 | private suspend fun saveUpdateDate() { 77 | val simpleDateFormat = SimpleDateFormat("MMM dd HH:mm", Locale.US) 78 | storageRepository.setLastUpdateCheck(simpleDateFormat.format(Date())) 79 | } 80 | 81 | private fun showNotification(updatedLibraries: List) { 82 | if (updatedLibraries.isNotEmpty()) { 83 | val notificationBody = context.getString( 84 | R.string.notification_body, 85 | updatedLibraries.joinToString(separator = "\n") 86 | ) 87 | notificationManager.showNotification( 88 | title = context.getString(R.string.notification_title), 89 | body = notificationBody 90 | ) 91 | } 92 | } 93 | 94 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/fallahpoor/releasetracker/common/Consts.kt: -------------------------------------------------------------------------------- 1 | package ir.fallahpoor.releasetracker.common 2 | 3 | const val GITHUB_BASE_URL = "https://github.com/" -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/fallahpoor/releasetracker/common/NotificationManager.kt: -------------------------------------------------------------------------------- 1 | package ir.fallahpoor.releasetracker.common 2 | 3 | import android.app.Notification 4 | import android.app.NotificationChannel 5 | import android.app.NotificationManager 6 | import android.app.PendingIntent 7 | import android.content.Context 8 | import android.content.Intent 9 | import android.os.Build 10 | import androidx.core.app.NotificationCompat 11 | import androidx.core.app.NotificationManagerCompat 12 | import ir.fallahpoor.releasetracker.R 13 | import ir.fallahpoor.releasetracker.entrypoint.MainActivity 14 | import javax.inject.Inject 15 | 16 | class NotificationManager 17 | @Inject constructor( 18 | private val context: Context 19 | ) { 20 | 21 | companion object { 22 | private const val CHANNEL_ID = "general_channel" 23 | } 24 | 25 | fun createNotificationChannel() { 26 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 27 | val channelName = context.getString(R.string.notification_channel_name) 28 | val channelDescription = context.getString(R.string.notification_channel_description) 29 | val importance = NotificationManager.IMPORTANCE_DEFAULT 30 | val notificationChannel = 31 | NotificationChannel(CHANNEL_ID, channelName, importance).apply { 32 | description = channelDescription 33 | } 34 | val notificationManager: NotificationManager = 35 | context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager 36 | notificationManager.createNotificationChannel(notificationChannel) 37 | } 38 | } 39 | 40 | fun showNotification(title: String, body: String) { 41 | val notification: Notification = createNotification(title, body) 42 | NotificationManagerCompat.from(context) 43 | .notify(0, notification) 44 | } 45 | 46 | private fun createNotification(title: String, content: String): Notification = 47 | NotificationCompat.Builder(context, CHANNEL_ID) 48 | .setSmallIcon(R.mipmap.ic_launcher) 49 | .setContentTitle(title) 50 | .setContentText(content) 51 | .setStyle(NotificationCompat.BigTextStyle().bigText(content)) 52 | .setPriority(NotificationCompat.PRIORITY_DEFAULT) 53 | .setContentIntent(createContentIntent()) 54 | .setAutoCancel(true) 55 | .build() 56 | 57 | private fun createContentIntent(): PendingIntent { 58 | val intent = Intent(context, MainActivity::class.java).apply { 59 | flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK 60 | } 61 | return PendingIntent.getActivity(context, 0, intent, 0) 62 | } 63 | 64 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/fallahpoor/releasetracker/di/ContextModule.kt: -------------------------------------------------------------------------------- 1 | package ir.fallahpoor.releasetracker.di 2 | 3 | import android.content.Context 4 | import dagger.Module 5 | import dagger.Provides 6 | import dagger.hilt.InstallIn 7 | import dagger.hilt.android.qualifiers.ApplicationContext 8 | import dagger.hilt.components.SingletonComponent 9 | 10 | @Module 11 | @InstallIn(SingletonComponent::class) 12 | object ContextModule { 13 | 14 | @Provides 15 | fun provideContext(@ApplicationContext context: Context): Context = context 16 | 17 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/fallahpoor/releasetracker/di/DatabaseModule.kt: -------------------------------------------------------------------------------- 1 | package ir.fallahpoor.releasetracker.di 2 | 3 | import android.content.Context 4 | import com.squareup.sqldelight.android.AndroidSqliteDriver 5 | import com.squareup.sqldelight.db.SqlDriver 6 | import dagger.Module 7 | import dagger.Provides 8 | import dagger.hilt.InstallIn 9 | import dagger.hilt.components.SingletonComponent 10 | import ir.fallahpoor.releasetracker.data.Database 11 | import ir.fallahpoor.releasetracker.data.database.LibraryDao 12 | import ir.fallahpoor.releasetracker.data.database.LibraryDaoImpl 13 | import kotlinx.coroutines.CoroutineDispatcher 14 | import kotlinx.coroutines.Dispatchers 15 | import javax.inject.Singleton 16 | 17 | @Module 18 | @InstallIn(SingletonComponent::class) 19 | object DatabaseModule { 20 | 21 | @Singleton 22 | @Provides 23 | fun provideDatabase(context: Context): Database { 24 | val driver: SqlDriver = AndroidSqliteDriver(Database.Schema, context, "ReleaseTracker.db") 25 | return Database(driver) 26 | } 27 | 28 | @Provides 29 | @Singleton 30 | fun provideLibraryDao(libraryDaoImpl: LibraryDaoImpl): LibraryDao = libraryDaoImpl 31 | 32 | @Provides 33 | fun provideCoroutineDispatcher(): CoroutineDispatcher = Dispatchers.IO 34 | 35 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/fallahpoor/releasetracker/di/LibraryRepositoryModule.kt: -------------------------------------------------------------------------------- 1 | package ir.fallahpoor.releasetracker.di 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | import io.ktor.client.* 8 | import io.ktor.client.plugins.* 9 | import io.ktor.client.plugins.contentnegotiation.* 10 | import io.ktor.client.plugins.logging.* 11 | import io.ktor.serialization.kotlinx.json.* 12 | import ir.fallahpoor.releasetracker.data.repository.library.LibraryRepository 13 | import ir.fallahpoor.releasetracker.data.repository.library.LibraryRepositoryImpl 14 | 15 | @Module 16 | @InstallIn(SingletonComponent::class) 17 | object LibraryRepositoryModule { 18 | 19 | @Provides 20 | fun provideLibraryRepository(libraryRepositoryImpl: LibraryRepositoryImpl): LibraryRepository = 21 | libraryRepositoryImpl 22 | 23 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/fallahpoor/releasetracker/di/NetworkModule.kt: -------------------------------------------------------------------------------- 1 | package ir.fallahpoor.releasetracker.di 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | import io.ktor.client.* 8 | import io.ktor.client.plugins.* 9 | import io.ktor.client.plugins.contentnegotiation.* 10 | import io.ktor.client.plugins.logging.* 11 | import io.ktor.serialization.kotlinx.json.* 12 | import ir.fallahpoor.releasetracker.data.BuildConfig 13 | import ir.fallahpoor.releasetracker.data.exceptions.InternetNotConnectedException 14 | import ir.fallahpoor.releasetracker.data.exceptions.LibraryDoesNotExistException 15 | import ir.fallahpoor.releasetracker.data.exceptions.UnknownException 16 | import ir.fallahpoor.releasetracker.data.network.GithubApi 17 | import ir.fallahpoor.releasetracker.data.network.GithubApiImpl 18 | import kotlinx.serialization.json.Json 19 | import java.io.IOException 20 | import javax.inject.Singleton 21 | 22 | @Module 23 | @InstallIn(SingletonComponent::class) 24 | object NetworkModule { 25 | 26 | @Provides 27 | fun provideGithubWebService(githubWebServiceImpl: GithubApiImpl): GithubApi = 28 | githubWebServiceImpl 29 | 30 | @Provides 31 | @Singleton 32 | fun provideHttpClient() = 33 | HttpClient { 34 | expectSuccess = true 35 | install(Logging) { 36 | level = if (BuildConfig.DEBUG) LogLevel.BODY else LogLevel.NONE 37 | } 38 | install(ContentNegotiation) { 39 | json(Json { 40 | prettyPrint = true 41 | ignoreUnknownKeys = true 42 | }) 43 | } 44 | HttpResponseValidator { 45 | handleResponseExceptionWithRequest { exception, _ -> 46 | when (exception) { 47 | is ClientRequestException -> throw LibraryDoesNotExistException() 48 | is IOException -> throw InternetNotConnectedException() 49 | else -> throw UnknownException() 50 | } 51 | } 52 | } 53 | } 54 | 55 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/fallahpoor/releasetracker/di/NightModeRepositoryModule.kt: -------------------------------------------------------------------------------- 1 | package ir.fallahpoor.releasetracker.di 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | import ir.fallahpoor.releasetracker.data.repository.nightmode.NightModeRepository 8 | import ir.fallahpoor.releasetracker.data.repository.nightmode.NightModeRepositoryImpl 9 | 10 | @Module 11 | @InstallIn(SingletonComponent::class) 12 | object NightModeRepositoryModule { 13 | 14 | @Provides 15 | fun provideNightModeRepository(nightModeRepositoryImpl: NightModeRepositoryImpl): NightModeRepository = 16 | nightModeRepositoryImpl 17 | 18 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/fallahpoor/releasetracker/di/StorageModule.kt: -------------------------------------------------------------------------------- 1 | package ir.fallahpoor.releasetracker.di 2 | 3 | import android.content.Context 4 | import androidx.datastore.core.DataStore 5 | import androidx.datastore.preferences.core.PreferenceDataStoreFactory 6 | import androidx.datastore.preferences.core.Preferences 7 | import androidx.datastore.preferences.preferencesDataStoreFile 8 | import dagger.Module 9 | import dagger.Provides 10 | import dagger.hilt.InstallIn 11 | import dagger.hilt.components.SingletonComponent 12 | import ir.fallahpoor.releasetracker.data.storage.LocalStorage 13 | import ir.fallahpoor.releasetracker.data.storage.Storage 14 | import javax.inject.Singleton 15 | 16 | @Module 17 | @InstallIn(SingletonComponent::class) 18 | object StorageModule { 19 | 20 | @Provides 21 | fun provideStorage(localStorage: LocalStorage): Storage = localStorage 22 | 23 | @Provides 24 | @Singleton 25 | fun provideDataStore(context: Context): DataStore = 26 | PreferenceDataStoreFactory.create( 27 | produceFile = { 28 | context.preferencesDataStoreFile("settings") 29 | } 30 | ) 31 | 32 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/fallahpoor/releasetracker/di/StorageRepositoryModule.kt: -------------------------------------------------------------------------------- 1 | package ir.fallahpoor.releasetracker.di 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | import ir.fallahpoor.releasetracker.data.repository.storage.StorageRepository 8 | import ir.fallahpoor.releasetracker.data.repository.storage.StorageRepositoryImpl 9 | 10 | @Module 11 | @InstallIn(SingletonComponent::class) 12 | object StorageRepositoryModule { 13 | 14 | @Provides 15 | fun provideStorageRepository(storageRepositoryImpl: StorageRepositoryImpl): StorageRepository = 16 | storageRepositoryImpl 17 | 18 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/fallahpoor/releasetracker/entrypoint/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package ir.fallahpoor.releasetracker.entrypoint 2 | 3 | import android.os.Bundle 4 | import androidx.activity.compose.setContent 5 | import androidx.appcompat.app.AppCompatActivity 6 | import dagger.hilt.android.AndroidEntryPoint 7 | 8 | @AndroidEntryPoint 9 | class MainActivity : AppCompatActivity() { 10 | override fun onCreate(savedInstanceState: Bundle?) { 11 | super.onCreate(savedInstanceState) 12 | setContent { 13 | ReleaseTracker() 14 | } 15 | } 16 | } 17 | 18 | -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/fallahpoor/releasetracker/entrypoint/ReleaseTracker.kt: -------------------------------------------------------------------------------- 1 | package ir.fallahpoor.releasetracker.entrypoint 2 | 3 | import android.content.ActivityNotFoundException 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.net.Uri 7 | import android.widget.Toast 8 | import androidx.compose.foundation.isSystemInDarkTheme 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.collectAsState 11 | import androidx.compose.runtime.getValue 12 | import androidx.compose.ui.platform.LocalContext 13 | import androidx.hilt.navigation.compose.hiltViewModel 14 | import androidx.navigation.compose.NavHost 15 | import androidx.navigation.compose.composable 16 | import androidx.navigation.compose.rememberNavController 17 | import ir.fallahpoor.releasetracker.Event 18 | import ir.fallahpoor.releasetracker.NightModeViewModel 19 | import ir.fallahpoor.releasetracker.R 20 | import ir.fallahpoor.releasetracker.data.NightMode 21 | import ir.fallahpoor.releasetracker.data.repository.library.Library 22 | import ir.fallahpoor.releasetracker.features.addlibrary.ui.AddLibraryScreen 23 | import ir.fallahpoor.releasetracker.features.libraries.ui.LibrariesListScreen 24 | 25 | @Composable 26 | fun ReleaseTracker() { 27 | val navController = rememberNavController() 28 | val nightModeViewModel: NightModeViewModel = hiltViewModel() 29 | val nightMode: NightMode by nightModeViewModel.state.collectAsState() 30 | NavHost( 31 | navController = navController, 32 | startDestination = Screen.LibrariesList 33 | ) { 34 | composable(Screen.LibrariesList) { 35 | val context = LocalContext.current 36 | LibrariesListScreen( 37 | isNightModeSupported = nightModeViewModel.isNightModeSupported, 38 | currentNightMode = nightMode, 39 | onNightModeChange = { nightMode: NightMode -> 40 | nightModeViewModel.handleEvent(Event.ChangeNightMode(nightMode)) 41 | }, 42 | onLibraryClick = { library: Library -> 43 | openUrl(context, library) 44 | }, 45 | onAddLibraryClick = { navController.navigate(Screen.AddLibrary) }, 46 | ) 47 | } 48 | composable(Screen.AddLibrary) { 49 | val isNightModeOn = when (nightMode) { 50 | NightMode.OFF -> false 51 | NightMode.ON -> true 52 | NightMode.AUTO -> isSystemInDarkTheme() 53 | } 54 | AddLibraryScreen( 55 | isDarkTheme = isNightModeOn, 56 | onBackClick = { navController.navigateUp() } 57 | ) 58 | } 59 | } 60 | } 61 | 62 | private fun openUrl(context: Context, library: Library) { 63 | val intent = Intent(Intent.ACTION_VIEW, Uri.parse(library.url)) 64 | try { 65 | context.startActivity(intent) 66 | } catch (e: ActivityNotFoundException) { 67 | Toast.makeText( 68 | context, 69 | context.getString(R.string.no_browser_found_message, library.url), 70 | Toast.LENGTH_LONG 71 | ).show() 72 | } 73 | } 74 | 75 | private object Screen { 76 | const val LibrariesList = "librariesList" 77 | const val AddLibrary = "addLibrary" 78 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/fallahpoor/releasetracker/entrypoint/ReleaseTrackerApp.kt: -------------------------------------------------------------------------------- 1 | package ir.fallahpoor.releasetracker.entrypoint 2 | 3 | import android.app.Application 4 | import androidx.hilt.work.HiltWorkerFactory 5 | import androidx.work.* 6 | import dagger.hilt.android.HiltAndroidApp 7 | import ir.fallahpoor.releasetracker.BuildConfig 8 | import ir.fallahpoor.releasetracker.R 9 | import ir.fallahpoor.releasetracker.UpdateVersionsWorker 10 | import ir.fallahpoor.releasetracker.common.NotificationManager 11 | import timber.log.Timber 12 | import java.util.concurrent.TimeUnit 13 | import javax.inject.Inject 14 | 15 | @HiltAndroidApp 16 | class ReleaseTrackerApp : Application(), Configuration.Provider { 17 | 18 | @Inject 19 | lateinit var workerFactory: HiltWorkerFactory 20 | 21 | @Inject 22 | lateinit var notificationManager: NotificationManager 23 | 24 | override fun onCreate() { 25 | super.onCreate() 26 | notificationManager.createNotificationChannel() 27 | setupTimber() 28 | startUpdateWorker() 29 | } 30 | 31 | private fun setupTimber() { 32 | if (BuildConfig.DEBUG) { 33 | Timber.plant(Timber.DebugTree()) 34 | } 35 | } 36 | 37 | private fun startUpdateWorker() { 38 | WorkManager.getInstance(this) 39 | .enqueueUniquePeriodicWork( 40 | getString(R.string.worker_tag), 41 | ExistingPeriodicWorkPolicy.REPLACE, 42 | createWorkRequest() 43 | ) 44 | } 45 | 46 | private fun createWorkRequest(): PeriodicWorkRequest { 47 | val constraints = Constraints.Builder() 48 | .setRequiredNetworkType(NetworkType.CONNECTED) 49 | .setRequiresBatteryNotLow(true) 50 | .build() 51 | return PeriodicWorkRequestBuilder(1, TimeUnit.DAYS) 52 | .setConstraints(constraints) 53 | .setBackoffCriteria( 54 | BackoffPolicy.LINEAR, 55 | 1, 56 | TimeUnit.HOURS 57 | ) 58 | .setInitialDelay(10, TimeUnit.SECONDS) 59 | .addTag(getString(R.string.worker_tag)) 60 | .build() 61 | } 62 | 63 | override fun getWorkManagerConfiguration(): Configuration = 64 | Configuration.Builder() 65 | .setWorkerFactory(workerFactory) 66 | .build() 67 | 68 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/fallahpoor/releasetracker/features/addlibrary/AddLibraryViewModel.kt: -------------------------------------------------------------------------------- 1 | package ir.fallahpoor.releasetracker.features.addlibrary 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import dagger.hilt.android.lifecycle.HiltViewModel 6 | import ir.fallahpoor.releasetracker.common.GITHUB_BASE_URL 7 | import ir.fallahpoor.releasetracker.data.exceptions.ExceptionParser 8 | import ir.fallahpoor.releasetracker.data.repository.library.LibraryRepository 9 | import kotlinx.coroutines.flow.MutableStateFlow 10 | import kotlinx.coroutines.flow.StateFlow 11 | import kotlinx.coroutines.flow.asStateFlow 12 | import kotlinx.coroutines.launch 13 | import javax.inject.Inject 14 | 15 | @HiltViewModel 16 | class AddLibraryViewModel 17 | @Inject constructor( 18 | private val libraryRepository: LibraryRepository, 19 | private val exceptionParser: ExceptionParser 20 | ) : ViewModel() { 21 | 22 | private val GITHUB_URL_PATH_REGEX = Regex("([-.\\w]+)/([-.\\w]+)", RegexOption.IGNORE_CASE) 23 | 24 | private val _uiState = MutableStateFlow(AddLibraryScreenUiState()) 25 | val uiState: StateFlow = _uiState.asStateFlow() 26 | 27 | fun handleEvent(event: Event) { 28 | when (event) { 29 | is Event.UpdateLibraryName -> updateLibraryName(event.libraryName) 30 | is Event.UpdateLibraryUrlPath -> updateLibraryUrlPath(event.libraryUrlPath) 31 | is Event.AddLibrary -> addLibrary(event.libraryName.trim(), event.libraryUrlPath.trim()) 32 | is Event.ErrorDismissed -> resetUiState() 33 | } 34 | } 35 | 36 | private fun updateLibraryName(libraryName: String) { 37 | setUiState(_uiState.value.copy(libraryName = libraryName)) 38 | } 39 | 40 | private fun updateLibraryUrlPath(libraryUrlPath: String) { 41 | setUiState(_uiState.value.copy(libraryUrlPath = libraryUrlPath)) 42 | } 43 | 44 | private fun addLibrary(libraryName: String, libraryUrlPath: String) { 45 | 46 | if (libraryName.isEmpty()) { 47 | setUiState(_uiState.value.copy(addLibraryState = AddLibraryState.EmptyLibraryName)) 48 | return 49 | } 50 | 51 | if (libraryUrlPath.isEmpty()) { 52 | setUiState(_uiState.value.copy(addLibraryState = AddLibraryState.EmptyLibraryUrl)) 53 | return 54 | } 55 | 56 | if (!isGithubUrlPath(libraryUrlPath)) { 57 | setUiState(_uiState.value.copy(addLibraryState = AddLibraryState.InvalidLibraryUrl)) 58 | return 59 | } 60 | 61 | setUiState(_uiState.value.copy(addLibraryState = AddLibraryState.InProgress)) 62 | 63 | viewModelScope.launch { 64 | val uiState: AddLibraryScreenUiState = try { 65 | if (libraryAlreadyExists(libraryName)) { 66 | _uiState.value.copy(addLibraryState = AddLibraryState.Error("Library already exists")) 67 | } else { 68 | val libraryVersion: String = 69 | libraryRepository.getLibraryVersion(libraryName, libraryUrlPath) 70 | libraryRepository.addLibrary( 71 | libraryName = libraryName, 72 | libraryUrl = GITHUB_BASE_URL + libraryUrlPath, 73 | libraryVersion = libraryVersion 74 | ) 75 | _uiState.value.copy( 76 | libraryName = "", 77 | libraryUrlPath = "", 78 | addLibraryState = AddLibraryState.LibraryAdded 79 | ) 80 | } 81 | } catch (t: Throwable) { 82 | val message = exceptionParser.getMessage(t) 83 | _uiState.value.copy(addLibraryState = AddLibraryState.Error(message)) 84 | } 85 | setUiState(uiState) 86 | } 87 | 88 | } 89 | 90 | private suspend fun libraryAlreadyExists(libraryName: String): Boolean = 91 | libraryRepository.getLibrary(libraryName) != null 92 | 93 | private fun setUiState(uiState: AddLibraryScreenUiState) { 94 | _uiState.value = uiState 95 | } 96 | 97 | private fun isGithubUrlPath(url: String): Boolean = GITHUB_URL_PATH_REGEX.matches(url) 98 | 99 | private fun resetUiState() { 100 | setUiState( 101 | AddLibraryScreenUiState( 102 | libraryName = _uiState.value.libraryName, 103 | libraryUrlPath = _uiState.value.libraryUrlPath 104 | ) 105 | ) 106 | } 107 | 108 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/fallahpoor/releasetracker/features/addlibrary/Event.kt: -------------------------------------------------------------------------------- 1 | package ir.fallahpoor.releasetracker.features.addlibrary 2 | 3 | sealed class Event { 4 | data class AddLibrary(val libraryName: String, val libraryUrlPath: String) : Event() 5 | data class UpdateLibraryName(val libraryName: String) : Event() 6 | data class UpdateLibraryUrlPath(val libraryUrlPath: String) : Event() 7 | object ErrorDismissed : Event() 8 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/fallahpoor/releasetracker/features/addlibrary/UiState.kt: -------------------------------------------------------------------------------- 1 | package ir.fallahpoor.releasetracker.features.addlibrary 2 | 3 | data class AddLibraryScreenUiState( 4 | val libraryName: String = "", 5 | val libraryUrlPath: String = "", 6 | val addLibraryState: AddLibraryState = AddLibraryState.Initial 7 | ) 8 | 9 | sealed class AddLibraryState { 10 | object Initial : AddLibraryState() 11 | object EmptyLibraryName : AddLibraryState() 12 | object EmptyLibraryUrl : AddLibraryState() 13 | object InvalidLibraryUrl : AddLibraryState() 14 | object InProgress : AddLibraryState() 15 | object LibraryAdded : AddLibraryState() 16 | data class Error(val message: String) : AddLibraryState() 17 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/fallahpoor/releasetracker/features/addlibrary/ui/AddLibraryScreen.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalComposeUiApi::class) 2 | 3 | package ir.fallahpoor.releasetracker.features.addlibrary.ui 4 | 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.material.* 7 | import androidx.compose.material.icons.Icons 8 | import androidx.compose.material.icons.filled.ArrowBack 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.collectAsState 11 | import androidx.compose.runtime.getValue 12 | import androidx.compose.ui.ExperimentalComposeUiApi 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.platform.LocalSoftwareKeyboardController 15 | import androidx.compose.ui.platform.testTag 16 | import androidx.compose.ui.res.stringResource 17 | import androidx.compose.ui.tooling.preview.Preview 18 | import androidx.hilt.navigation.compose.hiltViewModel 19 | import ir.fallahpoor.releasetracker.R 20 | import ir.fallahpoor.releasetracker.features.addlibrary.AddLibraryScreenUiState 21 | import ir.fallahpoor.releasetracker.features.addlibrary.AddLibraryViewModel 22 | import ir.fallahpoor.releasetracker.features.addlibrary.Event 23 | import ir.fallahpoor.releasetracker.theme.ReleaseTrackerTheme 24 | 25 | object AddLibraryScreenTags { 26 | const val SCREEN = "screen" 27 | const val CONTENT = "content" 28 | const val TITLE = "title" 29 | const val BACK_BUTTON = "backButton" 30 | } 31 | 32 | @Composable 33 | fun AddLibraryScreen( 34 | addLibraryViewModel: AddLibraryViewModel = hiltViewModel(), 35 | isDarkTheme: Boolean, 36 | onBackClick: () -> Unit 37 | ) { 38 | ReleaseTrackerTheme(darkTheme = isDarkTheme) { 39 | val scaffoldState = rememberScaffoldState() 40 | Scaffold( 41 | modifier = Modifier.testTag(AddLibraryScreenTags.SCREEN), 42 | topBar = { AppBar(onBackClick) }, 43 | scaffoldState = scaffoldState, 44 | snackbarHost = { 45 | scaffoldState.snackbarHostState 46 | } 47 | ) { 48 | val uiState: AddLibraryScreenUiState by addLibraryViewModel.uiState.collectAsState() 49 | val keyboardController = LocalSoftwareKeyboardController.current 50 | AddLibraryContent( 51 | modifier = Modifier 52 | .fillMaxSize() 53 | .testTag(AddLibraryScreenTags.CONTENT), 54 | snackbarHostState = scaffoldState.snackbarHostState, 55 | state = uiState.addLibraryState, 56 | libraryName = uiState.libraryName, 57 | onLibraryNameChange = { libraryName -> 58 | addLibraryViewModel.handleEvent(Event.UpdateLibraryName(libraryName)) 59 | }, 60 | libraryUrlPath = uiState.libraryUrlPath, 61 | onLibraryUrlPathChange = { libraryUrlPath -> 62 | addLibraryViewModel.handleEvent(Event.UpdateLibraryUrlPath(libraryUrlPath)) 63 | }, 64 | onAddLibraryClick = { libraryName, libraryUrlPath -> 65 | addLibraryViewModel.handleEvent(Event.AddLibrary(libraryName, libraryUrlPath)) 66 | keyboardController?.hide() 67 | }, 68 | onErrorDismissed = { addLibraryViewModel.handleEvent(Event.ErrorDismissed) } 69 | ) 70 | } 71 | } 72 | } 73 | 74 | @Composable 75 | private fun AppBar(onBackClick: () -> Unit) { 76 | TopAppBar( 77 | title = { 78 | Text( 79 | modifier = Modifier.testTag(AddLibraryScreenTags.TITLE), 80 | text = stringResource(R.string.add_library) 81 | ) 82 | }, 83 | navigationIcon = { 84 | BackButton { onBackClick() } 85 | } 86 | ) 87 | } 88 | 89 | @Composable 90 | private fun BackButton(onBackClick: () -> Unit) { 91 | IconButton( 92 | modifier = Modifier.testTag(AddLibraryScreenTags.BACK_BUTTON), 93 | onClick = onBackClick 94 | ) { 95 | Icon( 96 | imageVector = Icons.Filled.ArrowBack, 97 | contentDescription = stringResource(R.string.back) 98 | ) 99 | } 100 | } 101 | 102 | @Composable 103 | @Preview 104 | private fun AddLibraryScreenPreview() { 105 | ReleaseTrackerTheme { 106 | Surface { 107 | AddLibraryScreen( 108 | isDarkTheme = false, 109 | onBackClick = {} 110 | ) 111 | } 112 | } 113 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/fallahpoor/releasetracker/features/addlibrary/ui/OutlinedTextFieldWithPrefix.kt: -------------------------------------------------------------------------------- 1 | package ir.fallahpoor.releasetracker.features.addlibrary.ui 2 | 3 | import androidx.compose.foundation.text.KeyboardActions 4 | import androidx.compose.foundation.text.KeyboardOptions 5 | import androidx.compose.material.OutlinedTextField 6 | import androidx.compose.material.Text 7 | import androidx.compose.runtime.* 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.text.AnnotatedString 10 | import androidx.compose.ui.text.input.ImeAction 11 | import androidx.compose.ui.text.input.OffsetMapping 12 | import androidx.compose.ui.text.input.TransformedText 13 | import androidx.compose.ui.text.input.VisualTransformation 14 | import androidx.compose.ui.tooling.preview.Preview 15 | 16 | @Composable 17 | fun OutlinedTextFieldWithPrefix( 18 | modifier: Modifier = Modifier, 19 | prefix: String, 20 | text: String, 21 | onTextChange: (String) -> Unit, 22 | hint: String, 23 | imeAction: ImeAction, 24 | keyboardActions: KeyboardActions = KeyboardActions(), 25 | isError: Boolean 26 | ) { 27 | OutlinedTextField( 28 | value = text, 29 | label = { 30 | Text(text = hint) 31 | }, 32 | onValueChange = onTextChange, 33 | keyboardOptions = KeyboardOptions( 34 | imeAction = imeAction 35 | ), 36 | visualTransformation = PrefixTransformation(prefix), 37 | keyboardActions = keyboardActions, 38 | singleLine = true, 39 | isError = isError, 40 | modifier = modifier 41 | ) 42 | } 43 | 44 | private class PrefixTransformation(private val prefix: String) : VisualTransformation { 45 | 46 | override fun filter(text: AnnotatedString): TransformedText { 47 | return prefixFilter(text, prefix) 48 | } 49 | 50 | } 51 | 52 | private fun prefixFilter(text: AnnotatedString, prefix: String): TransformedText { 53 | 54 | val prefixOffset = prefix.length 55 | 56 | val textOffsetTranslator = object : OffsetMapping { 57 | 58 | override fun originalToTransformed(offset: Int): Int { 59 | return offset + prefixOffset 60 | } 61 | 62 | override fun transformedToOriginal(offset: Int): Int = 63 | if (offset < prefixOffset) { 64 | prefixOffset 65 | } else { 66 | offset - prefixOffset 67 | } 68 | 69 | } 70 | 71 | val outputText = AnnotatedString(prefix + text.text) 72 | 73 | return TransformedText(outputText, textOffsetTranslator) 74 | 75 | } 76 | 77 | @Preview(showBackground = true) 78 | @Composable 79 | private fun OutlinedTextFieldWithPrefixPreview() { 80 | var text by remember { mutableStateOf("") } 81 | OutlinedTextFieldWithPrefix( 82 | prefix = "https://github.com/", 83 | hint = "Library URL", 84 | text = text, 85 | onTextChange = { 86 | text = it 87 | }, 88 | isError = false, 89 | imeAction = ImeAction.Done 90 | ) 91 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/fallahpoor/releasetracker/features/libraries/Event.kt: -------------------------------------------------------------------------------- 1 | package ir.fallahpoor.releasetracker.features.libraries 2 | 3 | import ir.fallahpoor.releasetracker.data.SortOrder 4 | import ir.fallahpoor.releasetracker.data.repository.library.Library 5 | 6 | sealed class Event { 7 | data class PinLibrary(val library: Library, val pin: Boolean) : Event() 8 | data class DeleteLibrary(val library: Library) : Event() 9 | data class ChangeSortOrder(val sortOrder: SortOrder) : Event() 10 | data class ChangeSearchQuery(val searchQuery: String) : Event() 11 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/fallahpoor/releasetracker/features/libraries/LibrariesViewModel.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalCoroutinesApi::class) 2 | 3 | package ir.fallahpoor.releasetracker.features.libraries 4 | 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.viewModelScope 7 | import dagger.hilt.android.lifecycle.HiltViewModel 8 | import ir.fallahpoor.releasetracker.data.SortOrder 9 | import ir.fallahpoor.releasetracker.data.repository.library.Library 10 | import ir.fallahpoor.releasetracker.data.repository.library.LibraryRepository 11 | import ir.fallahpoor.releasetracker.data.repository.storage.StorageRepository 12 | import kotlinx.coroutines.ExperimentalCoroutinesApi 13 | import kotlinx.coroutines.flow.* 14 | import kotlinx.coroutines.launch 15 | import timber.log.Timber 16 | import java.util.* 17 | import javax.inject.Inject 18 | 19 | @HiltViewModel 20 | class LibrariesViewModel 21 | @Inject constructor( 22 | private val libraryRepository: LibraryRepository, 23 | private val storageRepository: StorageRepository 24 | ) : ViewModel() { 25 | 26 | private data class Params( 27 | val sortOrder: SortOrder, 28 | val searchQuery: String 29 | ) 30 | 31 | private val searchQueryFlow = MutableStateFlow("") 32 | private val getLibrariesTriggerFlow: Flow = combine( 33 | storageRepository.getSortOrderAsFlow(), 34 | searchQueryFlow 35 | ) { sortOrder: SortOrder, searchQuery: String -> 36 | Params(sortOrder, searchQuery) 37 | } 38 | 39 | val uiState: StateFlow = 40 | getLibrariesTriggerFlow.distinctUntilChanged() 41 | .flatMapLatest { params -> getLibrariesAsFlow(params) } 42 | .map { libraries -> 43 | LibrariesListScreenUiState( 44 | sortOrder = storageRepository.getSortOrder(), 45 | searchQuery = searchQueryFlow.value, 46 | librariesListState = LibrariesListState.LibrariesLoaded(libraries) 47 | ) 48 | } 49 | .stateIn( 50 | scope = viewModelScope, 51 | started = SharingStarted.Eagerly, 52 | initialValue = LibrariesListScreenUiState(sortOrder = storageRepository.getSortOrder()) 53 | ) 54 | 55 | val lastUpdateCheck: StateFlow = storageRepository.getLastUpdateCheck() 56 | .stateIn(scope = viewModelScope, started = SharingStarted.Eagerly, initialValue = "N/A") 57 | 58 | fun handleEvent(event: Event) { 59 | when (event) { 60 | is Event.PinLibrary -> pinLibrary(event.library, event.pin) 61 | is Event.DeleteLibrary -> deleteLibrary(event.library) 62 | is Event.ChangeSortOrder -> changeSortOrder(event.sortOrder) 63 | is Event.ChangeSearchQuery -> searchQueryFlow.value = event.searchQuery 64 | } 65 | } 66 | 67 | private fun pinLibrary(library: Library, pin: Boolean) { 68 | viewModelScope.launch { 69 | try { 70 | libraryRepository.pinLibrary(library, pin) 71 | } catch (t: Throwable) { 72 | Timber.e(t) 73 | } 74 | } 75 | } 76 | 77 | private fun deleteLibrary(library: Library) { 78 | viewModelScope.launch { 79 | try { 80 | libraryRepository.deleteLibrary(library) 81 | } catch (t: Throwable) { 82 | Timber.e(t) 83 | } 84 | } 85 | } 86 | 87 | private fun changeSortOrder(sortOrder: SortOrder) { 88 | viewModelScope.launch { 89 | try { 90 | storageRepository.setSortOrder(sortOrder) 91 | } catch (t: Throwable) { 92 | Timber.e(t) 93 | } 94 | } 95 | } 96 | 97 | private fun getLibrariesAsFlow(params: Params): Flow> = 98 | libraryRepository.getLibrariesAsFlow() 99 | .map { libraries -> 100 | libraries.filter { 101 | it.name.contains(params.searchQuery, ignoreCase = true) 102 | } 103 | } 104 | .map { libraries -> 105 | libraries.sort(params.sortOrder) 106 | } 107 | 108 | private fun List.sort(sortOrder: SortOrder): List = when (sortOrder) { 109 | SortOrder.A_TO_Z -> sortedBy { it.name.lowercase(Locale.getDefault()) } 110 | SortOrder.Z_TO_A -> sortedByDescending { it.name.lowercase(Locale.getDefault()) } 111 | SortOrder.PINNED_FIRST -> sortedByDescending { it.isPinned } 112 | } 113 | 114 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/fallahpoor/releasetracker/features/libraries/UiState.kt: -------------------------------------------------------------------------------- 1 | package ir.fallahpoor.releasetracker.features.libraries 2 | 3 | import ir.fallahpoor.releasetracker.data.SortOrder 4 | import ir.fallahpoor.releasetracker.data.repository.library.Library 5 | 6 | data class LibrariesListScreenUiState( 7 | val sortOrder: SortOrder = SortOrder.A_TO_Z, 8 | val searchQuery: String = "", 9 | val librariesListState: LibrariesListState = LibrariesListState.Loading 10 | ) 11 | 12 | sealed class LibrariesListState { 13 | object Loading : LibrariesListState() 14 | data class LibrariesLoaded(val libraries: List) : LibrariesListState() 15 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/fallahpoor/releasetracker/features/libraries/ui/LibrariesList.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalFoundationApi::class) 2 | 3 | package ir.fallahpoor.releasetracker.features.libraries.ui 4 | 5 | import androidx.compose.foundation.ExperimentalFoundationApi 6 | import androidx.compose.foundation.layout.Box 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.lazy.LazyColumn 11 | import androidx.compose.foundation.lazy.items 12 | import androidx.compose.material.* 13 | import androidx.compose.material.icons.Icons 14 | import androidx.compose.material.icons.filled.Add 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.platform.testTag 19 | import androidx.compose.ui.res.stringResource 20 | import ir.fallahpoor.releasetracker.R 21 | import ir.fallahpoor.releasetracker.data.repository.library.Library 22 | import ir.fallahpoor.releasetracker.theme.spacing 23 | 24 | object LibrariesListTags { 25 | const val LIBRARIES_LIST = "librariesList" 26 | const val ADD_LIBRARY_BUTTON = "LibrariesListAddLibraryButton" 27 | const val NO_LIBRARIES_TEXT = "librariesListNoLibraries" 28 | } 29 | 30 | @Composable 31 | fun LibrariesList( 32 | libraries: List, 33 | onLibraryClick: (Library) -> Unit, 34 | onLibraryDismissed: (Library) -> Unit, 35 | onPinLibraryClick: (Library, Boolean) -> Unit, 36 | onAddLibraryClick: () -> Unit 37 | ) { 38 | Box( 39 | modifier = Modifier 40 | .fillMaxSize() 41 | .testTag(LibrariesListTags.LIBRARIES_LIST), 42 | contentAlignment = Alignment.BottomStart 43 | ) { 44 | if (libraries.isEmpty()) { 45 | NoLibrariesText() 46 | } else { 47 | LazyColumn(modifier = Modifier.fillMaxSize()) { 48 | items( 49 | items = libraries, 50 | key = { library: Library -> library.name } 51 | ) { library: Library -> 52 | LibraryItem( 53 | modifier = Modifier 54 | .fillMaxWidth() 55 | .animateItemPlacement(), 56 | library = library, 57 | onLibraryClick = onLibraryClick, 58 | onPinLibraryClick = onPinLibraryClick, 59 | onLibraryDismissed = onLibraryDismissed 60 | ) 61 | Divider() 62 | } 63 | } 64 | } 65 | AddLibraryButton(onClick = onAddLibraryClick) 66 | } 67 | } 68 | 69 | @Composable 70 | private fun NoLibrariesText() { 71 | Box( 72 | modifier = Modifier 73 | .fillMaxSize() 74 | .testTag(LibrariesListTags.NO_LIBRARIES_TEXT), 75 | contentAlignment = Alignment.Center 76 | ) { 77 | Text( 78 | modifier = Modifier.padding(MaterialTheme.spacing.normal), 79 | text = stringResource(R.string.no_libraries) 80 | ) 81 | } 82 | } 83 | 84 | @Composable 85 | private fun AddLibraryButton(onClick: () -> Unit) { 86 | FloatingActionButton( 87 | modifier = Modifier 88 | .padding(MaterialTheme.spacing.normal) 89 | .testTag(LibrariesListTags.ADD_LIBRARY_BUTTON), 90 | onClick = onClick 91 | ) { 92 | Icon( 93 | imageVector = Icons.Filled.Add, 94 | contentDescription = stringResource(R.string.add_library) 95 | ) 96 | } 97 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/fallahpoor/releasetracker/features/libraries/ui/LibrariesListContent.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalAnimationApi::class) 2 | 3 | package ir.fallahpoor.releasetracker.features.libraries.ui 4 | 5 | import androidx.compose.animation.AnimatedContent 6 | import androidx.compose.animation.ExperimentalAnimationApi 7 | import androidx.compose.foundation.layout.* 8 | import androidx.compose.material.CircularProgressIndicator 9 | import androidx.compose.material.MaterialTheme 10 | import androidx.compose.material.Surface 11 | import androidx.compose.material.Text 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.ui.Alignment 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.platform.testTag 16 | import androidx.compose.ui.res.stringResource 17 | import androidx.compose.ui.tooling.preview.Preview 18 | import ir.fallahpoor.releasetracker.R 19 | import ir.fallahpoor.releasetracker.data.repository.library.Library 20 | import ir.fallahpoor.releasetracker.features.libraries.LibrariesListState 21 | import ir.fallahpoor.releasetracker.theme.ReleaseTrackerTheme 22 | import ir.fallahpoor.releasetracker.theme.spacing 23 | 24 | object LibrariesListContentTags { 25 | const val LAST_UPDATE_CHECK_TEXT = "librariesListContentLastUpdateCheckText" 26 | const val PROGRESS_INDICATOR = "librariesListContentProgressIndicator" 27 | } 28 | 29 | @Composable 30 | fun LibrariesListContent( 31 | modifier: Modifier = Modifier, 32 | librariesListState: LibrariesListState, 33 | lastUpdateCheck: String, 34 | onLibraryClick: (Library) -> Unit, 35 | onLibraryDismissed: (Library) -> Unit, 36 | onPinLibraryClick: (Library, Boolean) -> Unit, 37 | onAddLibraryClick: () -> Unit 38 | ) { 39 | Column( 40 | modifier = modifier, 41 | horizontalAlignment = Alignment.CenterHorizontally 42 | ) { 43 | LastUpdateCheckText(lastUpdateCheck) 44 | when (librariesListState) { 45 | is LibrariesListState.Loading -> ProgressIndicator() 46 | is LibrariesListState.LibrariesLoaded -> { 47 | LibrariesList( 48 | libraries = librariesListState.libraries, 49 | onLibraryClick = onLibraryClick, 50 | onLibraryDismissed = onLibraryDismissed, 51 | onPinLibraryClick = onPinLibraryClick, 52 | onAddLibraryClick = onAddLibraryClick 53 | ) 54 | } 55 | } 56 | } 57 | } 58 | 59 | @Composable 60 | private fun ProgressIndicator() { 61 | Box( 62 | modifier = Modifier 63 | .fillMaxSize() 64 | .testTag(LibrariesListContentTags.PROGRESS_INDICATOR), 65 | contentAlignment = Alignment.Center 66 | ) { 67 | CircularProgressIndicator() 68 | } 69 | } 70 | 71 | @Composable 72 | private fun LastUpdateCheckText(lastUpdateCheck: String) { 73 | AnimatedContent(targetState = lastUpdateCheck) { lastUpdateCheck: String -> 74 | Text( 75 | modifier = Modifier 76 | .fillMaxWidth() 77 | .padding(MaterialTheme.spacing.normal) 78 | .testTag(LibrariesListContentTags.LAST_UPDATE_CHECK_TEXT), 79 | text = stringResource(R.string.last_check_for_updates, lastUpdateCheck) 80 | ) 81 | } 82 | } 83 | 84 | @Preview 85 | @Composable 86 | private fun LibrariesListContentPreview() { 87 | ReleaseTrackerTheme { 88 | Surface { 89 | LibrariesListContent( 90 | librariesListState = LibrariesListState.Loading, 91 | lastUpdateCheck = "N/A", 92 | onLibraryClick = {}, 93 | onLibraryDismissed = {}, 94 | onPinLibraryClick = { _, _ -> }, 95 | onAddLibraryClick = {} 96 | ) 97 | } 98 | } 99 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/fallahpoor/releasetracker/features/libraries/ui/LibrariesListScreen.kt: -------------------------------------------------------------------------------- 1 | package ir.fallahpoor.releasetracker.features.libraries.ui 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.material.Scaffold 6 | import androidx.compose.material.ScaffoldState 7 | import androidx.compose.material.rememberScaffoldState 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.collectAsState 10 | import androidx.compose.runtime.derivedStateOf 11 | import androidx.compose.runtime.getValue 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.platform.testTag 14 | import androidx.hilt.navigation.compose.hiltViewModel 15 | import ir.fallahpoor.releasetracker.data.NightMode 16 | import ir.fallahpoor.releasetracker.data.SortOrder 17 | import ir.fallahpoor.releasetracker.data.repository.library.Library 18 | import ir.fallahpoor.releasetracker.features.libraries.Event 19 | import ir.fallahpoor.releasetracker.features.libraries.LibrariesListScreenUiState 20 | import ir.fallahpoor.releasetracker.features.libraries.LibrariesViewModel 21 | import ir.fallahpoor.releasetracker.theme.ReleaseTrackerTheme 22 | 23 | object LibrariesListScreenTags { 24 | const val SCREEN = "librariesListScreen" 25 | const val TOOLBAR = "librariesListScreenToolbar" 26 | const val CONTENT = "librariesListScreenContent" 27 | } 28 | 29 | @Composable 30 | fun LibrariesListScreen( 31 | librariesViewModel: LibrariesViewModel = hiltViewModel(), 32 | isNightModeSupported: Boolean, 33 | currentNightMode: NightMode, 34 | onNightModeChange: (NightMode) -> Unit, 35 | onLibraryClick: (Library) -> Unit, 36 | onAddLibraryClick: () -> Unit, 37 | scaffoldState: ScaffoldState = rememberScaffoldState() 38 | ) { 39 | val uiState: LibrariesListScreenUiState by librariesViewModel.uiState.collectAsState() 40 | val lastUpdateCheck: String by librariesViewModel.lastUpdateCheck.collectAsState() 41 | val isSystemInDarkTheme = isSystemInDarkTheme() 42 | val isNightModeOn by derivedStateOf { 43 | when (currentNightMode) { 44 | NightMode.OFF -> false 45 | NightMode.ON -> true 46 | NightMode.AUTO -> isSystemInDarkTheme 47 | } 48 | } 49 | 50 | ReleaseTrackerTheme(darkTheme = isNightModeOn) { 51 | Scaffold( 52 | modifier = Modifier.testTag(LibrariesListScreenTags.SCREEN), 53 | scaffoldState = scaffoldState, 54 | snackbarHost = { scaffoldState.snackbarHostState }, 55 | topBar = { 56 | Toolbar( 57 | modifier = Modifier.testTag(LibrariesListScreenTags.TOOLBAR), 58 | currentSortOrder = uiState.sortOrder, 59 | onSortOrderChange = { sortOrder: SortOrder -> 60 | librariesViewModel.handleEvent(Event.ChangeSortOrder(sortOrder)) 61 | }, 62 | isNightModeSupported = isNightModeSupported, 63 | currentNightMode = currentNightMode, 64 | onNightModeChange = onNightModeChange, 65 | onSearchQueryChange = { query: String -> 66 | librariesViewModel.handleEvent(Event.ChangeSearchQuery(query)) 67 | }, 68 | onSearchQuerySubmit = { query: String -> 69 | librariesViewModel.handleEvent(Event.ChangeSearchQuery(query)) 70 | } 71 | ) 72 | } 73 | ) { 74 | LibrariesListContent( 75 | modifier = Modifier 76 | .fillMaxSize() 77 | .testTag(LibrariesListScreenTags.CONTENT), 78 | librariesListState = uiState.librariesListState, 79 | lastUpdateCheck = lastUpdateCheck, 80 | onLibraryClick = onLibraryClick, 81 | onLibraryDismissed = { library: Library -> 82 | librariesViewModel.handleEvent(Event.DeleteLibrary(library)) 83 | }, 84 | onPinLibraryClick = { library: Library, pinned: Boolean -> 85 | librariesViewModel.handleEvent(Event.PinLibrary(library, pinned)) 86 | }, 87 | onAddLibraryClick = onAddLibraryClick 88 | ) 89 | } 90 | } 91 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/fallahpoor/releasetracker/features/libraries/ui/SearchBar.kt: -------------------------------------------------------------------------------- 1 | package ir.fallahpoor.releasetracker.features.libraries.ui 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.text.BasicTextField 7 | import androidx.compose.foundation.text.KeyboardActions 8 | import androidx.compose.foundation.text.KeyboardOptions 9 | import androidx.compose.material.* 10 | import androidx.compose.material.icons.Icons 11 | import androidx.compose.material.icons.filled.ArrowBack 12 | import androidx.compose.material.icons.filled.Clear 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.CompositionLocalProvider 15 | import androidx.compose.runtime.DisposableEffect 16 | import androidx.compose.runtime.remember 17 | import androidx.compose.ui.Alignment 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.focus.FocusRequester 20 | import androidx.compose.ui.focus.focusRequester 21 | import androidx.compose.ui.graphics.Shape 22 | import androidx.compose.ui.graphics.SolidColor 23 | import androidx.compose.ui.platform.testTag 24 | import androidx.compose.ui.res.stringResource 25 | import androidx.compose.ui.text.input.ImeAction 26 | import androidx.compose.ui.tooling.preview.Preview 27 | import androidx.compose.ui.unit.Dp 28 | import androidx.compose.ui.unit.dp 29 | import ir.fallahpoor.releasetracker.R 30 | import ir.fallahpoor.releasetracker.theme.ReleaseTrackerTheme 31 | import ir.fallahpoor.releasetracker.theme.spacing 32 | 33 | object SearchBarTags { 34 | const val CLOSE_BUTTON = "searchBarCloseButton" 35 | const val CLEAR_BUTTON = "searchBarClearButton" 36 | const val QUERY_TEXT_FIELD = "searchBarQueryTextField" 37 | const val SEARCH_BAR = "searchBar" 38 | } 39 | 40 | @Composable 41 | fun SearchBar( 42 | modifier: Modifier = Modifier, 43 | shape: Shape = MaterialTheme.shapes.small, 44 | elevation: Dp = MaterialTheme.spacing.small, 45 | hint: String, 46 | query: String, 47 | onQueryChange: (String) -> Unit, 48 | onQuerySubmit: (String) -> Unit, 49 | onClearClick: () -> Unit, 50 | onCloseClick: () -> Unit 51 | ) { 52 | Surface( 53 | modifier = modifier.testTag(SearchBarTags.SEARCH_BAR), 54 | shape = shape, 55 | elevation = elevation 56 | ) { 57 | Row( 58 | modifier = Modifier.fillMaxWidth(), 59 | verticalAlignment = Alignment.CenterVertically 60 | ) { 61 | CloseButton(onClick = onCloseClick) 62 | Box( 63 | modifier = Modifier 64 | .fillMaxWidth() 65 | .weight(1f) 66 | ) { 67 | if (query.isBlank()) { 68 | HintText(hint = hint) 69 | } 70 | SearchTextField( 71 | query = query, 72 | onQueryChange = onQueryChange, 73 | onQuerySubmit = onQuerySubmit 74 | ) 75 | } 76 | ClearButton(onClick = onClearClick) 77 | } 78 | } 79 | } 80 | 81 | @Composable 82 | private fun CloseButton(onClick: () -> Unit) { 83 | IconButton( 84 | modifier = Modifier.testTag(SearchBarTags.CLOSE_BUTTON), 85 | onClick = onClick 86 | ) { 87 | Icon( 88 | imageVector = Icons.Filled.ArrowBack, 89 | contentDescription = stringResource(R.string.close_search_bar) 90 | ) 91 | } 92 | } 93 | 94 | @Composable 95 | private fun SearchTextField( 96 | query: String, 97 | onQueryChange: (String) -> Unit, 98 | onQuerySubmit: (String) -> Unit 99 | ) { 100 | val focusRequester = remember { FocusRequester() } 101 | BasicTextField( 102 | modifier = Modifier 103 | .fillMaxWidth() 104 | .focusRequester(focusRequester) 105 | .testTag(SearchBarTags.QUERY_TEXT_FIELD), 106 | value = query, 107 | onValueChange = onQueryChange, 108 | singleLine = true, 109 | keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), 110 | keyboardActions = KeyboardActions( 111 | onSearch = { 112 | onQuerySubmit(query) 113 | } 114 | ), 115 | textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onSurface), 116 | cursorBrush = SolidColor(MaterialTheme.colors.onSurface) 117 | ) 118 | DisposableEffect(Unit) { 119 | focusRequester.requestFocus() 120 | onDispose { } 121 | } 122 | } 123 | 124 | @Composable 125 | private fun HintText(hint: String) { 126 | CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.disabled) { 127 | Text( 128 | modifier = Modifier.fillMaxWidth(), 129 | text = hint 130 | ) 131 | } 132 | } 133 | 134 | @Composable 135 | private fun ClearButton(onClick: () -> Unit) { 136 | IconButton( 137 | modifier = Modifier.testTag(SearchBarTags.CLEAR_BUTTON), 138 | onClick = onClick 139 | ) { 140 | Icon( 141 | imageVector = Icons.Filled.Clear, 142 | contentDescription = stringResource(R.string.clear_search_bar) 143 | ) 144 | } 145 | } 146 | 147 | @Composable 148 | @Preview 149 | private fun SearchBarPreview() { 150 | ReleaseTrackerTheme(darkTheme = false) { 151 | Surface { 152 | SearchBar( 153 | shape = MaterialTheme.shapes.small, 154 | elevation = 8.dp, 155 | hint = "Search", 156 | query = "Coil", 157 | onQueryChange = {}, 158 | onClearClick = {}, 159 | onCloseClick = {}, 160 | onQuerySubmit = {} 161 | ) 162 | } 163 | } 164 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/fallahpoor/releasetracker/features/libraries/ui/SingleSelectionDialog.kt: -------------------------------------------------------------------------------- 1 | package ir.fallahpoor.releasetracker.features.libraries.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.fillMaxWidth 7 | import androidx.compose.material.AlertDialog 8 | import androidx.compose.material.RadioButton 9 | import androidx.compose.material.Surface 10 | import androidx.compose.material.Text 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.platform.testTag 15 | import androidx.compose.ui.tooling.preview.Preview 16 | import ir.fallahpoor.releasetracker.data.NightMode 17 | import ir.fallahpoor.releasetracker.theme.ReleaseTrackerTheme 18 | 19 | @Composable 20 | fun SingleSelectionDialog( 21 | title: String, 22 | items: List, 23 | labels: List, 24 | selectedItem: T, 25 | onItemSelect: (T) -> Unit, 26 | onDismiss: () -> Unit 27 | ) { 28 | AlertDialog( 29 | onDismissRequest = onDismiss, 30 | title = { 31 | Text(text = title) 32 | }, 33 | text = { 34 | DialogContent( 35 | items = items, 36 | labels = labels, 37 | selectedItem = selectedItem, 38 | onItemSelect = onItemSelect 39 | ) 40 | }, 41 | confirmButton = {} 42 | ) 43 | } 44 | 45 | @Composable 46 | private fun DialogContent( 47 | items: List, 48 | labels: List, 49 | selectedItem: T, 50 | onItemSelect: (T) -> Unit 51 | ) { 52 | Column { 53 | labels.forEachIndexed { index, label -> 54 | Item( 55 | text = label, 56 | onItemSelect = { onItemSelect(items[index]) }, 57 | isSelected = items[index] == selectedItem 58 | ) 59 | } 60 | } 61 | } 62 | 63 | @Composable 64 | private fun Item( 65 | text: String, 66 | onItemSelect: () -> Unit, 67 | isSelected: Boolean 68 | ) { 69 | Row( 70 | modifier = Modifier 71 | .fillMaxWidth() 72 | .clickable(onClick = onItemSelect), 73 | verticalAlignment = Alignment.CenterVertically 74 | ) { 75 | RadioButton( 76 | modifier = Modifier.testTag(text), 77 | selected = isSelected, 78 | onClick = onItemSelect 79 | ) 80 | Text(text = text) 81 | } 82 | } 83 | 84 | @Preview 85 | @Composable 86 | private fun NightModeDialogPreview() { 87 | ReleaseTrackerTheme(darkTheme = false) { 88 | Surface { 89 | SingleSelectionDialog( 90 | title = "Awesome title", 91 | items = NightMode.values().toList(), 92 | labels = NightMode.values().map { it.name }.toList(), 93 | selectedItem = NightMode.ON, 94 | onItemSelect = {}, 95 | onDismiss = {} 96 | ) 97 | } 98 | } 99 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/fallahpoor/releasetracker/theme/Colors.kt: -------------------------------------------------------------------------------- 1 | package ir.fallahpoor.releasetracker.theme 2 | 3 | import androidx.compose.material.Colors 4 | import androidx.compose.material.darkColors 5 | import androidx.compose.material.lightColors 6 | import androidx.compose.ui.graphics.Color 7 | 8 | private object ColorPalette { 9 | val Magenta = Color(0xff6200EE) 10 | val Blue = Color(0xff3700B3) 11 | val Teal = Color(0xff03DAC5) 12 | val DarkRed = Color(0xffF44336) 13 | val White = Color(0xffEEEEEE) 14 | val Black = Color(0xff212121) 15 | } 16 | 17 | val lightColors: Colors = lightColors( 18 | primary = ColorPalette.Magenta, 19 | primaryVariant = ColorPalette.Blue, 20 | onPrimary = ColorPalette.White, 21 | secondary = ColorPalette.Teal, 22 | onSecondary = ColorPalette.Black, 23 | error = ColorPalette.DarkRed 24 | ) 25 | 26 | val darkColors: Colors = darkColors( 27 | primary = ColorPalette.Teal, 28 | primaryVariant = ColorPalette.Black, 29 | onPrimary = ColorPalette.Black, 30 | secondary = ColorPalette.Teal, 31 | onSecondary = ColorPalette.Black, 32 | error = ColorPalette.DarkRed 33 | ) -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/fallahpoor/releasetracker/theme/Spacing.kt: -------------------------------------------------------------------------------- 1 | package ir.fallahpoor.releasetracker.theme 2 | 3 | import androidx.compose.material.MaterialTheme 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.ReadOnlyComposable 6 | import androidx.compose.runtime.compositionLocalOf 7 | import androidx.compose.ui.unit.Dp 8 | import androidx.compose.ui.unit.dp 9 | 10 | data class Spacing( 11 | val default: Dp = 0.dp, 12 | val small: Dp = 8.dp, 13 | val normal: Dp = 16.dp 14 | ) 15 | 16 | val LocalSpacing = compositionLocalOf { Spacing() } 17 | 18 | val MaterialTheme.spacing: Spacing 19 | @Composable 20 | @ReadOnlyComposable 21 | get() = LocalSpacing.current -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/fallahpoor/releasetracker/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package ir.fallahpoor.releasetracker.theme 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.material.MaterialTheme 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.CompositionLocalProvider 7 | 8 | @Composable 9 | fun ReleaseTrackerTheme( 10 | darkTheme: Boolean = isSystemInDarkTheme(), 11 | content: @Composable () -> Unit 12 | ) { 13 | CompositionLocalProvider(LocalSpacing provides Spacing()) { 14 | MaterialTheme( 15 | colors = if (darkTheme) darkColors else lightColors, 16 | content = content 17 | ) 18 | } 19 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /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_pin_filled.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_pin_outline.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_sort.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MasoudFallahpour/ReleaseTracker/65f8c958f8bae1dee7fe96ed4113b8fd4fbfe4e4/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MasoudFallahpour/ReleaseTracker/65f8c958f8bae1dee7fe96ed4113b8fd4fbfe4e4/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MasoudFallahpour/ReleaseTracker/65f8c958f8bae1dee7fe96ed4113b8fd4fbfe4e4/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MasoudFallahpour/ReleaseTracker/65f8c958f8bae1dee7fe96ed4113b8fd4fbfe4e4/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MasoudFallahpour/ReleaseTracker/65f8c958f8bae1dee7fe96ed4113b8fd4fbfe4e4/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MasoudFallahpour/ReleaseTracker/65f8c958f8bae1dee7fe96ed4113b8fd4fbfe4e4/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MasoudFallahpour/ReleaseTracker/65f8c958f8bae1dee7fe96ed4113b8fd4fbfe4e4/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MasoudFallahpour/ReleaseTracker/65f8c958f8bae1dee7fe96ed4113b8fd4fbfe4e4/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MasoudFallahpour/ReleaseTracker/65f8c958f8bae1dee7fe96ed4113b8fd4fbfe4e4/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MasoudFallahpour/ReleaseTracker/65f8c958f8bae1dee7fe96ed4113b8fd4fbfe4e4/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Release Tracker 3 | Add library 4 | Library name 5 | Library URL 6 | Library added 7 | Library name cannot be empty 8 | Library URL cannot be empty 9 | Library URL is invalid 10 | UPDATE_VERSION_WORKER 11 | Sort 12 | Select Sort Order 13 | Last check: %s 14 | There are no libraries 15 | Night mode… 16 | Select night mode 17 | Search 18 | Pin library 19 | Unpin library 20 | More options 21 | Clear search bar 22 | Close search bar 23 | General 24 | All notifications of the app 25 | Updates Available 26 | There are updates for the following libraries:\n%s 27 | Go back 28 | No browser found to open %s 29 | Delete library 30 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 |