├── .editorconfig ├── .github ├── CODEOWNERS └── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── issue_report.yml ├── .gitignore ├── .woodpecker.yml ├── LICENSE ├── README.md ├── RELEASES.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ ├── debug │ └── res │ │ └── values │ │ └── strings.xml │ └── main │ ├── AndroidManifest.xml │ ├── app_icon.svg │ ├── assets │ └── RELEASES.md │ ├── java │ └── com │ │ └── dessalines │ │ └── rankmyfavs │ │ ├── MainActivity.kt │ │ ├── db │ │ ├── AppDB.kt │ │ ├── AppSettings.kt │ │ ├── FavList.kt │ │ ├── FavListItem.kt │ │ ├── FavListMatch.kt │ │ ├── Migrations.kt │ │ └── TierList.kt │ │ ├── ui │ │ ├── components │ │ │ ├── about │ │ │ │ └── AboutScreen.kt │ │ │ ├── common │ │ │ │ ├── AppBars.kt │ │ │ │ ├── Dialogs.kt │ │ │ │ └── Sizes.kt │ │ │ ├── favlist │ │ │ │ ├── CreateFavListScreen.kt │ │ │ │ ├── EditFavListScreen.kt │ │ │ │ ├── FavListForm.kt │ │ │ │ ├── ImportListScreen.kt │ │ │ │ ├── TierListScreen.kt │ │ │ │ └── favlistanddetails │ │ │ │ │ ├── FavListDetailPane.kt │ │ │ │ │ ├── FavListsAndDetailScreen.kt │ │ │ │ │ └── FavListsPane.kt │ │ │ ├── favlistitem │ │ │ │ ├── CreateFavListItemScreen.kt │ │ │ │ ├── EditFavListItemScreen.kt │ │ │ │ ├── FavListItemDetailScreen.kt │ │ │ │ └── FavListItemForm.kt │ │ │ ├── match │ │ │ │ └── MatchScreen.kt │ │ │ └── settings │ │ │ │ └── SettingsScreen.kt │ │ └── theme │ │ │ ├── Color.kt │ │ │ ├── Shape.kt │ │ │ ├── Theme.kt │ │ │ └── Type.kt │ │ └── utils │ │ ├── Types.kt │ │ └── Utils.kt │ ├── play_store_512.png │ └── res │ ├── drawable │ └── app_icon.xml │ ├── mipmap-anydpi-v26 │ └── ic_launcher.xml │ ├── mipmap-hdpi │ ├── ic_launcher.png │ ├── ic_launcher_background.png │ ├── ic_launcher_foreground.png │ └── ic_launcher_monochrome.png │ ├── mipmap-mdpi │ ├── ic_launcher.png │ ├── ic_launcher_background.png │ ├── ic_launcher_foreground.png │ └── ic_launcher_monochrome.png │ ├── mipmap-xhdpi │ ├── ic_launcher.png │ ├── ic_launcher_background.png │ ├── ic_launcher_foreground.png │ └── ic_launcher_monochrome.png │ ├── mipmap-xxhdpi │ ├── ic_launcher.png │ ├── ic_launcher_background.png │ ├── ic_launcher_foreground.png │ └── ic_launcher_monochrome.png │ ├── mipmap-xxxhdpi │ ├── ic_launcher.png │ ├── ic_launcher_background.png │ ├── ic_launcher_foreground.png │ └── ic_launcher_monochrome.png │ └── values │ └── strings.xml ├── build.gradle.kts ├── cliff.toml ├── fastlane └── metadata │ └── android │ └── en-US │ ├── changelogs │ ├── 1.txt │ ├── 28.txt │ ├── 29.txt │ ├── 30.txt │ ├── 31.txt │ ├── 32.txt │ ├── 33.txt │ ├── 34.txt │ ├── 35.txt │ ├── 36.txt │ └── 37.txt │ ├── full_description.txt │ ├── images │ ├── icon.png │ └── phoneScreenshots │ │ └── 1.jpg │ ├── short_description.txt │ └── title.txt ├── generate_changelog.sh ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── renovate.json └── settings.gradle /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{kt,kts}] 2 | ktlint_function_naming_ignore_when_annotated_with=Composable 3 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @dessalines 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Discussion 4 | url: https://lemmy.ml/c/rankmyfavs 5 | about: Discussion board 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: 🌟 Feature request 2 | description: Suggest a feature to improve the app 3 | labels: [feature] 4 | body: 5 | - type: textarea 6 | id: feature-description 7 | attributes: 8 | label: Describe your suggested feature 9 | description: How can the app be improved? 10 | placeholder: | 11 | Example: 12 | "It should work like this..." 13 | validations: 14 | required: true 15 | 16 | - type: textarea 17 | id: other-details 18 | attributes: 19 | label: Other details 20 | placeholder: Additional details and attachments. 21 | 22 | - type: checkboxes 23 | id: acknowledgements 24 | attributes: 25 | label: Acknowledgements 26 | description: >- 27 | Read this carefully, we will close and ignore your issue if you skimmed through this. 28 | options: 29 | - label: I have written a short but informative title. 30 | required: true 31 | - label: I have updated the app to **[the latest version](https://github.com/dessalines/rank-my-favs/releases/latest)**. 32 | required: true 33 | - label: I have checked through the app settings for my feature. 34 | required: true 35 | - label: >- 36 | I have searched the existing issues and this is a new one, **NOT** a 37 | duplicate or related to another open issue. 38 | required: true 39 | - label: >- 40 | This is a **single** feature request, in case of multiple feature 41 | requests I will open a separate issue for each one 42 | (they can always link to each other if related) 43 | required: true 44 | - label: >- 45 | This is not a question or a discussion, in which case I should have 46 | gone to [lemmy.ml/c/rankmyfavs](https://lemmy.ml/c/rankmyfavs) 47 | required: true 48 | - label: I have admitted that I am a clown by having checked this box, as I have not read these acknowledgements. 🤡 49 | - label: I will fill out all of the requested information in this form. 50 | required: true 51 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐞 Issue report 2 | description: Report an issue or bug 3 | labels: [bug] 4 | body: 5 | - type: textarea 6 | id: reproduce-steps 7 | attributes: 8 | label: Steps to reproduce 9 | description: Provide an example of the issue. 10 | placeholder: | 11 | Example: 12 | 1. First step 13 | 2. Second step 14 | 3. Issue here 15 | validations: 16 | required: true 17 | 18 | - type: textarea 19 | id: expected-behavior 20 | attributes: 21 | label: Expected behavior 22 | description: Explain what you should expect to happen. 23 | placeholder: | 24 | Example: 25 | "This should happen..." 26 | validations: 27 | required: true 28 | 29 | - type: textarea 30 | id: actual-behavior 31 | attributes: 32 | label: Actual behavior 33 | description: Explain what actually happens. 34 | placeholder: | 35 | Example: 36 | "This happened instead..." 37 | validations: 38 | required: true 39 | 40 | - type: input 41 | id: version 42 | attributes: 43 | label: version of the program 44 | placeholder: >- 45 | Example: "2.6.14" 46 | validations: 47 | required: true 48 | 49 | - type: input 50 | id: android-version 51 | attributes: 52 | label: Android version 53 | description: You can find this somewhere in your Android settings. 54 | placeholder: | 55 | Example: "Android 14" 56 | validations: 57 | required: true 58 | 59 | - type: input 60 | id: device 61 | attributes: 62 | label: Device 63 | description: List your device and model. 64 | placeholder: | 65 | Example: "Google Pixel 8" 66 | validations: 67 | required: true 68 | 69 | - type: textarea 70 | id: other-details 71 | attributes: 72 | label: Other details 73 | placeholder: Additional details and attachments. 74 | 75 | - type: checkboxes 76 | id: acknowledgements 77 | attributes: 78 | label: Acknowledgements 79 | description: >- 80 | Read this carefully, I will close and ignore your issue if you skimmed through this. 81 | options: 82 | - label: I have written a short but informative title. 83 | required: true 84 | - label: I have updated the app to **[the latest version](https://github.com/dessalines/rank-my-favs/releases/latest)**. 85 | required: true 86 | - label: >- 87 | I have searched the existing issues and this is a new one, **NOT** a 88 | duplicate or related to another open issue. 89 | required: true 90 | - label: >- 91 | This is not a question or a discussion, in which case I should have 92 | gone to [lemmy.ml/c/rankmyfavs](https://lemmy.ml/c/rankmyfavs) 93 | required: true 94 | - label: >- 95 | This is a **single** bug report, in case of multiple bugs I will open 96 | a separate issue for each one 97 | (they can always link to each other if related) 98 | required: true 99 | - label: I have admitted that I am a clown by having checked this box, as I have not read these acknowledgements. 🤡 100 | - label: I have filled out all of the requested information in this form. 101 | required: true 102 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea 5 | .DS_Store 6 | /build 7 | /*/build/ 8 | /captures 9 | /Gemfile* 10 | .externalNativeBuild 11 | .cxx 12 | local.properties 13 | app/release 14 | app/schemas 15 | .project 16 | .settings 17 | .classpath 18 | build.sh 19 | .kotlin 20 | -------------------------------------------------------------------------------- /.woodpecker.yml: -------------------------------------------------------------------------------- 1 | steps: 2 | prettier_markdown_check: 3 | image: tmknom/prettier 4 | commands: 5 | - prettier -c "**/*.md" "**/*.yml" 6 | when: 7 | - event: pull_request 8 | 9 | check_formatting: 10 | image: cimg/android:2025.04.1 11 | commands: 12 | - sudo chown -R circleci:circleci . 13 | - ./gradlew lintKotlin 14 | environment: 15 | GRADLE_USER_HOME: ".gradle" 16 | when: 17 | - event: pull_request 18 | 19 | check_android_lint: 20 | image: cimg/android:2025.04.1 21 | commands: 22 | - sudo chown -R circleci:circleci . 23 | - ./gradlew lint 24 | environment: 25 | GRADLE_USER_HOME: ".gradle" 26 | when: 27 | - event: pull_request 28 | 29 | build_project: 30 | image: cimg/android:2025.04.1 31 | commands: 32 | - sudo chown -R circleci:circleci . 33 | - ./gradlew assembleDebug 34 | environment: 35 | GRADLE_USER_HOME: ".gradle" 36 | when: 37 | - event: pull_request 38 | 39 | notify: 40 | image: alpine:3 41 | commands: 42 | - apk add curl 43 | - "curl -d'Rank-My-Favs build ${CI_PIPELINE_STATUS}: ${CI_PIPELINE_URL}' ntfy.sh/rank_my_favs_ci" 44 | when: 45 | - event: pull_request 46 | status: [failure, success] 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | ![GitHub tag (latest SemVer)](https://img.shields.io/github/tag/dessalines/rank-my-favs.svg) 4 | [![status-badge](https://woodpecker.join-lemmy.org/api/badges/dessalines/rank-my-favs/status.svg)](https://woodpecker.join-lemmy.org/dessalines/rank-my-favs) 5 | [![GitHub issues](https://img.shields.io/github/issues-raw/dessalines/rank-my-favs.svg)](https://github.com/dessalines/rank-my-favs/issues) 6 | [![License](https://img.shields.io/github/license/dessalines/rank-my-favs.svg)](LICENSE) 7 | ![GitHub stars](https://img.shields.io/github/stars/dessalines/rank-my-favs?style=social) 8 | 9 |
10 | 11 |

12 | 13 | phone_screen 14 | 15 | 16 |

Rank-My-Favs

17 |

18 | Rank your favorite things, using simple pair-wise matchups. 19 |
20 |
21 | Report Bug 22 | · 23 | Request Feature 24 | · 25 | Releases 26 |

27 |

28 | Get it on IzzyOnDroid 29 | Get it on F-Droid 30 | 31 | 32 |

33 |

34 | 35 | ## About Rank-My-Favs 36 | 37 |

38 | 39 | phone_screen 40 | 41 |

42 | 43 | Do you keep lists of your favorite things, such as movies, books, recipes, or music albums? You might keep list(s) that looks like this: 44 | 45 | - Network (1976) 46 | - Lone Star (1996) 47 | - Devils (1971) 48 | - The Seventh Seal (1957) 49 | - ... _Many more films_ 50 | 51 | But how do you rank these? 52 | 53 | You might be tempted to order them by preference, but this could quickly get overwhelming for long lists. 54 | 55 | A much easier method is to use [pairwise comparisons](https://www.opinionx.co/blog/pairwise-comparison), which shows you single head-to-head pairs, and has you choose which one you like best. 56 | 57 | After doing a small number of these matchups, Rank-My-Favs can confidently create a ranked list for you. 58 | 59 | Under the hood, Rank-My-Favs uses the advanced [Glicko rating system](https://en.m.wikipedia.org/wiki/Glicko_rating_system), to determine how many matches are necessary, and for ranking. 60 | 61 | ### Features 62 | 63 | - Import your existing lists quickly. 64 | - Uses the advanced [Glicko rating system](https://en.m.wikipedia.org/wiki/Glicko_rating_system). 65 | 66 | ### Built With 67 | 68 | - [Android Jetpack Compose](https://developer.android.com/jetpack/compose) 69 | 70 | ## Installation / Releases 71 | 72 | - [Releases](https://github.com/dessalines/rank-my-favs/releases) 73 | - [IzzyOnDroid](https://apt.izzysoft.de/fdroid/index/apk/com.dessalines.rankmyfavs) 74 | - [F-Droid](https://f-droid.org/en/packages/com.dessalines.rankmyfavs/) 75 | - [Google Play](https://play.google.com/store/apps/details?id=com.dessalines.rankmyfavs) 76 | 77 | ## Support / Donate 78 | 79 | Rank-My-Favs will always remain free, open-source software. We've seen many open-source projects go unmaintained after a few years. **Recurring donations have proven to be the only way these projects can stay alive.** 80 | 81 | Your donations directly support full-time development, and help keep this maintained. If you find yourself using rank-my-favs every day, consider donating: 82 | 83 | - [Support me on Liberapay](https://liberapay.com/dessalines). 84 | - [Support me Patreon](https://www.patreon.com/dessalines). 85 | 86 | ### Crypto 87 | 88 | - bitcoin: `1Hefs7miXS5ff5Ck5xvmjKjXf5242KzRtK` 89 | - ethereum: `0x400c96c96acbC6E7B3B43B1dc1BB446540a88A01` 90 | - monero: `41taVyY6e1xApqKyMVDRVxJ76sPkfZhALLTjRvVKpaAh2pBd4wv9RgYj1tSPrx8wc6iE1uWUfjtQdTmTy2FGMeChGVKPQuV` 91 | 92 | ## Social / Contact 93 | 94 | - [lemmy.ml/c/rankmyfavs](https://lemmy.ml/c/rankmyfavs) 95 | - [Mastodon](https://mastodon.social/@dessalines) 96 | - [Matrix chat](https://matrix.to/#/#rank-my-favs:matrix.org) 97 | 98 | ## Resources 99 | 100 | - https://github.com/goochjs/glicko2 101 | - https://prioneer.io/tools/pairwise-ranking-tool 102 | - https://www.opinionx.co/research-method-guides/best-free-pairwise-ranking-tools 103 | - https://medium.com/@anton.myller/from-elo-to-trueskill-a-journey-in-the-world-of-ranking-systems-part-i-b186341d5ed0 104 | - [Build adaptive navigation](https://developer.android.com/develop/ui/compose/layouts/adaptive/build-adaptive-navigation) 105 | - [Adaptive list-detail sample](https://github.com/android/user-interface-samples/blob/main/CanonicalLayouts/list-detail-compose/app/src/main/java/com/example/listdetailcompose/ui/ListDetailSample.kt) 106 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.application") 3 | id("org.jetbrains.kotlin.android") 4 | id("org.jetbrains.kotlin.plugin.compose") 5 | id("com.google.devtools.ksp") 6 | } 7 | 8 | android { 9 | buildToolsVersion = "35.0.0" 10 | compileSdk = 35 11 | 12 | defaultConfig { 13 | applicationId = "com.dessalines.rankmyfavs" 14 | minSdk = 21 15 | targetSdk = 35 16 | versionCode = 37 17 | versionName = "0.6.14" 18 | 19 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 20 | vectorDrawables { 21 | useSupportLibrary = true 22 | } 23 | ksp { arg("room.schemaLocation", "$projectDir/schemas") } 24 | } 25 | 26 | // Necessary for izzyondroid releases 27 | dependenciesInfo { 28 | // Disables dependency metadata when building APKs. 29 | includeInApk = false 30 | // Disables dependency metadata when building Android App Bundles. 31 | includeInBundle = false 32 | } 33 | 34 | if (project.hasProperty("RELEASE_STORE_FILE")) { 35 | signingConfigs { 36 | create("release") { 37 | storeFile = file(project.property("RELEASE_STORE_FILE")!!) 38 | storePassword = project.property("RELEASE_STORE_PASSWORD") as String? 39 | keyAlias = project.property("RELEASE_KEY_ALIAS") as String? 40 | keyPassword = project.property("RELEASE_KEY_PASSWORD") as String? 41 | 42 | // Optional, specify signing versions used 43 | enableV1Signing = true 44 | enableV2Signing = true 45 | } 46 | } 47 | } 48 | buildTypes { 49 | release { 50 | if (project.hasProperty("RELEASE_STORE_FILE")) { 51 | signingConfig = signingConfigs.getByName("release") 52 | } 53 | 54 | isMinifyEnabled = true 55 | isShrinkResources = true 56 | proguardFiles( 57 | // Includes the default ProGuard rules files that are packaged with 58 | // the Android Gradle plugin. To learn more, go to the section about 59 | // R8 configuration files. 60 | getDefaultProguardFile("proguard-android-optimize.txt"), 61 | 62 | // Includes a local, custom Proguard rules file 63 | "proguard-rules.pro" 64 | ) 65 | } 66 | debug { 67 | applicationIdSuffix = ".debug" 68 | versionNameSuffix = " (DEBUG)" 69 | } 70 | } 71 | 72 | lint { 73 | disable += "MissingTranslation" 74 | disable += "KtxExtensionAvailable" 75 | disable += "UseKtx" 76 | } 77 | 78 | compileOptions { 79 | sourceCompatibility = JavaVersion.VERSION_17 80 | targetCompatibility = JavaVersion.VERSION_17 81 | } 82 | kotlinOptions { 83 | jvmTarget = "17" 84 | freeCompilerArgs = listOf("-Xjvm-default=all-compatibility", "-opt-in=kotlin.RequiresOptIn") 85 | } 86 | buildFeatures { 87 | compose = true 88 | } 89 | namespace = "com.dessalines.rankmyfavs" 90 | } 91 | 92 | dependencies { 93 | // Color picker 94 | implementation("com.github.skydoves:colorpicker-compose:1.1.2") 95 | 96 | // Exporting / importing DB helper 97 | implementation("com.github.dessalines:room-db-export-import:0.1.0") 98 | 99 | // Composable screenshot 100 | implementation("dev.shreyaspatil:capturable:3.0.1") 101 | 102 | // CSV exporting 103 | implementation("com.floern.castingcsv:casting-csv-kt:1.2") 104 | 105 | // Tables 106 | implementation("com.github.Breens-Mbaka:BeeTablesCompose:1.2.0") 107 | 108 | // Glicko2 109 | implementation("com.github.goochjs:glicko2:master") 110 | 111 | // Compose BOM 112 | implementation(platform("androidx.compose:compose-bom:2025.05.01")) 113 | implementation("androidx.compose.ui:ui") 114 | implementation("androidx.compose.material3:material3") 115 | implementation("androidx.compose.material:material-icons-extended:1.7.8") 116 | implementation("androidx.compose.material3:material3-window-size-class") 117 | implementation("androidx.compose.ui:ui-tooling") 118 | implementation("androidx.compose.runtime:runtime-livedata:1.8.2") 119 | 120 | // Adaptive layouts 121 | implementation("androidx.compose.material3.adaptive:adaptive:1.1.0") 122 | implementation("androidx.compose.material3.adaptive:adaptive-layout:1.1.0") 123 | implementation("androidx.compose.material3.adaptive:adaptive-navigation:1.1.0") 124 | implementation("androidx.compose.material3:material3-adaptive-navigation-suite") 125 | 126 | // Activities 127 | implementation("androidx.activity:activity-compose:1.10.1") 128 | implementation("androidx.activity:activity-ktx:1.10.1") 129 | 130 | // LiveData 131 | implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.0") 132 | 133 | // Navigation 134 | implementation("androidx.navigation:navigation-compose:2.9.0") 135 | 136 | // Markdown 137 | implementation("com.github.jeziellago:compose-markdown:0.5.7") 138 | 139 | // Preferences 140 | implementation("me.zhanghai.compose.preference:library:1.1.1") 141 | 142 | // Room 143 | // To use Kotlin annotation processing tool 144 | ksp("androidx.room:room-compiler:2.7.1") 145 | implementation("androidx.room:room-runtime:2.7.1") 146 | annotationProcessor("androidx.room:room-compiler:2.7.1") 147 | 148 | // optional - Kotlin Extensions and Coroutines support for Room 149 | implementation("androidx.room:room-ktx:2.7.1") 150 | 151 | // App compat 152 | implementation("androidx.appcompat:appcompat:1.7.0") 153 | } 154 | -------------------------------------------------------------------------------- /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 | -dontwarn okhttp3.internal.platform.** 23 | -dontwarn org.conscrypt.** 24 | -dontwarn org.bouncycastle.** 25 | -dontwarn org.openjsse.** 26 | -------------------------------------------------------------------------------- /app/src/debug/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Rank My Favs (Debug) 3 | 4 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 14 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/src/main/app_icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/assets/RELEASES.md: -------------------------------------------------------------------------------- 1 | ## What's Changed in 0.6.14 2 | 3 | - Fix search bar clearing. by @dessalines in [#324](https://github.com/dessalines/rank-my-favs/pull/324) 4 | - Fix search bar clearing. by @dessalines 5 | - Adding android lint. by @dessalines in [#322](https://github.com/dessalines/rank-my-favs/pull/322) 6 | - Adding android lint. by @dessalines 7 | 8 | **Full Changelog**: https://github.com/dessalines/rank-my-favs/compare/0.6.13...0.6.14 9 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/java/com/dessalines/rankmyfavs/db/AppDB.kt: -------------------------------------------------------------------------------- 1 | package com.dessalines.rankmyfavs.db 2 | 3 | import android.content.ContentValues 4 | import android.content.Context 5 | import android.database.sqlite.SQLiteDatabase.CONFLICT_IGNORE 6 | import androidx.room.Database 7 | import androidx.room.Room 8 | import androidx.room.RoomDatabase 9 | import androidx.sqlite.db.SupportSQLiteDatabase 10 | import com.dessalines.rankmyfavs.utils.TAG 11 | import java.util.concurrent.Executors 12 | 13 | @Database( 14 | version = 3, 15 | entities = [ 16 | AppSettings::class, FavList::class, FavListItem::class, FavListMatch::class, TierList::class, 17 | ], 18 | exportSchema = true, 19 | ) 20 | abstract class AppDB : RoomDatabase() { 21 | abstract fun appSettingsDao(): AppSettingsDao 22 | 23 | abstract fun favListDao(): FavListDao 24 | 25 | abstract fun favListItemDao(): FavListItemDao 26 | 27 | abstract fun tierListDao(): TierListDao 28 | 29 | abstract fun favListMatchDao(): FavListMatchDao 30 | 31 | companion object { 32 | @Volatile 33 | private var instance: AppDB? = null 34 | 35 | fun getDatabase(context: Context): AppDB { 36 | // if the INSTANCE is not null, then return it, 37 | // if it is, then create the database 38 | return instance ?: synchronized(this) { 39 | val i = 40 | Room 41 | .databaseBuilder( 42 | context.applicationContext, 43 | AppDB::class.java, 44 | TAG, 45 | ).allowMainThreadQueries() 46 | .addMigrations( 47 | MIGRATION_1_2, 48 | MIGRATION_2_3, 49 | ) 50 | // Necessary because it can't insert data on creation 51 | .addCallback( 52 | object : Callback() { 53 | override fun onOpen(db: SupportSQLiteDatabase) { 54 | super.onCreate(db) 55 | Executors.newSingleThreadExecutor().execute { 56 | db.insert( 57 | "AppSettings", 58 | // Ensures it won't overwrite the existing data 59 | CONFLICT_IGNORE, 60 | ContentValues(2).apply { 61 | put("id", 1) 62 | }, 63 | ) 64 | } 65 | } 66 | }, 67 | ).build() 68 | instance = i 69 | // return instance 70 | i 71 | } 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /app/src/main/java/com/dessalines/rankmyfavs/db/AppSettings.kt: -------------------------------------------------------------------------------- 1 | package com.dessalines.rankmyfavs.db 2 | 3 | import android.content.Context 4 | import android.util.Log 5 | import androidx.annotation.WorkerThread 6 | import androidx.lifecycle.ViewModel 7 | import androidx.lifecycle.ViewModelProvider 8 | import androidx.lifecycle.viewModelScope 9 | import androidx.room.ColumnInfo 10 | import androidx.room.Dao 11 | import androidx.room.Entity 12 | import androidx.room.PrimaryKey 13 | import androidx.room.Query 14 | import androidx.room.Update 15 | import com.dessalines.rankmyfavs.utils.TAG 16 | import kotlinx.coroutines.Dispatchers 17 | import kotlinx.coroutines.flow.Flow 18 | import kotlinx.coroutines.flow.MutableStateFlow 19 | import kotlinx.coroutines.flow.asStateFlow 20 | import kotlinx.coroutines.launch 21 | import kotlinx.coroutines.withContext 22 | 23 | const val DEFAULT_THEME = 0 24 | const val DEFAULT_THEME_COLOR = 0 25 | const val DEFAULT_MIN_CONFIDENCE = 85 26 | const val MIN_CONFIDENCE_BOUND = 80 27 | 28 | @Entity 29 | data class AppSettings( 30 | @PrimaryKey(autoGenerate = true) val id: Int, 31 | @ColumnInfo( 32 | name = "theme", 33 | defaultValue = DEFAULT_THEME.toString(), 34 | ) 35 | val theme: Int, 36 | @ColumnInfo( 37 | name = "theme_color", 38 | defaultValue = DEFAULT_THEME_COLOR.toString(), 39 | ) 40 | val themeColor: Int, 41 | @ColumnInfo( 42 | name = "last_version_code_viewed", 43 | defaultValue = "0", 44 | ) 45 | val lastVersionCodeViewed: Int, 46 | @ColumnInfo( 47 | name = "min_confidence", 48 | defaultValue = DEFAULT_MIN_CONFIDENCE.toString(), 49 | ) 50 | val minConfidence: Int, 51 | ) 52 | 53 | data class SettingsUpdate( 54 | val id: Int, 55 | @ColumnInfo( 56 | name = "theme", 57 | ) 58 | val theme: Int, 59 | @ColumnInfo( 60 | name = "theme_color", 61 | ) 62 | val themeColor: Int, 63 | @ColumnInfo( 64 | name = "min_confidence", 65 | defaultValue = DEFAULT_MIN_CONFIDENCE.toString(), 66 | ) 67 | val minConfidence: Int, 68 | ) 69 | 70 | @Dao 71 | interface AppSettingsDao { 72 | @Query("SELECT * FROM AppSettings limit 1") 73 | fun getSettings(): Flow 74 | 75 | @Update(entity = AppSettings::class) 76 | suspend fun updateSettings(settings: SettingsUpdate) 77 | 78 | @Query("UPDATE AppSettings SET last_version_code_viewed = :versionCode") 79 | suspend fun updateLastVersionCode(versionCode: Int) 80 | } 81 | 82 | // Declares the DAO as a private property in the constructor. Pass in the DAO 83 | // instead of the whole database, because you only need access to the DAO 84 | class AppSettingsRepository( 85 | private val appSettingsDao: AppSettingsDao, 86 | ) { 87 | private val _changelog = MutableStateFlow("") 88 | val changelog = _changelog.asStateFlow() 89 | 90 | // Room executes all queries on a separate thread. 91 | // Observed Flow will notify the observer when the data has changed. 92 | val appSettings = appSettingsDao.getSettings() 93 | 94 | @WorkerThread 95 | suspend fun updateSettings(settings: SettingsUpdate) { 96 | appSettingsDao.updateSettings(settings) 97 | } 98 | 99 | @WorkerThread 100 | suspend fun updateLastVersionCodeViewed(versionCode: Int) { 101 | appSettingsDao.updateLastVersionCode(versionCode) 102 | } 103 | 104 | @WorkerThread 105 | suspend fun updateChangelog(ctx: Context) { 106 | withContext(Dispatchers.IO) { 107 | try { 108 | val releasesStr = 109 | ctx.assets 110 | .open("RELEASES.md") 111 | .bufferedReader() 112 | .use { it.readText() } 113 | _changelog.value = releasesStr 114 | } catch (e: Exception) { 115 | Log.e(TAG, "Failed to load changelog: $e") 116 | } 117 | } 118 | } 119 | } 120 | 121 | class AppSettingsViewModel( 122 | private val repository: AppSettingsRepository, 123 | ) : ViewModel() { 124 | val appSettings = repository.appSettings 125 | val changelog = repository.changelog 126 | 127 | fun updateSettings(settings: SettingsUpdate) = 128 | viewModelScope.launch { 129 | repository.updateSettings(settings) 130 | } 131 | 132 | fun updateLastVersionCodeViewed(versionCode: Int) = 133 | viewModelScope.launch { 134 | repository.updateLastVersionCodeViewed(versionCode) 135 | } 136 | 137 | fun updateChangelog(ctx: Context) = 138 | viewModelScope.launch { 139 | repository.updateChangelog(ctx) 140 | } 141 | } 142 | 143 | class AppSettingsViewModelFactory( 144 | private val repository: AppSettingsRepository, 145 | ) : ViewModelProvider.Factory { 146 | override fun create(modelClass: Class): T { 147 | if (modelClass.isAssignableFrom(AppSettingsViewModel::class.java)) { 148 | @Suppress("UNCHECKED_CAST") 149 | return AppSettingsViewModel(repository) as T 150 | } 151 | throw IllegalArgumentException("Unknown ViewModel class") 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /app/src/main/java/com/dessalines/rankmyfavs/db/FavList.kt: -------------------------------------------------------------------------------- 1 | package com.dessalines.rankmyfavs.db 2 | 3 | import androidx.annotation.Keep 4 | import androidx.annotation.WorkerThread 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.ViewModelProvider 7 | import androidx.lifecycle.viewModelScope 8 | import androidx.room.ColumnInfo 9 | import androidx.room.Dao 10 | import androidx.room.Delete 11 | import androidx.room.Entity 12 | import androidx.room.Index 13 | import androidx.room.Insert 14 | import androidx.room.OnConflictStrategy 15 | import androidx.room.PrimaryKey 16 | import androidx.room.Query 17 | import androidx.room.Update 18 | import kotlinx.coroutines.flow.Flow 19 | import kotlinx.coroutines.launch 20 | 21 | @Entity( 22 | indices = [Index(value = ["name"], unique = true)], 23 | ) 24 | @Keep 25 | data class FavList( 26 | @PrimaryKey(autoGenerate = true) val id: Int, 27 | @ColumnInfo( 28 | name = "name", 29 | ) 30 | val name: String, 31 | @ColumnInfo( 32 | name = "description", 33 | ) 34 | val description: String? = null, 35 | @ColumnInfo( 36 | name = "tier_list_initialized", 37 | ) 38 | val tierListInitialized: Boolean = false, 39 | ) 40 | 41 | @Entity 42 | data class FavListInsert( 43 | @ColumnInfo( 44 | name = "name", 45 | ) 46 | val name: String, 47 | @ColumnInfo( 48 | name = "description", 49 | ) 50 | val description: String? = null, 51 | @ColumnInfo( 52 | name = "tier_list_initialized", 53 | ) 54 | val tierListInitialized: Boolean = false, 55 | ) 56 | 57 | @Entity 58 | data class FavListUpdate( 59 | val id: Int, 60 | @ColumnInfo( 61 | name = "name", 62 | ) 63 | val name: String, 64 | @ColumnInfo( 65 | name = "description", 66 | ) 67 | val description: String? = null, 68 | @ColumnInfo( 69 | name = "tier_list_initialized", 70 | ) 71 | val tierListInitialized: Boolean = false, 72 | ) 73 | 74 | private const val BY_ID_QUERY = "SELECT * FROM FavList where id = :id" 75 | 76 | @Dao 77 | interface FavListDao { 78 | @Query("SELECT * FROM FavList") 79 | fun getAll(): Flow> 80 | 81 | @Query(BY_ID_QUERY) 82 | fun getById(id: Int): Flow 83 | 84 | @Query(BY_ID_QUERY) 85 | fun getByIdSync(id: Int): FavList? 86 | 87 | @Insert(entity = FavList::class, onConflict = OnConflictStrategy.IGNORE) 88 | fun insert(favList: FavListInsert): Long 89 | 90 | @Update(entity = FavList::class) 91 | suspend fun update(favList: FavListUpdate) 92 | 93 | @Delete 94 | suspend fun delete(favList: FavList) 95 | } 96 | 97 | // Declares the DAO as a private property in the constructor. Pass in the DAO 98 | // instead of the whole database, because you only need access to the DAO 99 | class FavListRepository( 100 | private val favListDao: FavListDao, 101 | ) { 102 | // Room executes all queries on a separate thread. 103 | // Observed Flow will notify the observer when the data has changed. 104 | val getAll = favListDao.getAll() 105 | 106 | fun getById(id: Int) = favListDao.getById(id) 107 | 108 | fun getByIdSync(id: Int) = favListDao.getByIdSync(id) 109 | 110 | fun insert(favList: FavListInsert) = favListDao.insert(favList) 111 | 112 | @WorkerThread 113 | suspend fun update(favList: FavListUpdate) = favListDao.update(favList) 114 | 115 | @WorkerThread 116 | suspend fun delete(favList: FavList) = favListDao.delete(favList) 117 | } 118 | 119 | class FavListViewModel( 120 | private val repository: FavListRepository, 121 | ) : ViewModel() { 122 | val getAll = repository.getAll 123 | 124 | fun getById(id: Int) = repository.getById(id) 125 | 126 | fun getByIdSync(id: Int) = repository.getByIdSync(id) 127 | 128 | fun insert(favList: FavListInsert) = repository.insert(favList) 129 | 130 | fun update(favList: FavListUpdate) = 131 | viewModelScope.launch { 132 | repository.update(favList) 133 | } 134 | 135 | fun delete(favList: FavList) = 136 | viewModelScope.launch { 137 | repository.delete(favList) 138 | } 139 | } 140 | 141 | class FavListViewModelFactory( 142 | private val repository: FavListRepository, 143 | ) : ViewModelProvider.Factory { 144 | override fun create(modelClass: Class): T { 145 | if (modelClass.isAssignableFrom(FavListViewModel::class.java)) { 146 | @Suppress("UNCHECKED_CAST") 147 | return FavListViewModel(repository) as T 148 | } 149 | throw IllegalArgumentException("Unknown ViewModel class") 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /app/src/main/java/com/dessalines/rankmyfavs/db/FavListItem.kt: -------------------------------------------------------------------------------- 1 | package com.dessalines.rankmyfavs.db 2 | 3 | import androidx.annotation.Keep 4 | import androidx.annotation.WorkerThread 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.ViewModelProvider 7 | import androidx.lifecycle.viewModelScope 8 | import androidx.room.ColumnInfo 9 | import androidx.room.Dao 10 | import androidx.room.Delete 11 | import androidx.room.Entity 12 | import androidx.room.ForeignKey 13 | import androidx.room.Index 14 | import androidx.room.Insert 15 | import androidx.room.OnConflictStrategy 16 | import androidx.room.PrimaryKey 17 | import androidx.room.Query 18 | import androidx.room.Update 19 | import kotlinx.coroutines.flow.Flow 20 | import kotlinx.coroutines.launch 21 | 22 | const val DEFAULT_WIN_RATE = 0F 23 | const val DEFAULT_GLICKO_RATING = 1500F 24 | const val DEFAULT_GLICKO_DEVIATION = 350F 25 | const val DEFAULT_GLICKO_VOLATILITY = 0.06F 26 | const val DEFAULT_MATCH_COUNT = 0 27 | 28 | @Entity( 29 | foreignKeys = [ 30 | ForeignKey( 31 | entity = FavList::class, 32 | parentColumns = arrayOf("id"), 33 | childColumns = arrayOf("fav_list_id"), 34 | onDelete = ForeignKey.CASCADE, 35 | ), 36 | ], 37 | indices = [Index(value = ["fav_list_id", "name"], unique = true)], 38 | ) 39 | @Keep 40 | data class FavListItem( 41 | @PrimaryKey(autoGenerate = true) val id: Int, 42 | @ColumnInfo( 43 | name = "fav_list_id", 44 | ) 45 | val favListId: Int, 46 | @ColumnInfo( 47 | name = "name", 48 | ) 49 | val name: String, 50 | @ColumnInfo( 51 | name = "description", 52 | ) 53 | val description: String? = null, 54 | @ColumnInfo( 55 | name = "win_rate", 56 | defaultValue = DEFAULT_WIN_RATE.toString(), 57 | ) 58 | val winRate: Float, 59 | @ColumnInfo( 60 | name = "glicko_rating", 61 | defaultValue = DEFAULT_GLICKO_RATING.toString(), 62 | ) 63 | val glickoRating: Float, 64 | @ColumnInfo( 65 | name = "glicko_deviation", 66 | defaultValue = DEFAULT_GLICKO_DEVIATION.toString(), 67 | ) 68 | val glickoDeviation: Float, 69 | @ColumnInfo( 70 | name = "glicko_volatility", 71 | defaultValue = DEFAULT_GLICKO_VOLATILITY.toString(), 72 | ) 73 | val glickoVolatility: Float, 74 | @ColumnInfo( 75 | name = "match_count", 76 | defaultValue = DEFAULT_MATCH_COUNT.toString(), 77 | ) 78 | val matchCount: Int, 79 | ) 80 | 81 | @Entity 82 | data class FavListItemInsert( 83 | @ColumnInfo( 84 | name = "fav_list_id", 85 | ) 86 | val favListId: Int, 87 | @ColumnInfo( 88 | name = "name", 89 | ) 90 | val name: String, 91 | @ColumnInfo( 92 | name = "description", 93 | ) 94 | val description: String? = null, 95 | ) 96 | 97 | @Entity 98 | data class FavListItemUpdateNameAndDesc( 99 | val id: Int, 100 | @ColumnInfo( 101 | name = "name", 102 | ) 103 | val name: String, 104 | @ColumnInfo( 105 | name = "description", 106 | ) 107 | val description: String?, 108 | ) 109 | 110 | @Entity 111 | data class FavListItemUpdateStats( 112 | val id: Int, 113 | @ColumnInfo( 114 | name = "win_rate", 115 | defaultValue = DEFAULT_WIN_RATE.toString(), 116 | ) 117 | val winRate: Float, 118 | @ColumnInfo( 119 | name = "glicko_rating", 120 | defaultValue = DEFAULT_GLICKO_RATING.toString(), 121 | ) 122 | val glickoRating: Float, 123 | @ColumnInfo( 124 | name = "glicko_deviation", 125 | defaultValue = DEFAULT_GLICKO_DEVIATION.toString(), 126 | ) 127 | val glickoDeviation: Float, 128 | @ColumnInfo( 129 | name = "glicko_volatility", 130 | defaultValue = DEFAULT_GLICKO_VOLATILITY.toString(), 131 | ) 132 | val glickoVolatility: Float, 133 | @ColumnInfo( 134 | name = "match_count", 135 | defaultValue = DEFAULT_MATCH_COUNT.toString(), 136 | ) 137 | val matchCount: Int, 138 | ) 139 | 140 | private const val BY_ID_QUERY = "SELECT * FROM FavListItem where id = :favListItemId" 141 | 142 | @Dao 143 | interface FavListItemDao { 144 | @Query("SELECT * FROM FavListItem where fav_list_id = :favListId order by glicko_rating desc") 145 | fun getFromList(favListId: Int): Flow> 146 | 147 | @Query("SELECT COUNT(*) FROM FavListItem where fav_list_id = :favListId") 148 | fun getCountByIdSync(favListId: Int): Int 149 | 150 | @Query(BY_ID_QUERY) 151 | fun getById(favListItemId: Int): Flow 152 | 153 | @Query(BY_ID_QUERY) 154 | fun getByIdSync(favListItemId: Int): FavListItem? 155 | 156 | // The first option is the one with the lowest glicko_deviation, and a stop gap. 157 | // The second option is a random one. 158 | 159 | @Query( 160 | """ 161 | SELECT * FROM FavListItem 162 | WHERE fav_list_id = :favListId 163 | AND glicko_deviation > (1500 * (1 - (SELECT min_confidence/100.0 from AppSettings))) 164 | ORDER BY RANDOM() 165 | LIMIT 1 166 | """, 167 | ) 168 | fun leastTrained(favListId: Int): FavListItem? 169 | 170 | // Sort the second match by the closest neighbor, IE abs(difference) 171 | @Query( 172 | """ 173 | SELECT * FROM FavListItem 174 | WHERE fav_list_id = :favListId 175 | AND id <> :firstItemId 176 | ORDER BY ABS(glicko_rating - :firstGlickoRating), RANDOM() 177 | LIMIT 1 178 | """, 179 | ) 180 | fun closestMatch( 181 | favListId: Int, 182 | firstItemId: Int, 183 | firstGlickoRating: Float, 184 | ): FavListItem? 185 | 186 | @Query( 187 | """ 188 | SELECT * FROM FavListItem 189 | WHERE fav_list_id = :favListId 190 | AND id <> :firstItemId 191 | ORDER BY RANDOM() 192 | LIMIT 1 193 | """, 194 | ) 195 | fun randomMatch( 196 | favListId: Int, 197 | firstItemId: Int, 198 | ): FavListItem? 199 | 200 | @Insert(entity = FavListItem::class, onConflict = OnConflictStrategy.IGNORE) 201 | fun insert(favListItem: FavListItemInsert): Long 202 | 203 | @Update(entity = FavListItem::class) 204 | suspend fun updateNameAndDesc(favListItem: FavListItemUpdateNameAndDesc) 205 | 206 | @Update(entity = FavListItem::class) 207 | suspend fun updateStats(favListItem: FavListItemUpdateStats) 208 | 209 | @Query( 210 | """ 211 | UPDATE FavListItem 212 | SET win_rate = $DEFAULT_WIN_RATE, 213 | glicko_rating = $DEFAULT_GLICKO_RATING, 214 | glicko_deviation = $DEFAULT_GLICKO_DEVIATION, 215 | glicko_volatility = $DEFAULT_GLICKO_VOLATILITY 216 | WHERE fav_list_id = :favListId 217 | """, 218 | ) 219 | suspend fun clearStatsForList(favListId: Int) 220 | 221 | @Delete 222 | suspend fun delete(favListItem: FavListItem) 223 | } 224 | 225 | // Declares the DAO as a private property in the constructor. Pass in the DAO 226 | // instead of the whole database, because you only need access to the DAO 227 | class FavListItemRepository( 228 | private val favListItemDao: FavListItemDao, 229 | ) { 230 | // Room executes all queries on a separate thread. 231 | // Observed Flow will notify the observer when the data has changed. 232 | fun getFromList(favListId: Int) = favListItemDao.getFromList(favListId) 233 | 234 | fun getById(favListItemId: Int) = favListItemDao.getById(favListItemId) 235 | 236 | fun getByIdSync(favListItemId: Int) = favListItemDao.getByIdSync(favListItemId) 237 | 238 | fun leastTrained(favListId: Int) = favListItemDao.leastTrained(favListId) 239 | 240 | fun closestMatch( 241 | favListId: Int, 242 | firstItemId: Int, 243 | firstGlickoRating: Float, 244 | ) = favListItemDao.closestMatch(favListId, firstItemId, firstGlickoRating) 245 | 246 | fun randomMatch( 247 | favListId: Int, 248 | firstItemId: Int, 249 | ) = favListItemDao.randomMatch(favListId, firstItemId) 250 | 251 | fun insert(favListItem: FavListItemInsert) = favListItemDao.insert(favListItem) 252 | 253 | @WorkerThread 254 | suspend fun updateNameAndDesc(favListItem: FavListItemUpdateNameAndDesc) = favListItemDao.updateNameAndDesc(favListItem) 255 | 256 | @WorkerThread 257 | suspend fun updateStats(favListItem: FavListItemUpdateStats) = favListItemDao.updateStats(favListItem) 258 | 259 | @WorkerThread 260 | suspend fun clearStatsForList(favListId: Int) = favListItemDao.clearStatsForList(favListId) 261 | 262 | @WorkerThread 263 | suspend fun delete(favListItem: FavListItem) = favListItemDao.delete(favListItem) 264 | } 265 | 266 | class FavListItemViewModel( 267 | private val repository: FavListItemRepository, 268 | ) : ViewModel() { 269 | fun getFromList(favListId: Int) = repository.getFromList(favListId) 270 | 271 | fun getById(favListItemId: Int) = repository.getById(favListItemId) 272 | 273 | fun getByIdSync(favListItemId: Int) = repository.getByIdSync(favListItemId) 274 | 275 | fun leastTrained(favListId: Int) = repository.leastTrained(favListId) 276 | 277 | fun closestMatch( 278 | favListId: Int, 279 | firstItemId: Int, 280 | firstGlickoRating: Float, 281 | ) = repository.closestMatch(favListId, firstItemId, firstGlickoRating) 282 | 283 | fun randomMatch( 284 | favListId: Int, 285 | firstItemId: Int, 286 | ) = repository.randomMatch(favListId, firstItemId) 287 | 288 | fun insert(favListItem: FavListItemInsert) = repository.insert(favListItem) 289 | 290 | fun updateNameAndDesc(favListItem: FavListItemUpdateNameAndDesc) = 291 | viewModelScope.launch { 292 | repository.updateNameAndDesc(favListItem) 293 | } 294 | 295 | fun updateStats(favListItem: FavListItemUpdateStats) = 296 | viewModelScope.launch { 297 | repository.updateStats(favListItem) 298 | } 299 | 300 | fun clearStatsForList(favListId: Int) = 301 | viewModelScope.launch { 302 | repository.clearStatsForList(favListId) 303 | } 304 | 305 | fun delete(favListItem: FavListItem) = 306 | viewModelScope.launch { 307 | repository.delete(favListItem) 308 | } 309 | } 310 | 311 | class FavListItemViewModelFactory( 312 | private val repository: FavListItemRepository, 313 | ) : ViewModelProvider.Factory { 314 | override fun create(modelClass: Class): T { 315 | if (modelClass.isAssignableFrom(FavListItemViewModel::class.java)) { 316 | @Suppress("UNCHECKED_CAST") 317 | return FavListItemViewModel(repository) as T 318 | } 319 | throw IllegalArgumentException("Unknown ViewModel class") 320 | } 321 | } 322 | 323 | val sampleFavListItem = 324 | FavListItem( 325 | id = 1, 326 | favListId = 1, 327 | name = "Fav List 1", 328 | description = "ok", 329 | winRate = 66.5F, 330 | glickoRating = 1534F, 331 | glickoDeviation = 150F, 332 | glickoVolatility = 0.06F, 333 | matchCount = 5, 334 | ) 335 | -------------------------------------------------------------------------------- /app/src/main/java/com/dessalines/rankmyfavs/db/FavListMatch.kt: -------------------------------------------------------------------------------- 1 | package com.dessalines.rankmyfavs.db 2 | 3 | import androidx.annotation.Keep 4 | import androidx.annotation.WorkerThread 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.ViewModelProvider 7 | import androidx.lifecycle.viewModelScope 8 | import androidx.room.ColumnInfo 9 | import androidx.room.Dao 10 | import androidx.room.Delete 11 | import androidx.room.Entity 12 | import androidx.room.ForeignKey 13 | import androidx.room.Index 14 | import androidx.room.Insert 15 | import androidx.room.OnConflictStrategy 16 | import androidx.room.PrimaryKey 17 | import androidx.room.Query 18 | import kotlinx.coroutines.launch 19 | 20 | @Entity( 21 | foreignKeys = [ 22 | ForeignKey( 23 | entity = FavListItem::class, 24 | parentColumns = arrayOf("id"), 25 | childColumns = arrayOf("item_id_1"), 26 | onDelete = ForeignKey.CASCADE, 27 | ), 28 | ForeignKey( 29 | entity = FavListItem::class, 30 | parentColumns = arrayOf("id"), 31 | childColumns = arrayOf("item_id_2"), 32 | onDelete = ForeignKey.CASCADE, 33 | ), 34 | ForeignKey( 35 | entity = FavListItem::class, 36 | parentColumns = arrayOf("id"), 37 | childColumns = arrayOf("winner_id"), 38 | onDelete = ForeignKey.CASCADE, 39 | ), 40 | ], 41 | indices = [ 42 | Index(value = ["item_id_1"], unique = false), 43 | Index(value = ["item_id_2"], unique = false), 44 | Index(value = ["winner_id"], unique = false), 45 | ], 46 | ) 47 | @Keep 48 | data class FavListMatch( 49 | @PrimaryKey(autoGenerate = true) val id: Int, 50 | @ColumnInfo( 51 | name = "item_id_1", 52 | ) 53 | val itemId1: Int, 54 | @ColumnInfo( 55 | name = "item_id_2", 56 | ) 57 | val itemId2: Int, 58 | /** 59 | * This can be either 1 or 2 60 | */ 61 | @ColumnInfo( 62 | name = "winner_id", 63 | ) 64 | val winnerId: Int, 65 | ) 66 | 67 | @Entity 68 | data class FavListMatchInsert( 69 | @ColumnInfo( 70 | name = "item_id_1", 71 | ) 72 | val itemId1: Int, 73 | @ColumnInfo( 74 | name = "item_id_2", 75 | ) 76 | val itemId2: Int, 77 | @ColumnInfo( 78 | name = "winner_id", 79 | ) 80 | val winnerId: Int, 81 | ) 82 | 83 | @Dao 84 | interface FavListMatchDao { 85 | // TODO do this in SQL, not in code 86 | @Query("SELECT * FROM FavListMatch where item_id_1 = :itemId or item_id_2 = :itemId") 87 | fun getMatchups(itemId: Int): List 88 | 89 | @Insert(entity = FavListMatch::class, onConflict = OnConflictStrategy.IGNORE) 90 | fun insert(match: FavListMatchInsert): Long 91 | 92 | @Delete 93 | suspend fun delete(match: FavListMatch) 94 | 95 | @Query( 96 | """ 97 | DELETE FROM FavListMatch 98 | WHERE item_id_1 in ( select id from FavListItem WHERE fav_list_id = :favListId) 99 | or item_id_2 in ( select id from FavListItem WHERE fav_list_id = :favListId) 100 | """, 101 | ) 102 | suspend fun deleteMatchesForList(favListId: Int) 103 | 104 | @Query( 105 | """ 106 | DELETE FROM FavListMatch 107 | WHERE item_id_1 = :itemId 108 | or item_id_2 = :itemId 109 | """, 110 | ) 111 | suspend fun deleteMatchesForItem(itemId: Int) 112 | } 113 | 114 | // Declares the DAO as a private property in the constructor. Pass in the DAO 115 | // instead of the whole database, because you only need access to the DAO 116 | class FavListMatchRepository( 117 | private val favListDao: FavListMatchDao, 118 | ) { 119 | // Room executes all queries on a separate thread. 120 | // Observed Flow will notify the observer when the data has changed. 121 | fun getMatchups(itemId: Int) = favListDao.getMatchups(itemId) 122 | 123 | fun insert(match: FavListMatchInsert) = favListDao.insert(match) 124 | 125 | @WorkerThread 126 | suspend fun delete(match: FavListMatch) = favListDao.delete(match) 127 | 128 | @WorkerThread 129 | suspend fun deleteMatchesForList(favListId: Int) = favListDao.deleteMatchesForList(favListId) 130 | 131 | @WorkerThread 132 | suspend fun deleteMatchesForItem(itemId: Int) = favListDao.deleteMatchesForItem(itemId) 133 | } 134 | 135 | class FavListMatchViewModel( 136 | private val repository: FavListMatchRepository, 137 | ) : ViewModel() { 138 | fun getMatchups(itemId: Int) = repository.getMatchups(itemId) 139 | 140 | fun insert(match: FavListMatchInsert) = repository.insert(match) 141 | 142 | fun delete(match: FavListMatch) = 143 | viewModelScope.launch { 144 | repository.delete(match) 145 | } 146 | 147 | fun deleteMatchesForList(favListId: Int) = 148 | viewModelScope.launch { 149 | repository.deleteMatchesForList(favListId) 150 | } 151 | 152 | fun deleteMatchesForItem(itemId: Int) = 153 | viewModelScope.launch { 154 | repository.deleteMatchesForItem(itemId) 155 | } 156 | } 157 | 158 | class FavListMatchViewModelFactory( 159 | private val repository: FavListMatchRepository, 160 | ) : ViewModelProvider.Factory { 161 | override fun create(modelClass: Class): T { 162 | if (modelClass.isAssignableFrom(FavListMatchViewModel::class.java)) { 163 | @Suppress("UNCHECKED_CAST") 164 | return FavListMatchViewModel(repository) as T 165 | } 166 | throw IllegalArgumentException("Unknown ViewModel class") 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /app/src/main/java/com/dessalines/rankmyfavs/db/Migrations.kt: -------------------------------------------------------------------------------- 1 | package com.dessalines.rankmyfavs.db 2 | 3 | import androidx.room.migration.Migration 4 | import androidx.sqlite.db.SupportSQLiteDatabase 5 | 6 | val MIGRATION_1_2 = 7 | object : Migration(1, 2) { 8 | override fun migrate(db: SupportSQLiteDatabase) { 9 | // Add min_confidence to settings 10 | db.execSQL( 11 | """ 12 | ALTER TABLE AppSettings 13 | ADD COLUMN min_confidence 14 | INTEGER NOT NULL DEFAULT $DEFAULT_MIN_CONFIDENCE 15 | """.trimIndent(), 16 | ) 17 | 18 | // Add match_count to favlistitem 19 | db.execSQL( 20 | """ 21 | ALTER TABLE FavListItem 22 | ADD COLUMN match_count 23 | INTEGER NOT NULL DEFAULT 0 24 | """.trimIndent(), 25 | ) 26 | } 27 | } 28 | 29 | val MIGRATION_2_3 = 30 | object : Migration(2, 3) { 31 | override fun migrate(db: SupportSQLiteDatabase) { 32 | // Add TierList table 33 | db.execSQL( 34 | """ 35 | CREATE TABLE IF NOT EXISTS TierList ( 36 | id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 37 | fav_list_id INTEGER NOT NULL, 38 | name TEXT NOT NULL, 39 | color INTEGER NOT NULL, 40 | tier_order INTEGER NOT NULL, 41 | FOREIGN KEY(fav_list_id) REFERENCES FavList(id) ON DELETE CASCADE 42 | ) 43 | """.trimIndent(), 44 | ) 45 | 46 | // Create an index on fav_list_id to optimize queries 47 | db.execSQL( 48 | """ 49 | CREATE INDEX index_TierList_fav_list_id ON TierList(fav_list_id) 50 | """.trimIndent(), 51 | ) 52 | 53 | // Create an index on tier_order to support ordering 54 | db.execSQL( 55 | """ 56 | CREATE INDEX index_TierList_tier_order ON TierList(tier_order) 57 | """.trimIndent(), 58 | ) 59 | 60 | // Add tier_list_initialized to FavList 61 | db.execSQL( 62 | """ 63 | ALTER TABLE FavList 64 | ADD COLUMN tier_list_initialized 65 | INTEGER NOT NULL DEFAULT 0 66 | """.trimIndent(), 67 | ) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /app/src/main/java/com/dessalines/rankmyfavs/db/TierList.kt: -------------------------------------------------------------------------------- 1 | package com.dessalines.rankmyfavs.db 2 | 3 | import androidx.annotation.Keep 4 | import androidx.annotation.WorkerThread 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.ViewModelProvider 7 | import androidx.lifecycle.viewModelScope 8 | import androidx.room.ColumnInfo 9 | import androidx.room.Dao 10 | import androidx.room.Delete 11 | import androidx.room.Entity 12 | import androidx.room.ForeignKey 13 | import androidx.room.Index 14 | import androidx.room.Insert 15 | import androidx.room.OnConflictStrategy 16 | import androidx.room.PrimaryKey 17 | import androidx.room.Query 18 | import androidx.room.Update 19 | import kotlinx.coroutines.flow.Flow 20 | import kotlinx.coroutines.launch 21 | 22 | @Entity( 23 | foreignKeys = [ 24 | ForeignKey( 25 | entity = FavList::class, 26 | parentColumns = arrayOf("id"), 27 | childColumns = arrayOf("fav_list_id"), 28 | onDelete = ForeignKey.CASCADE, 29 | ), 30 | ], 31 | indices = [ 32 | Index(value = ["fav_list_id"], unique = false), 33 | // Uniqueness is hard to deal with when swapping tier orders so I kept it false 34 | Index(value = ["tier_order"], unique = false), 35 | ], 36 | ) 37 | @Keep 38 | data class TierList( 39 | @PrimaryKey(autoGenerate = true) val id: Int, 40 | @ColumnInfo( 41 | name = "fav_list_id", 42 | ) 43 | val favListId: Int, 44 | @ColumnInfo( 45 | name = "name", 46 | ) 47 | val name: String, 48 | @ColumnInfo( 49 | name = "color", 50 | ) 51 | val color: Int, 52 | @ColumnInfo( 53 | name = "tier_order", 54 | ) 55 | val tierOrder: Int, 56 | ) 57 | 58 | @Entity 59 | data class TierListInsert( 60 | @ColumnInfo( 61 | name = "fav_list_id", 62 | ) 63 | val favListId: Int, 64 | @ColumnInfo( 65 | name = "name", 66 | ) 67 | val name: String, 68 | @ColumnInfo( 69 | name = "color", 70 | ) 71 | val color: Int, 72 | @ColumnInfo( 73 | name = "tier_order", 74 | ) 75 | val tierOrder: Int, 76 | ) 77 | 78 | @Entity 79 | data class TierListUpdate( 80 | val id: Int, 81 | @ColumnInfo( 82 | name = "fav_list_id", 83 | ) 84 | val favListId: Int, 85 | @ColumnInfo( 86 | name = "name", 87 | ) 88 | val name: String, 89 | @ColumnInfo( 90 | name = "color", 91 | ) 92 | val color: Int, 93 | @ColumnInfo( 94 | name = "tier_order", 95 | ) 96 | val tierOrder: Int, 97 | ) 98 | 99 | @Dao 100 | interface TierListDao { 101 | @Query("SELECT * FROM TierList where fav_list_id = :favListId ORDER BY tier_order ASC") 102 | fun getFromList(favListId: Int): Flow> 103 | 104 | @Query("SELECT * FROM TierList where tier_order = :tierOrder") 105 | fun getByTierOrder(tierOrder: Int): TierList? 106 | 107 | @Query( 108 | """ 109 | UPDATE TierList 110 | SET tier_order = CASE 111 | WHEN id = :tier1Id THEN :tier2Order 112 | WHEN id = :tier2Id THEN :tier1Order 113 | ELSE tier_order 114 | END 115 | WHERE id IN (:tier1Id, :tier2Id) 116 | """, 117 | ) 118 | suspend fun swapTierOrders( 119 | tier1Id: Int, 120 | tier1Order: Int, 121 | tier2Id: Int, 122 | tier2Order: Int, 123 | ) 124 | 125 | @Insert(entity = TierList::class, onConflict = OnConflictStrategy.IGNORE) 126 | fun insert(tierList: TierListInsert): Long 127 | 128 | @Update(entity = TierList::class) 129 | suspend fun update(tierList: TierListUpdate) 130 | 131 | @Delete 132 | suspend fun delete(tierList: TierList) 133 | 134 | @Query( 135 | """ 136 | UPDATE TierList 137 | SET tier_order = tier_order - 1 138 | WHERE tier_order > :deletedTierOrder 139 | AND fav_list_id = :favListId 140 | """, 141 | ) 142 | suspend fun decrementHigherTierOrders( 143 | deletedTierOrder: Int, 144 | favListId: Int, 145 | ) 146 | } 147 | 148 | // Declares the DAO as a private property in the constructor. Pass in the DAO 149 | // instead of the whole database, because you only need access to the DAO 150 | class TierListRepository( 151 | private val tierListDao: TierListDao, 152 | ) { 153 | // Room executes all queries on a separate thread. 154 | // Observed Flow will notify the observer when the data has changed. 155 | fun getFromList(favListId: Int) = tierListDao.getFromList(favListId) 156 | 157 | fun getByTierOrder(tierOrder: Int) = tierListDao.getByTierOrder(tierOrder) 158 | 159 | @WorkerThread 160 | suspend fun swapTierOrders( 161 | tierList1: TierList, 162 | tierList2: TierList, 163 | ) = tierListDao.swapTierOrders( 164 | tierList1.id, 165 | tierList1.tierOrder, 166 | tierList2.id, 167 | tierList2.tierOrder, 168 | ) 169 | 170 | fun insert(tierList: TierListInsert) = tierListDao.insert(tierList) 171 | 172 | @WorkerThread 173 | suspend fun update(tierList: TierListUpdate) = tierListDao.update(tierList) 174 | 175 | @WorkerThread 176 | suspend fun delete(tierList: TierList) = tierListDao.delete(tierList) 177 | 178 | @WorkerThread 179 | suspend fun decrementHigherTierOrders( 180 | deletedTierOrder: Int, 181 | favListId: Int, 182 | ) = tierListDao.decrementHigherTierOrders(deletedTierOrder, favListId) 183 | } 184 | 185 | class TierListViewModel( 186 | private val repository: TierListRepository, 187 | ) : ViewModel() { 188 | fun getFromList(favListId: Int) = repository.getFromList(favListId) 189 | 190 | fun getByTierOrder(tierOrder: Int) = repository.getByTierOrder(tierOrder) 191 | 192 | fun swapTierOrders( 193 | tierList: TierList, 194 | relativeTierOrder: Int, 195 | ) = viewModelScope.launch { 196 | getByTierOrder(tierList.tierOrder + relativeTierOrder)?.let { 197 | repository.swapTierOrders(tierList, it) 198 | } 199 | } 200 | 201 | fun insert(tierList: TierListInsert) = repository.insert(tierList) 202 | 203 | fun update(tierList: TierListUpdate) = 204 | viewModelScope.launch { 205 | repository.update(tierList) 206 | } 207 | 208 | fun delete(tierList: TierList) = 209 | viewModelScope.launch { 210 | repository.delete(tierList) 211 | repository.decrementHigherTierOrders(tierList.tierOrder, tierList.favListId) 212 | } 213 | } 214 | 215 | class TierListViewModelFactory( 216 | private val repository: TierListRepository, 217 | ) : ViewModelProvider.Factory { 218 | override fun create(modelClass: Class): T { 219 | if (modelClass.isAssignableFrom(TierListViewModel::class.java)) { 220 | @Suppress("UNCHECKED_CAST") 221 | return TierListViewModel(repository) as T 222 | } 223 | throw IllegalArgumentException("Unknown ViewModel class") 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /app/src/main/java/com/dessalines/rankmyfavs/ui/components/about/AboutScreen.kt: -------------------------------------------------------------------------------- 1 | package com.dessalines.rankmyfavs.ui.components.about 2 | 3 | import android.util.Log 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.foundation.layout.size 7 | import androidx.compose.foundation.rememberScrollState 8 | import androidx.compose.foundation.verticalScroll 9 | import androidx.compose.material.icons.Icons 10 | import androidx.compose.material.icons.automirrored.outlined.Chat 11 | import androidx.compose.material.icons.outlined.AttachMoney 12 | import androidx.compose.material.icons.outlined.BugReport 13 | import androidx.compose.material.icons.outlined.Code 14 | import androidx.compose.material.icons.outlined.NewReleases 15 | import androidx.compose.material.icons.outlined.TravelExplore 16 | import androidx.compose.material3.ExperimentalMaterial3Api 17 | import androidx.compose.material3.HorizontalDivider 18 | import androidx.compose.material3.Icon 19 | import androidx.compose.material3.Scaffold 20 | import androidx.compose.material3.Text 21 | import androidx.compose.material3.TopAppBar 22 | import androidx.compose.runtime.Composable 23 | import androidx.compose.ui.Modifier 24 | import androidx.compose.ui.platform.LocalContext 25 | import androidx.compose.ui.res.painterResource 26 | import androidx.compose.ui.res.stringResource 27 | import androidx.compose.ui.tooling.preview.Preview 28 | import androidx.compose.ui.unit.dp 29 | import androidx.navigation.NavController 30 | import androidx.navigation.compose.rememberNavController 31 | import com.dessalines.rankmyfavs.R 32 | import com.dessalines.rankmyfavs.ui.components.common.BackButton 33 | import com.dessalines.rankmyfavs.utils.DONATE_URL 34 | import com.dessalines.rankmyfavs.utils.GITHUB_URL 35 | import com.dessalines.rankmyfavs.utils.LEMMY_URL 36 | import com.dessalines.rankmyfavs.utils.MASTODON_URL 37 | import com.dessalines.rankmyfavs.utils.MATRIX_CHAT_URL 38 | import com.dessalines.rankmyfavs.utils.TAG 39 | import com.dessalines.rankmyfavs.utils.openLink 40 | import me.zhanghai.compose.preference.Preference 41 | import me.zhanghai.compose.preference.PreferenceCategory 42 | import me.zhanghai.compose.preference.ProvidePreferenceTheme 43 | 44 | @OptIn(ExperimentalMaterial3Api::class) 45 | @Composable 46 | fun AboutScreen(navController: NavController) { 47 | Log.d(TAG, "Got to About activity") 48 | 49 | val ctx = LocalContext.current 50 | 51 | val version = ctx.packageManager.getPackageInfo(ctx.packageName, 0).versionName ?: "1" 52 | val scrollState = rememberScrollState() 53 | 54 | Scaffold( 55 | topBar = { 56 | TopAppBar( 57 | title = { Text(stringResource(R.string.about)) }, 58 | navigationIcon = { 59 | BackButton( 60 | onBackClick = { navController.navigateUp() }, 61 | ) 62 | }, 63 | ) 64 | }, 65 | content = { padding -> 66 | Column( 67 | modifier = 68 | Modifier 69 | .padding(padding) 70 | .verticalScroll(scrollState), 71 | ) { 72 | ProvidePreferenceTheme { 73 | Preference( 74 | title = { Text(stringResource(R.string.whats_new)) }, 75 | summary = { Text(stringResource(R.string.version, version)) }, 76 | icon = { 77 | Icon( 78 | imageVector = Icons.Outlined.NewReleases, 79 | contentDescription = stringResource(R.string.releases), 80 | ) 81 | }, 82 | onClick = { 83 | openLink("$GITHUB_URL/blob/main/RELEASES.md", ctx) 84 | }, 85 | ) 86 | SettingsDivider() 87 | PreferenceCategory( 88 | title = { Text(stringResource(R.string.support)) }, 89 | ) 90 | Preference( 91 | title = { Text(stringResource(R.string.issue_tracker)) }, 92 | icon = { 93 | Icon( 94 | imageVector = Icons.Outlined.BugReport, 95 | contentDescription = stringResource(R.string.issue_tracker), 96 | ) 97 | }, 98 | onClick = { 99 | openLink("$GITHUB_URL/issues", ctx) 100 | }, 101 | ) 102 | Preference( 103 | title = { Text(stringResource(R.string.developer_matrix_chatroom)) }, 104 | icon = { 105 | Icon( 106 | imageVector = Icons.AutoMirrored.Outlined.Chat, 107 | contentDescription = stringResource(R.string.developer_matrix_chatroom), 108 | ) 109 | }, 110 | onClick = { 111 | openLink(MATRIX_CHAT_URL, ctx) 112 | }, 113 | ) 114 | 115 | Preference( 116 | title = { Text(stringResource(R.string.donate_to_rank_my_favs)) }, 117 | icon = { 118 | Icon( 119 | imageVector = Icons.Outlined.AttachMoney, 120 | contentDescription = stringResource(R.string.donate_to_rank_my_favs), 121 | ) 122 | }, 123 | onClick = { 124 | openLink(DONATE_URL, ctx) 125 | }, 126 | ) 127 | SettingsDivider() 128 | PreferenceCategory( 129 | title = { Text(stringResource(R.string.social)) }, 130 | ) 131 | Preference( 132 | title = { Text(stringResource(R.string.join_c_rankmyfavs)) }, 133 | icon = { 134 | Icon( 135 | painter = painterResource(id = R.drawable.app_icon), 136 | modifier = Modifier.size(32.dp), 137 | contentDescription = stringResource(R.string.join_c_rankmyfavs), 138 | ) 139 | }, 140 | onClick = { 141 | openLink(LEMMY_URL, ctx) 142 | }, 143 | ) 144 | Preference( 145 | title = { Text(stringResource(R.string.follow_me_mastodon)) }, 146 | icon = { 147 | Icon( 148 | imageVector = Icons.Outlined.TravelExplore, 149 | contentDescription = stringResource(R.string.follow_me_mastodon), 150 | ) 151 | }, 152 | onClick = { 153 | openLink(MASTODON_URL, ctx) 154 | }, 155 | ) 156 | SettingsDivider() 157 | PreferenceCategory( 158 | title = { Text(stringResource(R.string.open_source)) }, 159 | ) 160 | Preference( 161 | title = { Text(stringResource(R.string.source_code)) }, 162 | summary = { 163 | Text(stringResource(R.string.source_code_subtitle)) 164 | }, 165 | icon = { 166 | Icon( 167 | imageVector = Icons.Outlined.Code, 168 | contentDescription = stringResource(R.string.source_code), 169 | ) 170 | }, 171 | onClick = { 172 | openLink(GITHUB_URL, ctx) 173 | }, 174 | ) 175 | } 176 | } 177 | }, 178 | ) 179 | } 180 | 181 | @Composable 182 | fun SettingsDivider() { 183 | HorizontalDivider(modifier = Modifier.padding(vertical = 10.dp)) 184 | } 185 | 186 | @Preview 187 | @Composable 188 | fun AboutPreview() { 189 | AboutScreen(navController = rememberNavController()) 190 | } 191 | -------------------------------------------------------------------------------- /app/src/main/java/com/dessalines/rankmyfavs/ui/components/common/AppBars.kt: -------------------------------------------------------------------------------- 1 | package com.dessalines.rankmyfavs.ui.components.common 2 | 3 | import androidx.compose.foundation.BasicTooltipBox 4 | import androidx.compose.foundation.ExperimentalFoundationApi 5 | import androidx.compose.foundation.rememberBasicTooltipState 6 | import androidx.compose.material.icons.Icons 7 | import androidx.compose.material.icons.automirrored.outlined.ArrowBack 8 | import androidx.compose.material3.ExperimentalMaterial3Api 9 | import androidx.compose.material3.Icon 10 | import androidx.compose.material3.IconButton 11 | import androidx.compose.material3.TooltipDefaults 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.ui.res.stringResource 14 | import com.dessalines.rankmyfavs.R 15 | 16 | @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) 17 | @Composable 18 | fun BackButton(onBackClick: () -> Unit) { 19 | val tooltipPosition = TooltipDefaults.rememberPlainTooltipPositionProvider() 20 | BasicTooltipBox( 21 | positionProvider = tooltipPosition, 22 | state = rememberBasicTooltipState(isPersistent = false), 23 | tooltip = { 24 | ToolTip(stringResource(R.string.go_back)) 25 | }, 26 | ) { 27 | IconButton( 28 | onClick = onBackClick, 29 | ) { 30 | Icon( 31 | Icons.AutoMirrored.Outlined.ArrowBack, 32 | contentDescription = stringResource(R.string.go_back), 33 | ) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/src/main/java/com/dessalines/rankmyfavs/ui/components/common/Dialogs.kt: -------------------------------------------------------------------------------- 1 | package com.dessalines.rankmyfavs.ui.components.common 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.Spacer 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.height 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.foundation.rememberScrollState 10 | import androidx.compose.foundation.shape.RoundedCornerShape 11 | import androidx.compose.foundation.verticalScroll 12 | import androidx.compose.material.icons.Icons 13 | import androidx.compose.material.icons.outlined.Info 14 | import androidx.compose.material3.AlertDialog 15 | import androidx.compose.material3.Button 16 | import androidx.compose.material3.ElevatedCard 17 | import androidx.compose.material3.Icon 18 | import androidx.compose.material3.MaterialTheme 19 | import androidx.compose.material3.Surface 20 | import androidx.compose.material3.Text 21 | import androidx.compose.material3.TextButton 22 | import androidx.compose.runtime.Composable 23 | import androidx.compose.runtime.LaunchedEffect 24 | import androidx.compose.runtime.MutableState 25 | import androidx.compose.runtime.collectAsState 26 | import androidx.compose.runtime.getValue 27 | import androidx.compose.runtime.livedata.observeAsState 28 | import androidx.compose.runtime.mutableStateOf 29 | import androidx.compose.runtime.remember 30 | import androidx.compose.runtime.setValue 31 | import androidx.compose.ui.Alignment 32 | import androidx.compose.ui.ExperimentalComposeUiApi 33 | import androidx.compose.ui.Modifier 34 | import androidx.compose.ui.graphics.Color 35 | import androidx.compose.ui.platform.LocalContext 36 | import androidx.compose.ui.res.stringResource 37 | import androidx.compose.ui.semantics.semantics 38 | import androidx.compose.ui.semantics.testTagsAsResourceId 39 | import androidx.compose.ui.tooling.preview.Preview 40 | import androidx.compose.ui.unit.dp 41 | import androidx.compose.ui.window.Dialog 42 | import androidx.lifecycle.asLiveData 43 | import com.dessalines.rankmyfavs.R 44 | import com.dessalines.rankmyfavs.db.AppSettingsViewModel 45 | import com.dessalines.rankmyfavs.utils.getVersionCode 46 | import com.github.skydoves.colorpicker.compose.ColorPickerController 47 | import com.github.skydoves.colorpicker.compose.HsvColorPicker 48 | import dev.jeziellago.compose.markdowntext.MarkdownText 49 | 50 | val DONATION_MARKDOWN = 51 | """ 52 | ### Support Rank-My-Favs 53 | [Rank-My-Favs](https://github.com/dessalines/rank-my-favs) is free, open-source software, meaning no spying, keylogging, or advertising, ever. 54 | 55 | No one likes recurring donations, but they've proven to be the only way open-source software like Rank-My-Favs can stay alive. If you find yourself using Rank-My-Favs every day, please consider donating: 56 | - [Support on Liberapay](https://liberapay.com/dessalines). 57 | - [Support on Patreon](https://www.patreon.com/dessalines). 58 | --- 59 | 60 | """.trimIndent() 61 | 62 | @OptIn(ExperimentalComposeUiApi::class) 63 | @Composable 64 | fun ShowChangelog(appSettingsViewModel: AppSettingsViewModel) { 65 | val ctx = LocalContext.current 66 | val lastVersionCodeViewed = 67 | appSettingsViewModel.appSettings 68 | .asLiveData() 69 | .observeAsState() 70 | .value 71 | ?.lastVersionCodeViewed 72 | 73 | // Make sure its initialized 74 | lastVersionCodeViewed?.also { lastViewed -> 75 | val currentVersionCode = ctx.getVersionCode() 76 | val viewed = lastViewed == currentVersionCode 77 | 78 | var whatsChangedDialogOpen by remember { mutableStateOf(!viewed) } 79 | 80 | if (whatsChangedDialogOpen) { 81 | val scrollState = rememberScrollState() 82 | val markdown by appSettingsViewModel.changelog.collectAsState() 83 | LaunchedEffect(appSettingsViewModel) { 84 | appSettingsViewModel.updateChangelog(ctx) 85 | } 86 | 87 | AlertDialog( 88 | text = { 89 | Column( 90 | modifier = 91 | Modifier 92 | .fillMaxSize() 93 | .verticalScroll(scrollState), 94 | ) { 95 | val markdownText = DONATION_MARKDOWN + markdown 96 | MarkdownText( 97 | markdown = markdownText, 98 | linkColor = MaterialTheme.colorScheme.primary, 99 | ) 100 | } 101 | }, 102 | confirmButton = { 103 | Button( 104 | onClick = { 105 | whatsChangedDialogOpen = false 106 | appSettingsViewModel.updateLastVersionCodeViewed(currentVersionCode) 107 | }, 108 | modifier = Modifier.fillMaxWidth(), 109 | ) { 110 | Text(stringResource(R.string.done)) 111 | } 112 | }, 113 | onDismissRequest = { 114 | whatsChangedDialogOpen = false 115 | appSettingsViewModel.updateLastVersionCodeViewed(currentVersionCode) 116 | }, 117 | modifier = Modifier.semantics { testTagsAsResourceId = true }, 118 | ) 119 | } 120 | } 121 | } 122 | 123 | @Composable 124 | fun ToolTip(text: String) { 125 | ElevatedCard { 126 | Text( 127 | text = text, 128 | modifier = Modifier.padding(SMALL_PADDING), 129 | ) 130 | } 131 | } 132 | 133 | @Composable 134 | fun AreYouSureDialog( 135 | show: MutableState, 136 | title: String, 137 | onYes: () -> Unit, 138 | ) { 139 | if (show.value) { 140 | AlertDialog( 141 | title = { Text(title) }, 142 | text = { Text(stringResource(R.string.are_you_sure)) }, 143 | icon = { 144 | Icon( 145 | imageVector = Icons.Outlined.Info, 146 | contentDescription = stringResource(R.string.are_you_sure), 147 | ) 148 | }, 149 | confirmButton = { 150 | TextButton( 151 | onClick = { 152 | onYes() 153 | show.value = false 154 | }, 155 | ) { 156 | Text( 157 | stringResource(R.string.yes), 158 | ) 159 | } 160 | }, 161 | onDismissRequest = { 162 | show.value = false 163 | }, 164 | dismissButton = { 165 | TextButton( 166 | onClick = { show.value = false }, 167 | ) { 168 | Text(stringResource(R.string.cancel)) 169 | } 170 | }, 171 | ) 172 | } 173 | } 174 | 175 | @Preview 176 | @Composable 177 | fun PreviewAreYouSureDialog() { 178 | val show = remember { mutableStateOf(true) } 179 | AreYouSureDialog( 180 | show = show, 181 | title = "Test title", 182 | onYes = {}, 183 | ) 184 | } 185 | 186 | @Composable 187 | fun ColorPickerDialog( 188 | onColorSelected: (Color) -> Unit, 189 | onDismissRequest: () -> Unit, 190 | controller: ColorPickerController, 191 | ) { 192 | Dialog(onDismissRequest = onDismissRequest) { 193 | Surface( 194 | shape = RoundedCornerShape(MEDIUM_PADDING), 195 | color = MaterialTheme.colorScheme.surface, 196 | modifier = Modifier.padding(LARGE_PADDING), 197 | ) { 198 | Column( 199 | modifier = Modifier.padding(LARGE_PADDING), 200 | horizontalAlignment = Alignment.CenterHorizontally, 201 | ) { 202 | Text( 203 | text = stringResource(R.string.pick_a_color), 204 | style = MaterialTheme.typography.headlineSmall, 205 | ) 206 | Spacer(modifier = Modifier.height(LARGE_PADDING)) 207 | HsvColorPicker( 208 | modifier = 209 | Modifier 210 | .fillMaxWidth() 211 | .height(250.dp), 212 | onColorChanged = { colorEnvelope -> 213 | onColorSelected(colorEnvelope.color) 214 | }, 215 | controller = controller, 216 | ) 217 | Spacer(modifier = Modifier.height(LARGE_PADDING)) 218 | Button(onClick = onDismissRequest) { 219 | Text("Done") 220 | } 221 | } 222 | } 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /app/src/main/java/com/dessalines/rankmyfavs/ui/components/common/Sizes.kt: -------------------------------------------------------------------------------- 1 | package com.dessalines.rankmyfavs.ui.components.common 2 | 3 | import androidx.compose.ui.unit.dp 4 | 5 | val SMALL_PADDING = 8.dp 6 | val MEDIUM_PADDING = 12.dp 7 | val LARGE_PADDING = 16.dp 8 | val SMALL_HEIGHT = 40.dp 9 | val MEDIUM_HEIGHT = 60.dp 10 | val LARGE_HEIGHT = 80.dp 11 | val MAX_HEIGHT = 160.dp 12 | val FLOATING_BUTTON_SIZE = 28.dp 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/dessalines/rankmyfavs/ui/components/favlist/CreateFavListScreen.kt: -------------------------------------------------------------------------------- 1 | package com.dessalines.rankmyfavs.ui.components.favlist 2 | 3 | import android.widget.Toast 4 | import androidx.compose.foundation.BasicTooltipBox 5 | import androidx.compose.foundation.ExperimentalFoundationApi 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.imePadding 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.foundation.rememberBasicTooltipState 10 | import androidx.compose.foundation.rememberScrollState 11 | import androidx.compose.foundation.shape.CircleShape 12 | import androidx.compose.foundation.verticalScroll 13 | import androidx.compose.material.icons.Icons 14 | import androidx.compose.material.icons.outlined.Save 15 | import androidx.compose.material3.ExperimentalMaterial3Api 16 | import androidx.compose.material3.FloatingActionButton 17 | import androidx.compose.material3.Icon 18 | import androidx.compose.material3.Scaffold 19 | import androidx.compose.material3.Text 20 | import androidx.compose.material3.TooltipDefaults 21 | import androidx.compose.material3.TopAppBar 22 | import androidx.compose.runtime.Composable 23 | import androidx.compose.ui.Modifier 24 | import androidx.compose.ui.platform.LocalContext 25 | import androidx.compose.ui.res.stringResource 26 | import androidx.navigation.NavController 27 | import com.dessalines.rankmyfavs.R 28 | import com.dessalines.rankmyfavs.db.FavList 29 | import com.dessalines.rankmyfavs.db.FavListInsert 30 | import com.dessalines.rankmyfavs.db.FavListViewModel 31 | import com.dessalines.rankmyfavs.ui.components.common.BackButton 32 | import com.dessalines.rankmyfavs.ui.components.common.ToolTip 33 | import com.dessalines.rankmyfavs.utils.nameIsValid 34 | 35 | @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) 36 | @Composable 37 | fun CreateFavListScreen( 38 | navController: NavController, 39 | favListViewModel: FavListViewModel, 40 | ) { 41 | val scrollState = rememberScrollState() 42 | val tooltipPosition = TooltipDefaults.rememberPlainTooltipPositionProvider() 43 | val ctx = LocalContext.current 44 | 45 | var favList: FavList? = null 46 | 47 | Scaffold( 48 | topBar = { 49 | TopAppBar( 50 | title = { Text(stringResource(R.string.create_list)) }, 51 | navigationIcon = { 52 | BackButton( 53 | onBackClick = { navController.navigateUp() }, 54 | ) 55 | }, 56 | ) 57 | }, 58 | content = { padding -> 59 | Column( 60 | modifier = 61 | Modifier 62 | .padding(padding) 63 | .verticalScroll(scrollState) 64 | .imePadding(), 65 | ) { 66 | FavListForm( 67 | onChange = { favList = it }, 68 | ) 69 | } 70 | }, 71 | floatingActionButton = { 72 | BasicTooltipBox( 73 | positionProvider = tooltipPosition, 74 | state = rememberBasicTooltipState(isPersistent = false), 75 | tooltip = { 76 | ToolTip(stringResource(R.string.save)) 77 | }, 78 | ) { 79 | FloatingActionButton( 80 | modifier = Modifier.imePadding(), 81 | onClick = { 82 | favList?.let { 83 | if (nameIsValid(it.name)) { 84 | val insert = 85 | FavListInsert(name = it.name, description = it.description) 86 | val insertedId = favListViewModel.insert(insert) 87 | 88 | // The id is -1 if its a failed insert 89 | if (insertedId != -1L) { 90 | navController.navigate("favLists?favListId=$insertedId") { 91 | popUpTo("favLists") 92 | } 93 | } else { 94 | Toast 95 | .makeText( 96 | ctx, 97 | ctx.getString(R.string.list_already_exists), 98 | Toast.LENGTH_SHORT, 99 | ).show() 100 | } 101 | } 102 | } 103 | }, 104 | shape = CircleShape, 105 | ) { 106 | Icon( 107 | imageVector = Icons.Outlined.Save, 108 | contentDescription = stringResource(R.string.save), 109 | ) 110 | } 111 | } 112 | }, 113 | ) 114 | } 115 | -------------------------------------------------------------------------------- /app/src/main/java/com/dessalines/rankmyfavs/ui/components/favlist/EditFavListScreen.kt: -------------------------------------------------------------------------------- 1 | package com.dessalines.rankmyfavs.ui.components.favlist 2 | 3 | import androidx.compose.foundation.BasicTooltipBox 4 | import androidx.compose.foundation.ExperimentalFoundationApi 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.imePadding 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.rememberBasicTooltipState 9 | import androidx.compose.foundation.rememberScrollState 10 | import androidx.compose.foundation.shape.CircleShape 11 | import androidx.compose.foundation.verticalScroll 12 | import androidx.compose.material.icons.Icons 13 | import androidx.compose.material.icons.outlined.Save 14 | import androidx.compose.material3.ExperimentalMaterial3Api 15 | import androidx.compose.material3.FloatingActionButton 16 | import androidx.compose.material3.Icon 17 | import androidx.compose.material3.Scaffold 18 | import androidx.compose.material3.Text 19 | import androidx.compose.material3.TooltipDefaults 20 | import androidx.compose.material3.TopAppBar 21 | import androidx.compose.runtime.Composable 22 | import androidx.compose.runtime.getValue 23 | import androidx.compose.runtime.mutableStateOf 24 | import androidx.compose.runtime.remember 25 | import androidx.compose.runtime.setValue 26 | import androidx.compose.ui.Modifier 27 | import androidx.compose.ui.res.stringResource 28 | import androidx.navigation.NavController 29 | import com.dessalines.rankmyfavs.R 30 | import com.dessalines.rankmyfavs.db.FavListUpdate 31 | import com.dessalines.rankmyfavs.db.FavListViewModel 32 | import com.dessalines.rankmyfavs.ui.components.common.BackButton 33 | import com.dessalines.rankmyfavs.ui.components.common.ToolTip 34 | import com.dessalines.rankmyfavs.utils.nameIsValid 35 | 36 | @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) 37 | @Composable 38 | fun EditFavListScreen( 39 | navController: NavController, 40 | favListViewModel: FavListViewModel, 41 | id: Int, 42 | ) { 43 | val scrollState = rememberScrollState() 44 | val tooltipPosition = TooltipDefaults.rememberPlainTooltipPositionProvider() 45 | 46 | val favList = favListViewModel.getByIdSync(id) 47 | 48 | // Copy the favlist from the DB first 49 | var editedList by remember { 50 | mutableStateOf(favList) 51 | } 52 | 53 | Scaffold( 54 | topBar = { 55 | TopAppBar( 56 | title = { Text(stringResource(R.string.edit_list)) }, 57 | navigationIcon = { 58 | BackButton( 59 | onBackClick = { navController.navigateUp() }, 60 | ) 61 | }, 62 | ) 63 | }, 64 | content = { padding -> 65 | Column( 66 | modifier = 67 | Modifier 68 | .padding(padding) 69 | .verticalScroll(scrollState) 70 | .imePadding(), 71 | ) { 72 | FavListForm( 73 | favList = editedList, 74 | onChange = { editedList = it }, 75 | ) 76 | } 77 | }, 78 | floatingActionButton = { 79 | BasicTooltipBox( 80 | positionProvider = tooltipPosition, 81 | state = rememberBasicTooltipState(isPersistent = false), 82 | tooltip = { 83 | ToolTip(stringResource(R.string.save)) 84 | }, 85 | ) { 86 | FloatingActionButton( 87 | modifier = Modifier.imePadding(), 88 | onClick = { 89 | editedList?.let { 90 | if (nameIsValid(it.name)) { 91 | val update = 92 | FavListUpdate( 93 | id = it.id, 94 | name = it.name, 95 | description = it.description, 96 | ) 97 | favListViewModel.update(update) 98 | navController.navigateUp() 99 | } 100 | } 101 | }, 102 | shape = CircleShape, 103 | ) { 104 | Icon( 105 | imageVector = Icons.Outlined.Save, 106 | contentDescription = stringResource(R.string.save), 107 | ) 108 | } 109 | } 110 | }, 111 | ) 112 | } 113 | -------------------------------------------------------------------------------- /app/src/main/java/com/dessalines/rankmyfavs/ui/components/favlist/FavListForm.kt: -------------------------------------------------------------------------------- 1 | package com.dessalines.rankmyfavs.ui.components.favlist 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.material3.OutlinedTextField 8 | import androidx.compose.material3.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.getValue 11 | import androidx.compose.runtime.mutableStateOf 12 | import androidx.compose.runtime.saveable.rememberSaveable 13 | import androidx.compose.runtime.setValue 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.res.stringResource 16 | import androidx.compose.ui.tooling.preview.Preview 17 | import com.dessalines.rankmyfavs.R 18 | import com.dessalines.rankmyfavs.db.FavList 19 | import com.dessalines.rankmyfavs.ui.components.common.SMALL_PADDING 20 | import com.dessalines.rankmyfavs.utils.nameIsValid 21 | 22 | @Composable 23 | fun FavListForm( 24 | favList: FavList? = null, 25 | onChange: (FavList) -> Unit, 26 | ) { 27 | var name by rememberSaveable { 28 | mutableStateOf(favList?.name.orEmpty()) 29 | } 30 | 31 | var description by rememberSaveable { 32 | mutableStateOf(favList?.description.orEmpty()) 33 | } 34 | 35 | Column( 36 | modifier = Modifier.padding(horizontal = SMALL_PADDING), 37 | verticalArrangement = Arrangement.spacedBy(SMALL_PADDING), 38 | ) { 39 | OutlinedTextField( 40 | label = { Text(stringResource(R.string.title)) }, 41 | singleLine = true, 42 | modifier = Modifier.fillMaxWidth(), 43 | value = name, 44 | isError = !nameIsValid(name), 45 | onValueChange = { 46 | name = it 47 | onChange( 48 | FavList( 49 | id = favList?.id ?: 0, 50 | name, 51 | description, 52 | ), 53 | ) 54 | }, 55 | ) 56 | 57 | OutlinedTextField( 58 | label = { Text(stringResource(R.string.description)) }, 59 | modifier = Modifier.fillMaxWidth(), 60 | value = description, 61 | onValueChange = { 62 | description = it 63 | onChange( 64 | FavList( 65 | id = favList?.id ?: 0, 66 | name, 67 | description, 68 | ), 69 | ) 70 | }, 71 | ) 72 | } 73 | } 74 | 75 | @Composable 76 | @Preview 77 | fun FavListFormPreview() { 78 | FavListForm(onChange = {}) 79 | } 80 | -------------------------------------------------------------------------------- /app/src/main/java/com/dessalines/rankmyfavs/ui/components/favlist/ImportListScreen.kt: -------------------------------------------------------------------------------- 1 | package com.dessalines.rankmyfavs.ui.components.favlist 2 | 3 | import androidx.compose.foundation.BasicTooltipBox 4 | import androidx.compose.foundation.ExperimentalFoundationApi 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.imePadding 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.rememberBasicTooltipState 11 | import androidx.compose.foundation.rememberScrollState 12 | import androidx.compose.foundation.shape.CircleShape 13 | import androidx.compose.foundation.verticalScroll 14 | import androidx.compose.material.icons.Icons 15 | import androidx.compose.material.icons.outlined.Save 16 | import androidx.compose.material3.ExperimentalMaterial3Api 17 | import androidx.compose.material3.FloatingActionButton 18 | import androidx.compose.material3.Icon 19 | import androidx.compose.material3.OutlinedTextField 20 | import androidx.compose.material3.Scaffold 21 | import androidx.compose.material3.Text 22 | import androidx.compose.material3.TooltipDefaults 23 | import androidx.compose.material3.TopAppBar 24 | import androidx.compose.runtime.Composable 25 | import androidx.compose.runtime.getValue 26 | import androidx.compose.runtime.mutableStateOf 27 | import androidx.compose.runtime.saveable.rememberSaveable 28 | import androidx.compose.runtime.setValue 29 | import androidx.compose.ui.Modifier 30 | import androidx.compose.ui.res.stringResource 31 | import androidx.compose.ui.tooling.preview.Preview 32 | import androidx.navigation.NavController 33 | import com.dessalines.rankmyfavs.R 34 | import com.dessalines.rankmyfavs.db.FavListItemInsert 35 | import com.dessalines.rankmyfavs.db.FavListItemViewModel 36 | import com.dessalines.rankmyfavs.ui.components.common.BackButton 37 | import com.dessalines.rankmyfavs.ui.components.common.SMALL_PADDING 38 | import com.dessalines.rankmyfavs.ui.components.common.ToolTip 39 | import com.dessalines.rankmyfavs.ui.components.favlistitem.FavListItemForm 40 | import com.dessalines.rankmyfavs.utils.nameIsValid 41 | 42 | @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) 43 | @Composable 44 | fun ImportListScreen( 45 | navController: NavController, 46 | favListItemViewModel: FavListItemViewModel, 47 | favListId: Int, 48 | ) { 49 | val scrollState = rememberScrollState() 50 | val tooltipPosition = TooltipDefaults.rememberPlainTooltipPositionProvider() 51 | 52 | var listStr = "" 53 | 54 | Scaffold( 55 | topBar = { 56 | TopAppBar( 57 | title = { Text(stringResource(R.string.import_list)) }, 58 | navigationIcon = { 59 | BackButton( 60 | onBackClick = { navController.navigateUp() }, 61 | ) 62 | }, 63 | ) 64 | }, 65 | content = { padding -> 66 | Column( 67 | modifier = 68 | Modifier 69 | .padding(padding) 70 | .verticalScroll(scrollState) 71 | .imePadding(), 72 | ) { 73 | ImportListForm( 74 | onChange = { listStr = it }, 75 | ) 76 | } 77 | }, 78 | floatingActionButton = { 79 | BasicTooltipBox( 80 | positionProvider = tooltipPosition, 81 | state = rememberBasicTooltipState(isPersistent = false), 82 | tooltip = { 83 | ToolTip(stringResource(R.string.save)) 84 | }, 85 | ) { 86 | FloatingActionButton( 87 | modifier = Modifier.imePadding(), 88 | onClick = { 89 | val listItems = extractLines(listStr) 90 | for (item in listItems) { 91 | val insert = 92 | FavListItemInsert( 93 | favListId = favListId, 94 | name = item.name, 95 | description = item.description, 96 | ) 97 | favListItemViewModel.insert(insert) 98 | } 99 | navController.navigateUp() 100 | }, 101 | shape = CircleShape, 102 | ) { 103 | Icon( 104 | imageVector = Icons.Outlined.Save, 105 | contentDescription = stringResource(R.string.save), 106 | ) 107 | } 108 | } 109 | }, 110 | ) 111 | } 112 | 113 | data class FavListItemLine( 114 | val name: String, 115 | val description: String?, 116 | ) 117 | 118 | /** 119 | * This is of the form: - option 1 name | option 1 description 120 | */ 121 | private fun extractLines(listStr: String): List { 122 | val listItems = 123 | listStr 124 | .lines() 125 | .map { it.trim() } 126 | // Remove the preceding list items if necessary 127 | .map { 128 | // Remove the markdown list start 129 | val removedMarkdownListStart = 130 | if (it.startsWith("- ") || it.startsWith("* ")) { 131 | it.substring(2) 132 | } else { 133 | it 134 | } 135 | 136 | // Split it with | 137 | val split = removedMarkdownListStart.split("|") 138 | val name = split[0].trim() 139 | val description = split.getOrNull(1)?.trim() 140 | FavListItemLine(name, description) 141 | }.filter { nameIsValid(it.name) } 142 | return listItems 143 | } 144 | 145 | @Composable 146 | fun ImportListForm(onChange: (String) -> Unit) { 147 | var listStr by rememberSaveable { 148 | mutableStateOf("") 149 | } 150 | 151 | Column( 152 | modifier = Modifier.padding(horizontal = SMALL_PADDING), 153 | verticalArrangement = Arrangement.spacedBy(SMALL_PADDING), 154 | ) { 155 | OutlinedTextField( 156 | label = { Text(stringResource(R.string.import_list_description)) }, 157 | minLines = 3, 158 | modifier = Modifier.fillMaxWidth(), 159 | value = listStr, 160 | onValueChange = { 161 | listStr = it 162 | onChange(listStr) 163 | }, 164 | ) 165 | } 166 | } 167 | 168 | @Composable 169 | @Preview 170 | fun FavListItemFormPreview() { 171 | FavListItemForm(onChange = {}) 172 | } 173 | -------------------------------------------------------------------------------- /app/src/main/java/com/dessalines/rankmyfavs/ui/components/favlist/favlistanddetails/FavListsAndDetailScreen.kt: -------------------------------------------------------------------------------- 1 | package com.dessalines.rankmyfavs.ui.components.favlist.favlistanddetails 2 | 3 | import android.annotation.SuppressLint 4 | import android.widget.Toast 5 | import androidx.activity.compose.BackHandler 6 | import androidx.compose.animation.AnimatedContent 7 | import androidx.compose.animation.ExperimentalSharedTransitionApi 8 | import androidx.compose.animation.SharedTransitionLayout 9 | import androidx.compose.foundation.ExperimentalFoundationApi 10 | import androidx.compose.foundation.lazy.rememberLazyListState 11 | import androidx.compose.material3.ExperimentalMaterial3Api 12 | import androidx.compose.material3.TopAppBarDefaults 13 | import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi 14 | import androidx.compose.material3.adaptive.layout.AnimatedPane 15 | import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold 16 | import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole 17 | import androidx.compose.material3.adaptive.layout.PaneAdaptedValue 18 | import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator 19 | import androidx.compose.material3.rememberTopAppBarState 20 | import androidx.compose.runtime.Composable 21 | import androidx.compose.runtime.getValue 22 | import androidx.compose.runtime.livedata.observeAsState 23 | import androidx.compose.runtime.mutableStateOf 24 | import androidx.compose.runtime.rememberCoroutineScope 25 | import androidx.compose.runtime.saveable.rememberSaveable 26 | import androidx.compose.runtime.setValue 27 | import androidx.compose.ui.platform.LocalContext 28 | import androidx.compose.ui.res.stringResource 29 | import androidx.lifecycle.asLiveData 30 | import androidx.navigation.NavController 31 | import com.dessalines.rankmyfavs.R 32 | import com.dessalines.rankmyfavs.db.FavListItemViewModel 33 | import com.dessalines.rankmyfavs.db.FavListMatchViewModel 34 | import com.dessalines.rankmyfavs.db.FavListViewModel 35 | import com.dessalines.rankmyfavs.utils.SelectionVisibilityState 36 | import kotlinx.coroutines.launch 37 | 38 | @SuppressLint("UnusedContentLambdaTargetStateParameter") 39 | @OptIn( 40 | ExperimentalFoundationApi::class, 41 | ExperimentalMaterial3AdaptiveApi::class, 42 | ExperimentalSharedTransitionApi::class, 43 | ExperimentalMaterial3Api::class, 44 | ) 45 | @Composable 46 | fun FavListsAndDetailScreen( 47 | navController: NavController, 48 | favListViewModel: FavListViewModel, 49 | favListItemViewModel: FavListItemViewModel, 50 | favListMatchViewModel: FavListMatchViewModel, 51 | favListId: Int?, 52 | ) { 53 | val scope = rememberCoroutineScope() 54 | val ctx = LocalContext.current 55 | 56 | var selectedFavListId: Int? by rememberSaveable { mutableStateOf(favListId) } 57 | val favLists by favListViewModel.getAll.asLiveData().observeAsState() 58 | 59 | val favListsPaneScrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState()) 60 | val favListsPaneListState = rememberLazyListState() 61 | val favListDetailListState = rememberLazyListState() 62 | val navigator = rememberListDetailPaneScaffoldNavigator() 63 | val isListAndDetailVisible = 64 | navigator.scaffoldValue[ListDetailPaneScaffoldRole.Detail] == PaneAdaptedValue.Companion.Expanded && 65 | navigator.scaffoldValue[ListDetailPaneScaffoldRole.List] == PaneAdaptedValue.Companion.Expanded 66 | val isDetailVisible = 67 | navigator.scaffoldValue[ListDetailPaneScaffoldRole.Detail] == PaneAdaptedValue.Companion.Expanded 68 | 69 | BackHandler(enabled = navigator.canNavigateBack()) { 70 | scope.launch { 71 | navigator.navigateBack() 72 | } 73 | } 74 | 75 | SharedTransitionLayout { 76 | AnimatedContent(targetState = isListAndDetailVisible, label = "simple sample") { 77 | ListDetailPaneScaffold( 78 | directive = navigator.scaffoldDirective, 79 | value = navigator.scaffoldValue, 80 | listPane = { 81 | val currentSelectedFavlistId = selectedFavListId 82 | val selectionState = 83 | if (isDetailVisible && currentSelectedFavlistId != null) { 84 | SelectionVisibilityState.ShowSelection(currentSelectedFavlistId) 85 | } else { 86 | SelectionVisibilityState.NoSelection 87 | } 88 | 89 | AnimatedPane { 90 | FavListsPane( 91 | favLists = favLists, 92 | listState = favListsPaneListState, 93 | scrollBehavior = favListsPaneScrollBehavior, 94 | onFavListClick = { favListId -> 95 | selectedFavListId = favListId 96 | scope.launch { 97 | navigator.navigateTo(ListDetailPaneScaffoldRole.Detail) 98 | } 99 | }, 100 | selectionState = selectionState, 101 | isListAndDetailVisible = isListAndDetailVisible, 102 | onCreateFavlistClick = { 103 | navController.navigate("createFavList") 104 | }, 105 | onSettingsClick = { 106 | navController.navigate("settings") 107 | }, 108 | ) 109 | } 110 | }, 111 | detailPane = { 112 | AnimatedPane { 113 | selectedFavListId?.let { favListId -> 114 | 115 | val favList by favListViewModel.getById(favListId).asLiveData().observeAsState() 116 | val favListItems by favListItemViewModel.getFromList(favListId).asLiveData().observeAsState() 117 | val clearStatsMessage = stringResource(R.string.clear_stats) 118 | val deletedMessage = stringResource(R.string.list_deleted) 119 | 120 | FavListDetailPane( 121 | favList = favList, 122 | favListItems = favListItems, 123 | listState = favListDetailListState, 124 | isListAndDetailVisible = isListAndDetailVisible, 125 | onBackClick = { 126 | scope.launch { 127 | navigator.navigateBack() 128 | } 129 | }, 130 | onClearStats = { 131 | favListItemViewModel.clearStatsForList(favListId = favListId) 132 | favListMatchViewModel.deleteMatchesForList(favListId = favListId) 133 | Toast 134 | .makeText(ctx, clearStatsMessage, Toast.LENGTH_SHORT) 135 | .show() 136 | }, 137 | onDelete = { 138 | favList?.let { 139 | favListViewModel.delete(it) 140 | navController.navigateUp() 141 | Toast 142 | .makeText(ctx, deletedMessage, Toast.LENGTH_SHORT) 143 | .show() 144 | } 145 | }, 146 | onCreateItemClick = { 147 | navController.navigate("createItem/$favListId") 148 | }, 149 | onEditClick = { 150 | navController.navigate("editFavList/$favListId") 151 | }, 152 | onImportListClick = { 153 | navController.navigate("importList/$favListId") 154 | }, 155 | onTierListClick = { 156 | navController.navigate("tierList/$favListId") 157 | }, 158 | onItemDetailsClick = { 159 | navController.navigate("itemDetails/$it") 160 | }, 161 | onMatchClick = { 162 | navController.navigate("match?favListId=$favListId") 163 | }, 164 | ) 165 | } 166 | } 167 | }, 168 | ) 169 | } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /app/src/main/java/com/dessalines/rankmyfavs/ui/components/favlist/favlistanddetails/FavListsPane.kt: -------------------------------------------------------------------------------- 1 | package com.dessalines.rankmyfavs.ui.components.favlist.favlistanddetails 2 | 3 | import androidx.compose.foundation.BasicTooltipBox 4 | import androidx.compose.foundation.ExperimentalFoundationApi 5 | import androidx.compose.foundation.clickable 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.imePadding 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.foundation.lazy.LazyColumn 10 | import androidx.compose.foundation.lazy.LazyListState 11 | import androidx.compose.foundation.lazy.items 12 | import androidx.compose.foundation.rememberBasicTooltipState 13 | import androidx.compose.foundation.shape.CircleShape 14 | import androidx.compose.material.icons.Icons 15 | import androidx.compose.material.icons.filled.Settings 16 | import androidx.compose.material.icons.outlined.Add 17 | import androidx.compose.material3.CenterAlignedTopAppBar 18 | import androidx.compose.material3.ExperimentalMaterial3Api 19 | import androidx.compose.material3.FloatingActionButton 20 | import androidx.compose.material3.Icon 21 | import androidx.compose.material3.IconButton 22 | import androidx.compose.material3.ListItem 23 | import androidx.compose.material3.ListItemDefaults 24 | import androidx.compose.material3.MaterialTheme 25 | import androidx.compose.material3.Scaffold 26 | import androidx.compose.material3.Text 27 | import androidx.compose.material3.TooltipDefaults 28 | import androidx.compose.material3.TopAppBarScrollBehavior 29 | import androidx.compose.runtime.Composable 30 | import androidx.compose.ui.Modifier 31 | import androidx.compose.ui.input.nestedscroll.nestedScroll 32 | import androidx.compose.ui.res.stringResource 33 | import androidx.compose.ui.tooling.preview.Preview 34 | import com.dessalines.rankmyfavs.R 35 | import com.dessalines.rankmyfavs.db.FavList 36 | import com.dessalines.rankmyfavs.ui.components.common.LARGE_PADDING 37 | import com.dessalines.rankmyfavs.ui.components.common.ToolTip 38 | import com.dessalines.rankmyfavs.utils.SelectionVisibilityState 39 | import kotlin.collections.orEmpty 40 | 41 | @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) 42 | @Composable 43 | fun FavListsPane( 44 | favLists: List?, 45 | listState: LazyListState, 46 | scrollBehavior: TopAppBarScrollBehavior, 47 | onFavListClick: (favListId: Int) -> Unit, 48 | selectionState: SelectionVisibilityState, 49 | isListAndDetailVisible: Boolean, 50 | onCreateFavlistClick: () -> Unit, 51 | onSettingsClick: () -> Unit, 52 | ) { 53 | val tooltipPosition = TooltipDefaults.rememberPlainTooltipPositionProvider() 54 | val title = 55 | if (!isListAndDetailVisible) stringResource(R.string.app_name) else stringResource(R.string.lists) 56 | 57 | Scaffold( 58 | topBar = { 59 | CenterAlignedTopAppBar( 60 | title = { Text(title) }, 61 | scrollBehavior = scrollBehavior, 62 | navigationIcon = { 63 | BasicTooltipBox( 64 | positionProvider = tooltipPosition, 65 | state = rememberBasicTooltipState(isPersistent = false), 66 | tooltip = { 67 | ToolTip(stringResource(R.string.settings)) 68 | }, 69 | ) { 70 | IconButton( 71 | onClick = onSettingsClick, 72 | ) { 73 | Icon( 74 | Icons.Filled.Settings, 75 | contentDescription = stringResource(R.string.settings), 76 | ) 77 | } 78 | } 79 | }, 80 | ) 81 | }, 82 | modifier = Modifier.Companion.nestedScroll(scrollBehavior.nestedScrollConnection), 83 | content = { padding -> 84 | Box( 85 | modifier = 86 | Modifier.Companion 87 | .padding(padding) 88 | .imePadding(), 89 | ) { 90 | LazyColumn( 91 | state = listState, 92 | ) { 93 | items(favLists.orEmpty()) { favList -> 94 | val selected = 95 | when (selectionState) { 96 | is SelectionVisibilityState.ShowSelection -> selectionState.selectedItem == favList.id 97 | else -> false 98 | } 99 | 100 | FavListRow( 101 | favList = favList, 102 | onClick = { onFavListClick(favList.id) }, 103 | selected = selected, 104 | ) 105 | } 106 | item { 107 | if (favLists.isNullOrEmpty()) { 108 | Text( 109 | text = stringResource(R.string.no_lists), 110 | modifier = Modifier.Companion.padding(horizontal = LARGE_PADDING), 111 | ) 112 | } 113 | } 114 | } 115 | } 116 | }, 117 | floatingActionButton = { 118 | BasicTooltipBox( 119 | positionProvider = tooltipPosition, 120 | state = rememberBasicTooltipState(isPersistent = false), 121 | tooltip = { 122 | ToolTip(stringResource(R.string.create_list)) 123 | }, 124 | ) { 125 | FloatingActionButton( 126 | modifier = Modifier.Companion.imePadding(), 127 | onClick = onCreateFavlistClick, 128 | shape = CircleShape, 129 | ) { 130 | Icon( 131 | imageVector = Icons.Outlined.Add, 132 | contentDescription = stringResource(R.string.create_list), 133 | ) 134 | } 135 | } 136 | }, 137 | ) 138 | } 139 | 140 | @Composable 141 | fun FavListRow( 142 | favList: FavList, 143 | selected: Boolean = false, 144 | onClick: () -> Unit, 145 | ) { 146 | val containerColor = 147 | if (!selected) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.surfaceVariant 148 | 149 | ListItem( 150 | headlineContent = { 151 | Text(favList.name) 152 | }, 153 | colors = ListItemDefaults.colors(containerColor = containerColor), 154 | modifier = 155 | Modifier.Companion.clickable { 156 | onClick() 157 | }, 158 | ) 159 | } 160 | 161 | @Composable 162 | @Preview 163 | fun FavListRowPreview() { 164 | FavListRow( 165 | favList = FavList(id = 1, name = "Fav List 1"), 166 | onClick = {}, 167 | ) 168 | } 169 | -------------------------------------------------------------------------------- /app/src/main/java/com/dessalines/rankmyfavs/ui/components/favlistitem/CreateFavListItemScreen.kt: -------------------------------------------------------------------------------- 1 | package com.dessalines.rankmyfavs.ui.components.favlistitem 2 | 3 | import androidx.compose.foundation.BasicTooltipBox 4 | import androidx.compose.foundation.ExperimentalFoundationApi 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.imePadding 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.rememberBasicTooltipState 9 | import androidx.compose.foundation.rememberScrollState 10 | import androidx.compose.foundation.shape.CircleShape 11 | import androidx.compose.foundation.verticalScroll 12 | import androidx.compose.material.icons.Icons 13 | import androidx.compose.material.icons.outlined.Save 14 | import androidx.compose.material3.ExperimentalMaterial3Api 15 | import androidx.compose.material3.FloatingActionButton 16 | import androidx.compose.material3.Icon 17 | import androidx.compose.material3.Scaffold 18 | import androidx.compose.material3.Text 19 | import androidx.compose.material3.TooltipDefaults 20 | import androidx.compose.material3.TopAppBar 21 | import androidx.compose.runtime.Composable 22 | import androidx.compose.ui.Modifier 23 | import androidx.compose.ui.res.stringResource 24 | import androidx.navigation.NavController 25 | import com.dessalines.rankmyfavs.R 26 | import com.dessalines.rankmyfavs.db.FavListItem 27 | import com.dessalines.rankmyfavs.db.FavListItemInsert 28 | import com.dessalines.rankmyfavs.db.FavListItemViewModel 29 | import com.dessalines.rankmyfavs.ui.components.common.BackButton 30 | import com.dessalines.rankmyfavs.ui.components.common.ToolTip 31 | import com.dessalines.rankmyfavs.utils.nameIsValid 32 | 33 | @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) 34 | @Composable 35 | fun CreateFavListItemScreen( 36 | navController: NavController, 37 | favListItemViewModel: FavListItemViewModel, 38 | favListId: Int, 39 | ) { 40 | val scrollState = rememberScrollState() 41 | val tooltipPosition = TooltipDefaults.rememberPlainTooltipPositionProvider() 42 | 43 | var favListItem: FavListItem? = null 44 | 45 | Scaffold( 46 | topBar = { 47 | TopAppBar( 48 | title = { Text(stringResource(R.string.create_item)) }, 49 | navigationIcon = { 50 | BackButton( 51 | onBackClick = { navController.navigateUp() }, 52 | ) 53 | }, 54 | ) 55 | }, 56 | content = { padding -> 57 | Column( 58 | modifier = 59 | Modifier 60 | .padding(padding) 61 | .verticalScroll(scrollState) 62 | .imePadding(), 63 | ) { 64 | FavListItemForm( 65 | onChange = { favListItem = it }, 66 | ) 67 | } 68 | }, 69 | floatingActionButton = { 70 | BasicTooltipBox( 71 | positionProvider = tooltipPosition, 72 | state = rememberBasicTooltipState(isPersistent = false), 73 | tooltip = { 74 | ToolTip(stringResource(R.string.save)) 75 | }, 76 | ) { 77 | FloatingActionButton( 78 | modifier = Modifier.imePadding(), 79 | onClick = { 80 | favListItem?.let { 81 | if (nameIsValid(it.name)) { 82 | val insert = 83 | FavListItemInsert( 84 | favListId = favListId, 85 | name = it.name, 86 | description = it.description, 87 | ) 88 | favListItemViewModel.insert(insert) 89 | navController.navigateUp() 90 | } 91 | } 92 | }, 93 | shape = CircleShape, 94 | ) { 95 | Icon( 96 | imageVector = Icons.Outlined.Save, 97 | contentDescription = stringResource(R.string.save), 98 | ) 99 | } 100 | } 101 | }, 102 | ) 103 | } 104 | -------------------------------------------------------------------------------- /app/src/main/java/com/dessalines/rankmyfavs/ui/components/favlistitem/EditFavListItemScreen.kt: -------------------------------------------------------------------------------- 1 | package com.dessalines.rankmyfavs.ui.components.favlistitem 2 | 3 | import androidx.compose.foundation.BasicTooltipBox 4 | import androidx.compose.foundation.ExperimentalFoundationApi 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.imePadding 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.rememberBasicTooltipState 9 | import androidx.compose.foundation.rememberScrollState 10 | import androidx.compose.foundation.shape.CircleShape 11 | import androidx.compose.foundation.verticalScroll 12 | import androidx.compose.material.icons.Icons 13 | import androidx.compose.material.icons.outlined.Save 14 | import androidx.compose.material3.ExperimentalMaterial3Api 15 | import androidx.compose.material3.FloatingActionButton 16 | import androidx.compose.material3.Icon 17 | import androidx.compose.material3.Scaffold 18 | import androidx.compose.material3.Text 19 | import androidx.compose.material3.TooltipDefaults 20 | import androidx.compose.material3.TopAppBar 21 | import androidx.compose.runtime.Composable 22 | import androidx.compose.runtime.getValue 23 | import androidx.compose.runtime.mutableStateOf 24 | import androidx.compose.runtime.remember 25 | import androidx.compose.runtime.setValue 26 | import androidx.compose.ui.Modifier 27 | import androidx.compose.ui.res.stringResource 28 | import androidx.navigation.NavController 29 | import com.dessalines.rankmyfavs.R 30 | import com.dessalines.rankmyfavs.db.FavListItemUpdateNameAndDesc 31 | import com.dessalines.rankmyfavs.db.FavListItemViewModel 32 | import com.dessalines.rankmyfavs.ui.components.common.BackButton 33 | import com.dessalines.rankmyfavs.ui.components.common.ToolTip 34 | import com.dessalines.rankmyfavs.utils.nameIsValid 35 | 36 | @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) 37 | @Composable 38 | fun EditFavListItemScreen( 39 | navController: NavController, 40 | favListItemViewModel: FavListItemViewModel, 41 | id: Int, 42 | ) { 43 | val scrollState = rememberScrollState() 44 | val tooltipPosition = TooltipDefaults.rememberPlainTooltipPositionProvider() 45 | 46 | val favListItem = favListItemViewModel.getByIdSync(id) 47 | 48 | // Copy the favlist from the DB first 49 | var editedItem by remember { 50 | mutableStateOf(favListItem) 51 | } 52 | 53 | Scaffold( 54 | topBar = { 55 | TopAppBar( 56 | title = { Text(stringResource(R.string.edit_item)) }, 57 | navigationIcon = { 58 | BackButton( 59 | onBackClick = { navController.navigateUp() }, 60 | ) 61 | }, 62 | ) 63 | }, 64 | content = { padding -> 65 | Column( 66 | modifier = 67 | Modifier 68 | .padding(padding) 69 | .verticalScroll(scrollState) 70 | .imePadding(), 71 | ) { 72 | FavListItemForm( 73 | favListItem = favListItem, 74 | onChange = { editedItem = it }, 75 | ) 76 | } 77 | }, 78 | floatingActionButton = { 79 | BasicTooltipBox( 80 | positionProvider = tooltipPosition, 81 | state = rememberBasicTooltipState(isPersistent = false), 82 | tooltip = { 83 | ToolTip(stringResource(R.string.save)) 84 | }, 85 | ) { 86 | FloatingActionButton( 87 | modifier = Modifier.imePadding(), 88 | onClick = { 89 | editedItem?.let { 90 | if (nameIsValid(it.name)) { 91 | val update = 92 | FavListItemUpdateNameAndDesc( 93 | id = it.id, 94 | name = it.name, 95 | description = it.description, 96 | ) 97 | favListItemViewModel.updateNameAndDesc(update) 98 | navController.navigateUp() 99 | } 100 | } 101 | }, 102 | shape = CircleShape, 103 | ) { 104 | Icon( 105 | imageVector = Icons.Outlined.Save, 106 | contentDescription = stringResource(R.string.save), 107 | ) 108 | } 109 | } 110 | }, 111 | ) 112 | } 113 | -------------------------------------------------------------------------------- /app/src/main/java/com/dessalines/rankmyfavs/ui/components/favlistitem/FavListItemDetailScreen.kt: -------------------------------------------------------------------------------- 1 | package com.dessalines.rankmyfavs.ui.components.favlistitem 2 | 3 | import android.widget.Toast 4 | import androidx.annotation.Keep 5 | import androidx.compose.foundation.BasicTooltipBox 6 | import androidx.compose.foundation.ExperimentalFoundationApi 7 | import androidx.compose.foundation.layout.Box 8 | import androidx.compose.foundation.layout.Row 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.imePadding 11 | import androidx.compose.foundation.layout.padding 12 | import androidx.compose.foundation.lazy.LazyColumn 13 | import androidx.compose.foundation.lazy.rememberLazyListState 14 | import androidx.compose.foundation.rememberBasicTooltipState 15 | import androidx.compose.foundation.shape.CircleShape 16 | import androidx.compose.material.icons.Icons 17 | import androidx.compose.material.icons.automirrored.outlined.Help 18 | import androidx.compose.material.icons.outlined.ClearAll 19 | import androidx.compose.material.icons.outlined.Delete 20 | import androidx.compose.material.icons.outlined.Edit 21 | import androidx.compose.material.icons.outlined.Reviews 22 | import androidx.compose.material3.ExperimentalMaterial3Api 23 | import androidx.compose.material3.FloatingActionButton 24 | import androidx.compose.material3.Icon 25 | import androidx.compose.material3.IconButton 26 | import androidx.compose.material3.MaterialTheme 27 | import androidx.compose.material3.MediumTopAppBar 28 | import androidx.compose.material3.Scaffold 29 | import androidx.compose.material3.Text 30 | import androidx.compose.material3.TooltipDefaults 31 | import androidx.compose.material3.TopAppBarDefaults 32 | import androidx.compose.material3.rememberTopAppBarState 33 | import androidx.compose.runtime.Composable 34 | import androidx.compose.runtime.getValue 35 | import androidx.compose.runtime.livedata.observeAsState 36 | import androidx.compose.runtime.mutableStateOf 37 | import androidx.compose.runtime.remember 38 | import androidx.compose.ui.Modifier 39 | import androidx.compose.ui.input.nestedscroll.nestedScroll 40 | import androidx.compose.ui.platform.LocalContext 41 | import androidx.compose.ui.res.stringResource 42 | import androidx.compose.ui.tooling.preview.Preview 43 | import androidx.compose.ui.unit.dp 44 | import androidx.lifecycle.asLiveData 45 | import androidx.navigation.NavController 46 | import com.breens.beetablescompose.BeeTablesCompose 47 | import com.dessalines.rankmyfavs.R 48 | import com.dessalines.rankmyfavs.db.DEFAULT_GLICKO_DEVIATION 49 | import com.dessalines.rankmyfavs.db.DEFAULT_GLICKO_RATING 50 | import com.dessalines.rankmyfavs.db.DEFAULT_GLICKO_VOLATILITY 51 | import com.dessalines.rankmyfavs.db.DEFAULT_MATCH_COUNT 52 | import com.dessalines.rankmyfavs.db.DEFAULT_WIN_RATE 53 | import com.dessalines.rankmyfavs.db.FavListItem 54 | import com.dessalines.rankmyfavs.db.FavListItemUpdateStats 55 | import com.dessalines.rankmyfavs.db.FavListItemViewModel 56 | import com.dessalines.rankmyfavs.db.FavListMatchViewModel 57 | import com.dessalines.rankmyfavs.db.sampleFavListItem 58 | import com.dessalines.rankmyfavs.ui.components.common.AreYouSureDialog 59 | import com.dessalines.rankmyfavs.ui.components.common.BackButton 60 | import com.dessalines.rankmyfavs.ui.components.common.LARGE_PADDING 61 | import com.dessalines.rankmyfavs.ui.components.common.SMALL_PADDING 62 | import com.dessalines.rankmyfavs.ui.components.common.ToolTip 63 | import com.dessalines.rankmyfavs.utils.GLICKO_WIKI_URL 64 | import com.dessalines.rankmyfavs.utils.numToString 65 | import com.dessalines.rankmyfavs.utils.openLink 66 | import dev.jeziellago.compose.markdowntext.MarkdownText 67 | 68 | @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) 69 | @Composable 70 | fun FavListItemDetailScreen( 71 | navController: NavController, 72 | favListItemViewModel: FavListItemViewModel, 73 | favListMatchViewModel: FavListMatchViewModel, 74 | id: Int, 75 | ) { 76 | val ctx = LocalContext.current 77 | val tooltipPosition = TooltipDefaults.rememberPlainTooltipPositionProvider() 78 | val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState()) 79 | val listState = rememberLazyListState() 80 | 81 | val favListItem by favListItemViewModel.getById(id).asLiveData().observeAsState() 82 | 83 | val showDeleteDialog = remember { mutableStateOf(false) } 84 | val showClearStatsDialog = remember { mutableStateOf(false) } 85 | 86 | val deletedMessage = stringResource(R.string.item_deleted) 87 | val clearStatsMessage = stringResource(R.string.clear_stats) 88 | 89 | Scaffold( 90 | topBar = { 91 | MediumTopAppBar( 92 | title = { Text(favListItem?.name.orEmpty()) }, 93 | scrollBehavior = scrollBehavior, 94 | navigationIcon = { 95 | BackButton( 96 | onBackClick = { navController.navigateUp() }, 97 | ) 98 | }, 99 | actions = { 100 | BasicTooltipBox( 101 | positionProvider = tooltipPosition, 102 | state = rememberBasicTooltipState(isPersistent = false), 103 | tooltip = { 104 | ToolTip(stringResource(R.string.what_do_these_numbers_mean)) 105 | }, 106 | ) { 107 | IconButton(onClick = { openLink(GLICKO_WIKI_URL, ctx) }) { 108 | Icon( 109 | imageVector = Icons.AutoMirrored.Outlined.Help, 110 | contentDescription = stringResource(R.string.what_do_these_numbers_mean), 111 | ) 112 | } 113 | } 114 | BasicTooltipBox( 115 | positionProvider = tooltipPosition, 116 | state = rememberBasicTooltipState(isPersistent = false), 117 | tooltip = { 118 | ToolTip(stringResource(R.string.edit_item)) 119 | }, 120 | ) { 121 | IconButton( 122 | onClick = { 123 | navController.navigate("editItem/$id") 124 | }, 125 | ) { 126 | Icon( 127 | imageVector = Icons.Outlined.Edit, 128 | contentDescription = stringResource(R.string.edit_item), 129 | ) 130 | } 131 | } 132 | BasicTooltipBox( 133 | positionProvider = tooltipPosition, 134 | state = rememberBasicTooltipState(isPersistent = false), 135 | tooltip = { 136 | ToolTip(clearStatsMessage) 137 | }, 138 | ) { 139 | IconButton( 140 | onClick = { 141 | showClearStatsDialog.value = true 142 | }, 143 | ) { 144 | Icon( 145 | Icons.Outlined.ClearAll, 146 | contentDescription = stringResource(R.string.clear_stats), 147 | ) 148 | } 149 | } 150 | BasicTooltipBox( 151 | positionProvider = tooltipPosition, 152 | state = rememberBasicTooltipState(isPersistent = false), 153 | tooltip = { 154 | ToolTip(stringResource(R.string.delete)) 155 | }, 156 | ) { 157 | IconButton( 158 | onClick = { 159 | showDeleteDialog.value = true 160 | }, 161 | ) { 162 | Icon( 163 | Icons.Outlined.Delete, 164 | contentDescription = stringResource(R.string.delete), 165 | ) 166 | } 167 | } 168 | }, 169 | ) 170 | }, 171 | content = { padding -> 172 | AreYouSureDialog( 173 | show = showDeleteDialog, 174 | title = stringResource(R.string.delete), 175 | onYes = { 176 | favListItem?.let { 177 | favListItemViewModel.delete(it) 178 | navController.navigateUp() 179 | Toast.makeText(ctx, deletedMessage, Toast.LENGTH_SHORT).show() 180 | } 181 | }, 182 | ) 183 | 184 | AreYouSureDialog( 185 | show = showClearStatsDialog, 186 | title = clearStatsMessage, 187 | onYes = { 188 | // Update the stats for that row 189 | favListItemViewModel.updateStats( 190 | FavListItemUpdateStats( 191 | id = id, 192 | winRate = DEFAULT_WIN_RATE, 193 | glickoRating = DEFAULT_GLICKO_RATING, 194 | glickoDeviation = DEFAULT_GLICKO_DEVIATION, 195 | glickoVolatility = DEFAULT_GLICKO_VOLATILITY, 196 | matchCount = DEFAULT_MATCH_COUNT, 197 | ), 198 | ) 199 | favListMatchViewModel.deleteMatchesForItem(id) 200 | Toast.makeText(ctx, clearStatsMessage, Toast.LENGTH_SHORT).show() 201 | }, 202 | ) 203 | 204 | Box( 205 | modifier = Modifier.padding(padding).imePadding().nestedScroll(scrollBehavior.nestedScrollConnection), 206 | ) { 207 | LazyColumn( 208 | state = listState, 209 | ) { 210 | favListItem?.let { 211 | item { 212 | FavListItemDetails(it) 213 | } 214 | 215 | item { 216 | Stats(it) 217 | } 218 | } 219 | } 220 | } 221 | }, 222 | floatingActionButton = { 223 | BasicTooltipBox( 224 | positionProvider = tooltipPosition, 225 | state = rememberBasicTooltipState(isPersistent = false), 226 | tooltip = { 227 | ToolTip(stringResource(R.string.rate)) 228 | }, 229 | ) { 230 | FloatingActionButton( 231 | modifier = Modifier.imePadding(), 232 | onClick = { 233 | navController.navigate("match?favListItemId=$id") 234 | }, 235 | shape = CircleShape, 236 | ) { 237 | Icon( 238 | Icons.Outlined.Reviews, 239 | contentDescription = stringResource(R.string.rate), 240 | ) 241 | } 242 | } 243 | }, 244 | ) 245 | } 246 | 247 | @Keep 248 | data class StatItem( 249 | val key: String, 250 | val v: String, 251 | ) 252 | 253 | @Composable 254 | fun Stats(favListItem: FavListItem) { 255 | val titles = listOf("Key", "V") 256 | val data = 257 | listOf( 258 | StatItem( 259 | key = stringResource(R.string.rating), 260 | v = numToString(favListItem.glickoRating, 0), 261 | ), 262 | StatItem( 263 | key = stringResource(R.string.confidence), 264 | v = calculateConfidenceStr(favListItem.glickoDeviation), 265 | ), 266 | StatItem( 267 | key = stringResource(R.string.deviation), 268 | v = numToString(favListItem.glickoDeviation, 0), 269 | ), 270 | // StatItem( 271 | // key = stringResource(R.string.Volatility), 272 | // v = numToString(favListItem.glickoVolatility, 2), 273 | // ), 274 | StatItem( 275 | key = stringResource(R.string.win_rate), 276 | v = numToString(favListItem.winRate, 0) + "%", 277 | ), 278 | StatItem( 279 | key = stringResource(R.string.match_count), 280 | v = favListItem.matchCount.toString(), 281 | ), 282 | ) 283 | Row( 284 | modifier = Modifier.padding(horizontal = LARGE_PADDING), 285 | ) { 286 | BeeTablesCompose(data = data, headerTableTitles = titles, enableTableHeaderTitles = false) 287 | } 288 | } 289 | 290 | fun calculateConfidence(deviation: Float) = ((1500F - deviation) / 1500F * 100F) 291 | 292 | fun calculateConfidenceStr(deviation: Float): String = numToString(calculateConfidence(deviation), 1) + "%" 293 | 294 | @Composable 295 | @Preview 296 | fun StatsPreview() { 297 | Stats(sampleFavListItem) 298 | } 299 | 300 | @Composable 301 | fun FavListItemDetails(favListItem: FavListItem) { 302 | if (!favListItem.description.isNullOrBlank()) { 303 | MarkdownText( 304 | markdown = favListItem.description, 305 | linkColor = MaterialTheme.colorScheme.primary, 306 | modifier = 307 | Modifier 308 | .padding( 309 | top = 0.dp, 310 | bottom = SMALL_PADDING, 311 | start = LARGE_PADDING, 312 | end = LARGE_PADDING, 313 | ).fillMaxWidth(), 314 | ) 315 | } 316 | } 317 | 318 | @Composable 319 | @Preview 320 | fun FavListItemDetailsPreview() { 321 | FavListItemDetails(sampleFavListItem) 322 | } 323 | -------------------------------------------------------------------------------- /app/src/main/java/com/dessalines/rankmyfavs/ui/components/favlistitem/FavListItemForm.kt: -------------------------------------------------------------------------------- 1 | package com.dessalines.rankmyfavs.ui.components.favlistitem 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.material3.OutlinedTextField 8 | import androidx.compose.material3.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.getValue 11 | import androidx.compose.runtime.mutableStateOf 12 | import androidx.compose.runtime.saveable.rememberSaveable 13 | import androidx.compose.runtime.setValue 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.res.stringResource 16 | import androidx.compose.ui.tooling.preview.Preview 17 | import com.dessalines.rankmyfavs.R 18 | import com.dessalines.rankmyfavs.db.FavListItem 19 | import com.dessalines.rankmyfavs.ui.components.common.SMALL_PADDING 20 | import com.dessalines.rankmyfavs.utils.nameIsValid 21 | 22 | @Composable 23 | fun FavListItemForm( 24 | favListItem: FavListItem? = null, 25 | onChange: (FavListItem) -> Unit, 26 | ) { 27 | var name by rememberSaveable { 28 | mutableStateOf(favListItem?.name.orEmpty()) 29 | } 30 | 31 | var description by rememberSaveable { 32 | mutableStateOf(favListItem?.description.orEmpty()) 33 | } 34 | 35 | Column( 36 | modifier = Modifier.padding(horizontal = SMALL_PADDING), 37 | verticalArrangement = Arrangement.spacedBy(SMALL_PADDING), 38 | ) { 39 | OutlinedTextField( 40 | label = { Text(stringResource(R.string.title)) }, 41 | singleLine = true, 42 | modifier = Modifier.fillMaxWidth(), 43 | value = name, 44 | isError = !nameIsValid(name), 45 | onValueChange = { 46 | name = it 47 | onChange( 48 | FavListItem( 49 | id = favListItem?.id ?: 0, 50 | favListId = favListItem?.favListId ?: 0, 51 | name = name, 52 | description = description, 53 | winRate = favListItem?.winRate ?: 0F, 54 | glickoRating = favListItem?.glickoRating ?: 0F, 55 | glickoDeviation = favListItem?.glickoDeviation ?: 0F, 56 | glickoVolatility = favListItem?.glickoVolatility ?: 0F, 57 | matchCount = favListItem?.matchCount ?: 0, 58 | ), 59 | ) 60 | }, 61 | ) 62 | 63 | OutlinedTextField( 64 | label = { Text(stringResource(R.string.description)) }, 65 | modifier = Modifier.fillMaxWidth(), 66 | value = description, 67 | onValueChange = { 68 | description = it 69 | onChange( 70 | FavListItem( 71 | id = favListItem?.id ?: 0, 72 | favListId = favListItem?.favListId ?: 0, 73 | name = name, 74 | description = description, 75 | winRate = favListItem?.winRate ?: 0F, 76 | glickoRating = favListItem?.glickoRating ?: 0F, 77 | glickoDeviation = favListItem?.glickoDeviation ?: 0F, 78 | glickoVolatility = favListItem?.glickoVolatility ?: 0F, 79 | matchCount = favListItem?.matchCount ?: 0, 80 | ), 81 | ) 82 | }, 83 | ) 84 | } 85 | } 86 | 87 | @Composable 88 | @Preview 89 | fun FavListItemFormPreview() { 90 | FavListItemForm(onChange = {}) 91 | } 92 | -------------------------------------------------------------------------------- /app/src/main/java/com/dessalines/rankmyfavs/ui/components/match/MatchScreen.kt: -------------------------------------------------------------------------------- 1 | package com.dessalines.rankmyfavs.ui.components.match 2 | 3 | import androidx.compose.foundation.BasicTooltipBox 4 | import androidx.compose.foundation.ExperimentalFoundationApi 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.Column 8 | import androidx.compose.foundation.layout.ExperimentalLayoutApi 9 | import androidx.compose.foundation.layout.FlowRow 10 | import androidx.compose.foundation.layout.fillMaxSize 11 | import androidx.compose.foundation.layout.fillMaxWidth 12 | import androidx.compose.foundation.layout.imePadding 13 | import androidx.compose.foundation.layout.padding 14 | import androidx.compose.foundation.rememberBasicTooltipState 15 | import androidx.compose.foundation.rememberScrollState 16 | import androidx.compose.foundation.shape.CircleShape 17 | import androidx.compose.foundation.verticalScroll 18 | import androidx.compose.material.icons.Icons 19 | import androidx.compose.material.icons.outlined.Done 20 | import androidx.compose.material.icons.outlined.SkipNext 21 | import androidx.compose.material3.ExperimentalMaterial3Api 22 | import androidx.compose.material3.FloatingActionButton 23 | import androidx.compose.material3.Icon 24 | import androidx.compose.material3.IconButton 25 | import androidx.compose.material3.MaterialTheme 26 | import androidx.compose.material3.OutlinedCard 27 | import androidx.compose.material3.Scaffold 28 | import androidx.compose.material3.Text 29 | import androidx.compose.material3.TooltipDefaults 30 | import androidx.compose.material3.TopAppBar 31 | import androidx.compose.runtime.Composable 32 | import androidx.compose.ui.Alignment 33 | import androidx.compose.ui.Modifier 34 | import androidx.compose.ui.res.stringResource 35 | import androidx.compose.ui.tooling.preview.Preview 36 | import androidx.navigation.NavController 37 | import com.dessalines.rankmyfavs.R 38 | import com.dessalines.rankmyfavs.db.FavListItem 39 | import com.dessalines.rankmyfavs.db.FavListItemUpdateStats 40 | import com.dessalines.rankmyfavs.db.FavListItemViewModel 41 | import com.dessalines.rankmyfavs.db.FavListMatchInsert 42 | import com.dessalines.rankmyfavs.db.FavListMatchViewModel 43 | import com.dessalines.rankmyfavs.db.sampleFavListItem 44 | import com.dessalines.rankmyfavs.ui.components.common.BackButton 45 | import com.dessalines.rankmyfavs.ui.components.common.SMALL_PADDING 46 | import com.dessalines.rankmyfavs.ui.components.common.ToolTip 47 | import dev.jeziellago.compose.markdowntext.MarkdownText 48 | import org.goochjs.glicko2.Rating 49 | import org.goochjs.glicko2.RatingCalculator 50 | import org.goochjs.glicko2.RatingPeriodResults 51 | 52 | @OptIn( 53 | ExperimentalMaterial3Api::class, 54 | ExperimentalFoundationApi::class, 55 | ExperimentalLayoutApi::class, 56 | ) 57 | @Composable 58 | fun MatchScreen( 59 | navController: NavController, 60 | favListItemViewModel: FavListItemViewModel, 61 | favListMatchViewModel: FavListMatchViewModel, 62 | favListId: Int?, 63 | favListItemId: Int?, 64 | ) { 65 | val tooltipPosition = TooltipDefaults.rememberPlainTooltipPositionProvider() 66 | val scrollState = rememberScrollState() 67 | 68 | val first = 69 | if (favListId !== null) { 70 | favListItemViewModel.leastTrained(favListId) 71 | } else if (favListItemId !== null) { 72 | favListItemViewModel.getByIdSync(favListItemId) 73 | } else { 74 | null 75 | } 76 | 77 | fun rematchNav() = 78 | if (favListId !== null) { 79 | navController.navigate("match?favListId=$favListId") { 80 | popUpTo("match?favListId=$favListId") { inclusive = true } 81 | } 82 | } else if (favListItemId !== null) { 83 | navController.navigate("match?favListItemId=$favListItemId") { 84 | popUpTo("match?favListItemId=$favListItemId") { inclusive = true } 85 | } 86 | } else { 87 | null 88 | } 89 | 90 | val second = 91 | if (first !== null) { 92 | // Randomly either go with the closest match, or a random one from the list. 93 | // This keeps the skip a little more random. 94 | if ((1..2).random() == 1) { 95 | favListItemViewModel.closestMatch(first.favListId, first.id, first.glickoRating) 96 | } else { 97 | favListItemViewModel.randomMatch(first.favListId, first.id) 98 | } 99 | } else { 100 | null 101 | } 102 | 103 | Scaffold( 104 | topBar = { 105 | TopAppBar( 106 | title = { Text(stringResource(R.string.rate)) }, 107 | navigationIcon = { 108 | BackButton( 109 | onBackClick = { navController.popBackStack() }, 110 | ) 111 | }, 112 | actions = { 113 | if (first !== null && second !== null) { 114 | BasicTooltipBox( 115 | positionProvider = tooltipPosition, 116 | state = rememberBasicTooltipState(isPersistent = false), 117 | tooltip = { 118 | ToolTip(stringResource(id = R.string.skip)) 119 | }, 120 | ) { 121 | IconButton( 122 | onClick = { 123 | rematchNav() 124 | }, 125 | ) { 126 | Icon( 127 | Icons.Outlined.SkipNext, 128 | contentDescription = stringResource(R.string.skip), 129 | ) 130 | } 131 | } 132 | } 133 | }, 134 | ) 135 | }, 136 | content = { padding -> 137 | Box( 138 | contentAlignment = Alignment.Center, 139 | modifier = 140 | Modifier 141 | .fillMaxSize() 142 | .padding(padding) 143 | .verticalScroll(scrollState), 144 | ) { 145 | if (first !== null && second !== null) { 146 | FlowRow( 147 | modifier = 148 | Modifier 149 | .fillMaxWidth() 150 | .padding(SMALL_PADDING), 151 | horizontalArrangement = Arrangement.SpaceAround, 152 | ) { 153 | MatchItem( 154 | favListItem = first, 155 | onClick = { 156 | recalculateStats( 157 | favListItemViewModel = favListItemViewModel, 158 | favListMatchViewModel = favListMatchViewModel, 159 | winner = first, 160 | loser = second, 161 | ) 162 | rematchNav() 163 | }, 164 | ) 165 | MatchItem( 166 | favListItem = second, 167 | onClick = { 168 | recalculateStats( 169 | favListItemViewModel = favListItemViewModel, 170 | favListMatchViewModel = favListMatchViewModel, 171 | winner = second, 172 | loser = first, 173 | ) 174 | rematchNav() 175 | }, 176 | ) 177 | } 178 | } else { 179 | Text(stringResource(R.string.no_more_training)) 180 | } 181 | } 182 | }, 183 | floatingActionButton = { 184 | BasicTooltipBox( 185 | positionProvider = tooltipPosition, 186 | state = rememberBasicTooltipState(isPersistent = false), 187 | tooltip = { 188 | ToolTip(stringResource(R.string.done)) 189 | }, 190 | ) { 191 | FloatingActionButton( 192 | modifier = Modifier.imePadding(), 193 | onClick = { 194 | navController.navigateUp() 195 | }, 196 | shape = CircleShape, 197 | ) { 198 | Icon( 199 | imageVector = Icons.Outlined.Done, 200 | contentDescription = stringResource(R.string.done), 201 | ) 202 | } 203 | } 204 | }, 205 | ) 206 | } 207 | 208 | /** 209 | * The win rate and other scores are stored on the item row. 210 | */ 211 | fun recalculateStats( 212 | favListItemViewModel: FavListItemViewModel, 213 | favListMatchViewModel: FavListMatchViewModel, 214 | winner: FavListItem, 215 | loser: FavListItem, 216 | ) { 217 | // Insert the winner 218 | favListMatchViewModel.insert( 219 | FavListMatchInsert(winner.id, loser.id, winner.id), 220 | ) 221 | val (winRateWinner, matchCountWinner) = calculateWinRate(favListMatchViewModel, winner) 222 | val (winRateLoser, matchCountLoser) = calculateWinRate(favListMatchViewModel, loser) 223 | 224 | // Initialize Glicko 225 | val ratingSystem = RatingCalculator(0.06, 0.5) 226 | val results = RatingPeriodResults() 227 | 228 | val gWinner = Rating("1", ratingSystem) 229 | val gLoser = Rating("2", ratingSystem) 230 | 231 | gWinner.rating = winner.glickoRating.toDouble() 232 | gWinner.ratingDeviation = winner.glickoDeviation.toDouble() 233 | gWinner.volatility = winner.glickoVolatility.toDouble() 234 | 235 | gLoser.rating = loser.glickoRating.toDouble() 236 | gLoser.ratingDeviation = loser.glickoDeviation.toDouble() 237 | gLoser.volatility = loser.glickoVolatility.toDouble() 238 | 239 | results.addResult(gWinner, gLoser) 240 | 241 | ratingSystem.updateRatings(results) 242 | 243 | // Update the winner 244 | favListItemViewModel.updateStats( 245 | FavListItemUpdateStats( 246 | id = winner.id, 247 | winRate = winRateWinner, 248 | glickoRating = gWinner.rating.toFloat(), 249 | glickoDeviation = gWinner.ratingDeviation.toFloat(), 250 | glickoVolatility = gWinner.volatility.toFloat(), 251 | matchCount = matchCountWinner, 252 | ), 253 | ) 254 | 255 | // Update the loser 256 | favListItemViewModel.updateStats( 257 | FavListItemUpdateStats( 258 | id = loser.id, 259 | winRate = winRateLoser, 260 | glickoRating = gLoser.rating.toFloat(), 261 | glickoDeviation = gLoser.ratingDeviation.toFloat(), 262 | glickoVolatility = gLoser.volatility.toFloat(), 263 | matchCount = matchCountLoser, 264 | ), 265 | ) 266 | } 267 | 268 | fun calculateWinRate( 269 | favListMatchViewModel: FavListMatchViewModel, 270 | item: FavListItem, 271 | ): Pair { 272 | val matches = favListMatchViewModel.getMatchups(item.id) 273 | 274 | val matchCount = matches.count() 275 | val winCount = matches.count { it.winnerId == item.id } 276 | val winRate = 100F * winCount / matchCount 277 | return Pair(winRate, matchCount) 278 | } 279 | 280 | @Composable 281 | fun MatchItem( 282 | favListItem: FavListItem, 283 | onClick: () -> Unit, 284 | ) { 285 | OutlinedCard( 286 | onClick = onClick, 287 | ) { 288 | Column( 289 | modifier = Modifier.padding(SMALL_PADDING), 290 | ) { 291 | Text( 292 | text = favListItem.name, 293 | ) 294 | if (!favListItem.description.isNullOrBlank()) { 295 | MarkdownText( 296 | markdown = favListItem.description, 297 | linkColor = MaterialTheme.colorScheme.primary, 298 | // There's no way to actually override this 299 | onClick = onClick, 300 | ) 301 | } 302 | } 303 | } 304 | } 305 | 306 | @Composable 307 | @Preview 308 | fun MatchItemPreview() { 309 | MatchItem( 310 | favListItem = sampleFavListItem, 311 | onClick = {}, 312 | ) 313 | } 314 | -------------------------------------------------------------------------------- /app/src/main/java/com/dessalines/rankmyfavs/ui/components/settings/SettingsScreen.kt: -------------------------------------------------------------------------------- 1 | package com.dessalines.rankmyfavs.ui.components.settings 2 | 3 | import android.os.Build 4 | import android.widget.Toast 5 | import androidx.activity.compose.rememberLauncherForActivityResult 6 | import androidx.activity.result.contract.ActivityResultContracts 7 | import androidx.compose.foundation.layout.Column 8 | import androidx.compose.foundation.layout.imePadding 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.rememberScrollState 11 | import androidx.compose.foundation.verticalScroll 12 | import androidx.compose.material.icons.Icons 13 | import androidx.compose.material.icons.outlined.Colorize 14 | import androidx.compose.material.icons.outlined.DataThresholding 15 | import androidx.compose.material.icons.outlined.Info 16 | import androidx.compose.material.icons.outlined.Palette 17 | import androidx.compose.material.icons.outlined.Restore 18 | import androidx.compose.material.icons.outlined.Save 19 | import androidx.compose.material3.ExperimentalMaterial3Api 20 | import androidx.compose.material3.Icon 21 | import androidx.compose.material3.Scaffold 22 | import androidx.compose.material3.Text 23 | import androidx.compose.material3.TopAppBar 24 | import androidx.compose.runtime.Composable 25 | import androidx.compose.runtime.getValue 26 | import androidx.compose.runtime.livedata.observeAsState 27 | import androidx.compose.runtime.mutableFloatStateOf 28 | import androidx.compose.runtime.remember 29 | import androidx.compose.runtime.setValue 30 | import androidx.compose.ui.Modifier 31 | import androidx.compose.ui.platform.LocalContext 32 | import androidx.compose.ui.res.stringResource 33 | import androidx.compose.ui.text.AnnotatedString 34 | import androidx.lifecycle.asLiveData 35 | import androidx.navigation.NavController 36 | import com.dessalines.rankmyfavs.R 37 | import com.dessalines.rankmyfavs.db.AppDB 38 | import com.dessalines.rankmyfavs.db.AppSettingsViewModel 39 | import com.dessalines.rankmyfavs.db.DEFAULT_MIN_CONFIDENCE 40 | import com.dessalines.rankmyfavs.db.DEFAULT_THEME 41 | import com.dessalines.rankmyfavs.db.DEFAULT_THEME_COLOR 42 | import com.dessalines.rankmyfavs.db.MIN_CONFIDENCE_BOUND 43 | import com.dessalines.rankmyfavs.db.SettingsUpdate 44 | import com.dessalines.rankmyfavs.ui.components.common.BackButton 45 | import com.dessalines.rankmyfavs.utils.ThemeColor 46 | import com.dessalines.rankmyfavs.utils.ThemeMode 47 | import com.roomdbexportimport.RoomDBExportImport 48 | import me.zhanghai.compose.preference.ListPreference 49 | import me.zhanghai.compose.preference.ListPreferenceType 50 | import me.zhanghai.compose.preference.Preference 51 | import me.zhanghai.compose.preference.ProvidePreferenceTheme 52 | import me.zhanghai.compose.preference.SliderPreference 53 | 54 | @OptIn(ExperimentalMaterial3Api::class) 55 | @Composable 56 | fun SettingsScreen( 57 | navController: NavController, 58 | appSettingsViewModel: AppSettingsViewModel, 59 | ) { 60 | val settings by appSettingsViewModel.appSettings.asLiveData().observeAsState() 61 | val ctx = LocalContext.current 62 | 63 | var minConfidenceState = (settings?.minConfidence ?: DEFAULT_MIN_CONFIDENCE).toFloat() 64 | var minConfidenceSliderState by remember { mutableFloatStateOf(minConfidenceState) } 65 | 66 | var themeState = ThemeMode.entries[settings?.theme ?: DEFAULT_THEME] 67 | var themeColorState = ThemeColor.entries[settings?.themeColor ?: DEFAULT_THEME_COLOR] 68 | 69 | val dbSavedText = stringResource(R.string.database_backed_up) 70 | val dbRestoredText = stringResource(R.string.database_restored) 71 | 72 | val dbHelper = RoomDBExportImport(AppDB.getDatabase(ctx).openHelper) 73 | 74 | val exportDbLauncher = 75 | rememberLauncherForActivityResult( 76 | ActivityResultContracts.CreateDocument("application/zip"), 77 | ) { 78 | it?.also { 79 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 80 | dbHelper.export(ctx, it) 81 | Toast.makeText(ctx, dbSavedText, Toast.LENGTH_SHORT).show() 82 | } 83 | } 84 | } 85 | 86 | val importDbLauncher = 87 | rememberLauncherForActivityResult( 88 | ActivityResultContracts.OpenDocument(), 89 | ) { 90 | it?.also { 91 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 92 | dbHelper.import(ctx, it, true) 93 | Toast.makeText(ctx, dbRestoredText, Toast.LENGTH_SHORT).show() 94 | } 95 | } 96 | } 97 | 98 | fun updateSettings() { 99 | appSettingsViewModel.updateSettings( 100 | SettingsUpdate( 101 | id = 1, 102 | minConfidence = minConfidenceState.toInt(), 103 | theme = themeState.ordinal, 104 | themeColor = themeColorState.ordinal, 105 | ), 106 | ) 107 | } 108 | 109 | val scrollState = rememberScrollState() 110 | 111 | Scaffold( 112 | topBar = { 113 | TopAppBar( 114 | title = { Text(stringResource(R.string.settings)) }, 115 | navigationIcon = { 116 | BackButton( 117 | onBackClick = { navController.navigateUp() }, 118 | ) 119 | }, 120 | ) 121 | }, 122 | content = { padding -> 123 | Column( 124 | modifier = 125 | Modifier 126 | .padding(padding) 127 | .verticalScroll(scrollState) 128 | .imePadding(), 129 | ) { 130 | ProvidePreferenceTheme { 131 | Preference( 132 | title = { Text(stringResource(R.string.about)) }, 133 | icon = { 134 | Icon( 135 | imageVector = Icons.Outlined.Info, 136 | contentDescription = null, 137 | ) 138 | }, 139 | onClick = { navController.navigate("about") }, 140 | ) 141 | SliderPreference( 142 | value = minConfidenceState, 143 | sliderValue = minConfidenceSliderState, 144 | onValueChange = { 145 | minConfidenceState = it 146 | updateSettings() 147 | }, 148 | onSliderValueChange = { minConfidenceSliderState = it }, 149 | valueRange = MIN_CONFIDENCE_BOUND.toFloat()..99f, 150 | title = { 151 | val confidenceStr = stringResource(R.string.min_confidence, minConfidenceSliderState.toInt().toString()) 152 | Text(confidenceStr) 153 | }, 154 | summary = { 155 | Text(stringResource(R.string.min_confidence_summary)) 156 | }, 157 | icon = { 158 | Icon( 159 | imageVector = Icons.Outlined.DataThresholding, 160 | contentDescription = null, 161 | ) 162 | }, 163 | ) 164 | ListPreference( 165 | type = ListPreferenceType.DROPDOWN_MENU, 166 | value = themeState, 167 | onValueChange = { 168 | themeState = it 169 | updateSettings() 170 | }, 171 | values = ThemeMode.entries, 172 | valueToText = { 173 | AnnotatedString(ctx.getString(it.resId)) 174 | }, 175 | title = { 176 | Text(stringResource(R.string.theme)) 177 | }, 178 | summary = { 179 | Text(stringResource(themeState.resId)) 180 | }, 181 | icon = { 182 | Icon( 183 | imageVector = Icons.Outlined.Palette, 184 | contentDescription = null, 185 | ) 186 | }, 187 | ) 188 | 189 | ListPreference( 190 | type = ListPreferenceType.DROPDOWN_MENU, 191 | value = themeColorState, 192 | onValueChange = { 193 | themeColorState = it 194 | updateSettings() 195 | }, 196 | values = ThemeColor.entries, 197 | valueToText = { 198 | AnnotatedString(ctx.getString(it.resId)) 199 | }, 200 | title = { 201 | Text(stringResource(R.string.theme_color)) 202 | }, 203 | summary = { 204 | Text(stringResource(themeColorState.resId)) 205 | }, 206 | icon = { 207 | Icon( 208 | imageVector = Icons.Outlined.Colorize, 209 | contentDescription = null, 210 | ) 211 | }, 212 | ) 213 | Preference( 214 | title = { Text(stringResource(R.string.backup_database)) }, 215 | icon = { 216 | Icon( 217 | imageVector = Icons.Outlined.Save, 218 | contentDescription = null, 219 | ) 220 | }, 221 | onClick = { 222 | exportDbLauncher.launch("rank-my-favs") 223 | }, 224 | ) 225 | Preference( 226 | title = { Text(stringResource(R.string.restore_database)) }, 227 | summary = { 228 | Text(stringResource(R.string.restore_database_warning)) 229 | }, 230 | icon = { 231 | Icon( 232 | imageVector = Icons.Outlined.Restore, 233 | contentDescription = null, 234 | ) 235 | }, 236 | onClick = { 237 | importDbLauncher.launch(arrayOf("application/zip")) 238 | }, 239 | ) 240 | } 241 | } 242 | }, 243 | ) 244 | } 245 | -------------------------------------------------------------------------------- /app/src/main/java/com/dessalines/rankmyfavs/ui/theme/Shape.kt: -------------------------------------------------------------------------------- 1 | package com.dessalines.rankmyfavs.ui.theme 2 | 3 | import androidx.compose.foundation.shape.RoundedCornerShape 4 | import androidx.compose.material3.Shapes 5 | import androidx.compose.ui.unit.dp 6 | 7 | val Shapes = 8 | Shapes( 9 | small = RoundedCornerShape(4.dp), 10 | medium = RoundedCornerShape(4.dp), 11 | large = RoundedCornerShape(0.dp), 12 | ) 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/dessalines/rankmyfavs/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.dessalines.rankmyfavs.ui.theme 2 | 3 | import android.os.Build 4 | import androidx.compose.foundation.isSystemInDarkTheme 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.compose.material3.dynamicDarkColorScheme 7 | import androidx.compose.material3.dynamicLightColorScheme 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.platform.LocalContext 10 | import com.dessalines.rankmyfavs.db.AppSettings 11 | import com.dessalines.rankmyfavs.utils.ThemeColor 12 | import com.dessalines.rankmyfavs.utils.ThemeMode 13 | 14 | @Composable 15 | fun RankMyFavsTheme( 16 | settings: AppSettings?, 17 | content: @Composable () -> Unit, 18 | ) { 19 | val themeMode = ThemeMode.entries[settings?.theme ?: 0] 20 | val themeColor = ThemeColor.entries[settings?.themeColor ?: 0] 21 | 22 | val ctx = LocalContext.current 23 | val android12OrLater = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S 24 | 25 | // Dynamic schemes crash on lower than android 12 26 | val dynamicPair = 27 | if (android12OrLater) { 28 | Pair(dynamicLightColorScheme(ctx), dynamicDarkColorScheme(ctx)) 29 | } else { 30 | pink() 31 | } 32 | 33 | val colorPair = 34 | when (themeColor) { 35 | ThemeColor.Dynamic -> dynamicPair 36 | ThemeColor.Green -> green() 37 | ThemeColor.Pink -> pink() 38 | } 39 | 40 | val systemTheme = 41 | if (!isSystemInDarkTheme()) { 42 | colorPair.first 43 | } else { 44 | colorPair.second 45 | } 46 | 47 | val colors = 48 | when (themeMode) { 49 | ThemeMode.System -> systemTheme 50 | ThemeMode.Light -> colorPair.first 51 | ThemeMode.Dark -> colorPair.second 52 | } 53 | 54 | MaterialTheme( 55 | colorScheme = colors, 56 | typography = Typography, 57 | shapes = Shapes, 58 | content = content, 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /app/src/main/java/com/dessalines/rankmyfavs/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.dessalines.rankmyfavs.ui.theme 2 | 3 | import androidx.compose.material3.Typography 4 | 5 | // Set of Material typography styles to start with 6 | val Typography = Typography() 7 | -------------------------------------------------------------------------------- /app/src/main/java/com/dessalines/rankmyfavs/utils/Types.kt: -------------------------------------------------------------------------------- 1 | package com.dessalines.rankmyfavs.utils 2 | 3 | import androidx.annotation.StringRes 4 | import com.dessalines.rankmyfavs.R 5 | 6 | enum class ThemeMode( 7 | @StringRes val resId: Int, 8 | ) { 9 | System(R.string.system), 10 | Light(R.string.light), 11 | Dark(R.string.dark), 12 | } 13 | 14 | enum class ThemeColor( 15 | @StringRes val resId: Int, 16 | ) { 17 | Dynamic(R.string.dynamic), 18 | Green(R.string.green), 19 | Pink(R.string.pink), 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/java/com/dessalines/rankmyfavs/utils/Utils.kt: -------------------------------------------------------------------------------- 1 | package com.dessalines.rankmyfavs.utils 2 | import android.content.ContentResolver 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.content.pm.PackageInfo 6 | import android.content.pm.PackageManager 7 | import android.graphics.Bitmap 8 | import android.net.Uri 9 | import android.os.Build 10 | import androidx.compose.ui.graphics.Color 11 | import androidx.compose.ui.graphics.lerp 12 | import androidx.core.net.toUri 13 | import com.dessalines.rankmyfavs.db.FavListItem 14 | import com.dessalines.rankmyfavs.db.TierList 15 | import java.io.IOException 16 | import java.io.OutputStream 17 | import java.util.Random 18 | 19 | const val TAG = "com.rank-my-favs" 20 | 21 | const val GITHUB_URL = "https://github.com/dessalines/rank-my-favs" 22 | const val MATRIX_CHAT_URL = "https://matrix.to/#/#rank-my-favs:matrix.org" 23 | const val DONATE_URL = "https://liberapay.com/dessalines" 24 | const val LEMMY_URL = "https://lemmy.ml/c/rankmyfavs" 25 | const val MASTODON_URL = "https://mastodon.social/@dessalines" 26 | const val GLICKO_WIKI_URL = "https://en.m.wikipedia.org/wiki/Glicko_rating_system" 27 | 28 | val TIER_COLORS = 29 | mapOf( 30 | "S" to Color(0XFFFF7F7F), 31 | "A" to Color(0XFFFFBF7F), 32 | "B" to Color(0XFFFFDF7F), 33 | "C" to Color(0XFFFFFF7F), 34 | "D" to Color(0XFFBFFF7F), 35 | ) 36 | 37 | fun openLink( 38 | url: String, 39 | ctx: Context, 40 | ) { 41 | val intent = Intent(Intent.ACTION_VIEW, url.toUri()) 42 | ctx.startActivity(intent) 43 | } 44 | 45 | fun Context.getPackageInfo(): PackageInfo = 46 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 47 | packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0)) 48 | } else { 49 | packageManager.getPackageInfo(packageName, 0) 50 | } 51 | 52 | fun Context.getVersionCode(): Int = 53 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { 54 | getPackageInfo().longVersionCode.toInt() 55 | } else { 56 | @Suppress("DEPRECATION") 57 | getPackageInfo().versionCode 58 | } 59 | 60 | fun numToString( 61 | num: Float, 62 | decimalPlaces: Int, 63 | ): String = String.format("%.${decimalPlaces}f", num) 64 | 65 | fun writeData( 66 | ctx: Context, 67 | uri: Uri, 68 | data: String, 69 | ) { 70 | ctx.contentResolver.openOutputStream(uri)?.use { 71 | val bytes = data.toByteArray() 72 | it.write(bytes) 73 | } 74 | } 75 | 76 | fun writeBitmap( 77 | contentResolver: ContentResolver, 78 | uri: Uri, 79 | bitmap: Bitmap, 80 | ) { 81 | try { 82 | val outputStream: OutputStream? = contentResolver.openOutputStream(uri) 83 | outputStream?.use { 84 | bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) 85 | } ?: throw IOException("Failed to get output stream") 86 | } catch (e: IOException) { 87 | e.printStackTrace() 88 | } 89 | } 90 | 91 | fun nameIsValid(name: String): Boolean = name.isNotEmpty() 92 | 93 | fun convertFavlistToMarkdown( 94 | title: String, 95 | favListItems: List, 96 | ): String { 97 | val items = favListItems.joinToString(separator = "\n") { "1. ${it.name}" } 98 | return "# $title\n\n$items" 99 | } 100 | 101 | fun generateRandomColor(): Color { 102 | val rnd = Random() 103 | return Color(rnd.nextInt(256), rnd.nextInt(256), rnd.nextInt(256)) 104 | } 105 | 106 | fun assignTiersToItems( 107 | tiers: List, 108 | items: List, 109 | limit: Int? = null, 110 | ): Map> { 111 | if (tiers.isEmpty()) { 112 | return emptyMap() 113 | } 114 | 115 | // Sort items by glickoRating in descending order 116 | val sortedItems = items.sortedByDescending { it.glickoRating } 117 | 118 | // Apply limit if provided 119 | val limitedItems = limit?.let { sortedItems.take(it) } ?: sortedItems 120 | 121 | // Calculate tier thresholds 122 | val tierMap = mutableMapOf>() 123 | 124 | if (items.isNotEmpty()) { 125 | tiers.sortedBy { it.tierOrder }.forEachIndexed { index, tier -> 126 | 127 | val lowerBoundIndex = 128 | (limitedItems.size * index / tiers.size).coerceAtMost(limitedItems.size - 1) 129 | val upperBoundIndex = 130 | (limitedItems.size * (index + 1) / tiers.size).coerceAtMost(limitedItems.size) 131 | 132 | tierMap[tier] = limitedItems.subList(lowerBoundIndex, upperBoundIndex) 133 | } 134 | } 135 | 136 | return tierMap 137 | } 138 | 139 | fun Color.tint(factor: Float): Color = lerp(this, Color.White, factor) 140 | 141 | sealed interface SelectionVisibilityState { 142 | object NoSelection : SelectionVisibilityState 143 | 144 | data class ShowSelection( 145 | val selectedItem: Item, 146 | ) : SelectionVisibilityState 147 | } 148 | -------------------------------------------------------------------------------- /app/src/main/play_store_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dessalines/rank-my-favs/1359a4cf84df5efbdbdd7566cebf8896bf3f876e/app/src/main/play_store_512.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/app_icon.xml: -------------------------------------------------------------------------------- 1 | 7 | 14 | 21 | 28 | 35 | 36 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dessalines/rank-my-favs/1359a4cf84df5efbdbdd7566cebf8896bf3f876e/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dessalines/rank-my-favs/1359a4cf84df5efbdbdd7566cebf8896bf3f876e/app/src/main/res/mipmap-hdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dessalines/rank-my-favs/1359a4cf84df5efbdbdd7566cebf8896bf3f876e/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dessalines/rank-my-favs/1359a4cf84df5efbdbdd7566cebf8896bf3f876e/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dessalines/rank-my-favs/1359a4cf84df5efbdbdd7566cebf8896bf3f876e/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dessalines/rank-my-favs/1359a4cf84df5efbdbdd7566cebf8896bf3f876e/app/src/main/res/mipmap-mdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dessalines/rank-my-favs/1359a4cf84df5efbdbdd7566cebf8896bf3f876e/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dessalines/rank-my-favs/1359a4cf84df5efbdbdd7566cebf8896bf3f876e/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dessalines/rank-my-favs/1359a4cf84df5efbdbdd7566cebf8896bf3f876e/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dessalines/rank-my-favs/1359a4cf84df5efbdbdd7566cebf8896bf3f876e/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dessalines/rank-my-favs/1359a4cf84df5efbdbdd7566cebf8896bf3f876e/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dessalines/rank-my-favs/1359a4cf84df5efbdbdd7566cebf8896bf3f876e/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dessalines/rank-my-favs/1359a4cf84df5efbdbdd7566cebf8896bf3f876e/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dessalines/rank-my-favs/1359a4cf84df5efbdbdd7566cebf8896bf3f876e/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dessalines/rank-my-favs/1359a4cf84df5efbdbdd7566cebf8896bf3f876e/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dessalines/rank-my-favs/1359a4cf84df5efbdbdd7566cebf8896bf3f876e/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dessalines/rank-my-favs/1359a4cf84df5efbdbdd7566cebf8896bf3f876e/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dessalines/rank-my-favs/1359a4cf84df5efbdbdd7566cebf8896bf3f876e/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dessalines/rank-my-favs/1359a4cf84df5efbdbdd7566cebf8896bf3f876e/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dessalines/rank-my-favs/1359a4cf84df5efbdbdd7566cebf8896bf3f876e/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Rank My Favs 3 | Lists 4 | Items 5 | Settings 6 | System 7 | Light 8 | Dark 9 | Dynamic 10 | Green 11 | Pink 12 | About 13 | What\'s New 14 | Version %1$s 15 | Releases 16 | Support 17 | Issue tracker 18 | Developer Matrix chatroom 19 | Donate to Rank My Favs 20 | Social 21 | Join c/rankmyfavs 22 | Follow me on Mastodon 23 | Open source 24 | Source code 25 | Rank-My-Favs is libre open-source software, licensed under the GNU Affero General Public License v3.0 26 | Theme 27 | Theme color 28 | Done 29 | Create List 30 | Edit List 31 | Save 32 | Title 33 | Description 34 | Delete 35 | Create Item 36 | Edit Item 37 | Go Back 38 | List Deleted 39 | Item Deleted 40 | Rate 41 | No more training necessary. 42 | Rating 43 | Deviation 44 | Win Rate 45 | No Items. Create one to get started. 46 | No lists. Create one to get started. 47 | What do these numbers mean? 48 | Clear Stats 49 | Import List 50 | Tier List 51 | Limit top X number of items (optional) 52 | Copy-Paste an existing list for an import.\n\nEach line should be a different item, of the form:\n- Name | Description\n\nThe starting - and | are optional. 53 | Export List as CSV 54 | Confidence 55 | Match Count 56 | Minimum Confidence: %1$s%% 57 | Higher requires more matches 58 | List already exists. 59 | Cancel 60 | Are you sure? 61 | Yes 62 | Skip 63 | Search 64 | Hide Search Bar 65 | More Actions 66 | Backup Database 67 | Restore Database 68 | Warning: This will clear out your current database 69 | Database Restored. 70 | Database Backed up. 71 | Export List as Markdown 72 | Pick a Color 73 | Enter New Tier Name 74 | Add New Tier 75 | Move Up 76 | Move Down 77 | 78 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | buildscript { 3 | repositories { 4 | google() 5 | mavenCentral() 6 | } 7 | } 8 | 9 | plugins { 10 | id("com.android.application") version "8.10.1" apply false 11 | id("com.android.library") version "8.10.1" apply false 12 | id("org.jetbrains.kotlin.android") version "2.1.21" apply false 13 | id("org.jetbrains.kotlin.plugin.compose") version "2.1.21" apply false 14 | id("org.jmailen.kotlinter") version "5.1.0" apply false 15 | id("com.google.devtools.ksp") version "2.1.21-2.0.1" apply false 16 | } 17 | 18 | subprojects { 19 | apply(plugin = "org.jmailen.kotlinter") // Version should be inherited from parent 20 | } 21 | -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # git-cliff ~ configuration file 2 | # https://git-cliff.org/docs/configuration 3 | 4 | [remote.github] 5 | owner = "dessalines" 6 | repo = "rank-my-favs" 7 | # token = "" 8 | 9 | [changelog] 10 | # template for the changelog body 11 | # https://keats.github.io/tera/docs/#introduction 12 | body = """ 13 | ## What's Changed 14 | 15 | {%- if version %} in {{ version }}{%- endif -%} 16 | {% for commit in commits %} 17 | {% if commit.remote.pr_title -%} 18 | {%- set commit_message = commit.remote.pr_title -%} 19 | {%- else -%} 20 | {%- set commit_message = commit.message -%} 21 | {%- endif -%} 22 | * {{ commit_message | split(pat="\n") | first | trim }}\ 23 | {% if commit.remote.username %} by @{{ commit.remote.username }}{%- endif -%} 24 | {% if commit.remote.pr_number %} in \ 25 | [#{{ commit.remote.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.remote.pr_number }}) \ 26 | {%- endif %} 27 | {%- endfor -%} 28 | 29 | {%- if github -%} 30 | {% if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %} 31 | {% raw %}\n{% endraw -%} 32 | ## New Contributors 33 | {%- endif %}\ 34 | {% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %} 35 | * @{{ contributor.username }} made their first contribution 36 | {%- if contributor.pr_number %} in \ 37 | [#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \ 38 | {%- endif %} 39 | {%- endfor -%} 40 | {%- endif -%} 41 | 42 | {% if version %} 43 | {% if previous.version %} 44 | **Full Changelog**: {{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }} 45 | {% endif %} 46 | {% else -%} 47 | {% raw %}\n{% endraw %} 48 | {% endif %} 49 | 50 | {%- macro remote_url() -%} 51 | https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} 52 | {%- endmacro -%} 53 | """ 54 | # remove the leading and trailing whitespace from the template 55 | trim = true 56 | # template for the changelog footer 57 | footer = """ 58 | 59 | """ 60 | # postprocessors 61 | postprocessors = [] 62 | 63 | [git] 64 | # parse the commits based on https://www.conventionalcommits.org 65 | conventional_commits = false 66 | # filter out the commits that are not conventional 67 | filter_unconventional = true 68 | # process each line of a commit as an individual commit 69 | split_commits = false 70 | # regex for preprocessing the commit messages 71 | commit_preprocessors = [ 72 | # remove issue numbers from commits 73 | { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "" }, 74 | ] 75 | commit_parsers = [ 76 | { field = "author.name", pattern = "renovate", skip = true }, 77 | { field = "message", pattern = "Upping version", skip = true }, 78 | ] 79 | # filter out the commits that are not matched by commit parsers 80 | filter_commits = false 81 | # sort the tags topologically 82 | topo_order = false 83 | # sort the commits inside sections by oldest/newest order 84 | sort_commits = "newest" 85 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/1.txt: -------------------------------------------------------------------------------- 1 | An initial release. 2 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/28.txt: -------------------------------------------------------------------------------- 1 | ## What's Changed in 0.6.5 2 | 3 | - Adding fastlane generation. by @dessalines in [#225](https://github.com/dessalines/rank-my-favs/pull/225) 4 | - Using center-aligned main app bar. by @dessalines in [#224](https://github.com/dessalines/rank-my-favs/pull/224) 5 | 6 | **Full Changelog**: https://github.com/dessalines/rank-my-favs/compare/0.6.4...0.6.5 7 | 8 | 9 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/29.txt: -------------------------------------------------------------------------------- 1 | ## What's Changed in 0.6.6 2 | 3 | - Remembering scroll position on favlists pane. by @dessalines in [#226](https://github.com/dessalines/rank-my-favs/pull/226) 4 | 5 | **Full Changelog**: https://github.com/dessalines/rank-my-favs/compare/0.6.5...0.6.6 6 | 7 | 8 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/30.txt: -------------------------------------------------------------------------------- 1 | ## What's Changed in 0.6.7 2 | 3 | - Merge remote-tracking branch 'refs/remotes/origin/main' by @dessalines 4 | - Add randomness to matcher by @dessalines in [#237](https://github.com/dessalines/rank-my-favs/pull/237) 5 | - Revert "Change skip to a proper glicko tie. " by @dessalines 6 | 7 | **Full Changelog**: https://github.com/dessalines/rank-my-favs/compare/0.6.6...0.6.7 8 | 9 | 10 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/31.txt: -------------------------------------------------------------------------------- 1 | ## What's Changed in 0.6.8 2 | 3 | - Adding favlist details. by @dessalines in [#241](https://github.com/dessalines/rank-my-favs/pull/241) 4 | - Fixing rank order. by @dessalines in [#244](https://github.com/dessalines/rank-my-favs/pull/244) 5 | 6 | **Full Changelog**: https://github.com/dessalines/rank-my-favs/compare/0.6.7...0.6.8 7 | 8 | 9 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/32.txt: -------------------------------------------------------------------------------- 1 | ## What's Changed in 0.6.9 2 | 3 | - Simplifying top app bars, adding medium for dynamic content. by @dessalines in [#251](https://github.com/dessalines/rank-my-favs/pull/251) 4 | 5 | **Full Changelog**: https://github.com/dessalines/rank-my-favs/compare/0.6.8...0.6.9 6 | 7 | 8 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/33.txt: -------------------------------------------------------------------------------- 1 | ## What's Changed in 0.6.10 2 | 3 | - Fixing tier list crash. by @dessalines in [#282](https://github.com/dessalines/rank-my-favs/pull/282) 4 | - Adding horizontal divider and fixing no items overlap. by @dessalines in [#281](https://github.com/dessalines/rank-my-favs/pull/281) 5 | 6 | **Full Changelog**: https://github.com/dessalines/rank-my-favs/compare/0.6.9...0.6.10 7 | 8 | 9 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/34.txt: -------------------------------------------------------------------------------- 1 | ## What's Changed in 0.6.11 2 | 3 | - Update plugin com.google.devtools.ksp to v2.1.20-1.0.32 by @dessalines in [#290](https://github.com/dessalines/rank-my-favs/pull/290) 4 | 5 | **Full Changelog**: https://github.com/dessalines/rank-my-favs/compare/0.6.10...0.6.11 6 | 7 | 8 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/35.txt: -------------------------------------------------------------------------------- 1 | ## What's Changed in 0.6.12 2 | 3 | - Merge remote-tracking branch 'refs/remotes/origin/main' 4 | 5 | ## New Contributors 6 | 7 | - @renovate[bot] made their first contribution in [#300](https://github.com/dessalines/rank-my-favs/pull/300) 8 | 9 | **Full Changelog**: https://github.com/dessalines/rank-my-favs/compare/0.6.11...0.6.12 10 | 11 | 12 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/36.txt: -------------------------------------------------------------------------------- 1 | ## What's Changed in 0.6.13 2 | 3 | - Fixing delete crashes. by @dessalines in [#319](https://github.com/dessalines/rank-my-favs/pull/319) 4 | - Fixing delete crashes. by @dessalines 5 | 6 | **Full Changelog**: https://github.com/dessalines/rank-my-favs/compare/0.6.12...0.6.13 7 | 8 | 9 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/37.txt: -------------------------------------------------------------------------------- 1 | ## What's Changed in 0.6.14 2 | 3 | - Fix search bar clearing. by @dessalines in [#324](https://github.com/dessalines/rank-my-favs/pull/324) 4 | - Fix search bar clearing. by @dessalines 5 | - Adding android lint. by @dessalines in [#322](https://github.com/dessalines/rank-my-favs/pull/322) 6 | - Adding android lint. by @dessalines 7 | 8 | **Full Changelog**: https://github.com/dessalines/rank-my-favs/compare/0.6.13...0.6.14 9 | 10 | 11 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/full_description.txt: -------------------------------------------------------------------------------- 1 | Do you keep lists of your favorite things, such as movies, books, recipes, or music albums? You might keep list(s) that looks like this: 2 | 3 |
    4 |
  • Network (1976)
  • 5 |
  • Lone Star (1996)
  • 6 |
  • Devils (1971)
  • 7 |
  • The Seventh Seal (1957)
  • 8 |
  • ... Many more films_h
  • 9 |
10 | 11 | But how do you rank these? 12 | 13 | You might be tempted to order them by preference, but this could quickly get overwhelming for long lists. 14 | 15 | A much easier method is to use pairwise comparisons, which shows you single head-to-head pairs, and has you choose which one you like best. 16 | 17 | After doing a small number of these matchups, Rank-My-Favs can confidently create a ranked list for you. 18 | 19 | Under the hood, Rank-My-Favs can sort by either the win-rate, or the more advanced Glicko rating system. 20 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dessalines/rank-my-favs/1359a4cf84df5efbdbdd7566cebf8896bf3f876e/fastlane/metadata/android/en-US/images/icon.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dessalines/rank-my-favs/1359a4cf84df5efbdbdd7566cebf8896bf3f876e/fastlane/metadata/android/en-US/images/phoneScreenshots/1.jpg -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/short_description.txt: -------------------------------------------------------------------------------- 1 | Rank your favorite things, using simple pair-wise matchups. 2 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/title.txt: -------------------------------------------------------------------------------- 1 | Rank-My-Favs 2 | -------------------------------------------------------------------------------- /generate_changelog.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | # Creating the new tag and version code 5 | new_tag="$1" 6 | new_version_code="$2" 7 | 8 | # Replacing the versions in the app/build.gradle.kts 9 | app_build_gradle="app/build.gradle.kts" 10 | sed -i "s/versionCode = .*/versionCode = $new_version_code/" $app_build_gradle 11 | sed -i "s/versionName = .*/versionName = \"$new_tag\"/" $app_build_gradle 12 | 13 | # Writing to the Releases.md asset that's loaded inside the app, and the fastlane changelog 14 | tmp_file="tmp_release.md" 15 | fastlane_file="fastlane/metadata/android/en-US/changelogs/$new_version_code.txt" 16 | assets_releases="app/src/main/assets/RELEASES.md" 17 | git cliff --unreleased --tag "$new_tag" --output $tmp_file 18 | prettier -w $tmp_file 19 | 20 | cp $tmp_file $assets_releases 21 | cp $tmp_file $fastlane_file 22 | rm $tmp_file 23 | 24 | # Adding to RELEASES.md 25 | git cliff --tag "$new_tag" --output RELEASES.md 26 | prettier -w RELEASES.md 27 | 28 | # Add them all to git 29 | git add $assets_releases $fastlane_file $app_build_gradle RELEASES.md 30 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app"s APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Automatically convert third-party libraries to use AndroidX 19 | android.enableJetifier=false 20 | # Kotlin code style for this project: "official" or "obsolete": 21 | kotlin.code.style=official 22 | # Enables namespacing of each library's R class so that its R class includes only the 23 | # resources declared in the library itself and none from the library's dependencies, 24 | # thereby reducing the size of the R class for that library 25 | android.nonTransitiveRClass=true 26 | org.gradle.unsafe.configuration-cache=true 27 | android.nonFinalResIds=false 28 | org.gradle.daemon=false 29 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dessalines/rank-my-favs/1359a4cf84df5efbdbdd7566cebf8896bf3f876e/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionSha256Sum=845952a9d6afa783db70bb3b0effaae45ae5542ca2bb7929619e8af49cb634cf 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-bin.zip 5 | networkTimeout=10000 6 | validateDistributionUrl=true 7 | zipStoreBase=GRADLE_USER_HOME 8 | zipStorePath=wrapper/dists 9 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH="\\\"\\\"" 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | if ! command -v java >/dev/null 2>&1 137 | then 138 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 139 | 140 | Please set the JAVA_HOME variable in your environment to match the 141 | location of your Java installation." 142 | fi 143 | fi 144 | 145 | # Increase the maximum file descriptors if we can. 146 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 147 | case $MAX_FD in #( 148 | max*) 149 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 150 | # shellcheck disable=SC2039,SC3045 151 | MAX_FD=$( ulimit -H -n ) || 152 | warn "Could not query maximum file descriptor limit" 153 | esac 154 | case $MAX_FD in #( 155 | '' | soft) :;; #( 156 | *) 157 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 158 | # shellcheck disable=SC2039,SC3045 159 | ulimit -n "$MAX_FD" || 160 | warn "Could not set maximum file descriptor limit to $MAX_FD" 161 | esac 162 | fi 163 | 164 | # Collect all arguments for the java command, stacking in reverse order: 165 | # * args from the command line 166 | # * the main class name 167 | # * -classpath 168 | # * -D...appname settings 169 | # * --module-path (only if needed) 170 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 171 | 172 | # For Cygwin or MSYS, switch paths to Windows format before running java 173 | if "$cygwin" || "$msys" ; then 174 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 175 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 176 | 177 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 178 | 179 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 180 | for arg do 181 | if 182 | case $arg in #( 183 | -*) false ;; # don't mess with options #( 184 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 185 | [ -e "$t" ] ;; #( 186 | *) false ;; 187 | esac 188 | then 189 | arg=$( cygpath --path --ignore --mixed "$arg" ) 190 | fi 191 | # Roll the args list around exactly as many times as the number of 192 | # args, so each arg winds up back in the position where it started, but 193 | # possibly modified. 194 | # 195 | # NB: a `for` loop captures its iteration list before it begins, so 196 | # changing the positional parameters here affects neither the number of 197 | # iterations, nor the values presented in `arg`. 198 | shift # remove old arg 199 | set -- "$@" "$arg" # push replacement arg 200 | done 201 | fi 202 | 203 | 204 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 205 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 206 | 207 | # Collect all arguments for the java command: 208 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 209 | # and any embedded shellness will be escaped. 210 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 211 | # treated as '${Hostname}' itself on the command line. 212 | 213 | set -- \ 214 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 215 | -classpath "$CLASSPATH" \ 216 | -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ 217 | "$@" 218 | 219 | # Stop when "xargs" is not available. 220 | if ! command -v xargs >/dev/null 2>&1 221 | then 222 | die "xargs is not available" 223 | fi 224 | 225 | # Use "xargs" to parse quoted args. 226 | # 227 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 228 | # 229 | # In Bash we could simply go: 230 | # 231 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 232 | # set -- "${ARGS[@]}" "$@" 233 | # 234 | # but POSIX shell has neither arrays nor command substitution, so instead we 235 | # post-process each arg (as a line of input to sed) to backslash-escape any 236 | # character that might be a shell metacharacter, then use eval to reverse 237 | # that process (while maintaining the separation between arguments), and wrap 238 | # the whole thing up as a single "set" statement. 239 | # 240 | # This will of course break if any of these variables contains a newline or 241 | # an unmatched quote. 242 | # 243 | 244 | eval "set -- $( 245 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 246 | xargs -n1 | 247 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 248 | tr '\n' ' ' 249 | )" '"$@"' 250 | 251 | exec "$JAVACMD" "$@" 252 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH= 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:recommended"], 4 | "schedule": ["every weekend"], 5 | "automerge": true 6 | } 7 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | gradlePluginPortal() 4 | google() 5 | mavenCentral() 6 | } 7 | plugins { 8 | id 'com.android.application' version '8.10.1' 9 | id 'com.android.library' version '8.10.1' 10 | id 'org.jetbrains.kotlin.android' version '2.1.21' 11 | } 12 | } 13 | dependencyResolutionManagement { 14 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 15 | repositories { 16 | google() 17 | mavenCentral() 18 | maven { url 'https://jitpack.io' } 19 | } 20 | } 21 | rootProject.name = "com.dessalines.rankmyfavs" 22 | include ':app' 23 | --------------------------------------------------------------------------------