122 Commits

Author SHA1 Message Date
cf5c408464 Update dependencies to use newest Android SDK. Clean up.
All checks were successful
Build Android / build (pull_request) Successful in 2m52s
Build Android / build (push) Successful in 3m2s
2024-08-23 12:16:44 +02:00
fdf8c5c7df Update dependencies.
All checks were successful
Build Android / build (push) Successful in 2m57s
2024-08-23 12:02:58 +02:00
7ed3a29c5b Merge pull request 'feature/update-dependencies' (#55) from feature/update-dependencies into develop
All checks were successful
Build Android / build (push) Successful in 2m37s
Reviewed-on: #55
2024-06-06 13:43:27 +02:00
9434d1f1b9 Upload as gitea artifacts.
All checks were successful
Build Android / build (push) Successful in 2m36s
Build Android / build (pull_request) Successful in 2m39s
2024-05-31 16:24:06 +02:00
9abff51cb8 Update deprecated swipe to dismiss box. 2024-05-31 15:49:29 +02:00
93f51b9220 Update dependencies. 2024-05-31 14:55:38 +02:00
8bb2b9bf20 Merge tag '2.3.2' into develop
All checks were successful
Build Android / build (push) Successful in 2m29s
2023-11-02 22:34:48 +01:00
00569666be Improve delete multiple games in a row.
All checks were successful
Build Android / build (push) Successful in 4m33s
closes #53
2023-11-02 22:32:35 +01:00
2cf6578378 Update gradle. 2023-11-02 22:32:31 +01:00
fbcf9af761 Change deploy user. 2023-11-02 22:32:24 +01:00
80039a5f94 Merge tag '2.3.1' into develop
All checks were successful
Build Android / build (push) Successful in 2m32s
2023-10-01 07:45:09 +02:00
1c6674373f Merge branch 'hotfix/2.3.1'
All checks were successful
Build Android / build (push) Successful in 9m21s
2023-10-01 07:45:09 +02:00
67e803f913 Revert "Scroll to top on game activated."
All checks were successful
Build Android / build (push) Successful in 8m44s
This reverts commit fa9786de04.

# Conflicts:
#	app/src/main/java/me/zobrist/tichucounter/ui/history/HistoryView.kt
2023-09-27 22:45:10 +02:00
4c01fe13a8 Merge tag '2.3' into develop
All checks were successful
Build Android / build (push) Successful in 9m4s
2023-09-27 19:05:39 +02:00
2d30de8855 Merge branch 'release/2.3'
All checks were successful
Build Android / build (push) Successful in 8m34s
2023-09-27 19:05:39 +02:00
45ae61584d Prevent winning dialog on game selected with sufficient points.
All checks were successful
Build Android / build (push) Successful in 8m36s
2023-09-27 18:53:05 +02:00
fa9786de04 Scroll to top on game activated. 2023-09-27 18:51:26 +02:00
461dc4a30c Reset gameFinished on removed round.
closes: #52
2023-09-27 18:30:03 +02:00
44246e329e Request review on victory dialog closed. Reformat code.
All checks were successful
Build Android / build (push) Successful in 9m12s
closes: #51
2023-09-23 13:42:24 +02:00
c968d2ee91 Improve coloring and padding of history list.
All checks were successful
Build Android / build (push) Successful in 8m39s
2023-09-23 12:06:33 +02:00
1a2002e812 Improve translations. 2023-09-23 11:48:43 +02:00
c3c0f09313 Use game finished setting to display victory dialog. 2023-09-23 11:42:57 +02:00
81a0d680e9 Flow on new game.
Now review is always requested regardless of where the new game was started.
2023-09-08 18:40:52 +02:00
27cb2f670b Settings as state flow. 2023-09-08 18:20:56 +02:00
6a96749501 Increase version.
All checks were successful
Build Android / build (push) Successful in 8m33s
2023-09-01 18:31:21 +02:00
2fa4e218e5 Merge pull request 'feature/winning-points' (#50) from feature/winning-points into develop
All checks were successful
Build Android / build (push) Successful in 8m16s
Reviewed-on: #50
2023-09-01 18:30:29 +02:00
2599e3320a Add alerts for winning and draw.
All checks were successful
Build Android / build (push) Successful in 8m35s
Build Android / build (pull_request) Successful in 8m23s
2023-09-01 17:42:03 +02:00
c97d98704b Update view with victory points setting.
Add victory points to settings adapter.
2023-09-01 16:27:58 +02:00
37bbf45ce0 Merge pull request 'feature/store-review' (#49) from feature/store-review into develop
All checks were successful
Build Android / build (push) Successful in 8m31s
Reviewed-on: #49
2023-09-01 16:03:26 +02:00
dbef6e047a Request review only every third game after three months.
All checks were successful
Build Android / build (push) Successful in 8m29s
Build Android / build (pull_request) Has been cancelled
2023-09-01 13:00:41 +02:00
450c8a1547 Should request a review every new game.
All checks were successful
Build Android / build (push) Successful in 8m24s
2023-09-01 12:24:18 +02:00
536b5b7fd2 Merge pull request 'feature/android-14' (#48) from feature/android-14 into develop
All checks were successful
Build Android / build (push) Successful in 8m21s
Reviewed-on: #48
2023-09-01 11:07:46 +02:00
57b02ce74a Fix keyboard not hidden.
All checks were successful
Build Android / build (push) Successful in 8m14s
Build Android / build (pull_request) Successful in 8m4s
2023-09-01 10:57:57 +02:00
4e4653da97 Update to Sdk34 2023-09-01 10:57:57 +02:00
e5041c98e1 Merge pull request 'feature/swipe-actions' (#44) from feature/swipe-actions into develop
All checks were successful
Build Android / build (push) Successful in 7m26s
Reviewed-on: #44
2023-08-25 18:19:44 +02:00
8521247c58 Show all games in list but mark active game with an badge.
All checks were successful
Build Android / build (push) Successful in 7m50s
Build Android / build (pull_request) Successful in 7m27s
2023-08-25 15:59:58 +02:00
7108af4cf4 Fix typo.
All checks were successful
Build Android / build (push) Successful in 7m44s
2023-08-20 22:22:33 +02:00
55a2293b6c Show snackbar with undo option. Don't jump back to calculator on history click.
All checks were successful
Build Android / build (push) Has been cancelled
2023-08-20 22:20:45 +02:00
c8098fc904 Increase dismiss threshold. Change translation of continue. 2023-08-20 12:31:20 +02:00
ec3b51051a Add swipe to open and delete to history items.
All checks were successful
Build Android / build (push) Successful in 7m17s
2023-08-19 18:35:16 +02:00
07219bffb4 Update dependencies. 2023-08-19 16:05:41 +02:00
ff5495249f Merge pull request 'feature/act_runner' (#43) from feature/act_runner into develop
All checks were successful
Build Android / build (push) Successful in 7m41s
Reviewed-on: #43
2023-07-01 11:28:47 +02:00
217370079d Publish tagged builds.
All checks were successful
Build Android / build (push) Successful in 7m58s
Build Android / build (pull_request) Has been cancelled
2023-06-30 16:21:13 +02:00
add100146d Notify on slack.
All checks were successful
Build Android / build (push) Successful in 8m40s
2023-06-30 12:06:24 +02:00
ceebe92f8b Deploy latest to nextcloud.
All checks were successful
Build Android / build (push) Successful in 7m46s
2023-06-30 10:47:19 +02:00
4f87321e2c Use android build box iamge.
All checks were successful
Build Android / build (push) Successful in 7m25s
2023-06-30 10:33:02 +02:00
81c540c2a5 Add signing and version code.
Some checks failed
Build Android / build (push) Failing after 10m19s
2023-06-25 20:02:44 +02:00
a45043424e Use act runner
All checks were successful
Build Android / build (push) Has been cancelled
2023-06-25 19:48:31 +02:00
18326e952f Merge pull request 'release/2.2' (#40) from release/2.2 into develop
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #40
2023-06-16 11:11:33 +02:00
1819802748 Merge pull request 'release/2.2' (#41) from release/2.2 into master
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: #41
2023-06-16 11:11:22 +02:00
a099659b2c Try to make DRONE_TAG work.
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2023-06-02 22:16:40 +02:00
61745c95a4 Fix tagged upload url
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2023-06-02 16:58:36 +02:00
164cf6900f fix tagged upload link
All checks were successful
continuous-integration/drone/push Build is passing
2023-06-02 15:03:13 +02:00
89ab0afaf5 upload to nextcloud instead of seafile
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is failing
fix nextcloud upload
2023-06-02 14:40:23 +02:00
9d8de2dd3c Update dependencies. Code clean up.
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is passing
2023-05-25 18:09:16 +02:00
fcb8c12454 [#39] Case insensitive team name suggestions.
Some checks failed
continuous-integration/drone Build is failing
closes #39
2023-05-25 17:37:30 +02:00
34e64e6e50 Update Version to 2.2
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2023-05-16 12:07:52 +02:00
694989a917 Merge pull request 'feature/#14_team_name_suggestions' (#38) from feature/#14_team_name_suggestions into develop
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: fabian/TichuCounter#38
2023-05-16 12:05:49 +02:00
1e8e72557d Move suggestion generation to ViewModel.
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2023-05-11 23:40:17 +02:00
2e40d0315c Don't show dropdown when empty. 2023-05-11 23:37:12 +02:00
9ddbdb698c Better filtering and sorting of suggestions. 2023-05-11 23:06:29 +02:00
c40d473fa5 Update value of team name immediately. 2023-05-11 23:05:47 +02:00
507944794f Clear focus on opening drawer. 2023-05-11 23:04:21 +02:00
068e36c5f6 Merge commit '7e722feb275cdb7d47e87413ee7b11efc7b4ec51' into feature/#14_team_name_suggestions
Some checks failed
continuous-integration/drone Build is failing
2023-05-08 19:30:16 +02:00
7e722feb27 Update 'app/src/main/AndroidManifest.xml'
All checks were successful
continuous-integration/drone/push Build is passing
2023-05-07 22:41:51 +02:00
fb4c83d60a Fix display of app logo on About page.
All checks were successful
continuous-integration/drone/push Build is passing
2023-05-07 20:50:42 +02:00
0c80e05f5c Fix logo not found.
All checks were successful
continuous-integration/drone/push Build is passing
2023-05-05 20:10:55 +02:00
8ca82d1734 Merge branch 'develop' into feature/#14_team_name_suggestions
Some checks failed
continuous-integration/drone/push Build was killed
2023-05-05 19:53:55 +02:00
9385deeb24 [#25] Use vector as app logo. Enable monochrom logo.
Some checks failed
continuous-integration/drone/push Build is failing
closes #25
2023-05-05 19:50:58 +02:00
fadd206e9e Update dependencies. 2023-05-05 19:44:50 +02:00
6768391caf Update dependencies. 2023-05-05 19:44:12 +02:00
aa3e68dc5c Merge pull request 'release/2.1' (#35) from release/2.1 into develop
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: fabian/TichuCounter#35
2023-03-10 17:21:32 +01:00
a2b84640b1 Merge pull request 'release/2.1' (#36) from release/2.1 into master
Some checks are pending
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is running
Reviewed-on: fabian/TichuCounter#36
2023-03-10 17:21:17 +01:00
895264de2a [#34] Add contact button and play store button to about page.
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build was killed
closes [#34]
2023-03-10 15:08:38 +01:00
3ec8c27b96 First draft
All checks were successful
continuous-integration/drone/push Build is passing
2023-03-10 13:19:09 +01:00
343d1d8e75 [#20] Add missing translation key.
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
closes #20
2023-03-07 22:41:49 +01:00
801a17d759 Increase version to 2.1
All checks were successful
continuous-integration/drone/push Build is passing
2023-03-07 20:53:57 +01:00
8e26f6b337 fixBuild (#30)
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: fabian/TichuCounter#30
2023-03-04 08:34:26 +01:00
9e658853eb Update Gradle and Java
Some checks failed
continuous-integration/drone/push Build is failing
2023-03-03 15:31:36 +01:00
5a229d6c57 [#29] Disable swap button on invalid score.
Some checks failed
continuous-integration/drone/push Build is failing
closed [#29]
2023-03-03 15:04:02 +01:00
bcc3bd3848 Freeze build image version
Some checks failed
continuous-integration/drone/push Build is failing
2023-03-03 14:28:13 +01:00
17d861403e [#24] Add long press to delete functionality.
Some checks are pending
continuous-integration/drone/push Build is running
Reformat code.
closes #24
2023-03-03 13:20:22 +01:00
a1f344580d [#23] Prevent trailing zeros stacking up.
Some checks failed
continuous-integration/drone/push Build was killed
closes #23
2023-03-03 11:49:39 +01:00
b3bdbfbc05 [#23] Limit input to 5 digits. 2023-03-03 11:27:18 +01:00
a611de6da4 Add instrumented test to test repository together with room database. Improve data handling on first boot.
Some checks failed
continuous-integration/drone/push Build was killed
2023-02-17 15:19:00 +01:00
4108512139 Merge pull request 'release/2.0' (#22) from release/2.0 into develop
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: fabian/TichuCounter#22
2023-01-28 23:30:57 +01:00
30723f7862 Merge pull request 'release/2.0' (#21) from release/2.0 into master
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: fabian/TichuCounter#21
2023-01-28 23:29:25 +01:00
661b88b961 Keep back stack of navigation controller clean.
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2023-01-28 13:06:50 +01:00
c41816898e Add about page.
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-28 12:33:45 +01:00
2e8d6a7a4e Add App name to navigation drawer.
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-28 11:19:37 +01:00
ae0f85bec0 Rename screen to DrawerItem
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-28 10:25:07 +01:00
e1e25ff607 Move drawerContent to own file.
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-28 10:14:06 +01:00
6aedb0d7f9 Navigate with enum defines. Create new TopBarState
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-28 10:06:58 +01:00
48374c5980 Move new game back to app bar.
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-28 00:18:26 +01:00
02213f41b6 Add default android locale. Simplify language settings.
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-27 23:09:08 +01:00
9ae0890f71 Restyle history page
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-27 20:52:17 +01:00
cd8f1959af Simplify history page. Fix warnings.
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-27 20:13:14 +01:00
58d4fc0e43 Show blinking cursor in keyboard view. Give focus back to last focused input.
All checks were successful
continuous-integration/drone/push Build is passing
closes #12
2023-01-27 18:07:16 +01:00
c71b608a7b [#13] Hide keyboard in landscape.
All checks were successful
continuous-integration/drone/push Build is passing
closes #13
2023-01-27 13:18:00 +01:00
89f2e3ecd5 Improve default color scheme.
Some checks are pending
continuous-integration/drone/push Build is pending
2023-01-27 13:11:36 +01:00
f52bfa64ce [#18] Add a swap score button.
Some checks are pending
continuous-integration/drone/push Build is pending
closes #18
2023-01-27 12:37:59 +01:00
4346af3d2b Improve settings composable. Remove delay before setting the theme. as this did not help [#11]
Some checks are pending
continuous-integration/drone/push Build is pending
2023-01-27 12:03:36 +01:00
ca88bd1054 Move ui variable from viewModel to compose function. Move TopBar to separate file. 2023-01-27 10:22:32 +01:00
c54f63736e Enable drawer close gesture.
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-22 21:42:38 +01:00
8d24e46687 Simplify database handling.
Some checks are pending
continuous-integration/drone/push Build is running
2023-01-22 21:40:47 +01:00
f40b66077b [#11] Add a small delay before applying theme so compose has enough time to update remember states before the theme is applied.
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-22 20:45:45 +01:00
9ca830a707 [#11] ApplyDayNight should not be used.
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-22 18:36:38 +01:00
db58e475d1 [#11] Change how settings adapter work. Directly set system settings in MainActivity.
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-22 17:57:51 +01:00
33e57bcfd7 Show fab only on counter screen.
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-21 18:21:40 +01:00
c1567efe52 [#11] Close dropdown list first. the callback might restart the application on a settings change and keep the menu open.
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-21 18:12:30 +01:00
4d37e77f55 Fix round index.
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-21 16:40:00 +01:00
984afb610f Add counter again to navigation.
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-21 16:32:07 +01:00
c3c6c253bc no message
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-21 15:58:18 +01:00
9189d79982 Disable gesture navigation.
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-21 15:55:49 +01:00
ad0236556e Add delete all history with confirm dialog.
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-21 15:48:07 +01:00
52a73bf204 Simplify history card. Activate open for active game. 2023-01-21 14:47:36 +01:00
6d0192df18 Style game history. Add functionality to delete and open game from history.
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-21 13:04:45 +01:00
63f213bc75 Add hide show keyboard function. Fix keyboard too big on small screens. Improve colors.
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-21 11:30:20 +01:00
68a5d34e45 Add versions code to VersionName.
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-21 10:21:51 +01:00
98ea66772f Fix warnings. Remove unused files.
Some checks are pending
continuous-integration/drone/push Build is running
2023-01-21 10:15:59 +01:00
84a63a2bcb Revert "Upload files to 'app'"
This reverts commit c5de57c416.
2023-01-21 09:02:20 +01:00
c5de57c416 Upload files to 'app'
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-21 07:48:21 +01:00
99 changed files with 2917 additions and 1107 deletions

View File

@@ -1,74 +0,0 @@
---
kind: pipeline
type: docker
name: Android
steps:
- name: prepare signing
image: busybox
environment:
STOREPASSWORD:
from_secret: StorePassword
KEYPASSWORD:
from_secret: KeyPassword
commands:
- touch keystore.properties
- echo "storePassword=$STOREPASSWORD" >> keystore.properties
- echo "keyPassword=$KEYPASSWORD" >> keystore.properties
- echo "keyAlias=key0" >> keystore.properties
- echo "storeFile=../AndroidKey" >> keystore.properties
- name: generate versionCode
image: busybox
commands:
- touch version.properties
- let timestamp=$(date +%s)/10
- echo "versionCode=$timestamp" >> version.properties
- name: build
image: mingc/android-build-box
commands:
- ./gradlew test
- ./gradlew assembleRelease
- ./gradlew bundleRelease
- name: deploy latest build
image: curlimages/curl
environment:
SEAFILE_API_KEY:
from_secret: SeafileApiKey
APK_FILE: app/build/outputs/apk/release/app-release.apk
BUNDLE_FILE: app/build/outputs/bundle/release/app-release.aab
SEAFILE_REPO: daffda8b-5840-4a65-b6d0-73b991facfb6
commands:
- 'UPLOAD_URL=$(curl -H "Authorization: Token $SEAFILE_API_KEY" https://seafile.zobrist.me/api2/repos/$SEAFILE_REPO/upload-link/ | tr -d "\"")'
- 'curl -H "Authorization: Token $SEAFILE_API_KEY" -F file=@$APK_FILE -F parent_dir=/ -F relative_path=latest/ -F replace=1 "$UPLOAD_URL"'
- 'curl -H "Authorization: Token $SEAFILE_API_KEY" -F file=@$BUNDLE_FILE -F parent_dir=/ -F relative_path=latest/ -F replace=1 "$UPLOAD_URL"'
- name: deploy tagged build
image: curlimages/curl
environment:
SEAFILE_API_KEY:
from_secret: SeafileApiKey
APK_FILE: app/build/outputs/apk/release/app-release.apk
BUNDLE_FILE: app/build/outputs/bundle/release/app-release.aab
SEAFILE_REPO: daffda8b-5840-4a65-b6d0-73b991facfb6
commands:
- 'UPLOAD_URL=$(curl -H "Authorization: Token $SEAFILE_API_KEY" https://seafile.zobrist.me/api2/repos/$SEAFILE_REPO/upload-link/ | tr -d "\"")'
- 'curl -H "Authorization: Token $SEAFILE_API_KEY" -F file=@$APK_FILE -F parent_dir=/ -F relative_path=tagged/$DRONE_TAG/ -F replace=1 "$UPLOAD_URL"'
- 'curl -H "Authorization: Token $SEAFILE_API_KEY" -F file=@$BUNDLE_FILE -F parent_dir=/ -F relative_path=tagged/$DRONE_TAG/ -F replace=1 "$UPLOAD_URL"'
- 'curl -d "operation=rename&newname=app-release$DRONE_TAG.apk" -H "Authorization: Token $SEAFILE_API_KEY" https://seafile.zobrist.me/api2/repos/$SEAFILE_REPO/file/?p=/tagged/$DRONE_TAG/app-release.apk'
- 'curl -d "operation=rename&newname=app-release$DRONE_TAG.aab" -H "Authorization: Token $SEAFILE_API_KEY" https://seafile.zobrist.me/api2/repos/$SEAFILE_REPO/file/?p=/tagged/$DRONE_TAG/app-release.aab'
when:
event:
- tag
- name: slack notification
image: plugins/slack
settings:
webhook:
from_secret: SlackWebhook
when:
status:
- failure
- success

View File

@@ -0,0 +1,44 @@
name: Build Android
on: [pull_request, push]
jobs:
build:
runs-on: ubuntu-latest-android
steps:
- name: Checkout the code
uses: actions/checkout@v2
- name: Prepare signing
run: |
touch keystore.properties
echo "storePassword=${{ secrets.STOREPASSWORD }}" >> keystore.properties
echo "keyPassword=${{ secrets.KEYPASSWORD }}" >> keystore.properties
echo "keyAlias=key0" >> keystore.properties
echo "storeFile=../AndroidKey" >> keystore.properties
- name: Generate versionCode
run: |
touch version.properties
let timestamp=$(date +%s)/10
echo "versionCode=$timestamp" >> version.properties
- name: Test the app
run: ./gradlew test
- name: Build apk
run: ./gradlew assembleRelease
- name: Build abb
run: ./gradlew bundleRelease
- uses: actions/upload-artifact@v3
with:
name: app-release
retention-days: 10
path: app/build/outputs/**/release/app-release.*
- uses: https://github.com/ravsamhq/notify-slack-action@v2
if: always()
with:
status: ${{ job.status }} # required
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} # required

1
.gitignore vendored
View File

@@ -15,3 +15,4 @@
.idea .idea
keystore.properties keystore.properties
version.properties version.properties
.vscode/

View File

@@ -3,6 +3,7 @@ plugins {
id 'kotlin-android' id 'kotlin-android'
id 'com.google.dagger.hilt.android' id 'com.google.dagger.hilt.android'
id 'kotlin-kapt' id 'kotlin-kapt'
id 'com.google.devtools.ksp'
} }
// Create a variable called keystorePropertiesFile, and initialize it to your // Create a variable called keystorePropertiesFile, and initialize it to your
@@ -15,6 +16,8 @@ def versionPropertiesFile = rootProject.file("version.properties")
def keystoreProperties = new Properties() def keystoreProperties = new Properties()
def versionProperties = new Properties() def versionProperties = new Properties()
def versionMajor = 2
def versionMinor = 3
// Load your keystore.properties file into the keystoreProperties object. // Load your keystore.properties file into the keystoreProperties object.
keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
@@ -22,20 +25,26 @@ versionProperties.load(new FileInputStream(versionPropertiesFile))
android { android {
compileSdkVersion 33 compileSdk 34
defaultConfig { defaultConfig {
applicationId "me.zobrist.tichucounter" applicationId "me.zobrist.tichucounter"
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 33 targetSdkVersion 34
versionCode versionProperties["versionCode"].toInteger() versionCode versionProperties["versionCode"].toInteger()
versionName "1.1.0Beta1" versionName "${versionMajor}.${versionMinor}.${versionProperties["versionCode"].toInteger()}"
resConfigs 'de', 'en' resourceConfigurations += ['de', 'en']
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
multiDexEnabled true multiDexEnabled true
vectorDrawables { vectorDrawables {
useSupportLibrary true useSupportLibrary true
} }
javaCompileOptions {
annotationProcessorOptions {
arguments += ["room.schemaLocation": "$projectDir/schemas".toString()]
}
}
} }
signingConfigs { signingConfigs {
create("release") { create("release") {
@@ -55,20 +64,19 @@ android {
} }
buildFeatures { buildFeatures {
viewBinding = true
compose = true compose = true
} }
composeOptions { composeOptions {
kotlinCompilerExtensionVersion = "1.3.2" kotlinCompilerExtensionVersion = "1.5.14"
} }
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8 sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_17
} }
kotlinOptions { kotlinOptions {
jvmTarget = '1.8' jvmTarget = '17'
} }
namespace 'me.zobrist.tichucounter' namespace 'me.zobrist.tichucounter'
packagingOptions { packagingOptions {
@@ -81,44 +89,44 @@ android {
dependencies { dependencies {
implementation fileTree(dir: "libs", include: ["*.jar"]) implementation fileTree(dir: "libs", include: ["*.jar"])
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation 'androidx.core:core-ktx:1.9.0' implementation 'androidx.core:core-ktx:1.13.1'
implementation 'androidx.appcompat:appcompat:1.6.0-rc01' implementation 'androidx.appcompat:appcompat:1.7.0'
implementation "androidx.compose.material3:material3:1.0.1" implementation "androidx.compose.material3:material3:1.2.1"
implementation 'com.google.android.play:core-ktx:1.8.1' implementation 'com.google.android.play:review:2.0.1'
implementation 'com.google.android.play:core-ktx:1.8.1' implementation 'com.google.android.play:review-ktx:2.0.1'
implementation 'com.google.code.gson:gson:2.8.9' implementation 'com.google.code.gson:gson:2.10.1'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.navigation:navigation-fragment-ktx:2.5.3' implementation 'androidx.navigation:navigation-fragment-ktx:2.7.7'
implementation 'androidx.navigation:navigation-ui-ktx:2.5.3' implementation 'androidx.navigation:navigation-ui-ktx:2.7.7'
implementation 'androidx.legacy:legacy-support-v4:1.0.0' implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.1' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.8.4'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.4'
implementation 'androidx.fragment:fragment-ktx:1.5.5' implementation 'androidx.fragment:fragment-ktx:1.8.2'
implementation 'androidx.preference:preference-ktx:1.2.0' implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.recyclerview:recyclerview:1.2.1' implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.4'
implementation 'androidx.compose.material:material-icons-extended:1.3.1' implementation 'androidx.compose.material:material-icons-extended:1.6.8'
implementation "com.google.accompanist:accompanist-systemuicontroller:0.27.0" implementation "com.google.accompanist:accompanist-systemuicontroller:0.27.0"
implementation 'androidx.activity:activity-compose:1.6.1' implementation 'androidx.activity:activity-compose:1.9.1'
implementation "androidx.compose.ui:ui:1.3.3" implementation "androidx.compose.ui:ui:1.6.8"
implementation "androidx.compose.ui:ui-tooling-preview:1.3.3" implementation "androidx.compose.ui:ui-tooling-preview:1.6.8"
implementation "androidx.compose.runtime:runtime-livedata:1.3.3" implementation "androidx.compose.runtime:runtime-livedata:1.6.8"
implementation "androidx.navigation:navigation-compose:2.5.3" implementation "androidx.navigation:navigation-compose:2.7.7"
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1" implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.8.4"
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
implementation "com.google.dagger:hilt-android:2.44" implementation "com.google.dagger:hilt-android:2.51.1"
androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.3.3" androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.6.8"
debugImplementation "androidx.compose.ui:ui-tooling:1.3.3" debugImplementation "androidx.compose.ui:ui-tooling:1.6.8"
debugImplementation "androidx.compose.ui:ui-test-manifest:1.3.3" debugImplementation "androidx.compose.ui:ui-test-manifest:1.6.8"
kapt "com.google.dagger:hilt-compiler:2.44" kapt "com.google.dagger:hilt-compiler:2.51.1"
implementation "androidx.room:room-runtime:2.5.0" annotationProcessor "androidx.room:room-compiler:2.6.1"
annotationProcessor "androidx.room:room-compiler:2.5.0" implementation "androidx.room:room-runtime:2.6.1"
kapt "androidx.room:room-compiler:2.5.0" ksp "androidx.room:room-compiler:2.6.1"
implementation "androidx.room:room-ktx:2.5.0" implementation "androidx.room:room-ktx:2.6.1"
implementation "androidx.multidex:multidex:2.0.1" implementation "androidx.multidex:multidex:2.0.1"
api "androidx.navigation:navigation-fragment-ktx:2.5.3" api "androidx.navigation:navigation-fragment-ktx:2.7.7"
} }
// Allow references to generated code // Allow references to generated code

View File

@@ -0,0 +1,102 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "1739540cd7d5436941316932a1036d83",
"entities": [
{
"tableName": "Round",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gameId` INTEGER NOT NULL, `scoreA` INTEGER NOT NULL, `scoreB` INTEGER NOT NULL, `uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)",
"fields": [
{
"fieldPath": "gameId",
"columnName": "gameId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scoreA",
"columnName": "scoreA",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scoreB",
"columnName": "scoreB",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"uid"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "Game",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`active` INTEGER NOT NULL, `nameA` TEXT NOT NULL, `nameB` TEXT NOT NULL, `created` INTEGER NOT NULL, `modified` INTEGER NOT NULL, `uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)",
"fields": [
{
"fieldPath": "active",
"columnName": "active",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "nameA",
"columnName": "nameA",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "nameB",
"columnName": "nameB",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "created",
"columnName": "created",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "modified",
"columnName": "modified",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"uid"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1739540cd7d5436941316932a1036d83')"
]
}
}

View File

@@ -0,0 +1,102 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "1739540cd7d5436941316932a1036d83",
"entities": [
{
"tableName": "Round",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gameId` INTEGER NOT NULL, `scoreA` INTEGER NOT NULL, `scoreB` INTEGER NOT NULL, `uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)",
"fields": [
{
"fieldPath": "gameId",
"columnName": "gameId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scoreA",
"columnName": "scoreA",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scoreB",
"columnName": "scoreB",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"uid"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "Game",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`active` INTEGER NOT NULL, `nameA` TEXT NOT NULL, `nameB` TEXT NOT NULL, `created` INTEGER NOT NULL, `modified` INTEGER NOT NULL, `uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)",
"fields": [
{
"fieldPath": "active",
"columnName": "active",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "nameA",
"columnName": "nameA",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "nameB",
"columnName": "nameB",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "created",
"columnName": "created",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "modified",
"columnName": "modified",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"uid"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1739540cd7d5436941316932a1036d83')"
]
}
}

View File

@@ -1,22 +0,0 @@
package me.zobrist.tichucounter
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("me.zobrist.tichucounter", appContext.packageName)
}
}

View File

@@ -0,0 +1,275 @@
package me.zobrist.tichucounter
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.runTest
import me.zobrist.tichucounter.data.AppDatabase
import me.zobrist.tichucounter.data.GameDao
import me.zobrist.tichucounter.data.RoundDao
import me.zobrist.tichucounter.repository.GameRepository
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.IOException
import java.util.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class RepositoryInstrumentedTest {
private lateinit var gameDao: GameDao
private lateinit var roundDao: RoundDao
private lateinit var repository: GameRepository
private lateinit var db: AppDatabase
@Before
fun createDb() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = Room.inMemoryDatabaseBuilder(
context, AppDatabase::class.java
).build()
roundDao = db.roundDao()
gameDao = db.gameDao()
repository = GameRepository(gameDao, roundDao)
}
@After
@Throws(IOException::class)
fun closeDb() {
db.close()
}
@Test
@Throws(Exception::class)
fun gameInitialisation() = runTest {
repository.getActiveGameFlow().take(1).collect {
assertEquals("TeamA", it.game.nameA)
assertEquals("TeamB", it.game.nameB)
assertTrue(it.game.active)
assertEquals(0, it.rounds.count())
}
}
@Test
@Throws(Exception::class)
fun modifyNames() = runTest {
repository.getActiveGameFlow().take(1).collect {
}
repository.updateActiveTeamName(nameA = "aaa")
repository.getActiveGameFlow().take(1).collect {
assertEquals("aaa", it.game.nameA)
assertEquals("TeamB", it.game.nameB)
}
repository.updateActiveTeamName(nameB = "bbb")
repository.getActiveGameFlow().take(1).collect {
assertEquals("aaa", it.game.nameA)
assertEquals("bbb", it.game.nameB)
}
}
@Test
@Throws(Exception::class)
fun newGame() = runTest {
repository.getActiveGameFlow().take(1).collect {
}
repository.newGame()
repository.newGame()
repository.newGame()
repository.newGame()
repository.newGame()
repository.getAllWithRoundFlow().take(1).collect() { it ->
assertEquals(6, it.count())
var uid: Long = 1
it.forEach { game ->
assertEquals(uid++, game.game.uid)
assertEquals(0, game.rounds.count())
}
}
}
@Test
@Throws(Exception::class)
fun setActive() = runTest {
repository.getActiveGameFlow().take(1).collect {
}
repository.newGame()
repository.newGame()
repository.newGame()
repository.newGame()
repository.newGame()
repository.getAllWithRoundFlow().take(1).collect() { it ->
val filtered = it.filter { it.game.active }
assertEquals(1, filtered.count())
assertEquals(6, filtered.first().game.uid)
}
repository.setActive(2)
repository.getAllWithRoundFlow().take(1).collect() { it ->
val filtered = it.filter { it.game.active }
assertEquals(1, filtered.count())
assertEquals(2, filtered.first().game.uid)
}
}
@Test
@Throws(Exception::class)
fun addRoundToActiveGame() = runTest {
repository.getActiveGameFlow().take(1).collect {
}
repository.newGame()
repository.newGame()
repository.newGame()
repository.newGame()
repository.newGame()
repository.addRoundToActiveGame(1, 1)
repository.addRoundToActiveGame(2, 2)
repository.addRoundToActiveGame(3, 3)
repository.addRoundToActiveGame(4, 4)
repository.addRoundToActiveGame(5, 5)
repository.addRoundToActiveGame(6, 6)
repository.getAllWithRoundFlow().take(1).collect() { it ->
val filtered = it.filter { it.rounds.isNotEmpty() }
assertEquals(1, filtered.count())
assertEquals(6, filtered.first().rounds.count())
}
}
@Test
@Throws(Exception::class)
fun lastRound() = runTest {
repository.getActiveGameFlow().take(1).collect {
}
repository.newGame()
repository.newGame()
repository.newGame()
repository.newGame()
repository.newGame()
assertNull(repository.getLastRound())
repository.addRoundToActiveGame(1, 1)
repository.addRoundToActiveGame(2, 2)
repository.addRoundToActiveGame(3, 3)
repository.addRoundToActiveGame(4, 4)
repository.addRoundToActiveGame(5, 5)
repository.addRoundToActiveGame(6, 6)
var lastRound = repository.getLastRound()
assertEquals(6, lastRound?.scoreA)
assertEquals(6, lastRound?.scoreB)
repository.deleteLastRound()
lastRound = repository.getLastRound()
assertEquals(5, lastRound?.scoreA)
assertEquals(5, lastRound?.scoreB)
repository.deleteLastRound()
repository.deleteLastRound()
repository.deleteLastRound()
repository.deleteLastRound()
repository.deleteLastRound()
assertNull(repository.getLastRound())
// No error thrown
repository.deleteLastRound()
}
@Test
@Throws(Exception::class)
fun deleteInactive() = runTest {
repository.getActiveGameFlow().take(1).collect {
}
for (i in 1..6) {
repository.newGame()
repository.addRoundToActiveGame(1, 1)
repository.addRoundToActiveGame(2, 2)
repository.addRoundToActiveGame(3, 3)
repository.addRoundToActiveGame(4, 4)
repository.addRoundToActiveGame(5, 5)
repository.addRoundToActiveGame(6, 6)
}
assertEquals(6 * 6, roundDao.getAll().count())
repository.deleteAllInactive()
// Consists of two transactions. Delete games then delete rounds.
repository.getAllWithRoundFlow().take(1).collect() { it ->
assertEquals(1, it.count())
assertEquals(6, it.first().rounds.count())
}
assertEquals(6, roundDao.getAll().count())
}
@Test
@Throws(Exception::class)
fun deleteById() = runTest {
repository.getActiveGameFlow().take(1).collect {
}
for (i in 1..6) {
repository.newGame()
repository.addRoundToActiveGame(1, 1)
repository.addRoundToActiveGame(2, 2)
repository.addRoundToActiveGame(3, 3)
repository.addRoundToActiveGame(4, 4)
repository.addRoundToActiveGame(5, 5)
repository.addRoundToActiveGame(6, 6)
}
// Non existing Id
repository.deleteGame(10)
repository.getAllWithRoundFlow().take(1).collect() { it ->
assertEquals(7, it.count())
}
// Non existing Id
val toDelete: Long = 3
repository.deleteGame(toDelete)
repository.getAllWithRoundFlow().take(1).collect() { it ->
assertEquals(6, it.count())
assertEquals(0, it.count { it.game.uid == toDelete })
}
}
}

View File

@@ -9,7 +9,6 @@
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:localeConfig="@xml/locales_config" android:localeConfig="@xml/locales_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/AppTheme"> android:theme="@style/AppTheme">
<activity <activity

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -1,83 +0,0 @@
package me.zobrist.tichucounter
import android.content.SharedPreferences
import android.os.Bundle
import android.view.WindowManager
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import androidx.preference.PreferenceManager
import dagger.hilt.android.AndroidEntryPoint
import me.zobrist.tichucounter.domain.Language
import me.zobrist.tichucounter.domain.SettingsAdapter
import me.zobrist.tichucounter.domain.Theme
import javax.inject.Inject
@AndroidEntryPoint
abstract class BaseActivity : AppCompatActivity(),
SharedPreferences.OnSharedPreferenceChangeListener {
@Inject
lateinit var settingsAdapter: SettingsAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
keepScreenOn(settingsAdapter.keepScreenOn)
updateTheme(settingsAdapter.theme)
PreferenceManager.getDefaultSharedPreferences(this)
.registerOnSharedPreferenceChangeListener(this)
}
override fun onResume() {
super.onResume()
PreferenceManager.getDefaultSharedPreferences(this)
.registerOnSharedPreferenceChangeListener(this)
}
override fun onPause() {
super.onPause()
PreferenceManager.getDefaultSharedPreferences(this)
.unregisterOnSharedPreferenceChangeListener(this)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
when (key) {
settingsAdapter.language::class.simpleName -> setLanguage(settingsAdapter.language)
settingsAdapter.keepScreenOn::class.simpleName -> keepScreenOn(settingsAdapter.keepScreenOn)
settingsAdapter.theme::class.simpleName -> updateTheme(settingsAdapter.theme)
}
}
private fun updateTheme(theme: Theme) {
val themeValue = when (theme) {
Theme.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO
Theme.DARK -> AppCompatDelegate.MODE_NIGHT_YES
Theme.DEFAULT -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
if (themeValue != AppCompatDelegate.getDefaultNightMode()) {
AppCompatDelegate.setDefaultNightMode(themeValue)
delegate.applyDayNight()
}
}
private fun keepScreenOn(keepOn: Boolean) {
if (keepOn) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
private fun setLanguage(language: Language) {
val currentLocale = AppCompatDelegate.getApplicationLocales()[0].toString()
if (language.value != null && language.value != currentLocale) {
val newLocale = LocaleListCompat.forLanguageTags(language.value)
AppCompatDelegate.setApplicationLocales(newLocale)
}
}
}

View File

@@ -1,22 +1,40 @@
package me.zobrist.tichucounter package me.zobrist.tichucounter
import android.os.Bundle import android.os.Bundle
import android.view.WindowManager
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.* import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.automirrored.outlined.List
import androidx.compose.material.icons.outlined.* import androidx.compose.material.icons.automirrored.outlined.Redo
import androidx.compose.material3.* import androidx.compose.material.icons.automirrored.outlined.Undo
import androidx.compose.runtime.* import androidx.compose.material.icons.outlined.Calculate
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.Keyboard
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.DrawerState
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.lifecycleScope
import androidx.compose.ui.unit.dp
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
@@ -26,35 +44,88 @@ import com.google.accompanist.systemuicontroller.rememberSystemUiController
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.zobrist.tichucounter.domain.NavigationAction import me.zobrist.tichucounter.domain.DrawerItem
import me.zobrist.tichucounter.domain.KeepScreenOn
import me.zobrist.tichucounter.domain.Language
import me.zobrist.tichucounter.domain.ReviewService
import me.zobrist.tichucounter.domain.Route
import me.zobrist.tichucounter.domain.SettingsAdapter
import me.zobrist.tichucounter.domain.Theme
import me.zobrist.tichucounter.domain.TopBarAction import me.zobrist.tichucounter.domain.TopBarAction
import me.zobrist.tichucounter.domain.TopBarState
import me.zobrist.tichucounter.domain.navigate
import me.zobrist.tichucounter.repository.GameRepository import me.zobrist.tichucounter.repository.GameRepository
import me.zobrist.tichucounter.ui.AppTheme import me.zobrist.tichucounter.ui.AppTheme
import me.zobrist.tichucounter.ui.MainViewModel import me.zobrist.tichucounter.ui.MainViewModel
import me.zobrist.tichucounter.ui.counter.* import me.zobrist.tichucounter.ui.about.AboutView
import me.zobrist.tichucounter.ui.composables.DropDownMenu
import me.zobrist.tichucounter.ui.counter.Counter
import me.zobrist.tichucounter.ui.counter.CounterViewModel
import me.zobrist.tichucounter.ui.history.HistoryList import me.zobrist.tichucounter.ui.history.HistoryList
import me.zobrist.tichucounter.ui.history.HistoryViewModel import me.zobrist.tichucounter.ui.history.HistoryViewModel
import me.zobrist.tichucounter.ui.layout.DrawerContent
import me.zobrist.tichucounter.ui.layout.TopBar
import me.zobrist.tichucounter.ui.settings.SettingsView import me.zobrist.tichucounter.ui.settings.SettingsView
import me.zobrist.tichucounter.ui.settings.SettingsViewModel import me.zobrist.tichucounter.ui.settings.SettingsViewModel
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : BaseActivity() { class MainActivity : AppCompatActivity() {
@Inject @Inject
lateinit var gameRepository: GameRepository lateinit var settingsAdapter: SettingsAdapter
@Inject
lateinit var repository: GameRepository
@Inject
lateinit var reviewService: ReviewService
private val counterViewModel: CounterViewModel by viewModels() private val counterViewModel: CounterViewModel by viewModels()
private val historyViewModel: HistoryViewModel by viewModels() private val historyViewModel: HistoryViewModel by viewModels()
private val settingsViewModel: SettingsViewModel by viewModels() private val settingsViewModel: SettingsViewModel by viewModels()
private val mainViewModel: MainViewModel by viewModels() private val mainViewModel: MainViewModel by viewModels()
private var requestReview: Boolean = false
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
changeTheme(settingsAdapter.theme.value)
setKeepScreenOn(settingsAdapter.keepScreenOn.value)
changeLanguage(settingsAdapter.language.value)
lifecycleScope.launch {
settingsAdapter.theme.collect {
changeTheme(it)
}
}
lifecycleScope.launch {
settingsAdapter.keepScreenOn.collect {
setKeepScreenOn(it)
}
}
lifecycleScope.launch {
settingsAdapter.language.collect {
changeLanguage(it)
}
}
lifecycleScope.launch {
settingsAdapter.gameFinished.collect {
if (!requestReview) {
requestReview = true
return@collect
}
if (it) {
reviewService.request()
}
}
}
setContent { setContent {
AppTheme() { AppTheme {
val systemUiController = rememberSystemUiController() val systemUiController = rememberSystemUiController()
systemUiController.setStatusBarColor(MaterialTheme.colorScheme.background) systemUiController.setStatusBarColor(MaterialTheme.colorScheme.background)
NavigationDrawer() NavigationDrawer()
@@ -62,105 +133,27 @@ class MainActivity : BaseActivity() {
} }
} }
@OptIn(ExperimentalMaterial3Api::class) private fun changeLanguage(language: Language) {
@Composable AppCompatDelegate.setApplicationLocales(language.value)
fun MyScaffoldLayout( }
drawerState: DrawerState,
scope: CoroutineScope,
navController: NavHostController
) {
Scaffold( private fun changeTheme(theme: Theme) {
topBar = { val themeValue = when (theme) {
TopBar( Theme.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO
mainViewModel.topBarTitle, Theme.DARK -> AppCompatDelegate.MODE_NIGHT_YES
mainViewModel.topBarIcon, Theme.DEFAULT -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
{ mainViewModel.onNavigateClicked() }, }
mainViewModel.topBarActions AppCompatDelegate.setDefaultNightMode(themeValue)
) }
}) {
NavHost( private fun setKeepScreenOn(keepOn: KeepScreenOn) {
navController = navController, if (keepOn.value) {
startDestination = "counter", window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
modifier = Modifier.padding(it) } else {
) { window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
composable("counter") {
Counter(counterViewModel)
mainViewModel.topBarActions = (listOf(
TopBarAction(
Icons.Outlined.Undo,
mainViewModel.isUndoActionActive
) { mainViewModel.undoLastRound() },
TopBarAction(
Icons.Outlined.Redo,
mainViewModel.isRedoActionActive
) { mainViewModel.redoLastRound() }
))
mainViewModel.topBarIcon = Icons.Outlined.Menu
mainViewModel.topBarTitle = stringResource(R.string.app_name)
mainViewModel.topBarNavigationAction =
NavigationAction { scope.launch { drawerState.open() } }
}
composable("history") {
HistoryList(historyViewModel)
mainViewModel.topBarActions = emptyList()
mainViewModel.topBarIcon = Icons.Outlined.ArrowBack
mainViewModel.topBarTitle = stringResource(R.string.menu_history)
mainViewModel.topBarNavigationAction =
NavigationAction { navController.navigate("counter") }
}
composable("settings") {
SettingsView(settingsViewModel)
mainViewModel.topBarActions = emptyList()
mainViewModel.topBarIcon = Icons.Outlined.ArrowBack
mainViewModel.topBarTitle = stringResource(R.string.menu_settings)
mainViewModel.topBarNavigationAction =
NavigationAction { navController.navigate("counter") }
}
}
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TopBar(
title: String,
icon: ImageVector,
navigateAction: () -> Unit,
actions: List<TopBarAction>
) {
TopAppBar(
title = {
Text(
title,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
navigationIcon = {
IconButton(onClick = { navigateAction() }) {
Icon(
imageVector = icon,
contentDescription = "Localized description"
)
}
},
actions = {
actions.forEach {
IconButton(onClick = { it.action() }, enabled = it.isActive) {
Icon(
imageVector = it.imageVector,
contentDescription = null,
)
}
}
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun NavigationDrawer() { private fun NavigationDrawer() {
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
@@ -168,79 +161,153 @@ class MainActivity : BaseActivity() {
val navController = rememberNavController() val navController = rememberNavController()
val items = listOf( val items = listOf(
Screen("history", Icons.Outlined.List, R.string.menu_history),Screen("settings", Icons.Outlined.Settings, R.string.menu_settings) DrawerItem(
Route.COUNTER,
Icons.Outlined.Calculate,
stringResource(R.string.menu_counter)
),
DrawerItem(
Route.HISTORY,
Icons.AutoMirrored.Outlined.List,
stringResource(R.string.menu_history)
),
DrawerItem(
Route.SETTINGS,
Icons.Outlined.Settings,
stringResource(R.string.menu_settings)
),
DrawerItem(
Route.ABOUT,
Icons.Outlined.Info,
stringResource(R.string.menu_about)
)
) )
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination val currentDestination =
Route.valueOf(navBackStackEntry?.destination?.route ?: Route.COUNTER.name)
ModalNavigationDrawer( ModalNavigationDrawer(
drawerState = drawerState, drawerState = drawerState,
gesturesEnabled = drawerState.isOpen,
drawerContent = { drawerContent = {
ModalDrawerSheet { DrawerContent(
drawerItems = items,
selectedDrawerItem = items.first { it.route == currentDestination }) {
scope.launch {
drawerState.close()
Spacer(Modifier.height(20.dp)) }
navController.navigate(it)
}
}
) {
MyScaffoldLayout(
drawerState,
scope,
navController,
counterViewModel.keyboardHidden && (currentDestination == Route.COUNTER)
) { counterViewModel.showKeyboard() }
}
}
NavigationDrawerItem( @Composable
icon = { Icon(Icons.Outlined.RestartAlt, contentDescription = null) }, fun MyScaffoldLayout(
colors = NavigationDrawerItemDefaults.colors( drawerState: DrawerState,
unselectedContainerColor = MaterialTheme.colorScheme.secondaryContainer scope: CoroutineScope,
), navController: NavHostController,
label = { Text(stringResource(R.string.newGame)) }, showFab: Boolean,
selected = false, fabAction: () -> Unit
onClick = { ) {
scope.launch { drawerState.close() }
mainViewModel.newGame()
navController.navigate("counter") {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
// on the back stack as users select items
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
}
},
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
)
Spacer(Modifier.height(20.dp)) var topBarState by remember { mutableStateOf(TopBarState()) }
val snackbarHostState by remember { mutableStateOf(SnackbarHostState()) }
Divider() Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
items.forEach { screen -> floatingActionButton = {
NavigationDrawerItem( if (showFab) {
icon = { Icon(screen.icon, contentDescription = null) }, FloatingActionButton(
label = { Text(stringResource(screen.resourceId)) }, onClick = { fabAction() }) {
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true, Icon(Icons.Outlined.Keyboard, null)
onClick = {
scope.launch { drawerState.close() }
navController.navigate(screen.route) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
// on the back stack as users select items
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
}
},
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
)
} }
} }
}, },
content = { MyScaffoldLayout(drawerState, scope, navController) } topBar = { TopBar(topBarState) }) { paddings ->
)
}
private class Screen(val route: String, val icon: ImageVector, @StringRes val resourceId: Int) NavHost(
navController = navController,
startDestination = Route.COUNTER.name,
modifier = Modifier.padding(paddings)
) {
this.composable(Route.COUNTER.name) {
var expanded by remember { mutableStateOf(false) }
topBarState = TopBarState(
title = stringResource(R.string.app_name),
actions = (listOf(
TopBarAction(
Icons.AutoMirrored.Outlined.Undo,
mainViewModel.isUndoActionActive,
{ mainViewModel.undoLastRound() }),
TopBarAction(
Icons.AutoMirrored.Outlined.Redo,
mainViewModel.isRedoActionActive,
{ mainViewModel.redoLastRound() }),
TopBarAction(
Icons.Outlined.MoreVert,
mainViewModel.activeGameHasRounds,
{ expanded = true }
) {
val newGameTranslated = stringResource(R.string.new_game)
DropDownMenu(
listOf(newGameTranslated),
"",
expanded,
) {
expanded = false
it?.let {
when (it) {
newGameTranslated -> lifecycleScope.launch { repository.newGame() }
}
}
}
},
))
) {
scope.launch {
currentFocus?.clearFocus()
drawerState.open()
}
}
Counter(counterViewModel)
}
composable(Route.HISTORY.name) {
topBarState =
TopBarState(title = stringResource(R.string.menu_history)) { scope.launch { drawerState.open() } }
HistoryList(
historyViewModel,
snackbarHostState
) { navController.navigate(Route.COUNTER) }
}
composable(Route.SETTINGS.name) {
topBarState =
TopBarState(title = stringResource(R.string.menu_settings)) { scope.launch { drawerState.open() } }
SettingsView(settingsViewModel)
}
composable(Route.ABOUT.name) {
topBarState =
TopBarState(title = stringResource(R.string.menu_about)) { scope.launch { drawerState.open() } }
AboutView()
}
}
}
}
} }

View File

@@ -3,6 +3,8 @@ package me.zobrist.tichucounter.data
import androidx.room.Database import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters import androidx.room.TypeConverters
import me.zobrist.tichucounter.data.entity.Game
import me.zobrist.tichucounter.data.entity.Round
@Database(entities = [Round::class, Game::class], version = 1) @Database(entities = [Round::class, Game::class], version = 1)
@TypeConverters(DateConverter::class) @TypeConverters(DateConverter::class)

View File

@@ -16,4 +16,7 @@ interface DaoBase<T> {
@Delete @Delete
fun delete(entity: T) fun delete(entity: T)
@Delete
fun delete(entity: List<T>)
} }

View File

@@ -2,7 +2,7 @@ package me.zobrist.tichucounter.data
import androidx.room.ProvidedTypeConverter import androidx.room.ProvidedTypeConverter
import androidx.room.TypeConverter import androidx.room.TypeConverter
import java.util.* import java.util.Date
@ProvidedTypeConverter @ProvidedTypeConverter
object DateConverter { object DateConverter {

View File

@@ -1,15 +0,0 @@
package me.zobrist.tichucounter.data
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.util.*
@Entity
data class Game(
override var active: Boolean,
override var nameA: String,
override var nameB: String,
override val created: Date,
override var modified: Date,
@PrimaryKey(autoGenerate = true) override val uid: Long? = null
) : IGame, IEntity

View File

@@ -1,17 +0,0 @@
package me.zobrist.tichucounter.data
import androidx.room.Entity
import java.util.*
@Entity
data class GameAndScore(
override var active: Boolean,
override var nameA: String,
override var nameB: String,
override val created: Date,
override var modified: Date,
override var gameId: Long,
override var scoreA: Int,
override var scoreB: Int,
) : IGame, IRound {
}

View File

@@ -1,7 +1,10 @@
package me.zobrist.tichucounter.data package me.zobrist.tichucounter.data
import androidx.room.* import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import me.zobrist.tichucounter.data.entity.Game
@Dao @Dao
@@ -10,25 +13,23 @@ interface GameDao : DaoBase<Game> {
@Query("SELECT * FROM game") @Query("SELECT * FROM game")
fun getAll(): Flow<List<Game>> fun getAll(): Flow<List<Game>>
@Query( @Transaction
"SELECT active, " + @Query("SELECT * FROM game")
"nameA, " + fun getGamesWithRounds(): Flow<List<GameWithScores>>
"nameB, " +
"created, " + @Transaction
"modified, " + @Query("SELECT * FROM game WHERE active is 1")
"game.uid as gameId, " + fun getActiveWithRounds(): Flow<GameWithScores?>
"COALESCE(SUM(round.scoreA), 0) as scoreA, " +
"COALESCE(SUM(round.scoreB), 0) as scoreB " +
"FROM game " +
"LEFT JOIN round ON round.gameId = game.uid GROUP BY game.uid ORDER BY modified DESC"
)
fun getAllWithPoints(): Flow<List<GameAndScore>>
@Query("SELECT * FROM game WHERE uid is :gameId") @Query("SELECT * FROM game WHERE uid is :gameId")
fun getGameById(gameId: Long): Flow<Game> fun getGameById(gameId: Long): Game
@Query("SELECT * FROM game WHERE active is 1") @Query("SELECT * FROM game WHERE active is 1")
fun getActive(): Flow<Game?> fun getActiveAsFlow(): Flow<Game?>
@Query("SELECT * FROM game WHERE active is 1")
fun getActive(): Game?
@Query("UPDATE game SET active = 1 WHERE uid is :gameId;") @Query("UPDATE game SET active = 1 WHERE uid is :gameId;")
fun setActive(gameId: Long) fun setActive(gameId: Long)
@@ -36,4 +37,7 @@ interface GameDao : DaoBase<Game> {
@Query("UPDATE game SET active = 0 WHERE uid is not :gameId;") @Query("UPDATE game SET active = 0 WHERE uid is not :gameId;")
fun setOthersInactive(gameId: Long) fun setOthersInactive(gameId: Long)
@Query("SELECT names FROM (SELECT nameA AS names FROM game UNION ALL SELECT nameB AS names FROM game) GROUP BY names")
fun getDistinctTeamNames(): Flow<List<String>>
} }

View File

@@ -0,0 +1,17 @@
package me.zobrist.tichucounter.data
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.Relation
import me.zobrist.tichucounter.data.entity.Game
import me.zobrist.tichucounter.data.entity.Round
@Entity
data class GameWithScores(
@Embedded val game: Game = Game(),
@Relation(
parentColumn = "uid",
entityColumn = "gameId"
)
val rounds: List<Round> = emptyList()
)

View File

@@ -1,5 +0,0 @@
package me.zobrist.tichucounter.data
interface IEntity {
val uid: Long?
}

View File

@@ -1,11 +0,0 @@
package me.zobrist.tichucounter.data
import java.util.*
interface IGame {
var active: Boolean
var nameA: String
var nameB: String
val created: Date
var modified: Date
}

View File

@@ -1,7 +0,0 @@
package me.zobrist.tichucounter.data
interface IRound {
var gameId: Long
var scoreA: Int
var scoreB: Int
}

View File

@@ -1,12 +0,0 @@
package me.zobrist.tichucounter.data
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity
data class Round(
override var gameId: Long,
override var scoreA: Int,
override var scoreB: Int,
@PrimaryKey(autoGenerate = true) override val uid: Long? = null
) : IRound, IEntity

View File

@@ -1,27 +1,16 @@
package me.zobrist.tichucounter.data package me.zobrist.tichucounter.data
import androidx.room.* import androidx.room.Dao
import kotlinx.coroutines.flow.Flow import androidx.room.Query
import me.zobrist.tichucounter.data.entity.Round
@Dao @Dao
interface RoundDao : DaoBase<Round> { interface RoundDao : DaoBase<Round> {
@Query("SELECT * FROM round")
fun getAll(): List<Round>
@Query("SELECT * FROM round WHERE gameId is :gameId") @Query("SELECT * FROM round WHERE gameId is :gameId")
fun getAllForGame(gameId: Long?): List<Round> fun getAllForGame(gameId: Long?): List<Round>
@Query(
"SELECT gameId, SUM(scoreA) as scoreA, SUM(scoreB) as scoreB " +
"FROM round " +
"LEFT JOIN game ON game.uid = round.gameId " +
"WHERE game.active == 1"
)
fun getRoundSumForActiveGame(): Flow<Round>
@Query(
"SELECT gameId, scoreA, scoreB, round.uid " +
"FROM round " +
"LEFT JOIN game ON game.uid = round.gameId " +
"WHERE game.active == 1"
)
fun getForActiveGame(): Flow<List<Round>>
} }

View File

@@ -0,0 +1,15 @@
package me.zobrist.tichucounter.data.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.util.Date
@Entity
data class Game(
var active: Boolean = true,
var nameA: String = "TeamA",
var nameB: String = "TeamB",
val created: Date = Date(),
var modified: Date = Date(),
@PrimaryKey(autoGenerate = true) override val uid: Long = 0
) : IEntity

View File

@@ -0,0 +1,5 @@
package me.zobrist.tichucounter.data.entity
interface IEntity {
val uid: Long
}

View File

@@ -0,0 +1,12 @@
package me.zobrist.tichucounter.data.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity
data class Round(
var gameId: Long,
var scoreA: Int,
var scoreB: Int,
@PrimaryKey(autoGenerate = true) override val uid: Long = 0
) : IEntity

View File

@@ -0,0 +1,5 @@
package me.zobrist.tichucounter.domain
import androidx.compose.ui.graphics.vector.ImageVector
data class DrawerItem(val route: Route, val menuIcon: ImageVector, val menuName: String)

View File

@@ -0,0 +1,14 @@
package me.zobrist.tichucounter.domain
import me.zobrist.tichucounter.data.GameWithScores
fun GameWithScores.getTotalPoints(): Pair<Int, Int> {
var scoreA = 0
var scoreB = 0
this.rounds.forEach {
scoreA += it.scoreA
scoreB += it.scoreB
}
return Pair(scoreA, scoreB)
}

View File

@@ -1,7 +1,5 @@
package me.zobrist.tichucounter.domain package me.zobrist.tichucounter.domain
import kotlin.math.abs
fun Int.isMultipleOf5(): Boolean { fun Int.isMultipleOf5(): Boolean {
return (this % 5) == 0 return (this % 5) == 0
@@ -11,6 +9,3 @@ fun Int.isMultipleOf100(): Boolean {
return (this % 100) == 0 return (this % 100) == 0
} }
fun Int.getAbsoluteDifference(other: Int): Int {
return abs(this - other)
}

View File

@@ -0,0 +1,19 @@
package me.zobrist.tichucounter.domain
import androidx.navigation.NavController
fun NavController.navigate(route: Route) {
this.navigate(route.name) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
// on the back stack as users select items
popUpTo(Route.COUNTER.name) {
saveState = true
}
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
}
}

View File

@@ -1,3 +0,0 @@
package me.zobrist.tichucounter.domain
class NavigationAction(val aciton: () -> Unit)

View File

@@ -0,0 +1,55 @@
package me.zobrist.tichucounter.domain
import android.app.Activity
import android.content.Context
import androidx.preference.PreferenceManager
import com.google.android.play.core.review.ReviewManagerFactory
import dagger.hilt.android.qualifiers.ActivityContext
import java.util.Date
import javax.inject.Inject
class ReviewService @Inject constructor(@ActivityContext private val appContext: Context) {
private val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(appContext)
private val THREE_MONTHS: Long = 7776000000
private var requestCalled: Int
get() = sharedPreferences.getInt("requestCalled", 0)
set(value) {
val editor = sharedPreferences.edit()
editor.putInt("requestCalled", value)
editor.apply()
}
private var nextReviewedDate: Date
get() = Date(sharedPreferences.getLong("lastReviewedDate", 0))
set(value) {
val editor = sharedPreferences.edit()
editor.putLong("lastReviewedDate", value.time)
editor.apply()
}
fun request() {
requestCalled += 1
if (requestCalled >= 3) {
if (nextReviewedDate.time < System.currentTimeMillis()) {
requestCalled = 0
nextReviewedDate = Date(System.currentTimeMillis() + THREE_MONTHS)
val manager = ReviewManagerFactory.create(appContext)
val request = manager.requestReviewFlow()
request.addOnCompleteListener { task ->
if (task.isSuccessful) {
// We got the ReviewInfo object
val reviewInfo = task.result
manager.launchReviewFlow(appContext as Activity, reviewInfo)
}
}
}
}
}
}

View File

@@ -0,0 +1,3 @@
package me.zobrist.tichucounter.domain
enum class Route { COUNTER, HISTORY, SETTINGS, ABOUT }

View File

@@ -1,65 +1,112 @@
package me.zobrist.tichucounter.domain package me.zobrist.tichucounter.domain
import android.content.Context import android.content.Context
import androidx.appcompat.app.AppCompatDelegate.getApplicationLocales import androidx.core.os.LocaleListCompat
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
enum class Theme { DEFAULT, DARK, LIGHT } enum class Theme { DEFAULT, DARK, LIGHT }
enum class Language(val value: String) { ENGLISH("en"), GERMAN("de") } enum class Language(val value: LocaleListCompat) {
DEFAULT(LocaleListCompat.getEmptyLocaleList()),
ENGLISH(LocaleListCompat.forLanguageTags("en")),
GERMAN(LocaleListCompat.forLanguageTags("de"))
}
enum class KeepScreenOn(val value: Boolean) { ON(true), OFF(false) }
typealias VictoryPoints = Int
typealias GameWon = Boolean
@Singleton
class SettingsAdapter @Inject constructor(@ApplicationContext private val context: Context) { class SettingsAdapter @Inject constructor(@ApplicationContext private val context: Context) {
private val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) private val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
val language: Language val language = MutableStateFlow(Language.DEFAULT)
get() {
var setting = sharedPreferences.getString(Language::class.simpleName, null) val theme = MutableStateFlow(Theme.DEFAULT)
if (setting == null) {
setCurrentLanguage() val keepScreenOn = MutableStateFlow(KeepScreenOn.OFF)
setting =
sharedPreferences.getString(Language::class.simpleName, Language.ENGLISH.name) val victoryPoints = MutableStateFlow(0)
val gameFinished = MutableStateFlow(false)
init {
language.value = try {
enumValueOf(sharedPreferences.getString(Language::class.simpleName, null)!!)
} catch (_: NullPointerException) {
Language.DEFAULT
}
theme.value = try {
enumValueOf(sharedPreferences.getString(Theme::class.simpleName, null)!!)
} catch (_: java.lang.Exception) {
Theme.DEFAULT
}
keepScreenOn.value = try {
enumValueOf(sharedPreferences.getString(KeepScreenOn::class.simpleName, null)!!)
} catch (_: java.lang.Exception) {
KeepScreenOn.OFF
}
victoryPoints.value = sharedPreferences.getInt(VictoryPoints::class.simpleName, 1000)
gameFinished.value = sharedPreferences.getBoolean(GameWon::class.simpleName, false)
CoroutineScope(Dispatchers.IO).launch {
language.collect {
updatePreference(Language::class.simpleName, it.name)
} }
return enumValueOf(setting!!)
} }
val theme: Theme CoroutineScope(Dispatchers.IO).launch {
get() { theme.collect {
val setting = sharedPreferences.getString(Theme::class.simpleName, Theme.DEFAULT.name) updatePreference(Theme::class.simpleName, it.name)
return enumValueOf(setting!!) }
} }
val keepScreenOn: Boolean CoroutineScope(Dispatchers.IO).launch {
get() { keepScreenOn.collect {
return sharedPreferences.getBoolean("keep_screen_on", false) updatePreference(KeepScreenOn::class.simpleName, it.name)
}
} }
private fun setCurrentLanguage() { CoroutineScope(Dispatchers.IO).launch {
var setting = when (getApplicationLocales()[0].toString()) { victoryPoints.collect {
"de" -> Language.GERMAN updatePreference(VictoryPoints::class.simpleName, it)
else -> Language.ENGLISH }
}
CoroutineScope(Dispatchers.IO).launch {
gameFinished.collect {
updatePreference(GameWon::class.simpleName, it)
}
} }
setLanguage(setting)
} }
fun setLanguage(language: Language) { private fun updatePreference(name: String?, value: String) {
val editor = sharedPreferences.edit() val editor = sharedPreferences.edit()
editor.putString(Language::class.simpleName, language.name) editor.putString(name, value)
editor.commit() editor.apply()
} }
fun setTheme(theme: Theme) { private fun updatePreference(name: String?, value: Boolean) {
val editor = sharedPreferences.edit() val editor = sharedPreferences.edit()
editor.putString(Theme::class.simpleName, theme.name) editor.putBoolean(name, value)
editor.commit() editor.apply()
} }
fun setKeepScreenOn(setting: Boolean) { private fun updatePreference(name: String?, value: Int) {
val editor = sharedPreferences.edit() val editor = sharedPreferences.edit()
editor.putBoolean("keep_screen_on", setting) editor.putInt(name, value)
editor.commit() editor.apply()
} }
} }

View File

@@ -0,0 +1,12 @@
package me.zobrist.tichucounter.domain
fun String.digitCount(): Int {
var count = 0
this.forEach {
if (it.isDigit()) {
count++
}
}
return count
}

View File

@@ -5,9 +5,6 @@ import javax.inject.Inject
class Tichu @Inject constructor() { class Tichu @Inject constructor() {
fun calculateOtherScore(score: Int): Int? { fun calculateOtherScore(score: Int): Int? {
if (score == null) {
return null
}
if (!score.isMultipleOf5()) { if (!score.isMultipleOf5()) {
return null return null
} }
@@ -24,6 +21,6 @@ class Tichu @Inject constructor() {
if (scoreA == null || scoreB == null) { if (scoreA == null || scoreB == null) {
return false return false
} }
return (scoreA!!.isMultipleOf5()) && scoreB!!.isMultipleOf5() && (scoreA!! + scoreB!!).isMultipleOf100() return (scoreA.isMultipleOf5()) && scoreB.isMultipleOf5() && (scoreA + scoreB).isMultipleOf100()
} }
} }

View File

@@ -1,5 +1,11 @@
package me.zobrist.tichucounter.domain package me.zobrist.tichucounter.domain
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
class TopBarAction(val imageVector: ImageVector, val isActive: Boolean, val action: () -> Unit) class TopBarAction(
val imageVector: ImageVector,
val isActive: Boolean,
val action: () -> Unit,
val composeCode: @Composable () -> Unit = {}
)

View File

@@ -0,0 +1,12 @@
package me.zobrist.tichucounter.domain
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Menu
import androidx.compose.ui.graphics.vector.ImageVector
data class TopBarState(
var title: String = "",
var icon: ImageVector = Icons.Outlined.Menu,
var actions: List<TopBarAction> = emptyList(),
var onNavigate: () -> Unit = {}
)

View File

@@ -1,55 +0,0 @@
package me.zobrist.tichucounter.framework
import android.util.Log
import androidx.annotation.MainThread
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import java.util.concurrent.atomic.AtomicBoolean
/**
* A lifecycle-aware observable that sends only new updates after subscription, used for events like
* navigation and Snackbar messages.
*
*
* This avoids a common problem with events: on configuration change (like rotation) an update
* can be emitted if the observer is active. This LiveData only calls the observable if there's an
* explicit call to setValue() or call().
*
*
* Note that only one observer is going to be notified of changes.
*/
class SingleLiveEvent<T> : MutableLiveData<T>() {
private val pending = AtomicBoolean(false)
@MainThread
override fun observe(owner: LifecycleOwner, observer: Observer<in T>) {
if (hasActiveObservers()) {
Log.w(TAG, "Multiple observers registered but only one will be notified of changes.")
}
// Observe the internal MutableLiveData
super.observe(owner) { t ->
if (pending.compareAndSet(true, false)) {
observer.onChanged(t)
}
}
}
@MainThread
override fun setValue(t: T?) {
pending.set(true)
super.setValue(t)
}
/**
* Used for cases where T is Void, to make calls cleaner.
*/
@MainThread
fun call() {
value = null
}
companion object {
private const val TAG = "me.zobrist.tichucounter.framework.SingleLiveEvent"
}
}

View File

@@ -2,13 +2,19 @@ package me.zobrist.tichucounter.repository
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import me.zobrist.tichucounter.data.Game
import me.zobrist.tichucounter.data.GameDao import me.zobrist.tichucounter.data.GameDao
import me.zobrist.tichucounter.data.Round import me.zobrist.tichucounter.data.GameWithScores
import me.zobrist.tichucounter.data.RoundDao import me.zobrist.tichucounter.data.RoundDao
import java.util.* import me.zobrist.tichucounter.data.entity.Game
import me.zobrist.tichucounter.data.entity.Round
import java.util.Date
import javax.inject.Inject import javax.inject.Inject
class GameRepository @Inject constructor( class GameRepository @Inject constructor(
@@ -16,20 +22,19 @@ class GameRepository @Inject constructor(
private val roundDao: RoundDao private val roundDao: RoundDao
) { ) {
private var _activeGame: Game? = null private var activeGame: Game = Game(true, "TeamA", "TeamB", Date(), Date())
val activeGame: Game private val newGameFlow = MutableStateFlow(Game())
get() {
return _activeGame!! private var deletedGame: GameWithScores? = null
}
init { init {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
gameDao.getActive().collect { gameDao.getActiveAsFlow().collect {
if (it == null) { if (it == null) {
gameDao.insert(Game(true, "TeamA", "TeamB", Date(), Date())) newGame()
} else { } else {
_activeGame = it activeGame = it
} }
} }
} }
@@ -37,20 +42,30 @@ class GameRepository @Inject constructor(
suspend fun newGame() { suspend fun newGame() {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val id = val id = gameDao.insert(Game(true, activeGame.nameA, activeGame.nameB, Date(), Date()))
gameDao.insert(Game(true, activeGame.nameA, activeGame.nameB, Date(), Date()))
setActive(id) setActive(id)
newGameFlow.value = gameDao.getGameById(id)
} }
} }
suspend fun updateGame(game: Game) { suspend fun updateActiveTeamName(nameA: String? = null, nameB: String? = null) {
game.modified = Date()
val newA = nameA ?: activeGame.nameA
val newB = nameB ?: activeGame.nameB
if (newA == activeGame.nameA && newB == activeGame.nameB) {
return
}
activeGame.modified = Date()
activeGame.nameA = newA
activeGame.nameB = newB
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
gameDao.update(game) gameDao.update(activeGame)
} }
} }
private suspend fun setActive(id: Long) { suspend fun setActive(id: Long) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
gameDao.setActive(id) gameDao.setActive(id)
gameDao.setOthersInactive(id) gameDao.setOthersInactive(id)
@@ -80,9 +95,72 @@ class GameRepository @Inject constructor(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val active = activeGame val active = activeGame
active.modified = Date() active.modified = Date()
val round = Round(active.uid!!, scoreA, scoreB) val round = Round(active.uid, scoreA, scoreB)
roundDao.insert(round) roundDao.insert(round)
gameDao.update(active) gameDao.update(active)
} }
} }
suspend fun deleteGame(uid: Long) {
withContext(Dispatchers.IO) {
try {
val game = gameDao.getGameById(uid)
val rounds = roundDao.getAllForGame(game.uid)
deletedGame = GameWithScores(game, rounds)
gameDao.delete(game)
roundDao.delete(rounds)
} catch (_: NullPointerException) {
}
}
}
suspend fun restoreLastDeletedGame() {
if (deletedGame == null) {
return
}
val revert = deletedGame!!
deletedGame = null
withContext(Dispatchers.IO) {
gameDao.insert(revert.game)
revert.rounds.forEach {
roundDao.insert(it)
}
}
}
suspend fun deleteAllInactive() {
withContext(Dispatchers.IO) {
try {
gameDao.getAll().take(1).collect { games ->
val gamesToDelete = games.filter { it.uid != activeGame.uid }
val roundsToDelete = roundDao.getAll().filter { it.gameId != activeGame.uid }
gameDao.delete(gamesToDelete)
roundDao.delete(roundsToDelete)
}
} catch (_: NullPointerException) {
}
}
}
fun getActiveGameFlow(): Flow<GameWithScores> {
return gameDao.getActiveWithRounds().filter { it != null }.map { it!! }
}
fun getAllWithRoundFlow(): Flow<List<GameWithScores>> {
return gameDao.getGamesWithRounds()
}
fun getDistinctTeamNames(): Flow<List<String>> {
return gameDao.getDistinctTeamNames()
}
fun getNewGameStarted(): Flow<Game> {
return newGameFlow
}
} }

View File

@@ -1,19 +1,21 @@
@file:Suppress("unused")
package me.zobrist.tichucounter.ui package me.zobrist.tichucounter.ui
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
val md_theme_light_primary = Color(0xFFBE0034) val md_theme_light_primary = Color(0xFF9C404D)
val md_theme_light_onPrimary = Color(0xFFFFFFFF) val md_theme_light_onPrimary = Color(0xFFFFFFFF)
val md_theme_light_primaryContainer = Color(0xFFFFDADA) val md_theme_light_primaryContainer = Color(0xFFFFDADB)
val md_theme_light_onPrimaryContainer = Color(0xFF40000B) val md_theme_light_onPrimaryContainer = Color(0xFF40000F)
val md_theme_light_secondary = Color(0xFF6E5D00) val md_theme_light_secondary = Color(0xFF765659)
val md_theme_light_onSecondary = Color(0xFFFFFFFF) val md_theme_light_onSecondary = Color(0xFFFFFFFF)
val md_theme_light_secondaryContainer = Color(0xFFFFE261) val md_theme_light_secondaryContainer = Color(0xFFFFDADB)
val md_theme_light_onSecondaryContainer = Color(0xFF221B00) val md_theme_light_onSecondaryContainer = Color(0xFF2C1517)
val md_theme_light_tertiary = Color(0xFF76592F) val md_theme_light_tertiary = Color(0xFF775930)
val md_theme_light_onTertiary = Color(0xFFFFFFFF) val md_theme_light_onTertiary = Color(0xFFFFFFFF)
val md_theme_light_tertiaryContainer = Color(0xFFFFDDB1) val md_theme_light_tertiaryContainer = Color(0xFFFFDDB5)
val md_theme_light_onTertiaryContainer = Color(0xFF291800) val md_theme_light_onTertiaryContainer = Color(0xFF2A1800)
val md_theme_light_error = Color(0xFFBA1A1A) val md_theme_light_error = Color(0xFFBA1A1A)
val md_theme_light_errorContainer = Color(0xFFFFDAD6) val md_theme_light_errorContainer = Color(0xFFFFDAD6)
val md_theme_light_onError = Color(0xFFFFFFFF) val md_theme_light_onError = Color(0xFFFFFFFF)
@@ -22,29 +24,29 @@ val md_theme_light_background = Color(0xFFFFFBFF)
val md_theme_light_onBackground = Color(0xFF201A1A) val md_theme_light_onBackground = Color(0xFF201A1A)
val md_theme_light_surface = Color(0xFFFFFBFF) val md_theme_light_surface = Color(0xFFFFFBFF)
val md_theme_light_onSurface = Color(0xFF201A1A) val md_theme_light_onSurface = Color(0xFF201A1A)
val md_theme_light_surfaceVariant = Color(0xFFF4DDDD) val md_theme_light_surfaceVariant = Color(0xFFF4DDDE)
val md_theme_light_onSurfaceVariant = Color(0xFF524343) val md_theme_light_onSurfaceVariant = Color(0xFF524344)
val md_theme_light_outline = Color(0xFF857373) val md_theme_light_outline = Color(0xFF857374)
val md_theme_light_inverseOnSurface = Color(0xFFFBEEED) val md_theme_light_inverseOnSurface = Color(0xFFFBEEEE)
val md_theme_light_inverseSurface = Color(0xFF362F2F) val md_theme_light_inverseSurface = Color(0xFF362F2F)
val md_theme_light_inversePrimary = Color(0xFFFFB3B5) val md_theme_light_inversePrimary = Color(0xFFFFB2B9)
val md_theme_light_shadow = Color(0xFF000000) val md_theme_light_shadow = Color(0xFF000000)
val md_theme_light_surfaceTint = Color(0xFFBE0034) val md_theme_light_surfaceTint = Color(0xFF9C404D)
val md_theme_light_outlineVariant = Color(0xFFD7C1C1) val md_theme_light_outlineVariant = Color(0xFFD7C1C2)
val md_theme_light_scrim = Color(0xFF000000) val md_theme_light_scrim = Color(0xFF000000)
val md_theme_dark_primary = Color(0xFFFFB3B5) val md_theme_dark_primary = Color(0xFFFFB2B9)
val md_theme_dark_onPrimary = Color(0xFF680018) val md_theme_dark_onPrimary = Color(0xFF5F1222)
val md_theme_dark_primaryContainer = Color(0xFF920026) val md_theme_dark_primaryContainer = Color(0xFF7D2937)
val md_theme_dark_onPrimaryContainer = Color(0xFFFFDADA) val md_theme_dark_onPrimaryContainer = Color(0xFFFFDADB)
val md_theme_dark_secondary = Color(0xFFE6C500) val md_theme_dark_secondary = Color(0xFFE5BDBF)
val md_theme_dark_onSecondary = Color(0xFF3A3000) val md_theme_dark_onSecondary = Color(0xFF44292C)
val md_theme_dark_secondaryContainer = Color(0xFF534600) val md_theme_dark_secondaryContainer = Color(0xFF5C3F41)
val md_theme_dark_onSecondaryContainer = Color(0xFFFFE261) val md_theme_dark_onSecondaryContainer = Color(0xFFFFDADB)
val md_theme_dark_tertiary = Color(0xFFE6C18D) val md_theme_dark_tertiary = Color(0xFFE8C08E)
val md_theme_dark_onTertiary = Color(0xFF422C05) val md_theme_dark_onTertiary = Color(0xFF442B06)
val md_theme_dark_tertiaryContainer = Color(0xFF5C421A) val md_theme_dark_tertiaryContainer = Color(0xFF5D411B)
val md_theme_dark_onTertiaryContainer = Color(0xFFFFDDB1) val md_theme_dark_onTertiaryContainer = Color(0xFFFFDDB5)
val md_theme_dark_error = Color(0xFFFFB4AB) val md_theme_dark_error = Color(0xFFFFB4AB)
val md_theme_dark_errorContainer = Color(0xFF93000A) val md_theme_dark_errorContainer = Color(0xFF93000A)
val md_theme_dark_onError = Color(0xFF690005) val md_theme_dark_onError = Color(0xFF690005)
@@ -53,16 +55,16 @@ val md_theme_dark_background = Color(0xFF201A1A)
val md_theme_dark_onBackground = Color(0xFFECE0DF) val md_theme_dark_onBackground = Color(0xFFECE0DF)
val md_theme_dark_surface = Color(0xFF201A1A) val md_theme_dark_surface = Color(0xFF201A1A)
val md_theme_dark_onSurface = Color(0xFFECE0DF) val md_theme_dark_onSurface = Color(0xFFECE0DF)
val md_theme_dark_surfaceVariant = Color(0xFF524343) val md_theme_dark_surfaceVariant = Color(0xFF524344)
val md_theme_dark_onSurfaceVariant = Color(0xFFD7C1C1) val md_theme_dark_onSurfaceVariant = Color(0xFFD7C1C2)
val md_theme_dark_outline = Color(0xFF9F8C8C) val md_theme_dark_outline = Color(0xFF9F8C8D)
val md_theme_dark_inverseOnSurface = Color(0xFF201A1A) val md_theme_dark_inverseOnSurface = Color(0xFF201A1A)
val md_theme_dark_inverseSurface = Color(0xFFECE0DF) val md_theme_dark_inverseSurface = Color(0xFFECE0DF)
val md_theme_dark_inversePrimary = Color(0xFFBE0034) val md_theme_dark_inversePrimary = Color(0xFF9C404D)
val md_theme_dark_shadow = Color(0xFF000000) val md_theme_dark_shadow = Color(0xFF000000)
val md_theme_dark_surfaceTint = Color(0xFFFFB3B5) val md_theme_dark_surfaceTint = Color(0xFFFFB2B9)
val md_theme_dark_outlineVariant = Color(0xFF524343) val md_theme_dark_outlineVariant = Color(0xFF524344)
val md_theme_dark_scrim = Color(0xFF000000) val md_theme_dark_scrim = Color(0xFF000000)
val seed = Color(0xFFED0043) val seed = Color(0xFF833842)

View File

@@ -1,7 +1,5 @@
package me.zobrist.tichucounter.ui package me.zobrist.tichucounter.ui
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -10,48 +8,56 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.zobrist.tichucounter.data.Round import me.zobrist.tichucounter.data.entity.Game
import me.zobrist.tichucounter.data.RoundDao import me.zobrist.tichucounter.data.entity.Round
import me.zobrist.tichucounter.domain.NavigationAction
import me.zobrist.tichucounter.domain.TopBarAction
import me.zobrist.tichucounter.repository.GameRepository import me.zobrist.tichucounter.repository.GameRepository
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class MainViewModel @Inject constructor( class MainViewModel @Inject constructor(
private val gameRepository: GameRepository, private val gameRepository: GameRepository
roundDao: RoundDao
) : ViewModel() { ) : ViewModel() {
private var redoRounds = mutableStateListOf<Round>() private var redoRounds = mutableStateListOf<Round>()
private var expectedRoundCount = 0 private var expectedRoundCount = 0
var topBarTitle by mutableStateOf("")
var topBarActions by mutableStateOf(emptyList<TopBarAction>())
var topBarIcon by mutableStateOf(Icons.Filled.Menu)
var isUndoActionActive by mutableStateOf(false) var isUndoActionActive by mutableStateOf(false)
var topBarNavigationAction by mutableStateOf(NavigationAction {})
val isRedoActionActive: Boolean val isRedoActionActive: Boolean
get() = redoRounds.isNotEmpty() get() = redoRounds.isNotEmpty()
var activeGameHasRounds by mutableStateOf(false)
private set
private var newGame: Game? = null
init { init {
viewModelScope.launch { viewModelScope.launch {
roundDao.getForActiveGame().collect() {
isUndoActionActive = it.isNotEmpty()
if (expectedRoundCount != it.count()) { gameRepository.getActiveGameFlow().collect {
activeGameHasRounds = it.rounds.isNotEmpty() == true
isUndoActionActive = it.rounds.isNotEmpty()
if (expectedRoundCount != it.rounds.count()) {
redoRounds.clear() redoRounds.clear()
} }
expectedRoundCount = it.count() expectedRoundCount = it.rounds.count()
} }
} }
}
viewModelScope.launch {
fun onNavigateClicked() { gameRepository.getNewGameStarted().collect {
topBarNavigationAction.aciton() if (newGame == null) {
newGame = it
return@collect
}
redoRounds.clear()
}
}
} }
fun undoLastRound() { fun undoLastRound() {
@@ -76,15 +82,4 @@ class MainViewModel @Inject constructor(
} }
} }
} }
fun clearRedoList() {
redoRounds.clear()
}
fun newGame() {
viewModelScope.launch {
redoRounds.clear()
gameRepository.newGame()
}
}
} }

View File

@@ -2,7 +2,11 @@ package me.zobrist.tichucounter.ui
import android.os.Build import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.* import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -75,7 +79,7 @@ private val DarkColors = darkColorScheme(
@Composable @Composable
fun AppTheme( fun AppTheme(
useDarkTheme: Boolean = isSystemInDarkTheme(), useDarkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable() () -> Unit content: @Composable () -> Unit
) { ) {
val dynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S val dynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S

View File

@@ -0,0 +1,109 @@
package me.zobrist.tichucounter.ui.about
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Mail
import androidx.compose.material.icons.outlined.Shop
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ShapeDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment.Companion.Top
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import me.zobrist.tichucounter.BuildConfig
import me.zobrist.tichucounter.R
import me.zobrist.tichucounter.ui.AppTheme
@Composable
fun AboutView() {
val uriHandler = LocalUriHandler.current
Column(
modifier = Modifier
.padding(
top = 20.dp,
start = 20.dp,
end = 20.dp,
bottom = 40.dp
),
) {
Row {
Image(
modifier = Modifier
.padding(end = 10.dp)
.align(Top)
.size(75.dp)
.background(
color = Color.Red,
shape = ShapeDefaults.Medium
),
painter = painterResource(R.drawable.tichu_logo),
contentDescription = null,
contentScale = ContentScale.None
)
Column {
Text(
text = stringResource(id = R.string.app_name),
style = MaterialTheme.typography.headlineMedium
)
Text(text = "V" + BuildConfig.VERSION_NAME)
}
}
Button(
modifier = Modifier
.fillMaxWidth()
.padding(top = 30.dp),
onClick = { uriHandler.openUri("market://details?id=me.zobrist.tichucounter") }
) {
Icon(imageVector = Icons.Outlined.Shop, contentDescription = null)
Text(stringResource(id = R.string.play_store))
}
Button(
modifier = Modifier
.fillMaxWidth()
.padding(top = 30.dp),
onClick = { uriHandler.openUri("mailto:app@zobrist.me") }
) {
Icon(imageVector = Icons.Outlined.Mail, contentDescription = null)
Text(stringResource(id = R.string.contact_us))
}
}
}
@Preview(name = "Light Mode")
@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true)
@Composable
fun AboutViewPreview() {
AppTheme {
Surface {
AboutView()
}
}
}

View File

@@ -0,0 +1,36 @@
package me.zobrist.tichucounter.ui.composables
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@Composable
fun <T> DropDownMenu(
list: Collection<T>,
selected: T,
expanded: Boolean,
onSelected: (T?) -> Unit
) {
DropdownMenu(
expanded = expanded,
onDismissRequest = { onSelected(null) }
) {
list.forEach {
DropdownMenuItem(
onClick = {
onSelected(it)
},
trailingIcon = {
if (it == selected) {
Icon(Icons.Outlined.Check, null)
}
},
text = { Text(it.toString()) },
)
}
}
}

View File

@@ -0,0 +1,100 @@
package me.zobrist.tichucounter.ui.composables
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TypeaheadTextField(
value: String,
items: List<String>,
onValueChange: (String) -> Unit,
modifier: Modifier,
colors: TextFieldColors,
textStyle: TextStyle
) {
var isFocused by remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
ExposedDropdownMenuBox(
expanded = isFocused,
modifier = modifier,
onExpandedChange = {}
) {
var dropDownWidth by remember { mutableIntStateOf(0) }
TextField(
value = value,
textStyle = textStyle,
onValueChange = {
onValueChange(it)
},
singleLine = true,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
}
),
modifier = Modifier
.menuAnchor()
.onFocusChanged {
isFocused = it.isFocused
}
.onSizeChanged { dropDownWidth = it.width }
.onKeyEvent { event ->
if (event.key == Key.Back || event.key == Key.Enter) {
focusManager.clearFocus()
true
} else {
false
}
},
colors = colors
)
ExposedDropdownMenu(
expanded = isFocused && items.isNotEmpty(),
modifier = Modifier
.width(with(LocalDensity.current) { dropDownWidth.toDp() }),
onDismissRequest = { }
) {
items.forEach {
DropdownMenuItem(
onClick = {
onValueChange(it)
focusManager.clearFocus()
},
text = { Text(it) },
)
}
}
}
}

View File

@@ -3,22 +3,46 @@ package me.zobrist.tichucounter.ui.counter
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.EmojiEvents
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.* import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import me.zobrist.tichucounter.data.Round import me.zobrist.tichucounter.R
import me.zobrist.tichucounter.data.entity.Round
import me.zobrist.tichucounter.ui.AppTheme import me.zobrist.tichucounter.ui.AppTheme
@Composable @Composable
fun Counter(viewModel: ICounterViewModel = PreviewViewModel()) { fun Counter(viewModel: ICounterViewModel = PreviewViewModel()) {
var orientation by remember { mutableStateOf(Configuration.ORIENTATION_PORTRAIT) } var orientation by remember { mutableIntStateOf(Configuration.ORIENTATION_PORTRAIT) }
orientation = LocalConfiguration.current.orientation orientation = LocalConfiguration.current.orientation
if (viewModel.showVictoryDialog) {
GameVictoryDialog(
viewModel.totalScoreA,
viewModel.totalScoreB,
viewModel.teamNameA,
viewModel.teamNameB,
{ viewModel.victoryDialogExecuted(false) })
{
viewModel.victoryDialogExecuted(true)
}
}
Surface { Surface {
if (orientation == Configuration.ORIENTATION_LANDSCAPE) { if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
Landscape(viewModel) Landscape(viewModel)
@@ -35,6 +59,8 @@ fun Landscape(viewModel: ICounterViewModel) {
TeamNamesView( TeamNamesView(
viewModel.teamNameA, viewModel.teamNameA,
viewModel.teamNameB, viewModel.teamNameB,
viewModel.teamNameSuggestionsA,
viewModel.teamNameSuggestionsB,
{ viewModel.updateNameA(it) }, { viewModel.updateNameA(it) },
{ viewModel.updateNameB(it) } { viewModel.updateNameB(it) }
) )
@@ -49,23 +75,12 @@ fun Landscape(viewModel: ICounterViewModel) {
Modifier.weight(1f) Modifier.weight(1f)
) )
} }
if (!viewModel.keyboardHidden) {
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
KeyboardView( KeyBoardView(viewModel = viewModel)
viewModel.currentScoreA, }
viewModel.currentScoreB,
viewModel.requestFocusA,
viewModel.enableSubmit,
{ viewModel.updateFocusStateA(it) },
{ viewModel.updateFocusStateB(it) },
{ viewModel.digitClicked(it) },
{ viewModel.addSub100Clicked(it) },
{ viewModel.deleteClicked() },
{ viewModel.negateClicked() }
) { viewModel.submitClicked() }
} }
} }
} }
@Composable @Composable
@@ -75,6 +90,8 @@ fun Portrait(viewModel: ICounterViewModel) {
TeamNamesView( TeamNamesView(
viewModel.teamNameA, viewModel.teamNameA,
viewModel.teamNameB, viewModel.teamNameB,
viewModel.teamNameSuggestionsA,
viewModel.teamNameSuggestionsB,
{ viewModel.updateNameA(it) }, { viewModel.updateNameA(it) },
{ viewModel.updateNameB(it) } { viewModel.updateNameB(it) }
) )
@@ -89,18 +106,9 @@ fun Portrait(viewModel: ICounterViewModel) {
Modifier.weight(1f) Modifier.weight(1f)
) )
KeyboardView( if (!viewModel.keyboardHidden) {
viewModel.currentScoreA, KeyBoardView(viewModel = viewModel)
viewModel.currentScoreB, }
viewModel.requestFocusA,
viewModel.enableSubmit,
{ viewModel.updateFocusStateA(it) },
{ viewModel.updateFocusStateB(it) },
{ viewModel.digitClicked(it) },
{ viewModel.addSub100Clicked(it) },
{ viewModel.deleteClicked() },
{ viewModel.negateClicked() }
) { viewModel.submitClicked() }
} }
} }
@@ -114,6 +122,54 @@ fun CounterViewPreview() {
} }
} }
@Preview
@Composable
fun GameVictoryDialog(
pointsA: Int = 2000,
pointsB: Int = 50,
nameA: String = "nameA",
nameB: String = "nameB",
onDismiss: () -> Unit = {},
onNewGame: () -> Unit = {},
) {
val winner = if (pointsA > pointsB) {
nameA
} else {
nameB
}
val message = if (pointsA == pointsB) {
stringResource(R.string.draw_message, winner, 100)
} else {
stringResource(R.string.victory_message)
}
val title = if (pointsA == pointsB) {
stringResource(R.string.draw_title)
} else {
stringResource(R.string.victory_title, winner)
}
AlertDialog(
onDismissRequest = { onDismiss() },
dismissButton = {
TextButton({ onDismiss() }) {
Text(stringResource(R.string.abort))
}
},
confirmButton = {
TextButton({ onNewGame() }) {
Text(stringResource(R.string.new_game))
}
},
icon = { Icon(Icons.Outlined.EmojiEvents, null) },
title = { Text(title) },
text = { Text(message) }
)
}
internal class PreviewViewModel : ICounterViewModel { internal class PreviewViewModel : ICounterViewModel {
override var roundScoreList: List<Round> = override var roundScoreList: List<Round> =
listOf(Round(1, 10, 90), Round(1, 50, 50), Round(1, 70, 30)) listOf(Round(1, 10, 90), Round(1, 50, 50), Round(1, 70, 30))
@@ -123,14 +179,21 @@ internal class PreviewViewModel : ICounterViewModel {
override var teamNameB: String = "Team B" override var teamNameB: String = "Team B"
override var currentScoreA: String = "" override var currentScoreA: String = ""
override var currentScoreB: String = "45" override var currentScoreB: String = "45"
override var enableSubmit: Boolean = false override var isValidRound: Boolean = false
override var isAFocused: Boolean = false override var isAFocused: Boolean = false
override var isBFocused: Boolean = false override var isBFocused: Boolean = false
override var requestFocusA: FocusRequester = FocusRequester() override var requestFocusA: FocusRequester = FocusRequester()
override var requestFocusB: FocusRequester = FocusRequester()
override var activeValue: String = currentScoreA override var activeValue: String = currentScoreA
override var inactiveValue: String = currentScoreB override var inactiveValue: String = currentScoreB
override var keyboardHidden: Boolean = false
override val teamNameSuggestionsA: List<String> =
listOf("TeamA", "asdffd", "TeamB", "really really long Team Name that is way too long")
override val teamNameSuggestionsB: List<String> =
listOf("TeamA", "asdffd", "TeamB", "really really long Team Name that is way too long")
override var showVictoryDialog: Boolean = false
override fun giveFocusToAIfNone() { override fun focusLastInput() {
} }
override fun updateOtherScore() { override fun updateOtherScore() {
@@ -155,19 +218,31 @@ internal class PreviewViewModel : ICounterViewModel {
override fun addSub100Clicked(toAdd: Int) { override fun addSub100Clicked(toAdd: Int) {
} }
override fun deleteClicked() {
}
override fun updateNameA(value: String) { override fun updateNameA(value: String) {
} }
override fun updateNameB(value: String) { override fun updateNameB(value: String) {
} }
override fun victoryDialogExecuted(result: Boolean) {
}
override fun updateFocusStateA(state: Boolean) { override fun updateFocusStateA(state: Boolean) {
} }
override fun updateFocusStateB(state: Boolean) { override fun updateFocusStateB(state: Boolean) {
} }
override fun swapInputScores() {
}
override fun hideKeyboard() {
}
override fun showKeyboard() {
}
override fun deleteState(pressed: Boolean) {
}
} }

View File

@@ -1,36 +1,41 @@
package me.zobrist.tichucounter.ui.counter package me.zobrist.tichucounter.ui.counter
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.zobrist.tichucounter.data.GameDao import me.zobrist.tichucounter.data.entity.Game
import me.zobrist.tichucounter.data.Round import me.zobrist.tichucounter.data.entity.Round
import me.zobrist.tichucounter.data.RoundDao import me.zobrist.tichucounter.domain.SettingsAdapter
import me.zobrist.tichucounter.domain.Tichu import me.zobrist.tichucounter.domain.Tichu
import me.zobrist.tichucounter.domain.digitCount
import me.zobrist.tichucounter.domain.getTotalPoints
import me.zobrist.tichucounter.repository.GameRepository import me.zobrist.tichucounter.repository.GameRepository
import javax.inject.Inject import javax.inject.Inject
interface ICounterViewModel { private enum class Focused { TEAM_A, TEAM_B }
var roundScoreList: List<Round>
var totalScoreA: Int
var totalScoreB: Int
var teamNameA: String
var teamNameB: String
var currentScoreA: String
var currentScoreB: String
var enableSubmit: Boolean
var isAFocused: Boolean
var isBFocused: Boolean
var requestFocusA: FocusRequester
var activeValue: String
var inactiveValue: String
fun giveFocusToAIfNone() interface IKeyBoardViewModel {
val currentScoreA: String
val currentScoreB: String
val isValidRound: Boolean
val isAFocused: Boolean
val isBFocused: Boolean
val requestFocusA: FocusRequester
val requestFocusB: FocusRequester
val activeValue: String
val inactiveValue: String
val keyboardHidden: Boolean
fun focusLastInput()
fun updateOtherScore() fun updateOtherScore()
fun isValidTichuRound(): Boolean fun isValidTichuRound(): Boolean
fun updateSubmitButton() fun updateSubmitButton()
@@ -38,42 +43,83 @@ interface ICounterViewModel {
fun digitClicked(digit: String) fun digitClicked(digit: String)
fun negateClicked() fun negateClicked()
fun addSub100Clicked(toAdd: Int) fun addSub100Clicked(toAdd: Int)
fun deleteClicked()
fun updateNameA(value: String)
fun updateNameB(value: String)
fun updateFocusStateA(state: Boolean) fun updateFocusStateA(state: Boolean)
fun updateFocusStateB(state: Boolean) fun updateFocusStateB(state: Boolean)
fun swapInputScores()
fun hideKeyboard()
fun showKeyboard()
fun deleteState(pressed: Boolean)
}
interface ICounterViewModel : IKeyBoardViewModel {
val roundScoreList: List<Round>
val totalScoreA: Int
val totalScoreB: Int
val teamNameA: String
val teamNameB: String
val teamNameSuggestionsA: List<String>
val teamNameSuggestionsB: List<String>
val showVictoryDialog: Boolean
fun updateNameA(value: String)
fun updateNameB(value: String)
fun victoryDialogExecuted(result: Boolean)
} }
@HiltViewModel @HiltViewModel
class CounterViewModel @Inject constructor( class CounterViewModel @Inject constructor(
private val gameRepository: GameRepository, private val gameRepository: GameRepository,
private val roundDao: RoundDao, private val settings: SettingsAdapter
private val gameDao: GameDao
) : ) :
ViewModel(), ICounterViewModel { ViewModel(), ICounterViewModel {
override var roundScoreList by mutableStateOf(emptyList<Round>()) override var roundScoreList by mutableStateOf(emptyList<Round>())
private set
override var totalScoreA by mutableStateOf(0) override var totalScoreA by mutableIntStateOf(0)
private set
override var totalScoreB by mutableStateOf(0) override var totalScoreB by mutableIntStateOf(0)
private set
override var teamNameA by mutableStateOf("") override var teamNameA by mutableStateOf("")
private set
override var teamNameB by mutableStateOf("") override var teamNameB by mutableStateOf("")
private set
override var currentScoreA by mutableStateOf("") override var currentScoreA by mutableStateOf("")
private set
override var currentScoreB by mutableStateOf("") override var currentScoreB by mutableStateOf("")
private set
override var enableSubmit by mutableStateOf(false) override var isValidRound by mutableStateOf(false)
private set
override var isAFocused by mutableStateOf(false) override var isAFocused by mutableStateOf(false)
private set
override var isBFocused by mutableStateOf(false) override var isBFocused by mutableStateOf(false)
private set
override var requestFocusA by mutableStateOf(FocusRequester()) override var requestFocusA by mutableStateOf(FocusRequester())
private set
override var requestFocusB by mutableStateOf(FocusRequester())
private set
override var keyboardHidden by mutableStateOf(false)
private set
override var teamNameSuggestionsA by mutableStateOf(listOf<String>())
private set
override var teamNameSuggestionsB by mutableStateOf(listOf<String>())
private set
override var showVictoryDialog by mutableStateOf(false)
private set
override var activeValue: String override var activeValue: String
get() { get() {
@@ -107,33 +153,90 @@ class CounterViewModel @Inject constructor(
} }
} }
private var lastFocused = Focused.TEAM_A
private var deletePressed = false
private var deleteJob: Job? = null
private var distinctTeamNames = listOf<String>()
private var lastGame: Game? = null
private var lastVictoryPoints: Int? = null
private val gameWon: Boolean
get() = totalScoreA >= settings.victoryPoints.value || totalScoreB >= settings.victoryPoints.value
private var lastRoundCount: Int = 0
init { init {
viewModelScope.launch { viewModelScope.launch {
roundDao.getForActiveGame().collect { gameRepository.getActiveGameFlow().collect {
roundScoreList = it
}
}
viewModelScope.launch {
gameDao.getActive().collect {
if (it != null) { if (it != null) {
teamNameA = it.nameA val score = it.getTotalPoints()
teamNameB = it.nameB
roundScoreList = it.rounds
totalScoreA = score.first
totalScoreB = score.second
teamNameA = it.game.nameA
teamNameB = it.game.nameB
buildTeamNameSuggestions()
// Game has changed
if (it.game.uid != lastGame?.uid) {
if (lastGame != null) {
settings.gameFinished.value = false
}
lastGame = it.game
lastRoundCount = it.rounds.size
return@collect
}
// Game winning condition
if (!settings.gameFinished.value) {
if (gameWon) {
showVictoryDialog = true
}
}
// Undo game winning if rounds were removed
if (lastRoundCount > it.rounds.size) {
if (!gameWon) {
settings.gameFinished.value = false
}
}
lastRoundCount = it.rounds.size
} }
} }
} }
viewModelScope.launch { viewModelScope.launch {
roundDao.getRoundSumForActiveGame().collect { score -> gameRepository.getDistinctTeamNames().collect {
totalScoreA = score.scoreA distinctTeamNames = it
totalScoreB = score.scoreB
buildTeamNameSuggestions()
}
}
viewModelScope.launch {
settings.victoryPoints.collect {
if (lastVictoryPoints == null) {
lastVictoryPoints = it
return@collect
}
// Game was already won and will be won also with new settings
settings.gameFinished.value = settings.gameFinished.value && gameWon
} }
} }
} }
override fun giveFocusToAIfNone() { override fun focusLastInput() {
if (!isAFocused && !isBFocused) { when (lastFocused) {
requestFocusA.requestFocus() Focused.TEAM_A -> if (!isAFocused) requestFocusA.requestFocus()
Focused.TEAM_B -> if (!isBFocused) requestFocusB.requestFocus()
} }
} }
@@ -162,7 +265,7 @@ class CounterViewModel @Inject constructor(
} }
override fun updateSubmitButton() { override fun updateSubmitButton() {
enableSubmit = isValidTichuRound() isValidRound = isValidTichuRound()
} }
override fun submitClicked() { override fun submitClicked() {
@@ -171,19 +274,31 @@ class CounterViewModel @Inject constructor(
} }
currentScoreA = "" currentScoreA = ""
currentScoreB = "" currentScoreB = ""
enableSubmit = false isValidRound = false
} }
override fun digitClicked(digit: String) { override fun digitClicked(digit: String) {
giveFocusToAIfNone() focusLastInput()
if (activeValue.digitCount() >= 5) {
// 5 digits is enough
return
}
val newValue = activeValue + digit
try {
activeValue = newValue.toInt().toString()
} catch (_: NumberFormatException) {
}
activeValue += digit
updateOtherScore() updateOtherScore()
updateSubmitButton() updateSubmitButton()
} }
override fun negateClicked() { override fun negateClicked() {
giveFocusToAIfNone() focusLastInput()
activeValue = if (activeValue.contains("-")) { activeValue = if (activeValue.contains("-")) {
activeValue.replace("-", "") activeValue.replace("-", "")
@@ -195,7 +310,7 @@ class CounterViewModel @Inject constructor(
} }
override fun addSub100Clicked(toAdd: Int) { override fun addSub100Clicked(toAdd: Int) {
giveFocusToAIfNone() focusLastInput()
activeValue = try { activeValue = try {
val temp = activeValue.toInt() + toAdd val temp = activeValue.toInt() + toAdd
@@ -210,7 +325,72 @@ class CounterViewModel @Inject constructor(
updateSubmitButton() updateSubmitButton()
} }
override fun deleteClicked() { override fun updateNameA(value: String) {
teamNameA = value
viewModelScope.launch {
gameRepository.updateActiveTeamName(nameA = value)
}
}
override fun updateNameB(value: String) {
teamNameB = value
viewModelScope.launch {
gameRepository.updateActiveTeamName(nameB = value)
}
}
override fun victoryDialogExecuted(result: Boolean) {
showVictoryDialog = false
settings.gameFinished.value = true
if (result) {
viewModelScope.launch {
gameRepository.newGame()
}
}
}
override fun updateFocusStateA(state: Boolean) {
isAFocused = state
if (state) {
lastFocused = Focused.TEAM_A
}
}
override fun updateFocusStateB(state: Boolean) {
isBFocused = state
if (state) {
lastFocused = Focused.TEAM_B
}
}
override fun swapInputScores() {
val swap = currentScoreA
currentScoreA = currentScoreB
currentScoreB = swap
}
override fun hideKeyboard() {
keyboardHidden = true
}
override fun showKeyboard() {
keyboardHidden = false
}
override fun deleteState(pressed: Boolean) {
deletePressed = pressed
if (deletePressed) {
if (deleteJob?.isActive != true) {
deleteJob = deleteRepeatedlyUntilRelease()
}
} else {
deleteJob?.cancel()
}
}
private fun deleteLastDigitActive() {
if (activeValue != "") { if (activeValue != "") {
activeValue = activeValue.dropLast(1) activeValue = activeValue.dropLast(1)
} }
@@ -218,27 +398,29 @@ class CounterViewModel @Inject constructor(
updateSubmitButton() updateSubmitButton()
} }
override fun updateNameA(value: String) { private fun deleteRepeatedlyUntilRelease(): Job {
viewModelScope.launch { return viewModelScope.launch {
val game = gameRepository.activeGame deleteLastDigitActive()
game.nameA = value delay(500)
gameRepository.updateGame(game) while (deletePressed) {
deleteLastDigitActive()
delay(100)
}
} }
} }
override fun updateNameB(value: String) { private fun buildTeamNameSuggestions() {
viewModelScope.launch { teamNameSuggestionsA = buildTypeaheadList(distinctTeamNames, teamNameA)
val game = gameRepository.activeGame teamNameSuggestionsB = buildTypeaheadList(distinctTeamNames, teamNameB)
game.nameB = value }
gameRepository.updateGame(game)
private fun buildTypeaheadList(rawList: List<String>, currentInput: String): List<String> {
var filtered = rawList.filter { it.isNotEmpty() && it != currentInput }
if (currentInput.isNotEmpty()) {
filtered = filtered.filter { it.contains(currentInput, ignoreCase = true) }
} }
}
override fun updateFocusStateA(state: Boolean) { return filtered.sorted().sortedBy { it.length }.take(10)
isAFocused = state
}
override fun updateFocusStateB(state: Boolean) {
isBFocused = state
} }
} }

View File

@@ -1,72 +1,134 @@
package me.zobrist.tichucounter.ui.counter package me.zobrist.tichucounter.ui.counter
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.foundation.layout.* import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Backspace import androidx.compose.material.icons.automirrored.outlined.Backspace
import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.Check
import androidx.compose.material3.* import androidx.compose.material.icons.outlined.KeyboardHide
import androidx.compose.material.icons.outlined.SwapHoriz
import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.FocusState
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalTextInputService
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import me.zobrist.tichucounter.ui.AppTheme import me.zobrist.tichucounter.ui.AppTheme
@Composable
fun KeyBoardView(viewModel: IKeyBoardViewModel) {
KeyboardView(
viewModel.currentScoreA,
viewModel.currentScoreB,
viewModel.requestFocusA,
viewModel.requestFocusB,
viewModel.isValidRound,
viewModel.isAFocused,
viewModel.isBFocused,
{ viewModel.updateFocusStateA(it) },
{ viewModel.updateFocusStateB(it) },
{ viewModel.digitClicked(it) },
{ viewModel.addSub100Clicked(it) },
{ viewModel.negateClicked() },
{ viewModel.submitClicked() },
{ viewModel.hideKeyboard() },
{ viewModel.swapInputScores() },
{ viewModel.deleteState(it) }
)
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
fun KeyboardView( fun KeyboardView(
scoreA: String, scoreA: String,
scoreB: String, scoreB: String,
requestFocus: FocusRequester, requestFocusA: FocusRequester,
enableSubmit: Boolean, requestFocusB: FocusRequester,
isValidScore: Boolean,
focusStateA: Boolean,
focusStateB: Boolean,
updateFocusStateA: (Boolean) -> Unit, updateFocusStateA: (Boolean) -> Unit,
updateFocusStateB: (Boolean) -> Unit, updateFocusStateB: (Boolean) -> Unit,
digitClicked: (String) -> Unit, digitClicked: (String) -> Unit,
addSub100Clicked: (Int) -> Unit, addSub100Clicked: (Int) -> Unit,
deleteClicked: () -> Unit,
negateClicked: () -> Unit, negateClicked: () -> Unit,
submitClicked: () -> Unit submitClicked: () -> Unit,
hideKeyboardClicked: () -> Unit,
onSwapClicked: () -> Unit,
deleteButtonPressedState: (Boolean) -> Unit
) { ) {
val keyboardController = LocalSoftwareKeyboardController.current
Column { Column {
Row { Row(Modifier.height(IntrinsicSize.Max)) {
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
CenteredTextField( CenteredTextField(
scoreA, scoreA,
"0", "0",
Modifier focusStateA,
.focusRequester(requestFocus) requestFocusA
.onFocusChanged { ) { updateFocusStateA(it.isFocused) }
keyboardController?.hide()
updateFocusStateA(it.isFocused)
}
)
} }
Surface(
Modifier
.wrapContentWidth()
.fillMaxHeight(),
tonalElevation = 3.dp,
shape = MaterialTheme.shapes.extraSmall
) {
Column {
IconButton(onClick = onSwapClicked, enabled = isValidScore) {
Icon(Icons.Outlined.SwapHoriz, null)
}
}
}
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
CenteredTextField( CenteredTextField(
scoreB, scoreB,
"0", "0",
Modifier focusStateB,
.onFocusChanged { requestFocusB
keyboardController?.hide() ) {
updateFocusStateB(it.isFocused) updateFocusStateB(it.isFocused)
} }
)
} }
} }
Row { Row {
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
KeyboardTextButton("1") { KeyboardTextButton("1") {
@@ -129,16 +191,23 @@ fun KeyboardView(
} }
} }
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
KeyboardIconButton(Icons.Outlined.Backspace) {
deleteClicked() val interactionSource = remember { MutableInteractionSource() }
} val deletePressed by interactionSource.collectIsPressedAsState()
deleteButtonPressedState(deletePressed)
KeyboardIconButton(
icon = Icons.AutoMirrored.Outlined.Backspace,
interactionSource = interactionSource
) {}
} }
} }
Row { Row {
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
KeyboardTextButton("+/-") { KeyboardIconButton(Icons.Outlined.KeyboardHide) {
negateClicked() hideKeyboardClicked()
} }
} }
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
@@ -146,8 +215,14 @@ fun KeyboardView(
digitClicked("0") digitClicked("0")
} }
} }
Column(Modifier.weight(2f)) {
KeyboardIconButton(Icons.Outlined.Check, enableSubmit) { Column(Modifier.weight(1f)) {
KeyboardTextButton("+/-") {
negateClicked()
}
}
Column(Modifier.weight(1f)) {
KeyboardIconButton(Icons.Outlined.Check, isValidScore) {
submitClicked() submitClicked()
} }
} }
@@ -157,19 +232,42 @@ fun KeyboardView(
@Composable @Composable
fun KeyboardTextButton(text: String, onClicked: () -> Unit) { fun KeyboardTextButton(text: String, onClicked: () -> Unit) {
val configuration = LocalConfiguration.current
val screenWidth = configuration.screenWidthDp.dp
val style = if (screenWidth < 350.dp) {
MaterialTheme.typography.labelSmall
} else {
MaterialTheme.typography.labelLarge
}
ElevatedButton( ElevatedButton(
modifier = Modifier.fillMaxWidth().height(50.dp).padding(2.dp), modifier = Modifier
.fillMaxWidth()
.height(50.dp)
.padding(2.dp),
onClick = { onClicked() }, onClick = { onClicked() },
) { Text(text) } ) { Text(text, style = style) }
} }
@Composable @Composable
fun KeyboardIconButton(icon: ImageVector, enabled: Boolean = true, onClicked: () -> Unit) { fun KeyboardIconButton(
icon: ImageVector,
enabled: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
onClicked: () -> Unit
) {
ElevatedButton( ElevatedButton(
onClick = { onClicked() }, onClick = { onClicked() },
modifier = Modifier.fillMaxWidth().height(50.dp).padding(2.dp), modifier = Modifier
.fillMaxWidth()
.height(50.dp)
.padding(2.dp),
enabled = enabled, enabled = enabled,
interactionSource = interactionSource
) { ) {
Icon( Icon(
icon, icon,
@@ -178,28 +276,70 @@ fun KeyboardIconButton(icon: ImageVector, enabled: Boolean = true, onClicked: ()
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun CenteredTextField( fun CenteredTextField(
value: String, value: String,
placeholder: String, placeholder: String,
modifier: Modifier, focused: Boolean,
focusRequester: FocusRequester? = null,
onFocusStateChanged: (FocusState) -> Unit
) { ) {
TextField(
value = value, val modifier = if (focusRequester != null) {
onValueChange = { }, Modifier.focusRequester(focusRequester)
placeholder = { } else {
Text( Modifier
placeholder, }
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth() Box(contentAlignment = Alignment.Center) {
CompositionLocalProvider(LocalTextInputService provides null) {
TextField(
value = value,
onValueChange = { },
placeholder = {
if (!focused) {
Text(
placeholder,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
},
textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center),
singleLine = true,
readOnly = true,
modifier = modifier
.fillMaxWidth()
.onFocusChanged {
onFocusStateChanged(it)
}
) )
}, }
textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center),
singleLine = true, if (focused) {
readOnly = true, val cursorColor = MaterialTheme.colorScheme.onSurface
modifier = modifier.fillMaxWidth() val infiniteTransition = rememberInfiniteTransition(label = "blinkingCursor")
) val alpha by infiniteTransition.animateFloat(
0f,
cursorColor.alpha,
animationSpec = infiniteRepeatable(
animation = tween(500),
repeatMode = RepeatMode.Reverse
), label = "blinkingCursor"
)
Row {
Text(text = value, color = cursorColor.copy(alpha = 0f))
HorizontalDivider(
modifier = Modifier
.padding(start = 3.dp, top = 15.dp, bottom = 15.dp)
.width(1.dp)
.fillMaxHeight(),
color = cursorColor.copy(alpha = alpha)
)
}
}
}
} }
@@ -210,17 +350,22 @@ fun KeyboardViewPreview() {
AppTheme { AppTheme {
Surface { Surface {
KeyboardView( KeyboardView(
"", "10",
"350", "190",
FocusRequester(), FocusRequester(),
false, FocusRequester(),
{}, isValidScore = false,
{}, focusStateA = true,
{}, focusStateB = false,
{}, updateFocusStateA = {},
{}, updateFocusStateB = {},
{}, digitClicked = {},
{}) addSub100Clicked = {},
negateClicked = {},
submitClicked = {},
hideKeyboardClicked = {},
onSwapClicked = {},
deleteButtonPressedState = {})
} }
} }
} }

View File

@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -18,7 +17,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.zobrist.tichucounter.data.Round import me.zobrist.tichucounter.data.entity.Round
import me.zobrist.tichucounter.ui.AppTheme import me.zobrist.tichucounter.ui.AppTheme
@Composable @Composable
@@ -28,7 +27,7 @@ fun RoundListView(rounds: List<Round>, modifier: Modifier) {
LazyColumn(state = lazyListState, modifier = modifier) { LazyColumn(state = lazyListState, modifier = modifier) {
itemsIndexed(rounds) { index, item -> itemsIndexed(rounds) { index, item ->
RoundListItem(item, index, lazyListState) RoundListItem(item, index)
} }
scope.launch { scope.launch {
@@ -38,7 +37,7 @@ fun RoundListView(rounds: List<Round>, modifier: Modifier) {
} }
@Composable @Composable
private fun RoundListItem(round: Round, index: Int, lazyListState: LazyListState) { private fun RoundListItem(round: Round, index: Int) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -51,7 +50,7 @@ private fun RoundListItem(round: Round, index: Int, lazyListState: LazyListState
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Text( Text(
text = index.toString(), text = (index + 1).toString(),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
textAlign = TextAlign.Center textAlign = TextAlign.Center

View File

@@ -1,39 +1,57 @@
package me.zobrist.tichucounter.ui.counter package me.zobrist.tichucounter.ui.counter
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Row
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.* import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import me.zobrist.tichucounter.ui.AppTheme import me.zobrist.tichucounter.ui.AppTheme
import me.zobrist.tichucounter.ui.composables.TypeaheadTextField
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun TeamNamesView( fun TeamNamesView(
nameA: String, nameA: String,
nameB: String, nameB: String,
nameSuggestionsA: List<String>,
nameSuggestionsB: List<String>,
updateA: (String) -> Unit, updateA: (String) -> Unit,
updateB: (String) -> Unit updateB: (String) -> Unit
) { ) {
Row() {
TextField( val containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
val color = TextFieldDefaults.colors(
focusedContainerColor = containerColor,
unfocusedContainerColor = containerColor,
disabledContainerColor = containerColor,
)
Row {
TypeaheadTextField(
value = nameA, value = nameA,
textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), items = nameSuggestionsA,
onValueChange = { updateA(it) }, onValueChange = { updateA(it) },
singleLine = true, modifier = Modifier.weight(1f),
modifier = Modifier.weight(1f) colors = color,
textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center)
) )
TextField( TypeaheadTextField(
value = nameB, value = nameB,
textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), items = nameSuggestionsB,
onValueChange = { updateB(it) }, onValueChange = { updateB(it) },
singleLine = true, modifier = Modifier.weight(1f),
modifier = Modifier.weight(1f) colors = color,
textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center)
) )
} }
} }
@@ -42,6 +60,6 @@ fun TeamNamesView(
@Composable @Composable
private fun TeamNamesViewPreview() { private fun TeamNamesViewPreview() {
AppTheme { AppTheme {
TeamNamesView("TeamA", "TeamB", {}, {}) TeamNamesView("TeamA", "TeamB", listOf("Test1", "Test3"), listOf("Test3", "Test5"), {}, {})
} }
} }

View File

@@ -4,6 +4,7 @@ import android.content.res.Configuration
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -19,8 +20,8 @@ import me.zobrist.tichucounter.ui.AppTheme
@Composable @Composable
fun TeamScoresView(scoreA: Int, scoreB: Int) { fun TeamScoresView(scoreA: Int, scoreB: Int) {
ElevatedCard() { ElevatedCard(elevation = CardDefaults.elevatedCardElevation(3.dp)) {
Row() { Row {
Text( Text(
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
text = scoreA.toString(), text = scoreA.toString(),

View File

@@ -1,77 +1,371 @@
package me.zobrist.tichucounter.ui.history package me.zobrist.tichucounter.ui.history
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.DeleteForever
import androidx.compose.material.icons.outlined.RestartAlt
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Badge
import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Alignment.Companion.TopEnd
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import me.zobrist.tichucounter.data.GameAndScore import kotlinx.coroutines.launch
import me.zobrist.tichucounter.R
import me.zobrist.tichucounter.data.GameWithScores
import me.zobrist.tichucounter.data.entity.Game
import me.zobrist.tichucounter.data.entity.Round
import me.zobrist.tichucounter.domain.getTotalPoints
import java.text.DateFormat import java.text.DateFormat
import java.util.* import java.util.Date
import java.util.Locale
@Composable @Composable
fun HistoryList(viewModel: HistoryViewModel) { fun HistoryList(
HistoryList(viewModel.gameAndHistory) viewModel: HistoryViewModel,
snackbarHostState: SnackbarHostState,
navigateToCalculator: () -> Unit,
) {
val scope = rememberCoroutineScope()
val lazyListState = rememberLazyListState()
var showDeleteDialog by remember { mutableStateOf(false) }
DeleteConfirmDialog(showDeleteDialog) {
showDeleteDialog = false
if (it) {
viewModel.deleteAllInactiveGames()
}
}
val deletedMessage = stringResource(id = R.string.delete_success)
val deletedActionLabel = stringResource(id = R.string.undo_question)
val activatedMessage = stringResource(id = R.string.activated_success)
val activatedActionLabel = stringResource(id = R.string.to_calculator_question)
HistoryList(
games = viewModel.gameAndHistory,
onOpenClicked = {
scope.launch {
viewModel.activateGame(it)
lazyListState.animateScrollToItem(0)
snackbarHostState.currentSnackbarData?.dismiss()
val result = snackbarHostState.showSnackbar(
message = activatedMessage,
actionLabel = activatedActionLabel,
duration = SnackbarDuration.Short
)
if (result == SnackbarResult.ActionPerformed) {
navigateToCalculator()
}
}
},
onDeleteClicked = {
scope.launch {
viewModel.deleteGame(it)
snackbarHostState.currentSnackbarData?.dismiss()
val result = snackbarHostState.showSnackbar(
message = deletedMessage,
actionLabel = deletedActionLabel,
duration = SnackbarDuration.Short
)
if (result == SnackbarResult.Dismissed) {
viewModel.deleteGame(it)
} else {
viewModel.restoreLastDeletedGame()
}
}
},
onDeleteAllClicked = { showDeleteDialog = true },
lazyListState = lazyListState
)
} }
@Preview
@Composable @Composable
fun HistoryList(games: List<GameAndScore>) { fun DeleteConfirmDialog(show: Boolean = true, onExecuted: (Boolean) -> Unit = {}) {
LazyColumn { if (show) {
items(games) {
HistoryListItem(it) AlertDialog(
onDismissRequest = { onExecuted(false) },
dismissButton = {
TextButton({ onExecuted(false) }) {
Text(stringResource(R.string.cancel))
}
},
confirmButton = {
TextButton({ onExecuted(true) }) {
Text(stringResource(R.string.ok))
}
},
title = { Text(stringResource(R.string.delete_inactive_title)) },
text = { Text(stringResource(R.string.delete_inactive_text)) },
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HistoryList(
games: List<GameWithScores>,
onOpenClicked: (gameId: Long) -> Unit,
onDeleteClicked: (gameId: Long) -> Unit,
onDeleteAllClicked: () -> Unit,
lazyListState: LazyListState = LazyListState(),
) {
Row {
LazyColumn(state = lazyListState) {
items(
items = games,
key = { it.hashCode() }) {
if (it.game.active) {
HistoryListItem(
it,
Modifier
.animateItemPlacement()
.padding(2.dp)
)
} else {
DismissibleHistoryListItem(
it,
Modifier.animateItemPlacement(),
onOpenClicked,
onDeleteClicked
)
}
}
item {
Button(enabled = games.count() > 1,
modifier = Modifier
.padding(start = 4.dp, end = 4.dp, top = 10.dp)
.align(CenterVertically)
.fillMaxWidth()
.animateItemPlacement(),
onClick = { onDeleteAllClicked() }) {
Icon(imageVector = Icons.Outlined.DeleteForever, contentDescription = null)
Text(text = stringResource(id = R.string.deleteAll))
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DismissibleHistoryListItem(
game: GameWithScores,
modifier: Modifier = Modifier,
onOpenClicked: (gameId: Long) -> Unit,
onDeleteClicked: (gameId: Long) -> Unit,
) {
val density = LocalDensity.current
val dismissState =
rememberSwipeToDismissBoxState(positionalThreshold = { with(density) { 100.dp.toPx() } },
confirmValueChange = {
if (it == SwipeToDismissBoxValue.EndToStart) {
onDeleteClicked(game.game.uid)
}
if (it == SwipeToDismissBoxValue.StartToEnd) {
onOpenClicked(game.game.uid)
}
true
})
SwipeToDismissBox(
modifier = modifier,
state = dismissState,
enableDismissFromEndToStart = true,
enableDismissFromStartToEnd = true,
backgroundContent = {
ItemBackground(dismissState.targetValue)
}, content = {
HistoryListItem(game = game, modifier = Modifier.padding(2.dp))
})
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ItemBackground(
dismissBoxValue: SwipeToDismissBoxValue
) {
val backgroundColor by animateColorAsState(
when (dismissBoxValue) {
SwipeToDismissBoxValue.EndToStart -> MaterialTheme.colorScheme.error
SwipeToDismissBoxValue.StartToEnd -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.background
}, label = ""
)
val textColor by animateColorAsState(
when (dismissBoxValue) {
SwipeToDismissBoxValue.EndToStart -> MaterialTheme.colorScheme.onError
SwipeToDismissBoxValue.StartToEnd -> MaterialTheme.colorScheme.onPrimary
else -> MaterialTheme.colorScheme.onBackground
}, label = ""
)
val scale by animateFloatAsState(
if (dismissBoxValue == SwipeToDismissBoxValue.Settled) 0.75f else 1f, label = ""
)
Box(
Modifier
.fillMaxSize()
.padding(top = 2.dp, bottom = 2.dp)
.background(backgroundColor)
.padding(horizontal = 10.dp),
) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.align(Alignment.CenterStart)
) {
Icon(
Icons.Outlined.RestartAlt,
contentDescription = null,
tint = textColor,
modifier = Modifier.scale(scale),
)
Text(text = stringResource(id = R.string.continue_play), color = textColor)
}
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.align(Alignment.CenterEnd)
) {
Icon(
Icons.Outlined.Delete,
contentDescription = null,
tint = textColor,
modifier = Modifier.scale(scale),
)
Text(text = stringResource(id = R.string.delete), color = textColor)
} }
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Preview
@Composable @Composable
fun HistoryListItem(game: GameAndScore) { private fun BackgroundPreview() {
Column {
Box(modifier = Modifier.height(60.dp))
{
ItemBackground(SwipeToDismissBoxValue.Settled)
}
Box(modifier = Modifier.height(60.dp))
{
ItemBackground(SwipeToDismissBoxValue.EndToStart)
}
Box(modifier = Modifier.height(60.dp))
{
ItemBackground(SwipeToDismissBoxValue.StartToEnd)
}
}
}
@Composable
fun HistoryListItem(
game: GameWithScores, modifier: Modifier = Modifier
) {
val format = val format =
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT, Locale.getDefault()) DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT, Locale.getDefault())
val totalScores = game.getTotalPoints()
Card( Card(
modifier = Modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(all = 4.dp),
) { ) {
Column( Row(
Modifier Modifier.padding(all = 12.dp)
.padding(all = 12.dp),
) { ) {
Row() { Box(modifier = modifier.fillMaxSize()) {
Text( Column {
text = game.nameA, Text(
style = MaterialTheme.typography.headlineSmall text = game.game.nameA + " vs " + game.game.nameB,
) maxLines = 1,
Text( overflow = TextOverflow.Ellipsis,
text = game.scoreA.toString(), style = MaterialTheme.typography.headlineSmall
style = MaterialTheme.typography.headlineSmall )
) Text(
} text = totalScores.first.toString() + " : " + totalScores.second.toString(),
Row() { style = MaterialTheme.typography.bodyLarge
Text( )
text = game.nameB, Spacer(modifier = Modifier.padding(5.dp))
style = MaterialTheme.typography.headlineSmall Text(
) text = format.format(game.game.modified),
Text( style = MaterialTheme.typography.labelSmall
text = game.scoreB.toString(), )
style = MaterialTheme.typography.headlineSmall }
) if (game.game.active) {
} Badge(
Row() { modifier = Modifier.align(TopEnd),
Text( contentColor = MaterialTheme.colorScheme.onPrimary,
text = format.format(game.modified), containerColor = MaterialTheme.colorScheme.primary
style = MaterialTheme.typography.labelSmall ) {
) Text(
text = stringResource(id = R.string.active),
style = MaterialTheme.typography.labelSmall
)
}
}
} }
} }
} }
@@ -80,12 +374,19 @@ fun HistoryListItem(game: GameAndScore) {
@Preview @Preview
@Composable @Composable
private fun HistoryListPreview() { private fun HistoryListPreview() {
val tempData = listOf<GameAndScore>( val tempData = listOf(
GameAndScore(false, "abc", "def", Date(), Date(), 1, 10, 50), GameWithScores(
GameAndScore(true, "ADTH", "dogfg", Date(), Date(), 2, 20, 60), Game(true, "abcsdf sdaf asdf sdf ", "defsadf asdf sadf ", Date(), Date()),
GameAndScore(false, "TeamA3", "TeamB3", Date(), Date(), 3, 30, 70), listOf(Round(1, 550, 500))
GameAndScore(false, "TeamA4", "TeamB4", Date(), Date(), 4, 40, 80), ), GameWithScores(
GameAndScore(false, "TeamA5", "TeamB5", Date(), Date(), 5, 50, 90) Game(false, "ADTH", "dogfg", Date(), Date()), listOf(Round(2, 20, 60))
), GameWithScores(
Game(false, "TeamA3 langer Name", "TeamB3", Date(), Date()), listOf(Round(3, 30, 70))
), GameWithScores(
Game(false, "TeamA4", "TeamB4", Date(), Date()), listOf(Round(4, 40, 80))
), GameWithScores(
Game(false, "TeamA5", "TeamB5", Date(), Date()), listOf(Round(5, 50, 90))
)
) )
HistoryList(tempData) HistoryList(tempData, {}, {}, {})
} }

View File

@@ -7,24 +7,54 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.zobrist.tichucounter.data.GameAndScore import me.zobrist.tichucounter.data.GameWithScores
import me.zobrist.tichucounter.data.GameDao import me.zobrist.tichucounter.repository.GameRepository
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class HistoryViewModel @Inject constructor( class HistoryViewModel @Inject constructor(
private val gameDao: GameDao private val gameRepository: GameRepository
) : ViewModel() { ) : ViewModel() {
var gameAndHistory by mutableStateOf(emptyList<GameAndScore>()) var gameAndHistory by mutableStateOf(emptyList<GameWithScores>())
private set private set
private var fullList: List<GameWithScores> = emptyList()
init { init {
viewModelScope.launch { viewModelScope.launch {
gameDao.getAllWithPoints().collect { games ->
gameAndHistory = games gameRepository.getAllWithRoundFlow().collect { games ->
fullList =
games.sortedBy { it.game.modified }.sortedBy { it.game.active }.reversed()
gameAndHistory = fullList
} }
} }
} }
fun deleteGame(gameId: Long) {
viewModelScope.launch {
gameRepository.deleteGame(gameId)
}
}
fun restoreLastDeletedGame() {
viewModelScope.launch {
gameRepository.restoreLastDeletedGame()
}
}
fun activateGame(gameId: Long) {
viewModelScope.launch {
gameRepository.setActive(gameId)
}
}
fun deleteAllInactiveGames() {
viewModelScope.launch {
gameRepository.deleteAllInactive()
}
}
} }

View File

@@ -0,0 +1,71 @@
package me.zobrist.tichucounter.ui.layout
import android.content.res.Configuration
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.List
import androidx.compose.material.icons.outlined.Calculate
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import me.zobrist.tichucounter.R
import me.zobrist.tichucounter.domain.DrawerItem
import me.zobrist.tichucounter.domain.Route
import me.zobrist.tichucounter.ui.AppTheme
@Composable
fun DrawerContent(
drawerItems: List<DrawerItem>,
selectedDrawerItem: DrawerItem,
onElementClicked: (Route) -> Unit
) {
ModalDrawerSheet {
Text(
modifier = Modifier.padding(start = 10.dp, top = 10.dp),
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineSmall
)
HorizontalDivider(modifier = Modifier.padding(10.dp))
drawerItems.forEach { screen ->
NavigationDrawerItem(
icon = { Icon(screen.menuIcon, contentDescription = null) },
label = { Text(screen.menuName) },
selected = screen == selectedDrawerItem,
onClick = { onElementClicked(screen.route) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
)
}
}
}
@Preview(name = "Light Mode")
@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true)
@Composable
fun DrawerContentPreview() {
val counter = DrawerItem(Route.COUNTER, Icons.Outlined.Calculate, "Counter")
val history = DrawerItem(Route.HISTORY, Icons.AutoMirrored.Outlined.List, "History")
val settings = DrawerItem(Route.SETTINGS, Icons.Outlined.Settings, "Settings")
AppTheme {
Surface {
DrawerContent(
listOf(counter, history, settings),
counter
) {}
}
}
}

View File

@@ -0,0 +1,60 @@
package me.zobrist.tichucounter.ui.layout
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import me.zobrist.tichucounter.domain.TopBarAction
import me.zobrist.tichucounter.domain.TopBarState
@Composable
fun TopBar(topBarState: TopBarState) {
TopBar(
topBarState.title,
topBarState.icon,
topBarState.onNavigate,
topBarState.actions
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TopBar(
title: String,
icon: ImageVector,
navigateAction: () -> Unit,
actions: List<TopBarAction>
) {
TopAppBar(
title = {
Text(
title,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
navigationIcon = {
IconButton(onClick = { navigateAction() }) {
Icon(
imageVector = icon,
contentDescription = "Localized description"
)
}
},
actions = {
actions.forEach {
IconButton(onClick = { it.action() }, enabled = it.isActive) {
Icon(
imageVector = it.imageVector,
contentDescription = null,
)
it.composeCode()
}
}
}
)
}

View File

@@ -2,12 +2,22 @@ package me.zobrist.tichucounter.ui.settings
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowDropDown import androidx.compose.material.icons.outlined.ArrowDropDown
import androidx.compose.material.icons.outlined.Check import androidx.compose.material3.Icon
import androidx.compose.material3.* import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.* import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment.Companion.End import androidx.compose.ui.Alignment.Companion.End
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -15,12 +25,15 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import me.zobrist.tichucounter.R import me.zobrist.tichucounter.R
import me.zobrist.tichucounter.domain.KeepScreenOn
import me.zobrist.tichucounter.domain.Language import me.zobrist.tichucounter.domain.Language
import me.zobrist.tichucounter.domain.Theme import me.zobrist.tichucounter.domain.Theme
import me.zobrist.tichucounter.ui.AppTheme import me.zobrist.tichucounter.ui.AppTheme
import me.zobrist.tichucounter.ui.composables.DropDownMenu
val languageMap = mapOf( val languageMap = mapOf(
Language.DEFAULT to R.string.android_default_text,
Language.ENGLISH to R.string.english, Language.ENGLISH to R.string.english,
Language.GERMAN to R.string.german Language.GERMAN to R.string.german
) )
@@ -31,16 +44,19 @@ val themeMap = mapOf(
Theme.LIGHT to R.string.light Theme.LIGHT to R.string.light
) )
val victoryPointsList = listOf(500, 1000, 1500, 2000)
@Composable @Composable
fun SettingsView(viewModel: SettingsViewModel) { fun SettingsView(viewModel: SettingsViewModel) {
SettingsView( SettingsView(
viewModel.screenOn, viewModel.screenOn.value,
viewModel.language, viewModel.language,
viewModel.theme, viewModel.theme,
viewModel.victoryPoints,
{ viewModel.updateScreenOn(it) }, { viewModel.updateScreenOn(it) },
{ viewModel.updateLanguage(it) }, { viewModel.updateLanguage(it) },
{ viewModel.updateTheme(it) }) { viewModel.updateTheme(it) },
{ viewModel.updateVictoryPoints(it) })
} }
@Composable @Composable
@@ -48,15 +64,26 @@ fun SettingsView(
valueScreenOn: Boolean = true, valueScreenOn: Boolean = true,
valueLanguage: Language = Language.ENGLISH, valueLanguage: Language = Language.ENGLISH,
valueTheme: Theme = Theme.DARK, valueTheme: Theme = Theme.DARK,
updateScreenOn: (Boolean) -> Unit = {}, valueVictoryPoints: Int = 1000,
updateScreenOn: (KeepScreenOn) -> Unit = {},
updateLanguage: (Language) -> Unit = {}, updateLanguage: (Language) -> Unit = {},
updateTheme: (Theme) -> Unit = {} updateTheme: (Theme) -> Unit = {},
updateVictoryPoints: (Int) -> Unit = {}
) { ) {
Column() { Column(
Modifier
.padding(20.dp)
) {
Text(
text = stringResource(R.string.display),
style = MaterialTheme.typography.headlineMedium
)
BooleanSetting( BooleanSetting(
stringResource(R.string.keep_screen_on), stringResource(R.string.keep_screen_on),
valueScreenOn valueScreenOn
) { updateScreenOn(it) } ) { updateScreenOn(if (it) KeepScreenOn.ON else KeepScreenOn.OFF) }
StringSetting( StringSetting(
stringResource(R.string.choose_language_text), stringResource(R.string.choose_language_text),
@@ -69,16 +96,27 @@ fun SettingsView(
themeMap, themeMap,
valueTheme, valueTheme,
) { updateTheme(it) } ) { updateTheme(it) }
Text(
text = stringResource(R.string.game),
style = MaterialTheme.typography.headlineMedium
)
ListSetting(
stringResource(R.string.victory_points),
victoryPointsList,
valueVictoryPoints
) { updateVictoryPoints(it) }
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun BooleanSetting(name: String, value: Boolean, updateValue: (Boolean) -> Unit) { fun BooleanSetting(name: String, value: Boolean, updateValue: (Boolean) -> Unit) {
Row( Row(
Modifier Modifier
.padding(20.dp) .padding(bottom = 15.dp, top = 5.dp)
.fillMaxWidth() .fillMaxWidth()
) { ) {
Column(Modifier.weight(5f)) { Column(Modifier.weight(5f)) {
@@ -107,16 +145,32 @@ fun BooleanSetting(name: String, value: Boolean, updateValue: (Boolean) -> Unit)
@Composable @Composable
fun <T> StringSetting(name: String, map: Map<T, Int>, selected: T, onSelected: (T) -> Unit) { fun <T> StringSetting(name: String, map: Map<T, Int>, selected: T, onSelected: (T) -> Unit) {
val translated = map.map { it.key to stringResource(it.value) }.toMap()
val getValue = map.map { stringResource(it.value) to it.key }.toMap()
ListSetting(
name,
translated.values,
translated[selected]
) { getValue[it]?.let { it1 -> onSelected(it1) } }
}
@Composable
fun <T> ListSetting(name: String, list: Collection<T>, selected: T, onSelected: (T) -> Unit) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(20.dp) .padding(bottom = 15.dp, top = 5.dp)
.clickable { expanded = true }) { .clickable { expanded = true }) {
Column(Modifier.weight(5f)) { Column(Modifier.weight(5f)) {
Text(name, style = MaterialTheme.typography.bodyLarge, overflow = TextOverflow.Ellipsis) Text(name, style = MaterialTheme.typography.bodyLarge, overflow = TextOverflow.Ellipsis)
Text(stringResource(map[selected]!!), style = MaterialTheme.typography.labelLarge) Text(
selected.toString(),
style = MaterialTheme.typography.labelLarge
)
} }
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
@@ -125,36 +179,27 @@ fun <T> StringSetting(name: String, map: Map<T, Int>, selected: T, onSelected: (
contentDescription = null, contentDescription = null,
modifier = Modifier.align(End) modifier = Modifier.align(End)
) )
}
DropdownMenu( DropDownMenu(
expanded = expanded, list,
onDismissRequest = { expanded = false } selected,
) { expanded,
map.forEach { ) {
DropdownMenuItem( expanded = false
onClick = { it?.let { onSelected(it) }
onSelected(it.key)
expanded = false
},
text = { Text(stringResource(it.value)) },
trailingIcon = {
if (it.key == selected) {
Icon(Icons.Outlined.Check, contentDescription = null)
}
})
} }
} }
} }
} }
@Preview(name = "Light Mode") @Preview(name = "Light Mode")
@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true) @Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true)
@Composable @Composable
fun SettingsViewPreview() { fun SettingsViewPreview() {
AppTheme() { AppTheme {
Surface() { Surface {
SettingsView() SettingsView()
} }
} }

View File

@@ -1,10 +1,12 @@
package me.zobrist.tichucounter.ui.settings package me.zobrist.tichucounter.ui.settings
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import me.zobrist.tichucounter.domain.KeepScreenOn
import me.zobrist.tichucounter.domain.Language import me.zobrist.tichucounter.domain.Language
import me.zobrist.tichucounter.domain.SettingsAdapter import me.zobrist.tichucounter.domain.SettingsAdapter
import me.zobrist.tichucounter.domain.Theme import me.zobrist.tichucounter.domain.Theme
@@ -14,28 +16,35 @@ import javax.inject.Inject
class SettingsViewModel @Inject constructor(private val settings: SettingsAdapter) : ViewModel() { class SettingsViewModel @Inject constructor(private val settings: SettingsAdapter) : ViewModel() {
var language by mutableStateOf(settings.language) var language by mutableStateOf(settings.language.value)
private set private set
var theme by mutableStateOf(settings.theme) var theme by mutableStateOf(settings.theme.value)
private set private set
var screenOn by mutableStateOf(false) var screenOn by mutableStateOf(settings.keepScreenOn.value)
private set
var victoryPoints by mutableIntStateOf(settings.victoryPoints.value)
private set private set
fun updateLanguage(language: Language) { fun updateLanguage(language: Language) {
settings.setLanguage(language) settings.language.value = language
this.language = settings.language this.language = language
} }
fun updateTheme(theme: Theme) { fun updateTheme(theme: Theme) {
settings.setTheme(theme) settings.theme.value = theme
this.theme = settings.theme this.theme = theme
} }
fun updateScreenOn(value: Boolean) { fun updateScreenOn(value: KeepScreenOn) {
settings.setKeepScreenOn(value) settings.keepScreenOn.value = value
screenOn = settings.keepScreenOn screenOn = value
} }
fun updateVictoryPoints(value: Int) {
settings.victoryPoints.value = value
victoryPoints = value
}
} }

View File

@@ -1,12 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M12,12m-3.2,0a3.2,3.2 0,1 1,6.4 0a3.2,3.2 0,1 1,-6.4 0" />
<path
android:fillColor="#FF000000"
android:pathData="M9,2L7.17,4H4c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V6c0,-1.1 -0.9,-2 -2,-2h-3.17L15,2H9zm3,15c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5z" />
</vector>

View File

@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M22,16V4c0,-1.1 -0.9,-2 -2,-2H8c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2zm-11,-4l2.03,2.71L16,11l4,5H8l3,-4zM2,6v14c0,1.1 0.9,2 2,2h14v-2H4V6H2z" />
</vector>

View File

@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M4,6H2v14c0,1.1 0.9,2 2,2h14v-2H4V6zm16,-4H8c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zm-8,12.5v-9l6,4.5 -6,4.5z" />
</vector>

View File

@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#FFFFFF"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/primaryColor"
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
</vector>

View File

@@ -1,11 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:tint="#FFFFFF"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/primaryColor"
android:pathData="M12.5,8c-2.65,0 -5.05,0.99 -6.9,2.6L2,7v9h9l-3.62,-3.62c1.39,-1.16 3.16,-1.88 5.12,-1.88 3.54,0 6.55,2.31 7.6,5.5l2.37,-0.78C21.08,11.03 17.15,8 12.5,8z" />
</vector>

View File

@@ -0,0 +1,54 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="50.684"
android:viewportHeight="39.77">
<group
android:scaleX="0.46444446"
android:scaleY="0.36443365"
android:translateX="13.572049"
android:translateY="12.638237">
<path
android:fillColor="#ffff00"
android:pathData="m25.885,0.371 l-25.752,0.047 0.094,9.167 8.174,0.094 -0.094,29.957 9.592,-0.094 -0.047,-29.957h8.08z"
android:strokeWidth="0.26458300000000001"
android:strokeColor="#000000"
android:strokeLineCap="butt"
android:strokeLineJoin="miter" />
<path
android:fillColor="#ffff00"
android:pathData="m50.503,39.494 l-0.047,-9.214h-10.773c-0.738,-0.009 -2.147,-0.64 -2.139,-1.95 0.009,-1.415 0.012,-16.903 0.012,-16.903 -0.001,-1.41 1.227,-2.055 1.89,-2.079l11.009,-0.142 0.094,-9.072 -11.86,0.189c-2.609,0.059 -10.536,2.804 -10.537,13.23 -0,10.079 0.218,15.921 0.218,15.921 0.223,4.386 5.741,9.979 10.125,9.99l12.006,0.03z"
android:strokeWidth="0.26458300000000001"
android:strokeColor="#000000"
android:strokeLineCap="butt"
android:strokeLineJoin="miter" />
<path
android:fillColor="#000000"
android:pathData="m8.403,10.083c0.049,-0.713 -0.112,-0.88 -0.029,-2.095 0.366,-1.278 9.609,-5.62 16.985,-2.65 0.403,0.162 0.5,-0.375 0.299,-0.467 -2.964,-2.692 -13.318,-8.128 -25.314,0.286l8.059,4.521"
android:strokeWidth="0.264583"
android:strokeColor="#000000"
android:strokeLineCap="butt"
android:strokeLineJoin="miter" />
<path
android:fillColor="#000000"
android:pathData="m13.09,39.45 l-4.651,-7.072 1.436,-0.155c1.716,-3.55 8.698,-15.304 2.46,-24.75 -0.307,-0.496 0.802,-2.297 1.512,-0.563 5.772,10.547 5.161,23.028 -0.757,32.54z"
android:strokeWidth="0.264583"
android:strokeColor="#000000"
android:strokeLineCap="butt"
android:strokeLineJoin="miter" />
<path
android:fillColor="#000000"
android:pathData="m42.812,9.194 l7.51,-4.144c-4.938,-5.973 -14.39,-7.27 -20.302,4.385 -0.2,0.447 0.901,0.888 1.106,0.649 5.387,-7.535 11.325,-3.968 11.686,-2.476 0.158,0.461 0.006,1.054 0.001,1.585z"
android:strokeWidth="0.264583"
android:strokeColor="#000000"
android:strokeLineCap="butt"
android:strokeLineJoin="miter" />
<path
android:fillColor="#000000"
android:pathData="m42.429,30.41 l7.935,4.743c-13.982,13.838 -26.059,-8.724 -21.01,-24.194 0.143,-0.438 1.306,-0.12 1.201,0.408 -1.411,6.97 1.621,22.234 11.726,20.975z"
android:strokeWidth="0.264583"
android:strokeColor="#000000"
android:strokeLineCap="butt"
android:strokeLineJoin="miter" />
</group>
</vector>

View File

@@ -1,9 +0,0 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:angle="135"
android:centerColor="#009688"
android:endColor="#00695C"
android:startColor="#4DB6AC"
android:type="linear" />
</shape>

View File

@@ -0,0 +1,48 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="50.684dp"
android:height="39.77dp"
android:viewportWidth="50.684"
android:viewportHeight="39.77">
<path
android:fillColor="#ffff00"
android:pathData="m25.885,0.371 l-25.752,0.047 0.094,9.167 8.174,0.094 -0.094,29.957 9.592,-0.094 -0.047,-29.957h8.08z"
android:strokeWidth="0.26458300000000001"
android:strokeColor="#000000"
android:strokeLineCap="butt"
android:strokeLineJoin="miter" />
<path
android:fillColor="#ffff00"
android:pathData="m50.503,39.494 l-0.047,-9.214h-10.773c-0.738,-0.009 -2.147,-0.64 -2.139,-1.95 0.009,-1.415 0.012,-16.903 0.012,-16.903 -0.001,-1.41 1.227,-2.055 1.89,-2.079l11.009,-0.142 0.094,-9.072 -11.86,0.189c-2.609,0.059 -10.536,2.804 -10.537,13.23 -0,10.079 0.218,15.921 0.218,15.921 0.223,4.386 5.741,9.979 10.125,9.99l12.006,0.03z"
android:strokeWidth="0.26458300000000001"
android:strokeColor="#000000"
android:strokeLineCap="butt"
android:strokeLineJoin="miter" />
<path
android:fillColor="#000000"
android:pathData="m8.403,10.083c0.049,-0.713 -0.112,-0.88 -0.029,-2.095 0.366,-1.278 9.609,-5.62 16.985,-2.65 0.403,0.162 0.5,-0.375 0.299,-0.467 -2.964,-2.692 -13.318,-8.128 -25.314,0.286l8.059,4.521"
android:strokeWidth="0.264583"
android:strokeColor="#000000"
android:strokeLineCap="butt"
android:strokeLineJoin="miter" />
<path
android:fillColor="#000000"
android:pathData="m13.09,39.45 l-4.651,-7.072 1.436,-0.155c1.716,-3.55 8.698,-15.304 2.46,-24.75 -0.307,-0.496 0.802,-2.297 1.512,-0.563 5.772,10.547 5.161,23.028 -0.757,32.54z"
android:strokeWidth="0.264583"
android:strokeColor="#000000"
android:strokeLineCap="butt"
android:strokeLineJoin="miter" />
<path
android:fillColor="#000000"
android:pathData="m42.812,9.194 l7.51,-4.144c-4.938,-5.973 -14.39,-7.27 -20.302,4.385 -0.2,0.447 0.901,0.888 1.106,0.649 5.387,-7.535 11.325,-3.968 11.686,-2.476 0.158,0.461 0.006,1.054 0.001,1.585z"
android:strokeWidth="0.264583"
android:strokeColor="#000000"
android:strokeLineCap="butt"
android:strokeLineJoin="miter" />
<path
android:fillColor="#000000"
android:pathData="m42.429,30.41 l7.935,4.743c-13.982,13.838 -26.059,-8.724 -21.01,-24.194 0.143,-0.438 1.306,-0.12 1.201,0.408 -1.411,6.97 1.621,22.234 11.726,20.975z"
android:strokeWidth="0.264583"
android:strokeColor="#000000"
android:strokeLineCap="butt"
android:strokeLineJoin="miter" />
</vector>

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" /> <background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" /> <foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon> </adaptive-icon>

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" /> <background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" /> <foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon> </adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,29 +1,40 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="clear">Neues Spiel starten</string>
<string name="undo">Letzte Runde löschen</string>
<string name="choose_theme_text">Theme auswählen</string> <string name="choose_theme_text">Theme auswählen</string>
<string name="keep_screen_on">Bildschirm eingeschaltet lassen</string> <string name="keep_screen_on">Bildschirm eingeschaltet lassen</string>
<string name="confirmNew">Möchten Sie wirklich ein neues Spiel starten?</string>
<string name="yes">Ja</string>
<string name="no">Nein</string>
<string name="back">Zurück</string>
<string name="choose_language_text">Sprache wählen</string> <string name="choose_language_text">Sprache wählen</string>
<string name="android_default_text">Android Standard</string> <string name="android_default_text">Android Standard</string>
<string name="english">Englisch</string> <string name="english">Englisch</string>
<string name="german">Deutsch</string> <string name="german">Deutsch</string>
<string name="light">Hell</string> <string name="light">Hell</string>
<string name="dark">Dunkel</string> <string name="dark">Dunkel</string>
<string name="settings">Einstellungen</string>
<string name="display">Anzeige</string>
<string name="activate">Aktivieren</string>
<string name="delete">Löschen</string>
<string name="menu_history">Verlauf</string> <string name="menu_history">Verlauf</string>
<string name="menu_settings">Einstellungen</string> <string name="menu_settings">Einstellungen</string>
<string name="menu_counter">Counter</string>
<string name="submit">Übermitteln</string>
<string name="on">Ein</string> <string name="on">Ein</string>
<string name="off">Aus</string> <string name="off">Aus</string>
<string name="newGame">Neues Spiel</string> <string name="new_game">Neues Spiel</string>
<string name="delete_inactive_title">Verlauf löschen</string>
<string name="delete_inactive_text">Wirklich den gesamten Verlauf löschen? Diese Aktion kann nicht rückgängig gemacht werden.</string>
<string name="cancel">Abbrechen</string>
<string name="ok">Ok</string>
<string name="delete">Löschen</string>
<string name="deleteAll">Alle löschen</string>
<string name="active">Aktives Spiel</string>
<string name="inactive">Vergangene Spiele</string>
<string name="menu_counter">Counter</string>
<string name="menu_about">About</string>
<string name="contact_us">Schreib uns</string>
<string name="continue_play">Weiterspielen</string>
<string name="delete_success">Spiel gelöscht.</string>
<string name="undo_question">RÜCKGÄNGIG</string>
<string name="activated_success">Spiel aktiviert.</string>
<string name="to_calculator_question">WEITERSPIELEN</string>
<string name="display">Anzeige</string>
<string name="game">Spiel</string>
<string name="victory_points">Siegespunkte</string>
<string name="victory_title">%1$s hat gewonnen</string>
<string name="draw_message">Sieht aus, als ob ihr ein neues Spiel starten solltet, um das endgültig zu klären.</string>
<string name="draw_title">Unentschieden</string>
<string name="victory_message">Herzliche Gratulation! Wie wäre es mit einer Revanche?</string>
<string name="abort">Abbrechen</string>
</resources> </resources>

View File

@@ -1,3 +0,0 @@
<resources>
<dimen name="fab_margin">48dp</dimen>
</resources>

View File

@@ -1,3 +0,0 @@
<resources>
<dimen name="fab_margin">200dp</dimen>
</resources>

View File

@@ -1,3 +0,0 @@
<resources>
<dimen name="fab_margin">48dp</dimen>
</resources>

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="primaryColor">#d50000</color>
<color name="primaryLightColor">#ff5131</color>
<color name="primaryDarkColor">#9b0000</color>
<color name="secondaryColor">#ffccbc</color>
<color name="secondaryLightColor">#ffffee</color>
<color name="secondaryDarkColor">#cb9b8c</color>
<color name="primaryTextColor">#ffffff</color>
<color name="secondaryTextColor">#000000</color>
</resources>

View File

@@ -1,9 +0,0 @@
<resources>
<!-- Default screen margins, per the Android Design guidelines. -->
<dimen name="activity_horizontal_margin">16dp</dimen>
<dimen name="activity_vertical_margin">16dp</dimen>
<dimen name="nav_header_vertical_spacing">8dp</dimen>
<dimen name="nav_header_height">176dp</dimen>
<dimen name="fab_margin">16dp</dimen>
<dimen name="text_margin">16dp</dimen>
</resources>

View File

@@ -1,8 +0,0 @@
<resources xmlns:android="http://schemas.android.com/apk/res/android">
<item name="ic_menu_camera" type="drawable">@android:drawable/ic_menu_camera</item>
<item name="ic_menu_gallery" type="drawable">@android:drawable/ic_menu_gallery</item>
<item name="ic_menu_slideshow" type="drawable">@android:drawable/ic_menu_slideshow</item>
<item name="ic_menu_manage" type="drawable">@android:drawable/ic_menu_manage</item>
<item name="ic_menu_share" type="drawable">@android:drawable/ic_menu_share</item>
<item name="ic_menu_send" type="drawable">@android:drawable/ic_menu_send</item>
</resources>

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="ic_launcher_background">@color/primaryColor</color> <color name="ic_launcher_background">#DC0E00</color>
</resources> </resources>

View File

@@ -1,34 +1,44 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name" translatable="false">Tichu Counter</string> <string name="app_name" translatable="false">Tichu Counter</string>
<string name="team_a" translatable="false">Team A</string>
<string name="team_b" translatable="false">Team B</string>
<string name="title_activity_settings" translatable="false">SettingsActivity</string>
<string name="clear">Start new game</string>
<string name="undo">Undo last round</string>
<string name="choose_theme_text">Choose theme</string> <string name="choose_theme_text">Choose theme</string>
<string name="keep_screen_on">Keep screen on</string> <string name="keep_screen_on">Keep screen on</string>
<string name="confirmNew">Do you really want to start a new Game?</string>
<string name="yes">Yes</string>
<string name="no">No</string>
<string name="back">Back</string>
<string name="choose_language_text">Choose language</string> <string name="choose_language_text">Choose language</string>
<string name="android_default_text">Android Default</string> <string name="android_default_text">Android Default</string>
<string name="english">English</string> <string name="english">English</string>
<string name="german">German</string> <string name="german">German</string>
<string name="light">Light</string> <string name="light">Light</string>
<string name="dark">Dark</string> <string name="dark">Dark</string>
<string name="display">Display</string>
<string name="settings">Settings</string>
<string name="menu_counter">Counter</string>
<string name="menu_history">History</string> <string name="menu_history">History</string>
<string name="menu_settings">Settings</string> <string name="menu_settings">Settings</string>
<string name="activate">Activate</string>
<string name="delete">Delete</string>
<string name="submit">Submit</string>
<string name="on">On</string> <string name="on">On</string>
<string name="off">Off</string> <string name="off">Off</string>
<string name="newGame">New Game</string> <string name="new_game">New Game</string>
<string name="delete_inactive_title">Delete history</string>
<string name="delete_inactive_text">You really want to delete the history? This action can\'t be undone.</string>
<string name="cancel">Cancel</string>
<string name="ok">OK</string>
<string name="delete">Delete</string>
<string name="deleteAll">Delete all</string>
<string name="active">Current Game</string>
<string name="inactive">Old Games</string>
<string name="menu_counter">Counter</string>
<string name="menu_about">About</string>
<string name="contact_us">Contact us</string>
<string name="play_store" translatable="false">Play Store</string>
<string name="continue_play">Continue game</string>
<string name="delete_success">Game deleted.</string>
<string name="undo_question">UNDO</string>
<string name="activated_success">Game activated.</string>
<string name="to_calculator_question">CONTINUE PLAYING</string>
<string name="display">Display</string>
<string name="game">Game</string>
<string name="victory_points">Victory points</string>
<string name="victory_title">%1$s won the game</string>
<string name="draw_message">Looks like you should start a new game to settle this for good.</string>
<string name="draw_title">Draw</string>
<string name="victory_message">Congratulations! How about a rematch?</string>
<string name="abort">Abort</string>
</resources> </resources>

View File

@@ -1,8 +1,5 @@
<resources> <resources>
<style name="AppTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar"></style> <style name="AppTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar" />
<style name="AppTheme.AppBarOverlay" parent="ThemeOverlay.MaterialComponents.Dark.ActionBar" />
<style name="AppTheme.PopupOverlay" parent="ThemeOverlay.MaterialComponents.Light" />
</resources> </resources>

View File

@@ -0,0 +1,22 @@
package me.zobrist.tichucounter
import me.zobrist.tichucounter.domain.digitCount
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class StringExtensionTest {
@Test
fun calculation_isCorrect() {
assertEquals(0, "-".digitCount())
assertEquals(0, "".digitCount())
assertEquals(2, "-10".digitCount())
assertEquals(2, "10".digitCount())
assertEquals(10, "1234567890".digitCount())
assertEquals(10, "-1234567890".digitCount())
}
}

View File

@@ -1,7 +1,9 @@
package me.zobrist.tichucounter package me.zobrist.tichucounter
import me.zobrist.tichucounter.domain.Tichu import me.zobrist.tichucounter.domain.Tichu
import org.junit.Assert.* import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
/** /**

View File

@@ -1,15 +1,12 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
ext { ext.kotlin_version = "1.9.24"
compose_version = '1.1.1'
}
ext.kotlin_version = "1.7.20"
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:7.4.0' classpath 'com.android.tools.build:gradle:8.5.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong
@@ -18,8 +15,9 @@ buildscript {
} }
plugins { plugins {
id 'com.google.dagger.hilt.android' version '2.44' apply false id 'com.google.dagger.hilt.android' version '2.51.1' apply false
id 'org.jetbrains.kotlin.android' version '1.7.20' apply false id 'org.jetbrains.kotlin.android' version '1.7.20' apply false
id 'com.google.devtools.ksp' version '1.9.24-1.0.20' apply false
} }
allprojects { allprojects {
@@ -28,7 +26,3 @@ allprojects {
mavenCentral() mavenCentral()
} }
} }
task clean(type: Delete) {
delete rootProject.buildDir
}

View File

@@ -18,4 +18,7 @@ android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX # Automatically convert third-party libraries to use AndroidX
android.enableJetifier=false android.enableJetifier=false
# Kotlin code style for this project: "official" or "obsolete": # Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official kotlin.code.style=official
android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=true
android.nonFinalResIds=false

View File

@@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip