├── .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 | 
4 | [](https://woodpecker.join-lemmy.org/dessalines/rank-my-favs)
5 | [](https://github.com/dessalines/rank-my-favs/issues)
6 | [](LICENSE)
7 | 
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
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 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | ## About Rank-My-Favs
36 |
37 |
38 |
39 |
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------