Compare commits

...
10 Commits
Author SHA1 Message Date
Claude f8d63140e4 Replace background and sound effects with own artwork
The Pexels forest photo is replaced by the author's own background, so
its credit is dropped from the Licenses screen (all six locales) and the
NonFreeAssets anti-feature is removed from the F-Droid recipe. Sound
effects are replaced as well, and the old generator script in tools/ is
removed. Settings no longer duplicates the opening-bar switch, which
lives in Appearance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015g6ECVSKcBG8CJPrqC3G5o
2026-10-07 13:23:42 +00:00
Claude 64268ff935 Add F-Droid build recipe for Matewave
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015g6ECVSKcBG8CJPrqC3G5o
2026-10-06 18:26:59 +00:00
Claude 6ac5a582db Add app source for F-Droid release (v1.0.43)
Adds the full Android source tree with unit tests, fastlane store
metadata and the Gradle wrapper. The archive shipped without
gradle-wrapper.jar, so the official Gradle 9.7.1 wrapper jar is
included to make ./gradlew work from a fresh clone. .gitignore now
also excludes build output and keystores. README gains build
instructions and an updated structure tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015g6ECVSKcBG8CJPrqC3G5o
2026-10-06 18:23:20 +00:00
Claude 6851ba52ef Switch downloads badge to latest-release count
The all-releases /total endpoint is known to return "invalid" on
shields.io for repos with many releases, since it has to paginate
through every release instead of querying one. /latest/total hits a
single release and is reliable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015g6ECVSKcBG8CJPrqC3G5o
2026-10-05 15:58:14 +00:00
Claude a5761ef999 Clean up double blank lines and finish Matewawe→Matewave rename
Removed stray duplicate blank lines in README and a trailing space
in the <h1> tag. Updated remaining Matewawe references (icon alt
text, LICENSE notices, CHANGELOG repo links) to Matewave to match
the renamed repository — actual release asset filenames
(Matewawe-vX.X.X.apk) are left untouched since they were already
uploaded under the old name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015g6ECVSKcBG8CJPrqC3G5o
2026-10-05 15:52:38 +00:00
kalarina 5a4a143509 Fix spelling of 'Matewawe' to 'Matewave' in README 2026-10-05 18:49:32 +03:00
kalarina 4dc085c816 Update Gitea link in README 2026-10-05 18:21:37 +03:00
Claude 49f4c4ee4d Group the two friends/multiplayer screenshots together
Both screenshot 1 (friendly matches) and 3 (play together) show
the Friends screen, so placing them back to back reads better than
splitting them around the account/progress screenshot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015g6ECVSKcBG8CJPrqC3G5o
2026-10-05 10:16:33 +00:00
Claude 526c9277f1 Move screenshots 5 and 4 to the front, swapped
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015g6ECVSKcBG8CJPrqC3G5o
2026-10-05 10:14:16 +00:00
Claude 4713581e92 Move APK distribution to GitHub Releases; link Gitea badge and downloads badge there 2026-10-04 08:24:08 +00:00
287 changed files with 54201 additions and 63 deletions
+15 -1
View File
@@ -1,10 +1,24 @@
# Gradle / Android Studio
.gradle/
build/
local.properties
.idea/
*.iml
.kotlin/
captures/
.externalNativeBuild/
.cxx/
# Signing keys - never commit these
*.jks
*.keystore
# OS
.DS_Store
Thumbs.db
# Editors
.vscode/
.idea/
*.swp
# Local scratch
+44 -44
View File
@@ -1,49 +1,49 @@
# Changelog
All notable builds of Matewawe are tracked here. `v1.0.42` is the current, official latest build. `v1.0.32`-`v1.0.36` and `v1.0.38`-`v1.0.42` have no recoverable build timestamp (reproducible builds normalize internal timestamps).
All notable builds of Matewave are tracked here. `v1.0.42` is the current, official latest build. `v1.0.32`-`v1.0.36` and `v1.0.38`-`v1.0.42` have no recoverable build timestamp (reproducible builds normalize internal timestamps).
| Version | Build date (UTC) | APK | Size | SHA-256 |
|---|---|---|---|---|
| v1.0.42 | unknown | `Matewawe-v1.0.42.apk` | 3.04 MB | `d918de53e530096f7a0422a89a7e7bae44672daa1e7565d6b8f1044a1a4e130b` |
| v1.0.41 | unknown | `Matewawe-v1.0.41.apk` | 3.06 MB | `a42d4ba8fa394ae882c6de1378a9dcc862dba93b17498ddcf93ae59ca38dd2f5` |
| v1.0.40 | unknown | `Matewawe-v1.0.40.apk` | 3.11 MB | `df012e092a58a78174696b680a5e97450cc83c505281ddc2f8fe2ac2bd2c38c2` |
| v1.0.39 | unknown | `Matewawe-v1.0.39.apk` | 2.54 MB | `ba4a714f069b4948cf3ba7bdb1203543147f1e31ed9ecd9217d2caa12ada4052` |
| v1.0.38 | unknown | `Matewawe-v1.0.38.apk` | 2.23 MB | `1e528a920de91863f5daf1dca6fbf26b20e56f5e7192ad6c3e2acb9a620f8b05` |
| v1.0.37 | 2026-09-26 14:47:58 | `Matewawe-v1.0.37.apk` | 2.22 MB | `2bea8aa9307b4c6e4d12cf577879941fe98c262cf63fd173445bb69ba782b9a1` |
| v1.0.36 | unknown | `Matewawe-v1.0.36.apk` | 12.93 MB | `78915408540e257ab7867f4314a44042c8f8a18775c4f60bc0b7368c1410f987` |
| v1.0.35 | unknown | `Matewawe-v1.0.35.apk` | 13.70 MB | `6bf23265107666816833b7a1cf6aabf5212068b1af315d64f9631798c84790c6` |
| v1.0.34 | unknown | `Matewawe-v1.0.34.apk` | 13.70 MB | `22fe9d93da9e6db4899b7c1238d2934ce9ce4de292a0bc5e97ce11df4ac52091` |
| v1.0.33 | unknown | `Matewawe-v1.0.33.apk` | 13.70 MB | `be579a82b2257181ff308b0ad3912af51fc1ce32f4aec036a7685da360594e38` |
| v1.0.32 | unknown | `Matewawe-v1.0.32.apk` | 13.70 MB | `7100c6d6e2a158c802a720393479b3b0d40186352e0d54dc951846dbe812d929` |
| v1.0.31 | 2026-09-26 14:18:46 | `Matewawe-v1.0.31.apk` | 2.20 MB | `801357eaf576c4f7641c709339079886f17f36a1003534e5e2598b61a563df15` |
| v1.0.30 | 2026-09-26 14:18:44 | `Matewawe-v1.0.30.apk` | 2.22 MB | `d3a032d83f131cac55c196b5dcba864576c4502fd3303fc5a48945d988369952` |
| v1.0.29 | 2026-09-26 14:17:16 | `Matewawe-v1.0.29.apk` | 2.22 MB | `2bea8aa9307b4c6e4d12cf577879941fe98c262cf63fd173445bb69ba782b9a1` |
| v1.0.28 | 2026-09-26 14:17:16 | `Matewawe-v1.0.28.apk` | 2.22 MB | `f1777a3e19fcd2f3c008995fed954ae1d63391dc799d9bd895db5a33f7bf734f` |
| v1.0.27 | 2026-09-24 20:09:08 | `Matewawe-v1.0.27.apk` | 2.17 MB | `1dd9990b0a99581fd25133e7137f8b2fafe8832254a343d25897ffe3fbc4a67b` |
| v1.0.26 | 2026-09-24 19:24:34 | `Matewawe-v1.0.26.apk` | 2.17 MB | `03320f2b70575ede6637806d7278d897b2129c51098caf913720b215f8ac1158` |
| v1.0.25 | 2026-09-24 18:42:10 | `Matewawe-v1.0.25.apk` | 2.17 MB | `b5cd45ea214eb451c54f73c34b7dff1ef4c9820f0dd8542631c687eeec324f80` |
| v1.0.24 | 2026-09-20 13:47:22 | `Matewawe-v1.0.24.apk` | 2.17 MB | `ab82a55f07a00f8a7b1ebbaadf1cc50c151645bc3a1a09d7bd7078b26caf07ec` |
| v1.0.23 | 2026-09-20 12:43:54 | `Matewawe-v1.0.23.apk` | 2.12 MB | `619f0e3fc3738148cb6ce2705a861990fcc27c5826a6dcbe68b1d9613602368d` |
| v1.0.22 | 2026-09-20 12:16:18 | `Matewawe-v1.0.22.apk` | 2.12 MB | `4521859f4d7e258043cfaeb5429f03d5e3994d47a3d743489e57e7091b1e6b32` |
| v1.0.21 | 2026-09-20 11:13:24 | `Matewawe-v1.0.21.apk` | 2.08 MB | `ed5b874142a4a2629b7e6f24ddd17a9cd294e887bd5ec3535eb182aa150f0a56` |
| v1.0.20 | 2026-09-20 08:47:32 | `Matewawe-v1.0.20.apk` | 13.67 MB | `3ecb641bbe4231695fd22a62593f1b58c046346a7119ce218408d43bcc6ff984` |
| v1.0.19 | 2026-09-19 19:02:58 | `Matewawe-v1.0.19.apk` | 13.68 MB | `2bb58f82ef0f6d6bd2677973771659d0f5ccfb029125a38a166fcd87ae79d44b` |
| v1.0.18 | 2026-09-19 14:05:10 | `Matewawe-v1.0.18.apk` | 13.63 MB | `6fd29406fa9169834c485dbe03621cdde7f9ee412186d72293504848c300b995` |
| v1.0.17 | 2026-09-18 15:40:22 | `Matewawe-v1.0.17.apk` | 13.61 MB | `f76c10ea625e8d5441567a62422488ad699916bb6fcfede30e8977ad7d09151c` |
| v1.0.16 | 2026-09-14 14:57:40 | `Matewawe-v1.0.16.apk` | 13.68 MB | `c528fcfc5bd3e4b245eab2b69a71832bac4fd2ba4f2cab14bfa6c046859a4fb5` |
| v1.0.15 | 2026-09-13 16:05:32 | `Matewawe-v1.0.15.apk` | 13.00 MB | `e7c9e59938625ab73a761dd2a2f0fd8b6e5b112dc30c7ac01128e86346cdee39` |
| v1.0.14 | 2026-09-12 16:56:38 | `Matewawe-v1.0.14.apk` | 12.98 MB | `8814bdeb171f3b9abf3f2dce5e42e4d9d2690263a3fe6bd1726c21ecbb16a474` |
| v1.0.13 | 2026-09-12 15:53:32 | `Matewawe-v1.0.13.apk` | 12.98 MB | `1c331f9e8f82c27fa206130847d4f07a31f356919bb26fbb6c38ec71ed65c5d4` |
| v1.0.12 | 2026-09-12 15:33:10 | `Matewawe-v1.0.12.apk` | 12.98 MB | `df24ef36a0d211febc8b52077eaaa448f1102dee22b0d7047ca5300b56f241a7` |
| v1.0.11 | 2026-09-12 14:41:24 | `Matewawe-v1.0.11.apk` | 12.98 MB | `f50e079c8ee2b761ff3c796bd1a4e2ab20eb0547301d87cb6ba407e11f850faa` |
| v1.0.10 | 2026-09-12 10:26:18 | `Matewawe-v1.0.10.apk` | 12.96 MB | `f2770184ca2ec5087703719ae04801ba1f9410b24fee3167e62b84f42698f595` |
| v1.0.9 | 2026-09-12 10:00:18 | `Matewawe-v1.0.9.apk` | 12.96 MB | `934ff81456d19286d337b4463e521c292c285299318c2d28bb48d93cb714b105` |
| v1.0.8 | 2026-09-12 09:49:10 | `Matewawe-v1.0.8.apk` | 12.96 MB | `934ff81456d19286d337b4463e521c292c285299318c2d28bb48d93cb714b105` |
| v1.0.7 | 2026-09-12 09:36:08 | `Matewawe-v1.0.7.apk` | 12.96 MB | `d20610c84d61f4a6ca9039a44625a086258be390e707474a8e64f40e221abcdd` |
| v1.0.6 | 2026-09-11 20:57:14 | `Matewawe-v1.0.6.apk` | 12.96 MB | `e96ebcc9d4d7212a05b82602d5cbbe4bb859a6518cd72dd0093f1464ce1911be` |
| v1.0.5 | 2026-09-11 16:55:40 | `Matewawe-v1.0.5.apk` | 12.96 MB | `39af374d7285ccd664de7679e92d1ba394457b7562908b4a949d5875c028d2e2` |
| v1.0.4 | 2026-09-11 15:40:26 | `Matewawe-v1.0.4.apk` | 12.96 MB | `f2fe8c0bb3856ee47f2f0db5da3b1f30e48744673e0ba2da32ccf275dd3789d7` |
| v1.0.3 | 2026-09-10 18:03:12 | `Matewawe-v1.0.3.apk` | 12.96 MB | `754db9aeba44127fbe937fb9d8d18dafcf9c0e129160a7d14fb9391f2a26f4cf` |
| v1.0.2 | 2026-09-10 17:37:30 | `Matewawe-v1.0.2.apk` | 12.94 MB | `013311efc7279d9242d34d5c432ca7a1e39d60f8d00cb7f7dfba20ce04649523` |
| v1.0.1 | 2026-09-10 15:41:34 | `Matewawe-v1.0.1.apk` | 12.95 MB | `08f40d204d7a092bb669bf032bc871a265f5da93042adeb74585e258b7cb0b4b` |
| v1.0.0 | 2026-09-08 17:49:32 | `Matewawe-v1.0.0.apk` | 12.88 MB | `26d3674f1fb363c30cd6d08858610e284a4cd89c4c62cf9e6fff438ba8d96451` |
| v1.0.42 | unknown | [`Matewawe-v1.0.42.apk`](https://github.com/kalarina-bit/Matewave/releases/download/1.0.42/Matewawe-v1.0.42.apk) | 3.04 MB | `d918de53e530096f7a0422a89a7e7bae44672daa1e7565d6b8f1044a1a4e130b` |
| v1.0.41 | unknown | [`Matewawe-v1.0.41.apk`](https://github.com/kalarina-bit/Matewave/releases/download/1.0.41/Matewawe-v1.0.41.apk) | 3.06 MB | `a42d4ba8fa394ae882c6de1378a9dcc862dba93b17498ddcf93ae59ca38dd2f5` |
| v1.0.40 | unknown | [`Matewawe-v1.0.40.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.40/Matewawe-v1.0.40.apk) | 3.11 MB | `df012e092a58a78174696b680a5e97450cc83c505281ddc2f8fe2ac2bd2c38c2` |
| v1.0.39 | unknown | [`Matewawe-v1.0.39.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.39/Matewawe-v1.0.39.apk) | 2.54 MB | `ba4a714f069b4948cf3ba7bdb1203543147f1e31ed9ecd9217d2caa12ada4052` |
| v1.0.38 | unknown | [`Matewawe-v1.0.38.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.38/Matewawe-v1.0.38.apk) | 2.23 MB | `1e528a920de91863f5daf1dca6fbf26b20e56f5e7192ad6c3e2acb9a620f8b05` |
| v1.0.37 | 2026-09-26 14:47:58 | [`Matewawe-v1.0.37.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.37/Matewawe-v1.0.37.apk) | 2.22 MB | `2bea8aa9307b4c6e4d12cf577879941fe98c262cf63fd173445bb69ba782b9a1` |
| v1.0.36 | unknown | [`Matewawe-v1.0.36.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.36/Matewawe-v1.0.36.apk) | 12.93 MB | `78915408540e257ab7867f4314a44042c8f8a18775c4f60bc0b7368c1410f987` |
| v1.0.35 | unknown | [`Matewawe-v1.0.35.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.35/Matewawe-v1.0.35.apk) | 13.70 MB | `6bf23265107666816833b7a1cf6aabf5212068b1af315d64f9631798c84790c6` |
| v1.0.34 | unknown | [`Matewawe-v1.0.34.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.34/Matewawe-v1.0.34.apk) | 13.70 MB | `22fe9d93da9e6db4899b7c1238d2934ce9ce4de292a0bc5e97ce11df4ac52091` |
| v1.0.33 | unknown | [`Matewawe-v1.0.33.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.33/Matewawe-v1.0.33.apk) | 13.70 MB | `be579a82b2257181ff308b0ad3912af51fc1ce32f4aec036a7685da360594e38` |
| v1.0.32 | unknown | [`Matewawe-v1.0.32.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.32/Matewawe-v1.0.32.apk) | 13.70 MB | `7100c6d6e2a158c802a720393479b3b0d40186352e0d54dc951846dbe812d929` |
| v1.0.31 | 2026-09-26 14:18:46 | [`Matewawe-v1.0.31.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.31/Matewawe-v1.0.31.apk) | 2.20 MB | `801357eaf576c4f7641c709339079886f17f36a1003534e5e2598b61a563df15` |
| v1.0.30 | 2026-09-26 14:18:44 | [`Matewawe-v1.0.30.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.30/Matewawe-v1.0.30.apk) | 2.22 MB | `d3a032d83f131cac55c196b5dcba864576c4502fd3303fc5a48945d988369952` |
| v1.0.29 | 2026-09-26 14:17:16 | [`Matewawe-v1.0.29.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.29/Matewawe-v1.0.29.apk) | 2.22 MB | `2bea8aa9307b4c6e4d12cf577879941fe98c262cf63fd173445bb69ba782b9a1` |
| v1.0.28 | 2026-09-26 14:17:16 | [`Matewawe-v1.0.28.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.28/Matewawe-v1.0.28.apk) | 2.22 MB | `f1777a3e19fcd2f3c008995fed954ae1d63391dc799d9bd895db5a33f7bf734f` |
| v1.0.27 | 2026-09-24 20:09:08 | [`Matewawe-v1.0.27.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.27/Matewawe-v1.0.27.apk) | 2.17 MB | `1dd9990b0a99581fd25133e7137f8b2fafe8832254a343d25897ffe3fbc4a67b` |
| v1.0.26 | 2026-09-24 19:24:34 | [`Matewawe-v1.0.26.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.26/Matewawe-v1.0.26.apk) | 2.17 MB | `03320f2b70575ede6637806d7278d897b2129c51098caf913720b215f8ac1158` |
| v1.0.25 | 2026-09-24 18:42:10 | [`Matewawe-v1.0.25.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.25/Matewawe-v1.0.25.apk) | 2.17 MB | `b5cd45ea214eb451c54f73c34b7dff1ef4c9820f0dd8542631c687eeec324f80` |
| v1.0.24 | 2026-09-20 13:47:22 | [`Matewawe-v1.0.24.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.24/Matewawe-v1.0.24.apk) | 2.17 MB | `ab82a55f07a00f8a7b1ebbaadf1cc50c151645bc3a1a09d7bd7078b26caf07ec` |
| v1.0.23 | 2026-09-20 12:43:54 | [`Matewawe-v1.0.23.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.23/Matewawe-v1.0.23.apk) | 2.12 MB | `619f0e3fc3738148cb6ce2705a861990fcc27c5826a6dcbe68b1d9613602368d` |
| v1.0.22 | 2026-09-20 12:16:18 | [`Matewawe-v1.0.22.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.22/Matewawe-v1.0.22.apk) | 2.12 MB | `4521859f4d7e258043cfaeb5429f03d5e3994d47a3d743489e57e7091b1e6b32` |
| v1.0.21 | 2026-09-20 11:13:24 | [`Matewawe-v1.0.21.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.21/Matewawe-v1.0.21.apk) | 2.08 MB | `ed5b874142a4a2629b7e6f24ddd17a9cd294e887bd5ec3535eb182aa150f0a56` |
| v1.0.20 | 2026-09-20 08:47:32 | [`Matewawe-v1.0.20.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.20/Matewawe-v1.0.20.apk) | 13.67 MB | `3ecb641bbe4231695fd22a62593f1b58c046346a7119ce218408d43bcc6ff984` |
| v1.0.19 | 2026-09-19 19:02:58 | [`Matewawe-v1.0.19.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.19/Matewawe-v1.0.19.apk) | 13.68 MB | `2bb58f82ef0f6d6bd2677973771659d0f5ccfb029125a38a166fcd87ae79d44b` |
| v1.0.18 | 2026-09-19 14:05:10 | [`Matewawe-v1.0.18.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.18/Matewawe-v1.0.18.apk) | 13.63 MB | `6fd29406fa9169834c485dbe03621cdde7f9ee412186d72293504848c300b995` |
| v1.0.17 | 2026-09-18 15:40:22 | [`Matewawe-v1.0.17.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.17/Matewawe-v1.0.17.apk) | 13.61 MB | `f76c10ea625e8d5441567a62422488ad699916bb6fcfede30e8977ad7d09151c` |
| v1.0.16 | 2026-09-14 14:57:40 | [`Matewawe-v1.0.16.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.16/Matewawe-v1.0.16.apk) | 13.68 MB | `c528fcfc5bd3e4b245eab2b69a71832bac4fd2ba4f2cab14bfa6c046859a4fb5` |
| v1.0.15 | 2026-09-13 16:05:32 | [`Matewawe-v1.0.15.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.15/Matewawe-v1.0.15.apk) | 13.00 MB | `e7c9e59938625ab73a761dd2a2f0fd8b6e5b112dc30c7ac01128e86346cdee39` |
| v1.0.14 | 2026-09-12 16:56:38 | [`Matewawe-v1.0.14.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.14/Matewawe-v1.0.14.apk) | 12.98 MB | `8814bdeb171f3b9abf3f2dce5e42e4d9d2690263a3fe6bd1726c21ecbb16a474` |
| v1.0.13 | 2026-09-12 15:53:32 | [`Matewawe-v1.0.13.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.13/Matewawe-v1.0.13.apk) | 12.98 MB | `1c331f9e8f82c27fa206130847d4f07a31f356919bb26fbb6c38ec71ed65c5d4` |
| v1.0.12 | 2026-09-12 15:33:10 | [`Matewawe-v1.0.12.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.12/Matewawe-v1.0.12.apk) | 12.98 MB | `df24ef36a0d211febc8b52077eaaa448f1102dee22b0d7047ca5300b56f241a7` |
| v1.0.11 | 2026-09-12 14:41:24 | [`Matewawe-v1.0.11.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.11/Matewawe-v1.0.11.apk) | 12.98 MB | `f50e079c8ee2b761ff3c796bd1a4e2ab20eb0547301d87cb6ba407e11f850faa` |
| v1.0.10 | 2026-09-12 10:26:18 | [`Matewawe-v1.0.10.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.10/Matewawe-v1.0.10.apk) | 12.96 MB | `f2770184ca2ec5087703719ae04801ba1f9410b24fee3167e62b84f42698f595` |
| v1.0.9 | 2026-09-12 10:00:18 | [`Matewawe-v1.0.9.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.9/Matewawe-v1.0.9.apk) | 12.96 MB | `934ff81456d19286d337b4463e521c292c285299318c2d28bb48d93cb714b105` |
| v1.0.8 | 2026-09-12 09:49:10 | [`Matewawe-v1.0.8.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.8/Matewawe-v1.0.8.apk) | 12.96 MB | `934ff81456d19286d337b4463e521c292c285299318c2d28bb48d93cb714b105` |
| v1.0.7 | 2026-09-12 09:36:08 | [`Matewawe-v1.0.7.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.7/Matewawe-v1.0.7.apk) | 12.96 MB | `d20610c84d61f4a6ca9039a44625a086258be390e707474a8e64f40e221abcdd` |
| v1.0.6 | 2026-09-11 20:57:14 | [`Matewawe-v1.0.6.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.6/Matewawe-v1.0.6.apk) | 12.96 MB | `e96ebcc9d4d7212a05b82602d5cbbe4bb859a6518cd72dd0093f1464ce1911be` |
| v1.0.5 | 2026-09-11 16:55:40 | [`Matewawe-v1.0.5.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.5/Matewawe-v1.0.5.apk) | 12.96 MB | `39af374d7285ccd664de7679e92d1ba394457b7562908b4a949d5875c028d2e2` |
| v1.0.4 | 2026-09-11 15:40:26 | [`Matewawe-v1.0.4.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.4/Matewawe-v1.0.4.apk) | 12.96 MB | `f2fe8c0bb3856ee47f2f0db5da3b1f30e48744673e0ba2da32ccf275dd3789d7` |
| v1.0.3 | 2026-09-10 18:03:12 | [`Matewawe-v1.0.3.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.3/Matewawe-v1.0.3.apk) | 12.96 MB | `754db9aeba44127fbe937fb9d8d18dafcf9c0e129160a7d14fb9391f2a26f4cf` |
| v1.0.2 | 2026-09-10 17:37:30 | [`Matewawe-v1.0.2.apk`](https://github.com/kalarina-bit/Matewave/releases/download/v1.0.2/Matewawe-v1.0.2.apk) | 12.94 MB | `013311efc7279d9242d34d5c432ca7a1e39d60f8d00cb7f7dfba20ce04649523` |
| v1.0.1 | 2026-09-10 15:41:34 | [`Matewawe-v1.0.1.apk`](https://github.com/kalarina-bit/Matewave/releases/download/1.0.1/Matewawe-v1.0.1.apk) | 12.95 MB | `08f40d204d7a092bb669bf032bc871a265f5da93042adeb74585e258b7cb0b4b` |
| v1.0.0 | 2026-09-08 17:49:32 | [`Matewawe-v1.0.0.apk`](https://github.com/kalarina-bit/Matewave/releases/download/1.0.0/Matewawe-v1.0.0.apk) | 12.88 MB | `26d3674f1fb363c30cd6d08858610e284a4cd89c4c62cf9e6fff438ba8d96451` |
+3 -3
View File
@@ -631,8 +631,8 @@ to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
Matewawe
Copyright (C) 2026 Matewawe contributors
Matewave
Copyright (C) 2026 Matewave contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
@@ -652,7 +652,7 @@ Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
Matewawe Copyright (C) 2026 Matewawe contributors
Matewave Copyright (C) 2026 Matewave contributors
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
+32 -15
View File
@@ -1,14 +1,16 @@
<p align="center">
<img src="assets/icon-rounded.png" width="128" alt="Matewawe icon">
<img src="assets/icon-rounded.png" width="128" alt="Matewave icon">
</p>
<h1 align="center">Matewawe </h1>
<h1 align="center">Matewave</h1>
<p align="center">
<a href="releases/Matewawe-v1.0.42.apk"><img src="assets/get-it-on-gitea.png" height="60" alt="Get it on Gitea"></a>
<a href="https://git.skysparkle.cc/kalarina/Matewave/releases"><img src="assets/get-it-on-gitea.png" height="60" alt="Get it on Gitea"></a>
</p>
<p align="center">
<img src="https://img.shields.io/github/downloads/kalarina-bit/Matewave/latest/total?label=downloads&color=2e7d32&labelColor=1b1b1b" alt="downloads counter">
</p>
**A beautiful, open-source chess app for Android.**
@@ -28,29 +30,28 @@ over Bluetooth and Wi-Fi/LAN.
- 🌍 English, Russian, German, Japanese, Lithuanian and Chinese
- 🔓 100% open source
## Screenshots
<p align="center">
<img src="assets/screenshots/1-friendly-matches.png" width="200" alt="From solo games to friendly matches">
<img src="assets/screenshots/2-keep-growing.png" width="200" alt="Keep growing">
<img src="assets/screenshots/3-play-together.png" width="200" alt="Play together">
<img src="assets/screenshots/4-home.png" width="200" alt="Home screen">
<img src="assets/screenshots/5-move-count.png" width="200" alt="Make your next move count">
<img src="assets/screenshots/4-home.png" width="200" alt="Home screen">
<img src="assets/screenshots/1-friendly-matches.png" width="200" alt="From solo games to friendly matches">
<img src="assets/screenshots/3-play-together.png" width="200" alt="Play together">
<img src="assets/screenshots/2-keep-growing.png" width="200" alt="Keep growing">
</p>
## Installation
<img src="assets/icon-install.png" width="36" align="left">
1. Download the APK for the version you want from [`releases/`](releases/).
1. Download the APK for the version you want from the [Releases page](https://github.com/kalarina-bit/Matewave/releases).
2. On your Android device, allow installs from unknown sources for the app you use to open the file (Settings → Apps → Special access → Install unknown apps).
3. Open the downloaded `.apk` file and confirm the install.
Or via `adb`:
Or via `adb` (after downloading):
```sh
adb install releases/Matewawe-v1.0.42.apk
adb install Matewawe-v1.0.42.apk
```
## Verifying a download
@@ -60,23 +61,39 @@ adb install releases/Matewawe-v1.0.42.apk
Compare the SHA-256 checksum against the value listed in [CHANGELOG.md](CHANGELOG.md):
```sh
sha256sum releases/Matewawe-v1.0.42.apk
sha256sum Matewawe-v1.0.42.apk
```
## Building from source
Open the project in Android Studio (a version that supports AGP 9.2) and run the `app` configuration, or:
```sh
./gradlew assembleDebug
./gradlew testDebugUnitTest # JVM unit tests
```
Nearby multiplayer needs two physical devices — the emulator has no real Bluetooth, and mDNS discovery is often blocked on emulator networks.
## Repository structure
<img src="assets/icon-structure.png" width="36" align="left">
```
.
├── releases/ # Versioned APK builds (Matewawe-vX.Y.Z.apk)
├── app/ # Android app source (Kotlin, Jetpack Compose) and unit tests
├── assets/ # Icon and README images
│ └── screenshots/ # App screenshots
├── CHANGELOG.md # Per-version build dates, sizes and checksums
├── fastlane/ # F-Droid store listing, screenshots and changelogs
├── fdroid/ # Build recipe for fdroiddata
├── gradle/ # Gradle wrapper
├── CHANGELOG.md # Per-version build dates, sizes and checksums (links to GitHub Releases)
├── LICENSE # GNU GPLv3
└── README.md
```
APK builds themselves are published as assets on the [Releases page](https://github.com/kalarina-bit/Matewave/releases), not stored in this repository.
## License
This project is licensed under the **GNU General Public License v3.0** — see the [LICENSE](LICENSE) file for details.
+80
View File
@@ -0,0 +1,80 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
namespace = "cc.skysparkle.matewave"
compileSdk = 37
defaultConfig {
applicationId = "cc.skysparkle.matewave"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "1.0.43"
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
buildFeatures {
compose = true
}
testOptions {
unitTests.isReturnDefaultValues = true
}
// F-Droid rejects the Google-encrypted dependency metadata block in APKs.
dependenciesInfo {
includeInApk = false
includeInBundle = true
}
packaging {
jniLibs {
keepDebugSymbols += "**/libandroidx.graphics.path.so"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlin {
compilerOptions {
freeCompilerArgs.add("-opt-in=androidx.compose.material3.ExperimentalMaterial3Api")
}
}
}
dependencies {
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui")
implementation("androidx.activity:activity-compose:1.13.0")
implementation("androidx.core:core-ktx:1.18.0")
implementation("androidx.navigation:navigation-compose:2.9.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
implementation("com.google.zxing:core:3.5.4")
testImplementation("junit:junit:4.13.2")
testImplementation("org.json:json:20240303")
}
+4
View File
@@ -0,0 +1,4 @@
# Compose, AndroidX and coroutines ship their own consumer rules, and the app uses no
# reflection. Only keep line numbers so release crash reports stay readable.
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile
+72
View File
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission
android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
<!-- Scan results are used only to find other players, never to derive location. -->
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-feature android:name="android.hardware.bluetooth" android:required="false" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
<application
android:name=".MatewaveApp"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_title"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@android:style/Theme.Material.NoActionBar"
android:supportsRtl="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".presence.PresenceService"
android:exported="false"
android:foregroundServiceType="connectedDevice" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
+24
View File
@@ -0,0 +1,24 @@
W C50 Italian Game: Giuoco Piano e4 e5 Nf3 Nc6 Bc4 Bc5 c3 Nf6 d4 exd4 cxd4 Bb4 Bd2 Bxd2 Nbxd2 d5 exd5 Nxd5 Qb3
W C84 Ruy Lopez: Closed e4 e5 Nf3 Nc6 Bb5 a6 Ba4 Nf6 O-O Be7 Re1 b5 Bb3 d6 c3 O-O h3
W C44 Scotch Game e4 e5 Nf3 Nc6 d4 exd4 Nxd4 Nf6 Nxc6 bxc6 e5 Qe7 Qe2 Nd5 c4
W C29 Vienna Game: Vienna Gambit e4 e5 Nc3 Nf6 f4 d5 fxe5 Nxe4 Nf3 Be7 d4 O-O Bd3
W C39 King's Gambit Accepted: Kieseritzky Gambit e4 e5 f4 exf4 Nf3 g5 h4 g4 Ne5
W D60 Queen's Gambit Declined: Orthodox Defense d4 d5 c4 e6 Nc3 Nf6 Bg5 Be7 e3 O-O Nf3 Nbd7 Rc1
W D26 Queen's Gambit Accepted: Classical Defense d4 d5 c4 dxc4 Nf3 Nf6 e3 e6 Bxc4 c5 O-O a6 a4
W A48 London System d4 d5 Bf4 Nf6 e3 e6 Nf3 c5 c3 Nc6 Nbd2 Bd6 Bg3
W A20 English Opening: King's English Variation c4 e5 Nc3 Nf6 g3 d5 cxd5 Nxd5 Bg2 Nb6 Nf3 Nc6 O-O
W E01 Catalan Opening: Open Defense d4 Nf6 c4 e6 g3 d5 Bg2 dxc4 Nf3 Be7 O-O O-O Qc2
B B90 Sicilian Defense: Najdorf Variation e4 c5 Nf3 d6 d4 cxd4 Nxd4 Nf6 Nc3 a6 Be3 e5 Nb3 Be6 f3 Be7
B B70 Sicilian Defense: Dragon Variation e4 c5 Nf3 d6 d4 cxd4 Nxd4 Nf6 Nc3 g6 Be3 Bg7 f3 O-O Qd2 Nc6
B C02 French Defense: Advance Variation e4 e6 d4 d5 e5 c5 c3 Nc6 Nf3 Qb6 a3 c4
B B18 Caro-Kann Defense: Classical Variation e4 c6 d4 d5 Nc3 dxe4 Nxe4 Bf5 Ng3 Bg6 h4 h6 Nf3 Nd7
B B12 Caro-Kann Defense: Advance Variation e4 c6 d4 d5 e5 Bf5 Nf3 e6 Be2 c5
B B01 Scandinavian Defense: Main Line e4 d5 exd5 Qxd5 Nc3 Qa5 d4 Nf6 Nf3 c6 Bc4 Bf5
B B08 Pirc Defense: Classical Variation e4 d6 d4 Nf6 Nc3 g6 Nf3 Bg7 Be2 O-O O-O c6
B C42 Petrov's Defense: Classical Attack e4 e5 Nf3 Nf6 Nxe5 d6 Nf3 Nxe4 d4 d5 Bd3 Nc6
B D17 Slav Defense: Czech Variation d4 d5 c4 c6 Nf3 Nf6 Nc3 dxc4 a4 Bf5 e3 e6 Bxc4 Bb4
B E91 King's Indian Defense: Orthodox Variation d4 Nf6 c4 g6 Nc3 Bg7 e4 d6 Nf3 O-O Be2 e5 O-O Nc6 d5 Ne7
B E32 Nimzo-Indian Defense: Classical Variation d4 Nf6 c4 e6 Nc3 Bb4 Qc2 O-O a3 Bxc3 Qxc3 d5
B D85 Grünfeld Defense: Exchange Variation d4 Nf6 c4 g6 Nc3 d5 cxd5 Nxd5 e4 Nxc3 bxc3 Bg7 Nf3 c5
B A86 Dutch Defense: Leningrad Variation d4 f5 g3 Nf6 Bg2 g6 Nf3 Bg7 O-O O-O c4 d6
B D32 Queen's Gambit Declined: Tarrasch Defense d4 d5 c4 e6 Nc3 c5 cxd5 exd5 Nf3 Nc6 g3 Nf6 Bg2 Be7
1 W C50 Italian Game: Giuoco Piano e4 e5 Nf3 Nc6 Bc4 Bc5 c3 Nf6 d4 exd4 cxd4 Bb4 Bd2 Bxd2 Nbxd2 d5 exd5 Nxd5 Qb3
2 W C84 Ruy Lopez: Closed e4 e5 Nf3 Nc6 Bb5 a6 Ba4 Nf6 O-O Be7 Re1 b5 Bb3 d6 c3 O-O h3
3 W C44 Scotch Game e4 e5 Nf3 Nc6 d4 exd4 Nxd4 Nf6 Nxc6 bxc6 e5 Qe7 Qe2 Nd5 c4
4 W C29 Vienna Game: Vienna Gambit e4 e5 Nc3 Nf6 f4 d5 fxe5 Nxe4 Nf3 Be7 d4 O-O Bd3
5 W C39 King's Gambit Accepted: Kieseritzky Gambit e4 e5 f4 exf4 Nf3 g5 h4 g4 Ne5
6 W D60 Queen's Gambit Declined: Orthodox Defense d4 d5 c4 e6 Nc3 Nf6 Bg5 Be7 e3 O-O Nf3 Nbd7 Rc1
7 W D26 Queen's Gambit Accepted: Classical Defense d4 d5 c4 dxc4 Nf3 Nf6 e3 e6 Bxc4 c5 O-O a6 a4
8 W A48 London System d4 d5 Bf4 Nf6 e3 e6 Nf3 c5 c3 Nc6 Nbd2 Bd6 Bg3
9 W A20 English Opening: King's English Variation c4 e5 Nc3 Nf6 g3 d5 cxd5 Nxd5 Bg2 Nb6 Nf3 Nc6 O-O
10 W E01 Catalan Opening: Open Defense d4 Nf6 c4 e6 g3 d5 Bg2 dxc4 Nf3 Be7 O-O O-O Qc2
11 B B90 Sicilian Defense: Najdorf Variation e4 c5 Nf3 d6 d4 cxd4 Nxd4 Nf6 Nc3 a6 Be3 e5 Nb3 Be6 f3 Be7
12 B B70 Sicilian Defense: Dragon Variation e4 c5 Nf3 d6 d4 cxd4 Nxd4 Nf6 Nc3 g6 Be3 Bg7 f3 O-O Qd2 Nc6
13 B C02 French Defense: Advance Variation e4 e6 d4 d5 e5 c5 c3 Nc6 Nf3 Qb6 a3 c4
14 B B18 Caro-Kann Defense: Classical Variation e4 c6 d4 d5 Nc3 dxe4 Nxe4 Bf5 Ng3 Bg6 h4 h6 Nf3 Nd7
15 B B12 Caro-Kann Defense: Advance Variation e4 c6 d4 d5 e5 Bf5 Nf3 e6 Be2 c5
16 B B01 Scandinavian Defense: Main Line e4 d5 exd5 Qxd5 Nc3 Qa5 d4 Nf6 Nf3 c6 Bc4 Bf5
17 B B08 Pirc Defense: Classical Variation e4 d6 d4 Nf6 Nc3 g6 Nf3 Bg7 Be2 O-O O-O c6
18 B C42 Petrov's Defense: Classical Attack e4 e5 Nf3 Nf6 Nxe5 d6 Nf3 Nxe4 d4 d5 Bd3 Nc6
19 B D17 Slav Defense: Czech Variation d4 d5 c4 c6 Nf3 Nf6 Nc3 dxc4 a4 Bf5 e3 e6 Bxc4 Bb4
20 B E91 King's Indian Defense: Orthodox Variation d4 Nf6 c4 g6 Nc3 Bg7 e4 d6 Nf3 O-O Be2 e5 O-O Nc6 d5 Ne7
21 B E32 Nimzo-Indian Defense: Classical Variation d4 Nf6 c4 e6 Nc3 Bb4 Qc2 O-O a3 Bxc3 Qxc3 d5
22 B D85 Grünfeld Defense: Exchange Variation d4 Nf6 c4 g6 Nc3 d5 cxd5 Nxd5 e4 Nxc3 bxc3 Bg7 Nf3 c5
23 B A86 Dutch Defense: Leningrad Variation d4 f5 g3 Nf6 Bg2 g6 Nf3 Bg7 O-O O-O c4 d6
24 B D32 Queen's Gambit Declined: Tarrasch Defense d4 d5 c4 e6 Nc3 c5 cxd5 exd5 Nf3 Nc6 g3 Nf6 Bg2 Be7
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,357 @@
package cc.skysparkle.matewave
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent
import cc.skysparkle.matewave.audio.SoundManager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import cc.skysparkle.matewave.engine.PieceColor
import cc.skysparkle.matewave.network.TransportType
import cc.skysparkle.matewave.ui.screens.AccountScreen
import cc.skysparkle.matewave.ui.screens.ConnectionScreen
import cc.skysparkle.matewave.ui.screens.FriendsScreen
import cc.skysparkle.matewave.ui.screens.GameScreen
import cc.skysparkle.matewave.ui.screens.LicensesScreen
import cc.skysparkle.matewave.ui.screens.MainMenuScreen
import cc.skysparkle.matewave.ui.screens.AppearanceScreen
import cc.skysparkle.matewave.ui.screens.PermissionsScreen
import cc.skysparkle.matewave.ui.screens.PuzzleSolveScreen
import cc.skysparkle.matewave.ui.screens.SettingsScreen
import cc.skysparkle.matewave.ui.screens.ShareApkScreen
import cc.skysparkle.matewave.ui.screens.VsAiSetupScreen
import cc.skysparkle.matewave.ui.theme.MatewaveTheme
import cc.skysparkle.matewave.viewmodel.GameViewModel
import cc.skysparkle.matewave.presence.PresenceHub
class MainActivity : ComponentActivity() {
/** Asks for the highest refresh rate at the current resolution, for smoother animations. */
private fun enableHighRefreshRate() {
runCatching {
val screen: android.view.Display = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
display
} else {
@Suppress("DEPRECATION")
windowManager.defaultDisplay
}
val current = screen.mode
val best = screen.supportedModes
.filter { it.physicalWidth == current.physicalWidth && it.physicalHeight == current.physicalHeight }
.maxByOrNull { it.refreshRate } ?: return
window.attributes = window.attributes.apply { preferredDisplayModeId = best.modeId }
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// The app has one dark look: light system bar icons over the background photo.
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT)
)
enableHighRefreshRate()
SoundManager.init(this)
val appContext = applicationContext
kotlin.concurrent.thread(name = "warmup", isDaemon = true) {
runCatching { cc.skysparkle.matewave.data.FriendsStore.preload() }
runCatching { cc.skysparkle.matewave.puzzles.PuzzleRepository.load(appContext) }
}
setContent {
MatewaveTheme {
androidx.compose.foundation.layout.Box(
modifier = androidx.compose.ui.Modifier.fillMaxSize()
) {
cc.skysparkle.matewave.ui.components.AppBackgroundImage()
androidx.compose.foundation.layout.Box(
modifier = androidx.compose.ui.Modifier
.fillMaxSize()
.background(cc.skysparkle.matewave.ui.theme.PhotoScrim)
)
AppNavHost()
}
}
}
}
override fun onResume() {
super.onResume()
SoundManager.onAppResumed()
}
override fun onPause() {
super.onPause()
SoundManager.onAppPaused()
}
}
@Composable
private fun AppNavHost() {
val navController = rememberNavController()
val viewModel: GameViewModel = viewModel()
val studyViewModel: cc.skysparkle.matewave.viewmodel.OpeningStudyViewModel = viewModel()
val context = androidx.compose.ui.platform.LocalContext.current
androidx.compose.runtime.LaunchedEffect(Unit) { viewModel.init(context) }
// Presence is shared with the background service, so the lists stay the same in both.
val presence = remember { PresenceHub.get(context) }
androidx.compose.runtime.DisposableEffect(Unit) {
if (cc.skysparkle.matewave.presence.PresenceSettings(context).backgroundEnabled) {
cc.skysparkle.matewave.presence.PresenceService.start(context)
}
PresenceHub.acquire(context)
onDispose { PresenceHub.release(context) }
}
NavHost(
navController = navController,
startDestination = "menu",
enterTransition = {
if (isTabSwitch(initialState.destination.route, targetState.destination.route)) fadeThroughIn()
else sharedAxisIn(forward = true)
},
exitTransition = {
if (isTabSwitch(initialState.destination.route, targetState.destination.route)) fadeThroughOut()
else sharedAxisOut(forward = true)
},
popEnterTransition = {
if (isTabSwitch(initialState.destination.route, targetState.destination.route)) fadeThroughIn()
else sharedAxisIn(forward = false)
},
popExitTransition = {
if (isTabSwitch(initialState.destination.route, targetState.destination.route)) fadeThroughOut()
else sharedAxisOut(forward = false)
}
) {
composable("menu") {
MainMenuScreen(
viewModel = viewModel,
studyViewModel = studyViewModel,
onContinueGame = {
if (viewModel.resumeVsAi()) navController.navigate("game")
},
onQuickGame = {
// Quick game repeats the last setup against the AI.
val last = cc.skysparkle.matewave.settings.LastGameSetup(context)
val color = last.aiColor
viewModel.startVsAi(
last.aiDifficulty,
color ?: listOf(PieceColor.WHITE, PieceColor.BLACK).random(),
last.aiTime,
randomColor = color == null
)
navController.navigate("game")
},
onVsAiClick = { navController.navigate("vsAiSetup") },
onPuzzlesClick = {
val puzzle = cc.skysparkle.matewave.puzzles.PuzzleRepository.pickForRating(
context,
cc.skysparkle.matewave.data.ProfileStore.get().elo,
cc.skysparkle.matewave.stats.Stats.solvedPuzzles()
)
if (puzzle != null) {
viewModel.startPuzzle(puzzle)
navController.navigate("puzzleSolve")
}
},
onStudyClick = { navController.navigate("openingStudy") },
onVsPlayerClick = { navController.navigate("connectionNearby") },
onSettingsClick = { navController.navigate("settings") },
onAccountClick = { navController.navigate("account") },
onFriendsClick = { navController.navigate("friends") }
)
}
composable("settings") {
SettingsScreen(
onHomeClick = { navController.popBackStack("menu", inclusive = false) },
onFriendsClick = {
navController.popBackStack("menu", inclusive = false)
navController.navigate("friends")
},
onAccountClick = {
navController.popBackStack("menu", inclusive = false)
navController.navigate("account")
},
onOpenPermissions = { navController.navigate("permissions") },
onOpenAppearance = { navController.navigate("appearance") },
onOpenLicenses = { navController.navigate("licenses") },
onOpenShare = { navController.navigate("shareApk") }
)
}
composable("shareApk") {
ShareApkScreen(onBack = { navController.popBackStack() })
}
composable("licenses") {
LicensesScreen(onBack = { navController.popBackStack() })
}
composable("permissions") {
PermissionsScreen(onBack = { navController.popBackStack() })
}
composable("appearance") {
AppearanceScreen(onBack = { navController.popBackStack() })
}
composable("account") {
AccountScreen(
studyViewModel = studyViewModel,
onStudyClick = { navController.navigate("openingStudy") },
onHomeClick = { navController.popBackStack("menu", inclusive = false) },
onFriendsClick = {
navController.popBackStack("menu", inclusive = false)
navController.navigate("friends")
},
onSettingsClick = {
navController.popBackStack("menu", inclusive = false)
navController.navigate("settings")
}
)
}
composable("friends") {
FriendsScreen(
presence = presence,
onPlay = { navController.navigate("connectionNearby") },
onHomeClick = { navController.popBackStack("menu", inclusive = false) },
onSettingsClick = {
navController.popBackStack("menu", inclusive = false)
navController.navigate("settings")
},
onAccountClick = {
navController.popBackStack("menu", inclusive = false)
navController.navigate("account")
}
)
}
composable("vsAiSetup") {
VsAiSetupScreen(
onBack = { navController.popBackStack() },
onStart = { difficulty, color, timeControl ->
viewModel.startVsAi(
difficulty,
color ?: listOf(PieceColor.WHITE, PieceColor.BLACK).random(),
timeControl,
randomColor = color == null
)
navController.navigate("game")
}
)
}
composable("game") {
GameScreen(
viewModel = viewModel,
onExit = { navController.popBackStack("menu", inclusive = false) }
)
}
composable("openingStudy") {
cc.skysparkle.matewave.ui.screens.OpeningStudyScreen(
viewModel = studyViewModel,
onBack = { navController.popBackStack() },
onOpenTrainer = { navController.navigate("openingTrainer") }
)
}
composable("openingTrainer") {
cc.skysparkle.matewave.ui.screens.OpeningTrainerScreen(
viewModel = studyViewModel,
onBack = { navController.popBackStack() }
)
}
composable("puzzleSolve") {
PuzzleSolveScreen(
viewModel = viewModel,
onBack = { navController.popBackStack("menu", inclusive = false) },
onNextPuzzle = { next -> viewModel.startPuzzle(next) }
)
}
composable("connectionNearby") {
val bleTransport = remember { cc.skysparkle.matewave.network.Transports.ble(context) }
val lanTransport = remember { cc.skysparkle.matewave.network.Transports.lan(context) }
ConnectionScreen(
presence = presence,
onStartHost = { timeControl ->
// Wait for the opponent on Wi-Fi and, if allowed, Bluetooth at once.
val transports = buildList<cc.skysparkle.matewave.network.ConnectionTransport> {
add(lanTransport)
if (cc.skysparkle.matewave.network.AppPermissions.allGranted(context, cc.skysparkle.matewave.network.AppPermissions.nearby)) {
add(bleTransport)
}
}
val transport = if (transports.size == 1) lanTransport
else cc.skysparkle.matewave.network.DualHostTransport(transports)
viewModel.startVsPlayerNetwork(transport, PieceColor.WHITE, timeControl, isHost = true)
navController.navigate("game")
},
onJoin = { type, timeControl, targetId ->
val transport = if (type == TransportType.BLUETOOTH) bleTransport else lanTransport
viewModel.startVsPlayerNetwork(transport, PieceColor.BLACK, timeControl, isHost = false, targetId = targetId)
navController.navigate("game")
},
onBack = { navController.popBackStack() }
)
}
}
}
private val TAB_ROUTES = setOf("menu", "friends", "settings", "account")
private fun isTabSwitch(from: String?, to: String?): Boolean =
from in TAB_ROUTES && to in TAB_ROUTES && from != to
private val MotionEasing = androidx.compose.animation.core.CubicBezierEasing(0.2f, 0f, 0f, 1f)
private fun fadeThroughIn(): androidx.compose.animation.EnterTransition =
androidx.compose.animation.fadeIn(
androidx.compose.animation.core.tween(durationMillis = 260, delayMillis = 90, easing = MotionEasing)
) + androidx.compose.animation.scaleIn(
initialScale = 0.97f,
animationSpec = androidx.compose.animation.core.tween(durationMillis = 300, delayMillis = 90, easing = MotionEasing)
)
private fun fadeThroughOut(): androidx.compose.animation.ExitTransition =
androidx.compose.animation.fadeOut(
androidx.compose.animation.core.tween(durationMillis = 110, easing = androidx.compose.animation.core.LinearEasing)
)
private fun sharedAxisIn(forward: Boolean): androidx.compose.animation.EnterTransition =
androidx.compose.animation.slideInHorizontally(
androidx.compose.animation.core.tween(durationMillis = 340, easing = MotionEasing)
) { width -> if (forward) width / 14 else -width / 14 } +
androidx.compose.animation.fadeIn(
androidx.compose.animation.core.tween(durationMillis = 240, delayMillis = 80, easing = MotionEasing)
)
private fun sharedAxisOut(forward: Boolean): androidx.compose.animation.ExitTransition =
androidx.compose.animation.slideOutHorizontally(
androidx.compose.animation.core.tween(durationMillis = 340, easing = MotionEasing)
) { width -> if (forward) -width / 14 else width / 14 } +
androidx.compose.animation.fadeOut(
androidx.compose.animation.core.tween(durationMillis = 120, easing = androidx.compose.animation.core.LinearEasing)
)
@@ -0,0 +1,23 @@
package cc.skysparkle.matewave
import android.app.Application
import cc.skysparkle.matewave.data.FriendsStore
import cc.skysparkle.matewave.data.GameSaveStore
import cc.skysparkle.matewave.data.ProfileStore
import cc.skysparkle.matewave.settings.Haptics
import cc.skysparkle.matewave.stats.Stats
/**
* Sets up the stores once per process, before any screen or the background presence service
* runs: the service can be restarted by the system without the activity.
*/
class MatewaveApp : Application() {
override fun onCreate() {
super.onCreate()
ProfileStore.init(this)
FriendsStore.init(this)
Stats.init(this)
GameSaveStore.init(this)
Haptics.init(this)
}
}
@@ -0,0 +1,328 @@
package cc.skysparkle.matewave.ai
import cc.skysparkle.matewave.engine.Board
import cc.skysparkle.matewave.engine.ChessEngine
import cc.skysparkle.matewave.engine.Move
import cc.skysparkle.matewave.engine.Notation
import cc.skysparkle.matewave.engine.PieceColor
import cc.skysparkle.matewave.engine.PieceType
enum class AiDifficulty(val maxDepth: Int, val timeBudgetMs: Long, val noiseCp: Int) {
EASY(2, 400, 150),
MEDIUM(3, 1000, 40),
HARD(5, 2500, 8),
EXPERT(7, 4500, 0)
}
/**
* Chess engine opponent: minimax with alpha-beta pruning, iterative deepening and a
* quiescence search over captures. The time budget per move is derived from the
* remaining clock, and lower difficulty levels add Gaussian noise at the root so the
* AI does not play the same game every time (mates are never randomized away).
*/
object ChessAI {
private fun valueOf(type: PieceType): Int = when (type) {
PieceType.PAWN -> 100
PieceType.KNIGHT -> 320
PieceType.BISHOP -> 330
PieceType.ROOK -> 500
PieceType.QUEEN -> 900
PieceType.KING -> 20000
}
private const val MATE = 100_000
private val pawnTable = intArrayOf(
0, 0, 0, 0, 0, 0, 0, 0,
5, 10, 10, -20, -20, 10, 10, 5,
5, -5, -10, 0, 0, -10, -5, 5,
0, 0, 0, 20, 20, 0, 0, 0,
5, 5, 10, 25, 25, 10, 5, 5,
10, 10, 20, 30, 30, 20, 10, 10,
50, 50, 50, 50, 50, 50, 50, 50,
0, 0, 0, 0, 0, 0, 0, 0
)
private val knightTable = intArrayOf(
-50, -40, -30, -30, -30, -30, -40, -50,
-40, -20, 0, 5, 5, 0, -20, -40,
-30, 5, 10, 15, 15, 10, 5, -30,
-30, 0, 15, 20, 20, 15, 0, -30,
-30, 5, 15, 20, 20, 15, 5, -30,
-30, 0, 10, 15, 15, 10, 0, -30,
-40, -20, 0, 0, 0, 0, -20, -40,
-50, -40, -30, -30, -30, -30, -40, -50
)
private val bishopTable = intArrayOf(
-20, -10, -10, -10, -10, -10, -10, -20,
-10, 5, 0, 0, 0, 0, 5, -10,
-10, 10, 10, 10, 10, 10, 10, -10,
-10, 0, 10, 10, 10, 10, 0, -10,
-10, 5, 5, 10, 10, 5, 5, -10,
-10, 0, 5, 10, 10, 5, 0, -10,
-10, 0, 0, 0, 0, 0, 0, -10,
-20, -10, -10, -10, -10, -10, -10, -20
)
private val rookTable = intArrayOf(
0, 0, 0, 5, 5, 0, 0, 0,
-5, 0, 0, 0, 0, 0, 0, -5,
-5, 0, 0, 0, 0, 0, 0, -5,
-5, 0, 0, 0, 0, 0, 0, -5,
-5, 0, 0, 0, 0, 0, 0, -5,
-5, 0, 0, 0, 0, 0, 0, -5,
5, 10, 10, 10, 10, 10, 10, 5,
0, 0, 0, 0, 0, 0, 0, 0
)
private val queenTable = intArrayOf(
-20, -10, -10, -5, -5, -10, -10, -20,
-10, 0, 5, 0, 0, 0, 0, -10,
-10, 5, 5, 5, 5, 5, 0, -10,
0, 0, 5, 5, 5, 5, 0, -5,
-5, 0, 5, 5, 5, 5, 0, -5,
-10, 0, 5, 5, 5, 5, 0, -10,
-10, 0, 0, 0, 0, 0, 0, -10,
-20, -10, -10, -5, -5, -10, -10, -20
)
private val kingMiddleTable = intArrayOf(
20, 30, 10, 0, 0, 10, 30, 20,
20, 20, 0, 0, 0, 0, 20, 20,
-10, -20, -20, -20, -20, -20, -20, -10,
-20, -30, -30, -40, -40, -30, -30, -20,
-30, -40, -40, -50, -50, -40, -40, -30,
-30, -40, -40, -50, -50, -40, -40, -30,
-30, -40, -40, -50, -50, -40, -40, -30,
-30, -40, -40, -50, -50, -40, -40, -30
)
private val kingEndTable = intArrayOf(
-50, -30, -30, -30, -30, -30, -30, -50,
-30, -30, 0, 0, 0, 0, -30, -30,
-30, -10, 20, 30, 30, 20, -10, -30,
-30, -10, 30, 40, 40, 30, -10, -30,
-30, -10, 30, 40, 40, 30, -10, -30,
-30, -10, 20, 30, 30, 20, -10, -30,
-30, -20, -10, 0, 0, -10, -20, -30,
-50, -40, -30, -20, -20, -30, -40, -50
)
internal fun evaluate(board: Board): Int {
var nonPawnMaterial = 0
for (sq in 0 until 64) {
val p = board.squares[sq] ?: continue
if (p.type != PieceType.PAWN && p.type != PieceType.KING) nonPawnMaterial += valueOf(p.type)
}
val endgame = nonPawnMaterial <= 1_300
var score = 0
for (sq in 0 until 64) {
val piece = board.squares[sq] ?: continue
val white = piece.color == PieceColor.WHITE
val idx = if (white) sq else sq xor 56
val positional = when (piece.type) {
PieceType.PAWN -> pawnTable[idx]
PieceType.KNIGHT -> knightTable[idx]
PieceType.BISHOP -> bishopTable[idx]
PieceType.ROOK -> rookTable[idx]
PieceType.QUEEN -> queenTable[idx]
PieceType.KING -> if (endgame) kingEndTable[idx] else kingMiddleTable[idx]
}
val value = valueOf(piece.type) + positional
score += if (white) value else -value
}
return score
}
private fun moveScore(board: Board, m: Move): Int {
var score = 0
val victim = board.squares[m.to]
if (victim != null) {
val attacker = board.squares[m.from]
score = valueOf(victim.type) * 16 - (if (attacker != null) valueOf(attacker.type) / 100 else 0)
} else if (m.isEnPassant) {
score = valueOf(PieceType.PAWN) * 16
}
if (m.promotion == PieceType.QUEEN) score += 800
return score
}
internal fun orderMoves(board: Board, moves: List<Move>): List<Move> =
moves.sortedByDescending { m -> moveScore(board, m) }
internal fun isTactical(board: Board, m: Move): Boolean =
board.squares[m.to] != null || m.isEnPassant || m.promotion != null
private class Search(private val deadline: Long, val history: Set<String>) {
private var nodes = 0
var timedOut = false
private set
fun timeUp(): Boolean {
if (timedOut) return true
nodes++
if ((nodes and 1023) == 0 && System.currentTimeMillis() >= deadline) timedOut = true
return timedOut
}
}
private fun quiesce(
board: Board, alphaIn: Int, betaIn: Int, maximizing: Boolean, qDepth: Int, search: Search
): Int {
val standPat = evaluate(board)
if (search.timeUp() || qDepth == 0) return standPat
var alpha = alphaIn
var beta = betaIn
if (maximizing) {
if (standPat >= beta) return standPat
if (standPat > alpha) alpha = standPat
} else {
if (standPat <= alpha) return standPat
if (standPat < beta) beta = standPat
}
val captures = ChessEngine.legalMoves(board).filter { isTactical(board, it) }
if (captures.isEmpty()) return standPat
var best = standPat
for (m in orderMoves(board, captures)) {
val value = quiesce(ChessEngine.applyMove(board, m), alpha, beta, !maximizing, qDepth - 1, search)
if (maximizing) {
if (value > best) best = value
if (value > alpha) alpha = value
} else {
if (value < best) best = value
if (value < beta) beta = value
}
if (beta <= alpha || search.timedOut) break
}
return best
}
private fun minimax(
board: Board, depth: Int, alphaIn: Int, betaIn: Int, maximizing: Boolean, ply: Int, search: Search
): Int {
if (search.timeUp()) return evaluate(board)
var alpha = alphaIn
var beta = betaIn
if (ply in 1..2 && Notation.positionKey(board) in search.history) return 0
if (board.halfmoveClock >= 100 || ChessEngine.isDeadPosition(board)) return 0
val inCheck = ChessEngine.isInCheck(board, board.sideToMove)
if (depth <= 0 && !inCheck) return quiesce(board, alpha, beta, maximizing, QUIESCENCE_DEPTH, search)
val moves = ChessEngine.legalMoves(board)
if (moves.isEmpty()) {
return if (inCheck) {
if (maximizing) -MATE + ply else MATE - ply
} else 0
}
val nextDepth = if (depth <= 0) 0 else depth - 1
val ordered = orderMoves(board, moves)
if (maximizing) {
var best = Int.MIN_VALUE
for (m in ordered) {
val value = minimax(ChessEngine.applyMove(board, m), nextDepth, alpha, beta, false, ply + 1, search)
best = maxOf(best, value)
alpha = maxOf(alpha, value)
if (beta <= alpha || search.timedOut) break
}
return best
} else {
var best = Int.MAX_VALUE
for (m in ordered) {
val value = minimax(ChessEngine.applyMove(board, m), nextDepth, alpha, beta, true, ply + 1, search)
best = minOf(best, value)
beta = minOf(beta, value)
if (beta <= alpha || search.timedOut) break
}
return best
}
}
fun bestMove(
board: Board,
difficulty: AiDifficulty,
history: Set<String> = emptySet(),
clockMillis: Long? = null,
incrementMillis: Long = 0L,
random: java.util.Random = java.util.Random()
): Move? {
val rootMoves = ChessEngine.legalMoves(board)
if (rootMoves.isEmpty()) return null
if (rootMoves.size == 1) return rootMoves.first()
val budget = if (clockMillis == null || clockMillis > 24L * 3600 * 1000) {
difficulty.timeBudgetMs
} else {
minOf(difficulty.timeBudgetMs, clockMillis / 30 + incrementMillis * 3 / 4).coerceAtLeast(MIN_BUDGET_MS)
}
val maximizing = board.sideToMove == PieceColor.WHITE
val search = Search(System.currentTimeMillis() + budget, history)
var order = orderMoves(board, rootMoves)
var lastScored: List<Pair<Move, Int>> = emptyList()
var depth = 1
while (depth <= difficulty.maxDepth && !search.timedOut) {
var alpha = Int.MIN_VALUE
var beta = Int.MAX_VALUE
var bestValueThisDepth = if (maximizing) Int.MIN_VALUE else Int.MAX_VALUE
var bestMoveThisDepth: Move? = null
val scored = ArrayList<Pair<Move, Int>>(order.size)
var completed = true
for (m in order) {
val next = ChessEngine.applyMove(board, m)
val a = if (difficulty.noiseCp > 0) Int.MIN_VALUE else alpha
val b = if (difficulty.noiseCp > 0) Int.MAX_VALUE else beta
val value = minimax(next, depth - 1, a, b, !maximizing, 1, search)
if (search.timedOut) { completed = false; break }
scored.add(m to value)
if (maximizing) {
if (bestMoveThisDepth == null || value > bestValueThisDepth) {
bestValueThisDepth = value; bestMoveThisDepth = m
}
alpha = maxOf(alpha, bestValueThisDepth)
} else {
if (bestMoveThisDepth == null || value < bestValueThisDepth) {
bestValueThisDepth = value; bestMoveThisDepth = m
}
beta = minOf(beta, bestValueThisDepth)
}
}
if (completed && bestMoveThisDepth != null) {
lastScored = if (maximizing) scored.sortedByDescending { it.second }
else scored.sortedBy { it.second }
order = lastScored.map { it.first }
} else {
break
}
depth++
}
if (lastScored.isEmpty()) return order.first()
val bestEntry = lastScored.first()
if (difficulty.noiseCp <= 0) return bestEntry.first
if (kotlin.math.abs(bestEntry.second) > MATE / 2) return bestEntry.first
return lastScored
.filter { kotlin.math.abs(it.second) < MATE / 2 }
.maxByOrNull { (_, value) ->
val fromMover = if (maximizing) value else -value
fromMover + (random.nextGaussian() * difficulty.noiseCp).toInt()
}?.first ?: bestEntry.first
}
private const val QUIESCENCE_DEPTH = 6
private const val MIN_BUDGET_MS = 120L
}
@@ -0,0 +1,320 @@
package cc.skysparkle.matewave.audio
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.media.MediaPlayer
import android.media.SoundPool
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
enum class RadioState { IDLE, CONNECTING, PLAYING, ERROR }
object SoundManager {
private val RADIO_SOURCES = listOf(
"http://stream.srg-ssr.ch/srgssr/rsj/aac/192",
"https://stream.srg-ssr.ch/srgssr/rsj/mp3/128",
"http://stream.srg-ssr.ch/srgssr/rsj/aac/96"
)
private const val PREFS = "chess_sound_prefs"
private const val KEY_MUSIC = "music_enabled"
private const val KEY_SFX = "sfx_enabled"
private const val TAG = "SoundManager"
private const val RADIO_START_DELAY_MS = 1_500L
private const val MAX_RETRIES = 8
private const val CONNECT_TIMEOUT_MS = 8_000L
private val RETRY_DELAYS_MS = longArrayOf(1_000, 2_000, 4_000, 8_000, 15_000, 30_000)
private const val BASE_VOLUME = 0.35f
private var appContext: Context? = null
private var musicPlayer: MediaPlayer? = null
/** start/pause are only valid once the stream is prepared; before that they would throw. */
private var playerPrepared = false
private var initialized = false
private var retryCount = 0
private var sourceIndex = 0
private var connectTimeoutRunnable: Runnable? = null
private var audioManager: AudioManager? = null
private var focusRequest: AudioFocusRequest? = null
private var noisyReceiverRegistered = false
private val mainHandler = Handler(Looper.getMainLooper())
var musicEnabled: Boolean = true
private set
var sfxEnabled: Boolean = true
private set
private val _radioState = MutableStateFlow(RadioState.IDLE)
val radioState: StateFlow<RadioState> = _radioState
private fun pauseRadio() {
val player = musicPlayer ?: return
if (playerPrepared) runCatching { if (player.isPlaying) player.pause() }
}
private val noisyReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) pauseRadio()
}
}
private val focusChangeListener = AudioManager.OnAudioFocusChangeListener { change ->
when (change) {
AudioManager.AUDIOFOCUS_LOSS -> stopRadioInternal()
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> pauseRadio()
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK ->
runCatching { musicPlayer?.setVolume(BASE_VOLUME * 0.3f, BASE_VOLUME * 0.3f) }
AudioManager.AUDIOFOCUS_GAIN -> {
val player = musicPlayer
if (player != null && playerPrepared) runCatching {
player.setVolume(BASE_VOLUME, BASE_VOLUME)
if (!player.isPlaying) player.start()
}
}
}
}
fun init(context: Context) {
if (initialized) return
initialized = true
val ctx = context.applicationContext
appContext = ctx
audioManager = ctx.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
val prefs = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
musicEnabled = prefs.getBoolean(KEY_MUSIC, true)
sfxEnabled = prefs.getBoolean(KEY_SFX, true)
ensureSoundPool(ctx)
if (musicEnabled) {
mainHandler.postDelayed({ if (musicEnabled) startRadio() }, RADIO_START_DELAY_MS)
}
}
private fun requestAudioFocus(): Boolean {
val am = audioManager ?: return false
val attrs = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(attrs)
.setOnAudioFocusChangeListener(focusChangeListener, mainHandler)
.build()
focusRequest = request
return am.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
}
private fun abandonAudioFocus() {
val am = audioManager ?: return
focusRequest?.let { am.abandonAudioFocusRequest(it) }
focusRequest = null
}
private fun registerNoisyReceiver() {
val ctx = appContext ?: return
if (noisyReceiverRegistered) return
val filter = IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ctx.registerReceiver(noisyReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
ctx.registerReceiver(noisyReceiver, filter)
}
noisyReceiverRegistered = true
}
private fun unregisterNoisyReceiver() {
val ctx = appContext ?: return
if (!noisyReceiverRegistered) return
try { ctx.unregisterReceiver(noisyReceiver) } catch (_: IllegalArgumentException) {}
noisyReceiverRegistered = false
}
private fun startRadio() {
val ctx = appContext ?: return
if (musicPlayer != null) return
if (!requestAudioFocus()) {
Log.w(TAG, "Audio focus denied, radio not started")
_radioState.value = RadioState.ERROR
return
}
registerNoisyReceiver()
retryCount = 0
connect(ctx)
}
private fun connect(ctx: Context) {
_radioState.value = RadioState.CONNECTING
val url = RADIO_SOURCES[sourceIndex % RADIO_SOURCES.size]
try {
val player = MediaPlayer()
musicPlayer = player
playerPrepared = false
player.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
)
player.setVolume(BASE_VOLUME, BASE_VOLUME)
player.setDataSource(url)
player.setOnPreparedListener {
cancelConnectTimeout()
playerPrepared = true
it.start()
retryCount = 0
_radioState.value = RadioState.PLAYING
}
player.setOnErrorListener { mp, _, _ ->
cancelConnectTimeout()
try { mp.release() } catch (_: Exception) {}
if (musicPlayer === mp) {
musicPlayer = null
playerPrepared = false
}
scheduleRetry(ctx)
true
}
// A live stream only "completes" when the connection drops: reconnect.
player.setOnCompletionListener { mp ->
try { mp.release() } catch (_: Exception) {}
if (musicPlayer === mp) {
musicPlayer = null
playerPrepared = false
retryCount = 0
scheduleRetry(ctx)
}
}
player.prepareAsync()
scheduleConnectTimeout(ctx, player, url)
} catch (e: Exception) {
Log.w(TAG, "Radio connection error ($url): ${e.message}")
try { musicPlayer?.release() } catch (_: Exception) {}
musicPlayer = null
playerPrepared = false
scheduleRetry(ctx)
}
}
private fun scheduleConnectTimeout(ctx: Context, player: MediaPlayer, url: String) {
cancelConnectTimeout()
val runnable = Runnable {
if (musicPlayer === player) {
Log.w(TAG, "Radio connection timed out ($url)")
try { player.release() } catch (_: Exception) {}
musicPlayer = null
playerPrepared = false
scheduleRetry(ctx)
}
}
connectTimeoutRunnable = runnable
mainHandler.postDelayed(runnable, CONNECT_TIMEOUT_MS)
}
private fun cancelConnectTimeout() {
connectTimeoutRunnable?.let { mainHandler.removeCallbacks(it) }
connectTimeoutRunnable = null
}
private fun scheduleRetry(ctx: Context) {
cancelConnectTimeout()
if (!musicEnabled) {
_radioState.value = RadioState.IDLE
return
}
if (retryCount >= MAX_RETRIES) {
_radioState.value = RadioState.ERROR
Log.w(TAG, "Could not connect to the radio after $MAX_RETRIES attempts")
return
}
sourceIndex++
val delay = RETRY_DELAYS_MS.getOrElse(retryCount) { RETRY_DELAYS_MS.last() }
retryCount++
_radioState.value = RadioState.CONNECTING
mainHandler.postDelayed({
if (musicEnabled && musicPlayer == null) connect(ctx)
}, delay)
}
private fun stopRadioInternal() {
cancelConnectTimeout()
try { musicPlayer?.release() } catch (_: Exception) {}
musicPlayer = null
playerPrepared = false
unregisterNoisyReceiver()
abandonAudioFocus()
mainHandler.removeCallbacksAndMessages(null)
_radioState.value = RadioState.IDLE
}
fun onAppResumed() {
if (musicEnabled && musicPlayer == null) startRadio()
}
fun onAppPaused() {
stopRadioInternal()
}
fun setMusicEnabled(enabled: Boolean) {
musicEnabled = enabled
appContext?.getSharedPreferences(PREFS, Context.MODE_PRIVATE)?.edit()
?.putBoolean(KEY_MUSIC, enabled)?.apply()
if (enabled) startRadio() else stopRadioInternal()
}
fun setSfxEnabled(enabled: Boolean) {
sfxEnabled = enabled
appContext?.getSharedPreferences(PREFS, Context.MODE_PRIVATE)?.edit()
?.putBoolean(KEY_SFX, enabled)?.apply()
}
private var soundPool: SoundPool? = null
private val sfxSoundIds = HashMap<Int, Int>()
private val sfxLoaded = HashSet<Int>()
private fun ensureSoundPool(ctx: Context): SoundPool {
soundPool?.let { return it }
val attributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_GAME)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
val pool = SoundPool.Builder().setMaxStreams(4).setAudioAttributes(attributes).build()
pool.setOnLoadCompleteListener { _, soundId, status ->
if (status == 0) synchronized(sfxLoaded) { sfxLoaded.add(soundId) }
}
for (resId in intArrayOf(
cc.skysparkle.matewave.R.raw.sfx_move,
cc.skysparkle.matewave.R.raw.sfx_capture,
cc.skysparkle.matewave.R.raw.sfx_select
)) {
sfxSoundIds[resId] = pool.load(ctx, resId, 1)
}
soundPool = pool
return pool
}
private fun playSfx(resId: Int) {
if (!sfxEnabled) return
val ctx = appContext ?: return
val pool = ensureSoundPool(ctx)
val soundId = sfxSoundIds[resId] ?: return
val ready = synchronized(sfxLoaded) { soundId in sfxLoaded }
if (ready) pool.play(soundId, 1f, 1f, 1, 0, 1f)
}
fun playMove() { playSfx(cc.skysparkle.matewave.R.raw.sfx_move) }
fun playCapture() { playSfx(cc.skysparkle.matewave.R.raw.sfx_capture) }
fun playSelect() { playSfx(cc.skysparkle.matewave.R.raw.sfx_select) }
}
@@ -0,0 +1,126 @@
package cc.skysparkle.matewave.clock
import cc.skysparkle.matewave.engine.PieceColor
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
data class ClockState(
val whiteMillis: Long,
val blackMillis: Long,
val running: PieceColor?,
val flagged: PieceColor? = null
)
/** Chess clock with increment; state is exposed as a StateFlow for the UI. */
class GameClock(private val timeControl: TimeControl, private val scope: CoroutineScope) {
private val unlimited = timeControl.initialSeconds == Int.MAX_VALUE
private var whiteLeft = timeControl.initialSeconds * 1000L
private var blackLeft = timeControl.initialSeconds * 1000L
private var running: PieceColor? = null
private var flagged: PieceColor? = null
private var lastAccountedNs = System.nanoTime()
private val _state = MutableStateFlow(ClockState(whiteLeft, blackLeft, null))
val state: StateFlow<ClockState> = _state
private var tickJob: Job? = null
private fun account() {
val now = System.nanoTime()
val elapsedMs = (now - lastAccountedNs) / 1_000_000L
lastAccountedNs = now
val side = running ?: return
if (unlimited || flagged != null || elapsedMs <= 0) return
if (side == PieceColor.WHITE) whiteLeft = (whiteLeft - elapsedMs).coerceAtLeast(0)
else blackLeft = (blackLeft - elapsedMs).coerceAtLeast(0)
}
private fun publish() {
_state.value = ClockState(whiteLeft, blackLeft, running, flagged)
}
fun start(side: PieceColor) {
tickJob?.cancel()
account()
running = side
publish()
if (unlimited) return
tickJob = scope.launch {
while (isActive) {
val current = running ?: break
val left = if (current == PieceColor.WHITE) whiteLeft else blackLeft
val sinceAccount = (System.nanoTime() - lastAccountedNs) / 1_000_000L
delay(((left - sinceAccount) % 1000L + 1L).coerceIn(1L, 1000L))
account()
val nowLeft = if (current == PieceColor.WHITE) whiteLeft else blackLeft
if (nowLeft <= 0L) {
flagged = current
publish()
break
}
publish()
}
}
}
fun onMoveMade(movedSide: PieceColor) {
account()
val incMs = timeControl.incrementSeconds * 1000L
if (!unlimited) {
if (movedSide == PieceColor.WHITE) whiteLeft += incMs else blackLeft += incMs
}
start(if (movedSide == PieceColor.WHITE) PieceColor.BLACK else PieceColor.WHITE)
}
fun applyRemoteTime(side: PieceColor, remainingMillis: Long) {
if (remainingMillis < 0 || unlimited) return
account()
if (flagged == side && remainingMillis > 0) {
// The opponent's move arrived in time after all: unflag and keep the clock ticking.
flagged = null
if (side == PieceColor.WHITE) whiteLeft = remainingMillis else blackLeft = remainingMillis
lastAccountedNs = System.nanoTime()
val current = running
if (current != null) start(current) else publish()
return
}
val local = if (side == PieceColor.WHITE) whiteLeft else blackLeft
if (kotlin.math.abs(local - remainingMillis) < SYNC_THRESHOLD_MS) return
if (side == PieceColor.WHITE) whiteLeft = remainingMillis else blackLeft = remainingMillis
publish()
}
fun restore(whiteMillis: Long, blackMillis: Long) {
if (unlimited) return
account()
if (whiteMillis >= 0) whiteLeft = whiteMillis
if (blackMillis >= 0) blackLeft = blackMillis
publish()
}
fun pause() {
tickJob?.cancel()
account()
running = null
publish()
}
fun stop() {
tickJob?.cancel()
account()
running = null
publish()
}
private companion object {
const val SYNC_THRESHOLD_MS = 1_500L
}
}
@@ -0,0 +1,28 @@
package cc.skysparkle.matewave.clock
data class TimeControl(
val label: String,
val initialSeconds: Int,
val incrementSeconds: Int
) {
companion object {
val BULLET_1_0 = TimeControl("Bullet 1+0", 60, 0)
val BULLET_2_1 = TimeControl("Bullet 2+1", 120, 1)
val BLITZ_3_0 = TimeControl("Blitz 3+0", 180, 0)
val BLITZ_5_0 = TimeControl("Blitz 5+0", 300, 0)
val BLITZ_5_3 = TimeControl("Blitz 5+3", 300, 3)
val RAPID_10_0 = TimeControl("Rapid 10+0", 600, 0)
val RAPID_15_10 = TimeControl("Rapid 15+10", 900, 10)
val CLASSICAL_30_0 = TimeControl("Classical 30+0", 1800, 0)
val CLASSICAL_30_20 = TimeControl("Classical 30+20", 1800, 20)
val NO_LIMIT = TimeControl("No time limit", Int.MAX_VALUE, 0)
val presets = listOf(
BULLET_1_0, BULLET_2_1,
BLITZ_3_0, BLITZ_5_0, BLITZ_5_3,
RAPID_10_0, RAPID_15_10,
CLASSICAL_30_0, CLASSICAL_30_20,
NO_LIMIT
)
}
}
@@ -0,0 +1,25 @@
package cc.skysparkle.matewave.data
import android.content.Context
import java.util.UUID
object DeviceIdentity {
private const val PREFS = "device_identity"
private const val KEY_DEVICE_ID = "deviceId"
@Volatile private var cached: String? = null
fun deviceId(context: Context): String {
cached?.let { return it }
synchronized(this) {
cached?.let { return it }
val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val existing = prefs.getString(KEY_DEVICE_ID, null)
val id = existing ?: UUID.randomUUID().toString().also {
prefs.edit().putString(KEY_DEVICE_ID, it).apply()
}
cached = id
return id
}
}
}
@@ -0,0 +1,102 @@
package cc.skysparkle.matewave.data
import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
data class SavedGame(
val fen: String,
val myColorWhite: Boolean,
val difficultyName: String,
val initialSeconds: Int,
val incrementSeconds: Int,
val whiteMillis: Long = -1,
val blackMillis: Long = -1,
/** Moves from the standard start in UCI, space separated; empty for saves made before version 3. */
val movesUci: String = ""
)
/** Persists the unfinished game against the AI: moves, position and remaining clock time. */
object GameSaveStore {
private const val DB_NAME = "chess_save.db"
private const val DB_VERSION = 3
private const val TABLE = "saved_game"
@Volatile private var helper: DbHelper? = null
fun init(context: Context) {
if (helper == null) synchronized(this) {
if (helper == null) helper = DbHelper(context.applicationContext)
}
}
private class DbHelper(context: Context) : SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) {
override fun onCreate(db: SQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE $TABLE (
id INTEGER PRIMARY KEY CHECK (id = 1),
fen TEXT NOT NULL,
my_color_white INTEGER NOT NULL,
difficulty TEXT NOT NULL,
initial_seconds INTEGER NOT NULL,
increment_seconds INTEGER NOT NULL,
white_ms INTEGER NOT NULL DEFAULT -1,
black_ms INTEGER NOT NULL DEFAULT -1,
moves TEXT NOT NULL DEFAULT ''
)
""".trimIndent()
)
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
if (oldVersion < 2) {
db.execSQL("ALTER TABLE $TABLE ADD COLUMN white_ms INTEGER NOT NULL DEFAULT -1")
db.execSQL("ALTER TABLE $TABLE ADD COLUMN black_ms INTEGER NOT NULL DEFAULT -1")
}
if (oldVersion < 3) {
db.execSQL("ALTER TABLE $TABLE ADD COLUMN moves TEXT NOT NULL DEFAULT ''")
}
}
}
fun save(game: SavedGame) {
val db = runCatching { helper?.writableDatabase }.getOrNull() ?: return
val values = ContentValues().apply {
put("id", 1)
put("fen", game.fen)
put("my_color_white", if (game.myColorWhite) 1 else 0)
put("difficulty", game.difficultyName)
put("initial_seconds", game.initialSeconds)
put("increment_seconds", game.incrementSeconds)
put("white_ms", game.whiteMillis)
put("black_ms", game.blackMillis)
put("moves", game.movesUci)
}
runCatching { db.insertWithOnConflict(TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE) }
}
fun load(): SavedGame? {
val db = runCatching { helper?.readableDatabase }.getOrNull() ?: return null
return runCatching {
db.query(TABLE, null, "id = 1", null, null, null, null).use { cursor ->
if (!cursor.moveToFirst()) return@use null
SavedGame(
fen = cursor.getString(cursor.getColumnIndexOrThrow("fen")),
myColorWhite = cursor.getInt(cursor.getColumnIndexOrThrow("my_color_white")) == 1,
difficultyName = cursor.getString(cursor.getColumnIndexOrThrow("difficulty")),
initialSeconds = cursor.getInt(cursor.getColumnIndexOrThrow("initial_seconds")),
incrementSeconds = cursor.getInt(cursor.getColumnIndexOrThrow("increment_seconds")),
whiteMillis = cursor.getLong(cursor.getColumnIndexOrThrow("white_ms")),
blackMillis = cursor.getLong(cursor.getColumnIndexOrThrow("black_ms")),
movesUci = cursor.getString(cursor.getColumnIndexOrThrow("moves")) ?: ""
)
}
}.getOrNull()
}
fun clear() {
runCatching { helper?.writableDatabase?.delete(TABLE, "id = 1", null) }
}
}
@@ -0,0 +1,253 @@
package cc.skysparkle.matewave.data
import cc.skysparkle.matewave.ai.AiDifficulty
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
import kotlin.math.pow
import kotlin.math.roundToInt
data class Profile(
val userId: String,
val name: String,
val avatarBase64: String?,
val backgroundBase64: String?,
val elo: Int,
val levelChosen: Boolean
)
object ProfileStore {
private const val PREFS = "chess_profile_prefs"
private const val KEY_USER_ID = "user_id"
private const val KEY_NAME = "name"
private const val KEY_AVATAR = "avatar"
private const val KEY_BACKGROUND = "background"
private const val KEY_ELO = "elo"
private const val KEY_LEVEL_CHOSEN = "level_chosen"
private const val K_FACTOR = 32.0
private var appContext: Context? = null
fun init(context: Context) {
appContext = context.applicationContext
}
private fun prefs() = appContext?.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
fun userId(): String {
val p = prefs() ?: return "local"
p.getString(KEY_USER_ID, null)?.let { return it }
val fresh = java.util.UUID.randomUUID().toString()
p.edit().putString(KEY_USER_ID, fresh).apply()
return fresh
}
fun get(): Profile {
val p = prefs()
return Profile(
userId = userId(),
name = p?.getString(KEY_NAME, null)
?: appContext?.getString(cc.skysparkle.matewave.R.string.player_default_name)
?: "Player",
avatarBase64 = p?.getString(KEY_AVATAR, null),
backgroundBase64 = p?.getString(KEY_BACKGROUND, null),
elo = p?.getInt(KEY_ELO, 1200) ?: 1200,
levelChosen = p?.getBoolean(KEY_LEVEL_CHOSEN, false) ?: false
)
}
fun setName(name: String) {
prefs()?.edit()?.putString(KEY_NAME, name)?.apply()
}
fun setAvatar(base64: String?) {
prefs()?.edit()?.putString(KEY_AVATAR, base64)?.apply()
}
fun resetAvatarToDefault() = setAvatar(null)
fun setBackground(base64: String?) {
prefs()?.edit()?.putString(KEY_BACKGROUND, base64)?.apply()
}
fun setStartingElo(elo: Int) {
prefs()?.edit()
?.putInt(KEY_ELO, elo)
?.putBoolean(KEY_LEVEL_CHOSEN, true)
?.apply()
}
fun applyEloDelta(opponentElo: Int, result: Double) {
val current = get().elo
val expected = 1.0 / (1.0 + 10.0.pow((opponentElo - current) / 400.0))
val newElo = (current + K_FACTOR * (result - expected)).roundToInt().coerceIn(100, 3000)
prefs()?.edit()?.putInt(KEY_ELO, newElo)?.apply()
cc.skysparkle.matewave.stats.Stats.recordRating(current, newElo)
}
fun aiNominalElo(difficulty: AiDifficulty): Int = when (difficulty) {
AiDifficulty.EASY -> 800
AiDifficulty.MEDIUM -> 1200
AiDifficulty.HARD -> 1600
AiDifficulty.EXPERT -> 2000
}
}
data class FriendEntry(
val userId: String,
val name: String,
val avatarBase64: String?,
val backgroundBase64: String?,
val elo: Int,
val lastPlayedMs: Long,
val favorite: Boolean = false,
/** Last time the friend was seen nearby; together with the last game it gives "last seen". */
val lastSeenMs: Long = 0
)
object FriendsStore {
private const val PREFS = "chess_friends_prefs"
private const val KEY_LIST = "friends_json"
private const val MAX_FRIENDS = 30
private var appContext: Context? = null
private val lock = Any()
@Volatile private var cached: List<FriendEntry>? = null
fun init(context: Context) {
appContext = context.applicationContext
cached = null
}
private fun prefs() = appContext?.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
fun list(): List<FriendEntry> = synchronized(lock) { readAll() }
private fun readAll(): List<FriendEntry> {
cached?.let { return it }
val raw = prefs()?.getString(KEY_LIST, null) ?: return emptyList()
val arr = try {
JSONArray(raw)
} catch (e: Exception) {
return emptyList()
}
val result = ArrayList<FriendEntry>()
for (i in 0 until arr.length()) {
try {
val o = arr.getJSONObject(i)
result.add(
FriendEntry(
userId = o.optString("uid", "").ifBlank { o.getString("name") },
name = o.getString("name"),
avatarBase64 = if (o.has("avatar")) o.getString("avatar") else null,
backgroundBase64 = if (o.has("background")) o.getString("background") else null,
elo = o.optInt("elo", 1200),
lastPlayedMs = o.optLong("t", 0),
favorite = o.optBoolean("fav", false),
lastSeenMs = o.optLong("s", 0)
)
)
} catch (e: Exception) {
}
}
val sorted = result.sortedByDescending { it.lastPlayedMs }
cached = sorted
return sorted
}
private fun writeAll(entries: List<FriendEntry>) {
val p = prefs() ?: return
val arr = JSONArray()
entries.forEach { f ->
val o = JSONObject()
o.put("uid", f.userId)
o.put("name", f.name)
if (f.avatarBase64 != null) o.put("avatar", f.avatarBase64)
if (f.backgroundBase64 != null) o.put("background", f.backgroundBase64)
o.put("elo", f.elo)
o.put("t", f.lastPlayedMs)
if (f.favorite) o.put("fav", true)
if (f.lastSeenMs > 0) o.put("s", f.lastSeenMs)
arr.put(o)
}
p.edit().putString(KEY_LIST, arr.toString()).apply()
cached = entries.sortedByDescending { it.lastPlayedMs }
}
fun preload() {
synchronized(lock) { readAll() }
}
fun updateProfile(
userId: String,
name: String,
elo: Int,
avatarBase64: String? = null,
backgroundBase64: String? = null
) {
synchronized(lock) {
val existing = readAll().firstOrNull { it.userId == userId } ?: return
val updated = existing.copy(
name = name.ifBlank { existing.name },
elo = if (elo > 0) elo else existing.elo,
avatarBase64 = avatarBase64 ?: existing.avatarBase64,
backgroundBase64 = backgroundBase64 ?: existing.backgroundBase64
)
if (updated == existing) return
recordOpponent(updated)
}
}
fun toggleFavorite(userId: String) {
synchronized(lock) {
val existing = readAll().firstOrNull { it.userId == userId } ?: return
recordOpponent(existing.copy(favorite = !existing.favorite))
}
}
fun mergeOpponent(
userId: String,
name: String,
elo: Int,
avatarBase64: String? = null,
backgroundBase64: String? = null
): FriendEntry = synchronized(lock) {
val existing = readAll().firstOrNull { it.userId == userId }
val merged = FriendEntry(
userId = userId,
name = name.ifBlank { existing?.name ?: name },
avatarBase64 = avatarBase64 ?: existing?.avatarBase64,
backgroundBase64 = backgroundBase64 ?: existing?.backgroundBase64,
elo = if (elo > 0) elo else (existing?.elo ?: 1200),
lastPlayedMs = System.currentTimeMillis(),
favorite = existing?.favorite ?: false,
lastSeenMs = existing?.lastSeenMs ?: 0
)
recordOpponent(merged)
merged
}
/** Notes that a friend is nearby now. Written at most once a minute per friend. */
fun markSeen(userId: String, at: Long) {
synchronized(lock) {
val all = readAll()
val index = all.indexOfFirst { it.userId == userId }
if (index < 0 || at - all[index].lastSeenMs < 60_000) return
// Order is kept: being seen is not a game, so the friend does not jump to the top.
writeAll(all.toMutableList().also { it[index] = all[index].copy(lastSeenMs = at) })
}
}
fun recordOpponent(entry: FriendEntry) {
synchronized(lock) {
val current = readAll().filter { it.userId != entry.userId }
writeAll((listOf(entry) + current).take(MAX_FRIENDS))
}
}
}
@@ -0,0 +1,121 @@
package cc.skysparkle.matewave.engine
data class Board(
val squares: Array<Piece?>,
val sideToMove: PieceColor,
val whiteKingSideCastle: Boolean,
val whiteQueenSideCastle: Boolean,
val blackKingSideCastle: Boolean,
val blackQueenSideCastle: Boolean,
val enPassantTarget: Int?,
val halfmoveClock: Int,
val fullmoveNumber: Int
) {
fun pieceAt(sq: Int): Piece? = squares[sq]
fun deepCopySquares(): Array<Piece?> = squares.copyOf()
fun kingSquare(color: PieceColor): Int {
for (i in 0 until 64) {
val p = squares[i]
if (p != null && p.type == PieceType.KING && p.color == color) return i
}
return -1
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Board) return false
return squares.contentEquals(other.squares) &&
sideToMove == other.sideToMove &&
whiteKingSideCastle == other.whiteKingSideCastle &&
whiteQueenSideCastle == other.whiteQueenSideCastle &&
blackKingSideCastle == other.blackKingSideCastle &&
blackQueenSideCastle == other.blackQueenSideCastle &&
enPassantTarget == other.enPassantTarget
}
override fun hashCode(): Int {
var result = squares.contentHashCode()
result = 31 * result + sideToMove.hashCode()
return result
}
companion object {
fun startPosition(): Board = fromFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
fun fromFen(fen: String): Board {
val parts = fen.trim().split(" ")
val rows = parts[0].split("/")
val squares = arrayOfNulls<Piece?>(64)
for (r in 0 until 8) {
val rank = 7 - r
var file = 0
for (c in rows[r]) {
if (c.isDigit()) {
file += c.digitToInt()
} else {
val color = if (c.isUpperCase()) PieceColor.WHITE else PieceColor.BLACK
val type = when (c.uppercaseChar()) {
'P' -> PieceType.PAWN
'N' -> PieceType.KNIGHT
'B' -> PieceType.BISHOP
'R' -> PieceType.ROOK
'Q' -> PieceType.QUEEN
'K' -> PieceType.KING
else -> throw IllegalArgumentException("Bad FEN char $c")
}
squares[rank * 8 + file] = Piece(type, color)
file++
}
}
}
val side = if (parts.getOrElse(1) { "w" } == "w") PieceColor.WHITE else PieceColor.BLACK
val castling = parts.getOrElse(2) { "-" }
val ep = parts.getOrElse(3) { "-" }
val halfmove = parts.getOrElse(4) { "0" }.toIntOrNull() ?: 0
val fullmove = parts.getOrElse(5) { "1" }.toIntOrNull() ?: 1
return Board(
squares = squares,
sideToMove = side,
whiteKingSideCastle = castling.contains('K'),
whiteQueenSideCastle = castling.contains('Q'),
blackKingSideCastle = castling.contains('k'),
blackQueenSideCastle = castling.contains('q'),
enPassantTarget = if (ep == "-") null else Move.squareFromName(ep),
halfmoveClock = halfmove,
fullmoveNumber = fullmove
)
}
}
fun toFen(): String {
val sb = StringBuilder()
for (r in 7 downTo 0) {
var empty = 0
for (f in 0 until 8) {
val p = squares[r * 8 + f]
if (p == null) {
empty++
} else {
if (empty > 0) { sb.append(empty); empty = 0 }
sb.append(p.fenChar())
}
}
if (empty > 0) sb.append(empty)
if (r != 0) sb.append('/')
}
sb.append(if (sideToMove == PieceColor.WHITE) " w " else " b ")
val castle = buildString {
if (whiteKingSideCastle) append('K')
if (whiteQueenSideCastle) append('Q')
if (blackKingSideCastle) append('k')
if (blackQueenSideCastle) append('q')
}
sb.append(if (castle.isEmpty()) "-" else castle)
sb.append(' ')
sb.append(if (enPassantTarget == null) "-" else Move.squareName(enPassantTarget))
sb.append(' ').append(halfmoveClock).append(' ').append(fullmoveNumber)
return sb.toString()
}
}
@@ -0,0 +1,374 @@
package cc.skysparkle.matewave.engine
enum class GameStatus {
ONGOING, CHECKMATE, STALEMATE, DRAW_50_MOVES, DRAW_REPETITION, DRAW_MATERIAL, TIMEOUT,
/** A flag fell, but the other side has too little material to ever mate. */
TIMEOUT_DRAW,
RESIGNATION, DRAW_AGREEMENT
}
/**
* Rules of chess: legal move generation (castling, en passant, promotion), move
* application and game-status detection (mate, stalemate, 50-move rule, threefold
* repetition, insufficient material).
*/
object ChessEngine {
private fun file(sq: Int) = sq % 8
private fun rank(sq: Int) = sq / 8
private fun inBounds(f: Int, r: Int) = f in 0..7 && r in 0..7
private fun sq(f: Int, r: Int) = r * 8 + f
private val KNIGHT_DF = intArrayOf(1, 2, 2, 1, -1, -2, -2, -1)
private val KNIGHT_DR = intArrayOf(2, 1, -1, -2, -2, -1, 1, 2)
private val DIAG_DF = intArrayOf(1, 1, -1, -1)
private val DIAG_DR = intArrayOf(1, -1, 1, -1)
private val ORTHO_DF = intArrayOf(1, -1, 0, 0)
private val ORTHO_DR = intArrayOf(0, 0, 1, -1)
private val ALL_DF = intArrayOf(1, -1, 0, 0, 1, 1, -1, -1)
private val ALL_DR = intArrayOf(0, 0, 1, -1, 1, -1, 1, -1)
private val PAWN_DF = intArrayOf(-1, 1)
private fun pseudoLegalMoves(board: Board): List<Move> {
val moves = ArrayList<Move>(48)
for (from in 0 until 64) {
val piece = board.squares[from] ?: continue
if (piece.color != board.sideToMove) continue
when (piece.type) {
PieceType.PAWN -> generatePawnMoves(board, from, piece, moves)
PieceType.KNIGHT -> generateKnightMoves(board, from, piece, moves)
PieceType.BISHOP -> generateSlidingMoves(board, from, piece, moves, DIAG_DF, DIAG_DR)
PieceType.ROOK -> generateSlidingMoves(board, from, piece, moves, ORTHO_DF, ORTHO_DR)
PieceType.QUEEN -> generateSlidingMoves(board, from, piece, moves, ALL_DF, ALL_DR)
PieceType.KING -> generateKingMoves(board, from, piece, moves)
}
}
return moves
}
private fun generatePawnMoves(board: Board, from: Int, piece: Piece, out: MutableList<Move>) {
val dir = if (piece.color == PieceColor.WHITE) 1 else -1
val startRank = if (piece.color == PieceColor.WHITE) 1 else 6
val promoRank = if (piece.color == PieceColor.WHITE) 7 else 0
val f = file(from); val r = rank(from)
val oneR = r + dir
if (inBounds(f, oneR) && board.squares[sq(f, oneR)] == null) {
addPawnMoveOrPromotion(from, sq(f, oneR), oneR, promoRank, out)
val twoR = r + 2 * dir
if (r == startRank && board.squares[sq(f, twoR)] == null) {
out.add(Move(from, sq(f, twoR)))
}
}
for (df in PAWN_DF) {
val nf = f + df; val nr = r + dir
if (!inBounds(nf, nr)) continue
val target = sq(nf, nr)
val targetPiece = board.squares[target]
if (targetPiece != null && targetPiece.color != piece.color) {
addPawnMoveOrPromotion(from, target, nr, promoRank, out)
} else if (target == board.enPassantTarget) {
out.add(Move(from, target, isEnPassant = true))
}
}
}
private fun addPawnMoveOrPromotion(from: Int, to: Int, toRank: Int, promoRank: Int, out: MutableList<Move>) {
if (toRank == promoRank) {
out.add(Move(from, to, promotion = PieceType.QUEEN))
out.add(Move(from, to, promotion = PieceType.ROOK))
out.add(Move(from, to, promotion = PieceType.BISHOP))
out.add(Move(from, to, promotion = PieceType.KNIGHT))
} else {
out.add(Move(from, to))
}
}
private fun generateKnightMoves(board: Board, from: Int, piece: Piece, out: MutableList<Move>) {
val f = file(from); val r = rank(from)
for (i in 0 until 8) {
val nf = f + KNIGHT_DF[i]; val nr = r + KNIGHT_DR[i]
if (!inBounds(nf, nr)) continue
val target = sq(nf, nr)
val targetPiece = board.squares[target]
if (targetPiece == null || targetPiece.color != piece.color) out.add(Move(from, target))
}
}
private fun generateSlidingMoves(
board: Board, from: Int, piece: Piece, out: MutableList<Move>, dfs: IntArray, drs: IntArray
) {
val f = file(from); val r = rank(from)
for (i in dfs.indices) {
val df = dfs[i]; val dr = drs[i]
var nf = f + df; var nr = r + dr
while (inBounds(nf, nr)) {
val target = sq(nf, nr)
val targetPiece = board.squares[target]
if (targetPiece == null) {
out.add(Move(from, target))
} else {
if (targetPiece.color != piece.color) out.add(Move(from, target))
break
}
nf += df; nr += dr
}
}
}
private fun generateKingMoves(board: Board, from: Int, piece: Piece, out: MutableList<Move>) {
val f = file(from); val r = rank(from)
for (df in -1..1) for (dr in -1..1) {
if (df == 0 && dr == 0) continue
val nf = f + df; val nr = r + dr
if (!inBounds(nf, nr)) continue
val target = sq(nf, nr)
val targetPiece = board.squares[target]
if (targetPiece == null || targetPiece.color != piece.color) out.add(Move(from, target))
}
val backRank = if (piece.color == PieceColor.WHITE) 0 else 7
if (from == sq(4, backRank)) {
val ownRook = Piece(PieceType.ROOK, piece.color)
val kingSide = (if (piece.color == PieceColor.WHITE) board.whiteKingSideCastle else board.blackKingSideCastle) &&
board.squares[sq(7, backRank)] == ownRook
val queenSide = (if (piece.color == PieceColor.WHITE) board.whiteQueenSideCastle else board.blackQueenSideCastle) &&
board.squares[sq(0, backRank)] == ownRook
val enemy = piece.color.opposite()
if (kingSide &&
board.squares[sq(5, backRank)] == null && board.squares[sq(6, backRank)] == null &&
!isSquareAttacked(board, sq(4, backRank), enemy) &&
!isSquareAttacked(board, sq(5, backRank), enemy) &&
!isSquareAttacked(board, sq(6, backRank), enemy)
) {
out.add(Move(from, sq(6, backRank), isCastleKingSide = true))
}
if (queenSide &&
board.squares[sq(3, backRank)] == null && board.squares[sq(2, backRank)] == null && board.squares[sq(1, backRank)] == null &&
!isSquareAttacked(board, sq(4, backRank), enemy) &&
!isSquareAttacked(board, sq(3, backRank), enemy) &&
!isSquareAttacked(board, sq(2, backRank), enemy)
) {
out.add(Move(from, sq(2, backRank), isCastleQueenSide = true))
}
}
}
fun isSquareAttacked(board: Board, target: Int, byColor: PieceColor): Boolean =
attacked(board.squares, target, byColor)
private fun attacked(squares: Array<Piece?>, target: Int, byColor: PieceColor): Boolean {
val tf = file(target); val tr = rank(target)
val pawnDir = if (byColor == PieceColor.WHITE) -1 else 1
for (df in PAWN_DF) {
val f = tf + df; val r = tr + pawnDir
if (inBounds(f, r)) {
val p = squares[sq(f, r)]
if (p != null && p.color == byColor && p.type == PieceType.PAWN) return true
}
}
for (i in 0 until 8) {
val f = tf + KNIGHT_DF[i]; val r = tr + KNIGHT_DR[i]
if (inBounds(f, r)) {
val p = squares[sq(f, r)]
if (p != null && p.color == byColor && p.type == PieceType.KNIGHT) return true
}
}
for (i in 0 until 8) {
val f = tf + ALL_DF[i]; val r = tr + ALL_DR[i]
if (inBounds(f, r)) {
val p = squares[sq(f, r)]
if (p != null && p.color == byColor && p.type == PieceType.KING) return true
}
}
for (i in 0 until 4) {
val df = DIAG_DF[i]; val dr = DIAG_DR[i]
var f = tf + df; var r = tr + dr
while (inBounds(f, r)) {
val p = squares[sq(f, r)]
if (p != null) {
if (p.color == byColor && (p.type == PieceType.BISHOP || p.type == PieceType.QUEEN)) return true
break
}
f += df; r += dr
}
}
for (i in 0 until 4) {
val df = ORTHO_DF[i]; val dr = ORTHO_DR[i]
var f = tf + df; var r = tr + dr
while (inBounds(f, r)) {
val p = squares[sq(f, r)]
if (p != null) {
if (p.color == byColor && (p.type == PieceType.ROOK || p.type == PieceType.QUEEN)) return true
break
}
f += df; r += dr
}
}
return false
}
fun isInCheck(board: Board, color: PieceColor): Boolean {
val kingSq = board.kingSquare(color)
if (kingSq == -1) return false
return isSquareAttacked(board, kingSq, color.opposite())
}
fun legalMoves(board: Board): List<Move> {
val pseudo = pseudoLegalMoves(board)
val side = board.sideToMove
val enemy = side.opposite()
val work = board.squares.copyOf()
val ownKing = board.kingSquare(side)
val result = ArrayList<Move>(pseudo.size)
for (m in pseudo) {
val moving = work[m.from] ?: continue
val captured = work[m.to]
var epIdx = -1
var epPiece: Piece? = null
if (m.isEnPassant) {
val capturedRank = if (moving.color == PieceColor.WHITE) rank(m.to) - 1 else rank(m.to) + 1
epIdx = sq(file(m.to), capturedRank)
epPiece = work[epIdx]
work[epIdx] = null
}
work[m.to] = if (m.promotion != null) Piece(m.promotion, moving.color) else moving
work[m.from] = null
var rookFrom = -1
var rookTo = -1
var rook: Piece? = null
if (m.isCastleKingSide) {
val backRank = rank(m.from); rookFrom = sq(7, backRank); rookTo = sq(5, backRank)
} else if (m.isCastleQueenSide) {
val backRank = rank(m.from); rookFrom = sq(0, backRank); rookTo = sq(3, backRank)
}
if (rookFrom >= 0) {
rook = work[rookFrom]
work[rookTo] = rook
work[rookFrom] = null
}
val kingSq = if (moving.type == PieceType.KING) m.to else ownKing
val safe = kingSq == -1 || !attacked(work, kingSq, enemy)
if (rookFrom >= 0) {
work[rookFrom] = rook
work[rookTo] = null
}
work[m.from] = moving
work[m.to] = captured
if (epIdx >= 0) work[epIdx] = epPiece
if (safe) result.add(m)
}
return result
}
fun legalMovesFrom(board: Board, from: Int): List<Move> = legalMoves(board).filter { it.from == from }
fun applyMove(board: Board, move: Move): Board {
val squares = board.deepCopySquares()
val moving = squares[move.from] ?: return board
val isPawn = moving.type == PieceType.PAWN
val isCapture = squares[move.to] != null
if (move.isEnPassant) {
val capturedRank = if (moving.color == PieceColor.WHITE) rank(move.to) - 1 else rank(move.to) + 1
squares[sq(file(move.to), capturedRank)] = null
}
squares[move.to] = if (move.promotion != null) Piece(move.promotion, moving.color) else moving
squares[move.from] = null
if (move.isCastleKingSide) {
val backRank = rank(move.from)
squares[sq(5, backRank)] = squares[sq(7, backRank)]
squares[sq(7, backRank)] = null
}
if (move.isCastleQueenSide) {
val backRank = rank(move.from)
squares[sq(3, backRank)] = squares[sq(0, backRank)]
squares[sq(0, backRank)] = null
}
var wk = board.whiteKingSideCastle
var wq = board.whiteQueenSideCastle
var bk = board.blackKingSideCastle
var bq = board.blackQueenSideCastle
if (moving.type == PieceType.KING) {
if (moving.color == PieceColor.WHITE) { wk = false; wq = false } else { bk = false; bq = false }
}
if (move.from == sq(0, 0) || move.to == sq(0, 0)) wq = false
if (move.from == sq(7, 0) || move.to == sq(7, 0)) wk = false
if (move.from == sq(0, 7) || move.to == sq(0, 7)) bq = false
if (move.from == sq(7, 7) || move.to == sq(7, 7)) bk = false
val newEnPassant = if (isPawn && kotlin.math.abs(rank(move.to) - rank(move.from)) == 2) {
sq(file(move.from), (rank(move.from) + rank(move.to)) / 2)
} else null
val newHalfmove = if (isPawn || isCapture) 0 else board.halfmoveClock + 1
val newFullmove = if (board.sideToMove == PieceColor.BLACK) board.fullmoveNumber + 1 else board.fullmoveNumber
return Board(
squares = squares,
sideToMove = board.sideToMove.opposite(),
whiteKingSideCastle = wk,
whiteQueenSideCastle = wq,
blackKingSideCastle = bk,
blackQueenSideCastle = bq,
enPassantTarget = newEnPassant,
halfmoveClock = newHalfmove,
fullmoveNumber = newFullmove
)
}
fun gameStatus(board: Board, positionHistory: List<String> = emptyList()): GameStatus {
val moves = legalMoves(board)
if (moves.isEmpty()) {
return if (isInCheck(board, board.sideToMove)) GameStatus.CHECKMATE else GameStatus.STALEMATE
}
if (board.halfmoveClock >= 100) return GameStatus.DRAW_50_MOVES
val key = Notation.positionKey(board)
if (positionHistory.count { it == key } >= 3) return GameStatus.DRAW_REPETITION
if (isDeadPosition(board)) return GameStatus.DRAW_MATERIAL
return GameStatus.ONGOING
}
/**
* Neither side can ever mate: kings alone, a single minor piece, or only bishops that all
* stand on squares of one colour.
*/
fun isDeadPosition(board: Board): Boolean {
val minors = ArrayList<Int>(4)
for (i in 0 until 64) {
val p = board.squares[i] ?: continue
when (p.type) {
PieceType.KING -> Unit
PieceType.BISHOP, PieceType.KNIGHT -> minors.add(i)
else -> return false
}
}
if (minors.size <= 1) return true
if (minors.any { board.squares[it]?.type != PieceType.BISHOP }) return false
val shade = (file(minors[0]) + rank(minors[0])) % 2
return minors.all { (file(it) + rank(it)) % 2 == shade }
}
/**
* Whether [color] has enough material to mate at all. A player whose flag falls loses only if
* the opponent could still mate; otherwise the game is drawn.
*/
fun canMate(board: Board, color: PieceColor): Boolean {
var minors = 0
for (p in board.squares) {
if (p == null || p.color != color) continue
when (p.type) {
PieceType.KING -> Unit
PieceType.BISHOP, PieceType.KNIGHT -> minors++
else -> return true
}
}
return minors >= 2
}
}
@@ -0,0 +1,66 @@
package cc.skysparkle.matewave.engine
data class MaterialBalance(
val capturedByWhite: List<PieceType>,
val capturedByBlack: List<PieceType>,
val advantage: Int
)
/** Captured pieces and material balance, shown next to the board. */
object Material {
fun points(type: PieceType): Int = when (type) {
PieceType.PAWN -> 1
PieceType.KNIGHT -> 3
PieceType.BISHOP -> 3
PieceType.ROOK -> 5
PieceType.QUEEN -> 9
PieceType.KING -> 0
}
private fun initialCount(type: PieceType): Int = when (type) {
PieceType.PAWN -> 8
PieceType.KNIGHT, PieceType.BISHOP, PieceType.ROOK -> 2
PieceType.QUEEN, PieceType.KING -> 1
}
private val DISPLAY_ORDER = listOf(
PieceType.PAWN, PieceType.KNIGHT, PieceType.BISHOP, PieceType.ROOK, PieceType.QUEEN
)
fun balance(board: Board): MaterialBalance {
val white = IntArray(PieceType.values().size)
val black = IntArray(PieceType.values().size)
var whitePoints = 0
var blackPoints = 0
for (piece in board.squares) {
if (piece == null) continue
if (piece.color == PieceColor.WHITE) {
white[piece.type.ordinal]++
whitePoints += points(piece.type)
} else {
black[piece.type.ordinal]++
blackPoints += points(piece.type)
}
}
return MaterialBalance(
capturedByWhite = missing(black),
capturedByBlack = missing(white),
advantage = whitePoints - blackPoints
)
}
private fun missing(counts: IntArray): List<PieceType> {
val promoted = DISPLAY_ORDER.filter { it != PieceType.PAWN }
.sumOf { maxOf(0, counts[it.ordinal] - initialCount(it)) }
val result = ArrayList<PieceType>()
for (type in DISPLAY_ORDER) {
val gone = if (type == PieceType.PAWN) {
initialCount(type) - counts[type.ordinal] - promoted
} else {
initialCount(type) - counts[type.ordinal]
}
repeat(maxOf(0, gone)) { result.add(type) }
}
return result
}
}
@@ -0,0 +1,44 @@
package cc.skysparkle.matewave.engine
data class Move(
val from: Int,
val to: Int,
val promotion: PieceType? = null,
val isCastleKingSide: Boolean = false,
val isCastleQueenSide: Boolean = false,
val isEnPassant: Boolean = false
) {
fun toUci(): String {
val promo = promotion?.symbol?.lowercaseChar()?.toString() ?: ""
return squareName(from) + squareName(to) + promo
}
companion object {
fun squareName(sq: Int): String {
val file = sq % 8
val rank = sq / 8
return "${'a' + file}${rank + 1}"
}
fun squareFromName(name: String): Int {
val file = name[0] - 'a'
val rank = name[1] - '1'
return rank * 8 + file
}
fun fromUci(uci: String): Move {
val from = squareFromName(uci.substring(0, 2))
val to = squareFromName(uci.substring(2, 4))
val promo = if (uci.length > 4) {
when (uci[4].lowercaseChar()) {
'q' -> PieceType.QUEEN
'r' -> PieceType.ROOK
'b' -> PieceType.BISHOP
'n' -> PieceType.KNIGHT
else -> null
}
} else null
return Move(from, to, promo)
}
}
}
@@ -0,0 +1,96 @@
package cc.skysparkle.matewave.engine
/**
* Standard Algebraic Notation. SAN produced here matches Lichess notation without check marks
* (e.g. "Nbd7", "exd6", "e8=Q", "O-O"), which is the key format of the opening data.
*/
object Notation {
fun isCastle(board: Board, move: Move): Boolean {
if (move.isCastleKingSide || move.isCastleQueenSide) return true
val piece = board.squares[move.from] ?: return false
return piece.type == PieceType.KING && kotlin.math.abs(move.to % 8 - move.from % 8) == 2
}
fun toSan(
board: Board,
move: Move,
legal: List<Move> = ChessEngine.legalMoves(board),
withCheck: Boolean = false
): String {
val piece = board.squares[move.from] ?: return move.toUci()
val base = if (isCastle(board, move)) {
if (move.to % 8 > move.from % 8) "O-O" else "O-O-O"
} else {
val capture = board.squares[move.to] != null || move.isEnPassant
val dest = Move.squareName(move.to)
buildString {
if (piece.type == PieceType.PAWN) {
if (capture) append('a' + move.from % 8).append('x')
append(dest)
move.promotion?.let { append('=').append(it.symbol) }
} else {
append(piece.type.symbol)
val rivals = legal.filter {
it.to == move.to && it.from != move.from &&
board.squares[it.from]?.type == piece.type
}
if (rivals.isNotEmpty()) {
val sameFile = rivals.any { it.from % 8 == move.from % 8 }
val sameRank = rivals.any { it.from / 8 == move.from / 8 }
when {
!sameFile -> append('a' + move.from % 8)
!sameRank -> append('1' + move.from / 8)
else -> append(Move.squareName(move.from))
}
}
if (capture) append('x')
append(dest)
}
}
}
if (!withCheck) return base
val after = ChessEngine.applyMove(board, move)
if (!ChessEngine.isInCheck(after, after.sideToMove)) return base
return base + if (ChessEngine.legalMoves(after).isEmpty()) "#" else "+"
}
/** Strips check marks, annotations and "=" so SAN from any source compares equal. */
fun normalizeSan(san: String): String =
san.trim()
.replace("0-0-0", "O-O-O").replace("0-0", "O-O")
.trimEnd('+', '#', '!', '?')
.replace("=", "")
.trim()
fun fromSan(board: Board, san: String, legal: List<Move> = ChessEngine.legalMoves(board)): Move? {
val wanted = normalizeSan(san)
if (wanted.isEmpty()) return null
legal.firstOrNull { normalizeSan(toSan(board, it, legal)) == wanted }?.let { return it }
// Over-disambiguated or loosely written SAN, e.g. "Ngf3" or "e8Q".
val m = Regex("^([KQRBN])?([a-h])?([1-8])?x?([a-h][1-8])([QRBN])?$").find(wanted) ?: return null
val type = when (m.groupValues[1]) {
"K" -> PieceType.KING; "Q" -> PieceType.QUEEN; "R" -> PieceType.ROOK
"B" -> PieceType.BISHOP; "N" -> PieceType.KNIGHT; else -> PieceType.PAWN
}
val fromFile = m.groupValues[2].firstOrNull()?.let { it - 'a' }
val fromRank = m.groupValues[3].firstOrNull()?.let { it - '1' }
val to = Move.squareFromName(m.groupValues[4])
val promo = when (m.groupValues[5]) {
"Q" -> PieceType.QUEEN; "R" -> PieceType.ROOK; "B" -> PieceType.BISHOP; "N" -> PieceType.KNIGHT
else -> null
}
return legal.filter {
it.to == to && board.squares[it.from]?.type == type && it.promotion == promo &&
(fromFile == null || it.from % 8 == fromFile) &&
(fromRank == null || it.from / 8 == fromRank)
}.singleOrNull()
}
/** Position part of a FEN without move counters, used to compare positions. */
fun positionKey(board: Board): String =
board.toFen().substringBeforeLast(' ').substringBeforeLast(' ')
fun isStandardStart(board: Board): Boolean = positionKey(board) == positionKey(Board.startPosition())
}
@@ -0,0 +1,53 @@
package cc.skysparkle.matewave.engine
enum class PieceColor { WHITE, BLACK }
enum class PieceType(val symbol: Char) {
PAWN('P'), KNIGHT('N'), BISHOP('B'), ROOK('R'), QUEEN('Q'), KING('K')
}
data class Piece(val type: PieceType, val color: PieceColor) {
fun drawableRes(): Int = when (color) {
PieceColor.WHITE -> when (type) {
PieceType.PAWN -> cc.skysparkle.matewave.R.drawable.piece_white_pawn
PieceType.KNIGHT -> cc.skysparkle.matewave.R.drawable.piece_white_knight
PieceType.BISHOP -> cc.skysparkle.matewave.R.drawable.piece_white_bishop
PieceType.ROOK -> cc.skysparkle.matewave.R.drawable.piece_white_rook
PieceType.QUEEN -> cc.skysparkle.matewave.R.drawable.piece_white_queen
PieceType.KING -> cc.skysparkle.matewave.R.drawable.piece_white_king
}
PieceColor.BLACK -> when (type) {
PieceType.PAWN -> cc.skysparkle.matewave.R.drawable.piece_black_pawn
PieceType.KNIGHT -> cc.skysparkle.matewave.R.drawable.piece_black_knight
PieceType.BISHOP -> cc.skysparkle.matewave.R.drawable.piece_black_bishop
PieceType.ROOK -> cc.skysparkle.matewave.R.drawable.piece_black_rook
PieceType.QUEEN -> cc.skysparkle.matewave.R.drawable.piece_black_queen
PieceType.KING -> cc.skysparkle.matewave.R.drawable.piece_black_king
}
}
fun unicode(): String = when (color) {
PieceColor.WHITE -> when (type) {
PieceType.PAWN -> "\u2659"
PieceType.KNIGHT -> "\u2658"
PieceType.BISHOP -> "\u2657"
PieceType.ROOK -> "\u2656"
PieceType.QUEEN -> "\u2655"
PieceType.KING -> "\u2654"
}
PieceColor.BLACK -> when (type) {
PieceType.PAWN -> "\u265F"
PieceType.KNIGHT -> "\u265E"
PieceType.BISHOP -> "\u265D"
PieceType.ROOK -> "\u265C"
PieceType.QUEEN -> "\u265B"
PieceType.KING -> "\u265A"
}
}
fun fenChar(): Char =
if (color == PieceColor.WHITE) type.symbol else type.symbol.lowercaseChar()
}
fun PieceColor.opposite(): PieceColor =
if (this == PieceColor.WHITE) PieceColor.BLACK else PieceColor.WHITE
@@ -0,0 +1,24 @@
package cc.skysparkle.matewave.network
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
object AppPermissions {
/** Runtime permissions for Bluetooth play: scan, advertise and connect (location before Android 12). */
val nearby: List<String> =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
listOf(
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_ADVERTISE
)
} else {
listOf(Manifest.permission.ACCESS_FINE_LOCATION)
}
fun allGranted(context: Context, permissions: List<String>): Boolean =
permissions.all { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED }
}
@@ -0,0 +1,30 @@
package cc.skysparkle.matewave.network
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.PowerManager
import android.provider.Settings
object BatteryOptimization {
fun isIgnoring(context: Context): Boolean {
val power = context.getSystemService(Context.POWER_SERVICE) as? PowerManager ?: return true
return runCatching { power.isIgnoringBatteryOptimizations(context.packageName) }
.getOrDefault(true)
}
fun openSettings(context: Context) {
val intents = listOf(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
.setData(Uri.parse("package:${context.packageName}")),
Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
)
for (intent in intents) {
val ok = runCatching {
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}.isSuccess
if (ok) return
}
NetworkLog.log("SYS", "Could not open battery settings")
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,191 @@
package cc.skysparkle.matewave.network
import org.json.JSONObject
enum class TransportType { BLUETOOTH, LAN }
enum class ConnectionState { DISCONNECTED, CONNECTING, CONNECTED, FAILED }
/**
* Wire protocol shared by all transports: one JSON object per message.
* Game messages travel inside [SignedMsg] once the handshake is done; only
* [ProfileMsg] and [PingMsg] are sent in the clear. Bump [PROTOCOL_VERSION] on any
* incompatible change: peers with an older version are rejected during the handshake.
*/
sealed class GameMessage {
data class MoveMsg(
val uci: String,
val remainingMillisSelf: Long,
val gameId: String = "",
val ply: Int = 0
) : GameMessage()
data class ResignMsg(val byWhite: Boolean, val gameId: String = "") : GameMessage()
data class DrawOfferMsg(val accept: Boolean? = null, val gameId: String = "") : GameMessage()
data class ChatMsg(val text: String? = null, val imageBase64: String? = null, val gameId: String = "") : GameMessage()
data class ProfileMsg(
val userId: String,
val name: String,
val avatarBase64: String?,
val backgroundBase64: String?,
val elo: Int,
val publicKey: String = "",
val signature: String = "",
val protocolVersion: Int = PROTOCOL_VERSION,
val ephemeralKey: String = "",
val initialSeconds: Int = 0,
val incrementSeconds: Int = 0,
val gameId: String = ""
) : GameMessage()
data class TimeoutMsg(val flaggedWhite: Boolean, val gameId: String = "") : GameMessage()
data class LeaveMsg(val byWhite: Boolean, val gameId: String = "") : GameMessage()
data class ProfileImagesMsg(
val avatarBase64: String? = null,
val backgroundBase64: String? = null,
val gameId: String = ""
) : GameMessage()
data class PingMsg(val reply: Boolean = false, val sessionId: Int = 0) : GameMessage()
data class SignedMsg(val inner: String, val tag: String, val seq: Int = 0) : GameMessage()
data class RematchMsg(
val action: String,
val gameId: String = "",
val newGameId: String = ""
) : GameMessage()
fun toJson(): String {
val o = JSONObject()
when (this) {
is MoveMsg -> {
o.put("type", "move"); o.put("uci", uci); o.put("t", remainingMillisSelf)
o.put("gameId", gameId); o.put("ply", ply)
}
is ResignMsg -> { o.put("type", "resign"); o.put("white", byWhite); o.put("gameId", gameId) }
is DrawOfferMsg -> {
o.put("type", "draw"); o.put("gameId", gameId)
if (accept != null) o.put("accept", accept)
}
is ChatMsg -> {
o.put("type", "chat"); o.put("gameId", gameId)
if (text != null) o.put("text", text)
if (imageBase64 != null) o.put("image", imageBase64)
}
is LeaveMsg -> { o.put("type", "leave"); o.put("byWhite", byWhite); o.put("gameId", gameId) }
is ProfileImagesMsg -> {
o.put("type", "profile_images"); o.put("gameId", gameId)
avatarBase64?.let { o.put("avatar", it) }
backgroundBase64?.let { o.put("background", it) }
}
is PingMsg -> { o.put("type", "ping"); o.put("reply", reply); o.put("sid", sessionId) }
is TimeoutMsg -> { o.put("type", "timeout"); o.put("white", flaggedWhite); o.put("gameId", gameId) }
is SignedMsg -> {
o.put("type", "signed"); o.put("inner", inner); o.put("tag", tag); o.put("seq", seq)
}
is RematchMsg -> {
o.put("type", "rematch"); o.put("action", action); o.put("gameId", gameId)
if (newGameId.isNotEmpty()) o.put("newGameId", newGameId)
}
is ProfileMsg -> {
o.put("type", "profile")
o.put("userId", userId)
o.put("name", name)
o.put("pv", protocolVersion)
if (initialSeconds > 0) {
o.put("ti", initialSeconds)
o.put("tx", incrementSeconds)
}
if (ephemeralKey.isNotEmpty()) o.put("ek", ephemeralKey)
if (gameId.isNotEmpty()) o.put("gid", gameId)
if (publicKey.isNotEmpty()) o.put("pk", publicKey)
if (signature.isNotEmpty()) o.put("sig", signature)
if (avatarBase64 != null) o.put("avatar", avatarBase64)
if (backgroundBase64 != null) o.put("background", backgroundBase64)
o.put("elo", elo)
}
}
return o.toString()
}
companion object {
const val PROTOCOL_VERSION = 5
fun fromJson(raw: String): GameMessage {
val o = JSONObject(raw)
return when (o.getString("type")) {
"move" -> MoveMsg(
o.getString("uci"), o.optLong("t", 0),
o.optString("gameId", ""), o.optInt("ply", 0)
)
"resign" -> ResignMsg(o.getBoolean("white"), o.optString("gameId", ""))
"draw" -> DrawOfferMsg(
if (o.has("accept")) o.getBoolean("accept") else null,
o.optString("gameId", "")
)
"chat" -> ChatMsg(
text = if (o.has("text")) o.getString("text") else null,
imageBase64 = if (o.has("image")) o.getString("image") else null,
gameId = o.optString("gameId", "")
)
"leave" -> LeaveMsg(o.getBoolean("byWhite"), o.optString("gameId", ""))
"profile_images" -> ProfileImagesMsg(
if (o.has("avatar")) o.getString("avatar") else null,
if (o.has("background")) o.getString("background") else null,
gameId = o.optString("gameId", "")
)
"ping" -> PingMsg(o.optBoolean("reply", false), o.optInt("sid", 0))
"timeout" -> TimeoutMsg(o.getBoolean("white"), o.optString("gameId", ""))
"signed" -> SignedMsg(o.getString("inner"), o.getString("tag"), o.optInt("seq", 0))
"rematch" -> RematchMsg(
o.getString("action"),
o.optString("gameId", ""),
o.optString("newGameId", "")
)
"profile" -> ProfileMsg(
userId = o.optString("userId", "").ifBlank { o.getString("name") },
protocolVersion = o.optInt("pv", 0),
initialSeconds = o.optInt("ti", 0),
incrementSeconds = o.optInt("tx", 0),
ephemeralKey = o.optString("ek", ""),
gameId = o.optString("gid", ""),
publicKey = o.optString("pk", ""),
signature = o.optString("sig", ""),
name = o.getString("name"),
avatarBase64 = if (o.has("avatar")) o.getString("avatar") else null,
backgroundBase64 = if (o.has("background")) o.getString("background") else null,
elo = o.optInt("elo", 1200)
)
else -> throw IllegalArgumentException("Unknown message type")
}
}
}
}
/** A single peer-to-peer game connection (Bluetooth LE or LAN). */
interface ConnectionTransport {
val type: TransportType
val connectionState: kotlinx.coroutines.flow.StateFlow<ConnectionState>
fun startHosting()
fun startDiscoveryAndConnect(targetId: String? = null)
fun send(message: GameMessage)
fun addMessageListener(listener: (GameMessage) -> Unit): () -> Unit
fun disconnect()
}
@@ -0,0 +1,102 @@
package cc.skysparkle.matewave.network
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import java.util.concurrent.CopyOnWriteArrayList
/**
* Hosts a game on every available transport at once (LAN and, with permission, Bluetooth), so
* a guest can join over whichever channel found the host. The first transport to connect wins;
* hosting on the others is stopped and from then on this behaves exactly like the winner.
*/
class DualHostTransport(transports: List<ConnectionTransport>) : ConnectionTransport {
private val all = transports
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val lock = Any()
@Volatile private var active: ConnectionTransport? = null
private val listeners = CopyOnWriteArrayList<(GameMessage) -> Unit>()
private val unsubscribes = mutableListOf<() -> Unit>()
private var watchJob: Job? = null
private val _state = MutableStateFlow(ConnectionState.DISCONNECTED)
override val connectionState: StateFlow<ConnectionState> = _state
override val type: TransportType get() = active?.type ?: all.first().type
override fun startHosting() {
synchronized(lock) {
active = null
unsubscribes.forEach { it() }
unsubscribes.clear()
all.forEach { t ->
unsubscribes += t.addMessageListener { message ->
// The first message can arrive before the state change is observed: claim here too.
if (active == null && t.connectionState.value == ConnectionState.CONNECTED) claim(t)
if (active === t) listeners.forEach { it(message) }
}
}
}
_state.value = ConnectionState.CONNECTING
all.forEach { runCatching { it.startHosting() } }
watchJob?.cancel()
watchJob = scope.launch {
combine(all.map { it.connectionState }) { it.toList() }.collect { states ->
val current = active
if (current == null) {
val index = states.indexOfFirst { it == ConnectionState.CONNECTED }
when {
index >= 0 -> claim(all[index])
states.all { it == ConnectionState.FAILED } -> _state.value = ConnectionState.FAILED
else -> _state.value = ConnectionState.CONNECTING
}
} else {
_state.value = states[all.indexOf(current)]
}
}
}
}
private fun claim(winner: ConnectionTransport) {
val losers = synchronized(lock) {
if (active != null) return
active = winner
all.filter { it !== winner }
}
losers.forEach { runCatching { it.disconnect() } }
_state.value = ConnectionState.CONNECTED
}
override fun startDiscoveryAndConnect(targetId: String?) {
all.first().startDiscoveryAndConnect(targetId)
}
override fun send(message: GameMessage) {
active?.send(message)
}
override fun addMessageListener(listener: (GameMessage) -> Unit): () -> Unit {
listeners.add(listener)
return { listeners.remove(listener) }
}
override fun disconnect() {
watchJob?.cancel()
watchJob = null
synchronized(lock) {
unsubscribes.forEach { it() }
unsubscribes.clear()
active = null
}
all.forEach { runCatching { it.disconnect() } }
_state.value = ConnectionState.DISCONNECTED
}
}
@@ -0,0 +1,86 @@
package cc.skysparkle.matewave.network
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/** Periodic ping that detects a silently dropped connection. */
class Heartbeat(
private val scope: CoroutineScope,
private val transport: ConnectionTransport
) {
private var job: Job? = null
private val sessionId = java.util.concurrent.ThreadLocalRandom.current().nextInt(1, Int.MAX_VALUE)
private var unsubscribe: (() -> Unit)? = null
@Volatile private var lastReplyAt = 0L
@Volatile private var lastTrafficAt = 0L
fun start() {
if (job != null) return
lastReplyAt = System.currentTimeMillis()
lastTrafficAt = lastReplyAt
unsubscribe = transport.addMessageListener { msg ->
if (msg is GameMessage.PingMsg) {
if (msg.reply) {
if (msg.sessionId == sessionId) lastReplyAt = System.currentTimeMillis()
} else {
transport.send(GameMessage.PingMsg(reply = true, sessionId = msg.sessionId))
}
} else {
lastTrafficAt = System.currentTimeMillis()
}
}
job = scope.launch {
var wasConnected = false
while (true) {
delay(INTERVAL_MS)
val connected = transport.connectionState.value == ConnectionState.CONNECTED
if (!connected) {
wasConnected = false
continue
}
if (!wasConnected) {
val now = System.currentTimeMillis()
lastReplyAt = now
lastTrafficAt = now
wasConnected = true
}
val now = System.currentTimeMillis()
val silence = now - lastReplyAt
if (silence > TIMEOUT_MS && now - lastTrafficAt > TIMEOUT_MS) {
NetworkLog.reason(
transport.type.name,
DisconnectReason.REMOTE_DISCONNECTED,
"no reply for ${silence / 1000} s"
)
transport.disconnect()
continue
}
if (silence > TIMEOUT_MS) {
NetworkLog.log(transport.type.name, "No heartbeat reply for ${silence / 1000} s, but data is flowing")
}
transport.send(GameMessage.PingMsg(reply = false, sessionId = sessionId))
}
}
}
fun stop() {
job?.cancel()
job = null
unsubscribe?.invoke()
unsubscribe = null
}
private companion object {
const val INTERVAL_MS = 5_000L
const val TIMEOUT_MS = 16_000L
}
}
@@ -0,0 +1,663 @@
package cc.skysparkle.matewave.network
import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.net.wifi.WifiManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import java.io.BufferedReader
import java.io.IOException
import java.io.InputStreamReader
import java.io.OutputStream
import java.net.InetSocketAddress
import java.net.NetworkInterface
import java.net.ServerSocket
import java.net.Socket
data class LanPeer(
val name: String,
val host: String,
val port: Int,
val playerId: String = "",
val displayName: String = "",
val elo: Int = 0,
val fingerprint: String = "",
val signature: String = "",
/** Raw NSD service name, used to remove the peer when the service is lost. */
val serviceName: String = ""
) {
val id: String get() = "$host:$port"
}
/**
* LAN transport: plain TCP sockets carrying newline-delimited JSON, with the game
* announced over NSD (mDNS). The listening socket stays open for discovery, but
* incoming connections are accepted only while the user is hosting a game.
*/
class LanTransport(
private val context: Context,
private val scope: CoroutineScope
) : ConnectionTransport {
companion object {
// Wire identifiers from before the rename; changing them would split old and new installs.
const val SERVICE_TYPE = "_chessapp._tcp."
const val SERVICE_NAME = "ChessApp"
const val CONNECT_TIMEOUT_MS = 8_000
const val MAX_MESSAGE_CHARS = 1_400_000
const val ACCEPT_POLL_MS = 1_000
const val RESOLVE_RETRY_MS = 400L
fun presencePayload(playerId: String, name: String, elo: Int): String =
"presence|$playerId|$name|$elo"
}
override val type = TransportType.LAN
private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED)
override val connectionState: StateFlow<ConnectionState> = _connectionState
private val nsdManager: NsdManager? =
context.getSystemService(Context.NSD_SERVICE) as? NsdManager
private var serverSocket: ServerSocket? = null
private var socket: Socket? = null
private val socketLock = Any()
private fun claimSocket(candidate: Socket): Boolean = synchronized(socketLock) {
val existing = socket
if (existing != null && !existing.isClosed) return false
socket = candidate
true
}
private var output: OutputStream? = null
private val dispatcher = MessageDispatcher()
@Volatile private var hosting = false
private var registrationListener: NsdManager.RegistrationListener? = null
private var registeredServiceName: String? = null
private val ownServiceName: String by lazy {
val fingerprint = cc.skysparkle.matewave.security.DeviceKeys.publicKeyFingerprint(context)
?: cc.skysparkle.matewave.data.DeviceIdentity.deviceId(context).take(12)
"$SERVICE_NAME-$fingerprint"
}
fun fingerprintFromServiceName(serviceName: String): String? =
serviceName.substringAfter("$SERVICE_NAME-", "").ifEmpty { null }
private var discoveryListener: NsdManager.DiscoveryListener? = null
@Volatile private var discoveryGeneration = 0
private val resolveQueue = java.util.ArrayDeque<Pair<Int, NsdServiceInfo>>()
private var resolving = false
var lastError: String? = null
private set
private val outbox = Channel<String>(capacity = Channel.UNLIMITED)
private var writerStarted = false
override fun addMessageListener(listener: (GameMessage) -> Unit): () -> Unit =
dispatcher.add(listener)
private fun announceAllowed(): Boolean =
hosting || cc.skysparkle.matewave.presence.PresenceSettings(context).announceEnabled
fun applyAnnounceSetting() {
val port = serverSocket?.localPort
when {
!announceAllowed() -> unregisterService()
port != null -> registerService(port)
else -> startPresence()
}
}
fun republishProfile() {
val port = serverSocket?.localPort ?: return
unregisterService()
if (!announceAllowed()) return
scope.launch(Dispatchers.IO) {
kotlinx.coroutines.delay(400)
registerService(port)
}
}
private val presenceLock = Any()
private var presenceStarting = false
fun startPresence() {
synchronized(presenceLock) {
if (serverSocket != null || presenceStarting) return
presenceStarting = true
}
scope.launch(Dispatchers.IO) {
try {
val server = ServerSocket()
server.reuseAddress = true
server.bind(InetSocketAddress(0))
server.soTimeout = ACCEPT_POLL_MS
synchronized(presenceLock) { serverSocket = server; presenceStarting = false }
if (announceAllowed()) registerService(server.localPort)
acceptLoop(server)
} catch (e: IOException) {
synchronized(presenceLock) { presenceStarting = false }
lastError = "Could not open port: ${e.message}"
NetworkLog.reason("LAN", DisconnectReason.PORT_BUSY, e.message)
}
}
}
private suspend fun acceptLoop(server: ServerSocket) {
while (!server.isClosed) {
try {
val client = server.accept()
if (!hosting) {
try { client.close() } catch (_: IOException) {}
continue
}
if (socket != null && socket?.isClosed == false) {
try { client.close() } catch (_: IOException) {}
continue
}
client.tcpNoDelay = true
client.keepAlive = true
if (!claimSocket(client)) {
try { client.close() } catch (_: IOException) {}
continue
}
onConnected(client)
} catch (e: java.net.SocketTimeoutException) {
continue
} catch (e: IOException) {
if (server.isClosed) return
NetworkLog.log("LAN", "Accept failed: ${e.message}")
kotlinx.coroutines.delay(500)
}
}
}
override fun startHosting() {
hosting = true
_connectionState.value = ConnectionState.CONNECTING
lastError = null
val port = serverSocket?.localPort
if (port != null) registerService(port) else startPresence()
}
private fun onHostingEnded() {
if (!announceAllowed()) unregisterService()
}
private fun registerService(port: Int) {
val nsd = nsdManager ?: return
unregisterService()
val info = NsdServiceInfo().apply {
serviceName = ownServiceName
serviceType = SERVICE_TYPE
setPort(port)
val profile = cc.skysparkle.matewave.data.ProfileStore.get()
runCatching {
val shownName = profile.name.take(40)
setAttribute("id", profile.userId)
setAttribute("name", shownName)
setAttribute("elo", profile.elo.toString())
setAttribute("fp", cc.skysparkle.matewave.security.DeviceKeys.publicKeyFingerprint(context) ?: "")
cc.skysparkle.matewave.security.DeviceKeys.sign(
context, presencePayload(profile.userId, shownName, profile.elo)
)?.let { setAttribute("sig", it) }
}
}
val listener = object : NsdManager.RegistrationListener {
override fun onServiceRegistered(info: NsdServiceInfo?) {
registeredServiceName = info?.serviceName
}
override fun onRegistrationFailed(info: NsdServiceInfo?, errorCode: Int) {
lastError = "Network service registration failed (code $errorCode). Connect by address manually."
}
override fun onServiceUnregistered(info: NsdServiceInfo?) {}
override fun onUnregistrationFailed(info: NsdServiceInfo?, errorCode: Int) {}
}
registrationListener = listener
try {
nsd.registerService(info, NsdManager.PROTOCOL_DNS_SD, listener)
} catch (e: Exception) {
lastError = "Service registration: ${e.message}"
}
}
private fun unregisterService() {
val nsd = nsdManager ?: return
registrationListener?.let {
try { nsd.unregisterService(it) } catch (_: Exception) {}
}
registrationListener = null
registeredServiceName = null
}
fun discoverPeers(onFound: (List<LanPeer>) -> Unit) {
val nsd = nsdManager ?: return
stopDiscovery()
discoveryGeneration += 1
val myGeneration = discoveryGeneration
val found = LinkedHashMap<String, LanPeer>()
synchronized(resolveQueue) { resolveQueue.clear(); resolving = false }
val listener = object : NsdManager.DiscoveryListener {
override fun onDiscoveryStarted(serviceType: String?) {}
override fun onServiceFound(info: NsdServiceInfo) {
if (myGeneration != discoveryGeneration) return
if (info.serviceType?.contains("chessapp") != true) return
if (isSelf(info.serviceName)) return
synchronized(resolveQueue) { resolveQueue.addLast(myGeneration to info) }
drainResolveQueue(nsd, found, onFound)
}
override fun onServiceLost(info: NsdServiceInfo?) {
if (myGeneration != discoveryGeneration) return
val name = info?.serviceName ?: return
// Match the raw service name: LanPeer.name is a cleaned-up display form and never
// matched, so peers who left stayed "online".
val left = synchronized(found) { found.entries.removeAll { it.value.serviceName == name }; found.values.toList() }
scope.launch(Dispatchers.Main) { onFound(left) }
}
override fun onDiscoveryStopped(serviceType: String?) {}
override fun onStartDiscoveryFailed(serviceType: String?, errorCode: Int) {
lastError = "Network discovery failed (code $errorCode). Enter the address manually."
NetworkLog.log("LAN", "Network discovery failed to start, code $errorCode")
}
override fun onStopDiscoveryFailed(serviceType: String?, errorCode: Int) {}
}
discoveryListener = listener
try {
nsd.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener)
} catch (e: Exception) {
lastError = "Network discovery: ${e.message}"
NetworkLog.log("LAN", "Network discovery: ${e.message}")
}
}
@androidx.annotation.RequiresApi(34)
private fun resolveModern(
nsd: NsdManager,
info: NsdServiceInfo,
myGeneration: Int,
found: LinkedHashMap<String, LanPeer>,
onFound: (List<LanPeer>) -> Unit,
finish: () -> Unit
) {
val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
var done = false
val callback = object : NsdManager.ServiceInfoCallback {
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
NetworkLog.log("LAN", "Could not get peer address, code $errorCode")
executor.shutdown(); finish()
}
override fun onServiceUpdated(updated: NsdServiceInfo) {
if (done) return
done = true
if (myGeneration == discoveryGeneration) {
val host = hostAddressOf(updated)
if (host != null && !isSelfEndpoint(host, updated.port)) {
val peer = peerFrom(updated, host)
val now = synchronized(found) { found[peer.id] = peer; found.values.toList() }
scope.launch(Dispatchers.Main) { onFound(now) }
}
}
try { nsd.unregisterServiceInfoCallback(this) } catch (_: Exception) {}
}
override fun onServiceLost() {
if (myGeneration != discoveryGeneration) return
val left = synchronized(found) { found.entries.removeAll { it.value.serviceName == info.serviceName }; found.values.toList() }
scope.launch(Dispatchers.Main) { onFound(left) }
}
override fun onServiceInfoCallbackUnregistered() {
executor.shutdown(); finish()
}
}
try {
nsd.registerServiceInfoCallback(info, executor, callback)
} catch (e: Exception) {
NetworkLog.log("LAN", "Address resolution: ${e.message}")
executor.shutdown(); finish()
}
}
private fun peerFrom(info: NsdServiceInfo, host: String): LanPeer {
fun attr(key: String): String = runCatching {
info.attributes[key]?.toString(Charsets.UTF_8) ?: ""
}.getOrDefault("")
return LanPeer(
name = displayNameOf(info.serviceName),
host = host,
port = info.port,
playerId = attr("id"),
displayName = attr("name"),
elo = attr("elo").toIntOrNull() ?: 0,
fingerprint = attr("fp").ifEmpty { fingerprintFromServiceName(info.serviceName ?: "") ?: "" },
signature = attr("sig"),
serviceName = info.serviceName ?: ""
)
}
private fun displayNameOf(serviceName: String?): String {
val raw = serviceName ?: return SERVICE_NAME
return raw.substringBefore("-").ifBlank { SERVICE_NAME }
}
private fun isSelf(serviceName: String?): Boolean {
if (serviceName == null) return false
registeredServiceName?.let { if (serviceName == it) return true }
if (serviceName == ownServiceName) return true
return Regex("^${Regex.escape(ownServiceName)} \\(\\d+\\)$").matches(serviceName)
}
private fun isSelfEndpoint(host: String, port: Int): Boolean {
val ourPort = serverSocket?.localPort ?: return false
if (port != ourPort) return false
val ourAddress = localIpAddress()
return ourAddress == null || ourAddress == host
}
@Suppress("DEPRECATION")
private fun hostAddressOf(info: NsdServiceInfo): String? {
if (android.os.Build.VERSION.SDK_INT >= 34) {
val ipv4 = info.hostAddresses.firstOrNull { it is java.net.Inet4Address }
if (ipv4 != null) return ipv4.hostAddress
return info.hostAddresses.firstOrNull()?.hostAddress
}
return info.host?.hostAddress
}
private fun drainResolveQueue(
nsd: NsdManager,
found: LinkedHashMap<String, LanPeer>,
onFound: (List<LanPeer>) -> Unit
) {
val nextGeneration: Int
val next: NsdServiceInfo
synchronized(resolveQueue) {
if (resolving) return
val entry = resolveQueue.pollFirst() ?: return
nextGeneration = entry.first
next = entry.second
resolving = true
}
val finish = {
synchronized(resolveQueue) { resolving = false }
drainResolveQueue(nsd, found, onFound)
}
if (nextGeneration != discoveryGeneration) {
finish()
return
}
if (android.os.Build.VERSION.SDK_INT >= 34) {
resolveModern(nsd, next, nextGeneration, found, onFound, finish)
return
}
try {
@Suppress("DEPRECATION")
nsd.resolveService(next, object : NsdManager.ResolveListener {
override fun onResolveFailed(info: NsdServiceInfo?, errorCode: Int) {
if (errorCode == NsdManager.FAILURE_ALREADY_ACTIVE) {
synchronized(resolveQueue) { resolveQueue.addLast(nextGeneration to next) }
} else {
NetworkLog.log("LAN", "Could not resolve peer address, code $errorCode")
}
scope.launch { kotlinx.coroutines.delay(RESOLVE_RETRY_MS); finish() }
}
override fun onServiceResolved(resolved: NsdServiceInfo) {
if (nextGeneration == discoveryGeneration) {
val host = hostAddressOf(resolved)
if (host != null && !isSelfEndpoint(host, resolved.port)) {
val peer = peerFrom(resolved, host)
val now = synchronized(found) { found[peer.id] = peer; found.values.toList() }
scope.launch(Dispatchers.Main) { onFound(now) }
}
}
finish()
}
})
} catch (e: Exception) {
NetworkLog.log("LAN", "Address resolution: ${e.message}")
finish()
}
}
fun stopDiscovery() {
discoveryGeneration += 1
val nsd = nsdManager ?: return
discoveryListener?.let {
try { nsd.stopServiceDiscovery(it) } catch (_: Exception) {}
}
discoveryListener = null
}
override fun startDiscoveryAndConnect(targetId: String?) {
if (targetId.isNullOrBlank()) {
_connectionState.value = ConnectionState.FAILED
return
}
val host = targetId.substringBeforeLast(':', targetId).trim()
val port = targetId.substringAfterLast(':', "").toIntOrNull() ?: run {
lastError = "Address must look like 192.168.1.5:port"
NetworkLog.reason("LAN", DisconnectReason.PEER_NOT_FOUND)
_connectionState.value = ConnectionState.FAILED
return
}
_connectionState.value = ConnectionState.CONNECTING
lastError = null
scope.launch(Dispatchers.IO) {
try {
val s = Socket()
s.tcpNoDelay = true
s.keepAlive = true
s.connect(InetSocketAddress(host, port), CONNECT_TIMEOUT_MS)
if (!claimSocket(s)) {
// Another connection won the race; report its state instead of hanging here.
try { s.close() } catch (_: IOException) {}
_connectionState.value =
if (socket?.isClosed == false) ConnectionState.CONNECTED else ConnectionState.FAILED
return@launch
}
onConnected(s)
} catch (e: IOException) {
lastError = "Could not connect to $host:$port: ${e.message}"
NetworkLog.reason("LAN", DisconnectReason.CONNECT_TIMEOUT, "$host:$port")
_connectionState.value = ConnectionState.FAILED
}
}
}
private fun onConnected(sock: Socket) {
output = sock.getOutputStream()
startWriterIfNeeded()
_connectionState.value = ConnectionState.CONNECTED
scope.launch(Dispatchers.IO) {
try {
val reader = BufferedReader(InputStreamReader(sock.getInputStream(), Charsets.UTF_8))
while (true) {
val read = readBoundedLine(reader)
if (read is LineResult.EndOfStream) break
if (sock !== socket) break
if (read is LineResult.TooLong) {
NetworkLog.reason("LAN", DisconnectReason.MESSAGE_TOO_LARGE)
break
}
val line = (read as LineResult.Line).text
try {
dispatcher.dispatch(GameMessage.fromJson(line))
} catch (e: Exception) {
NetworkLog.log("LAN", "Malformed message skipped")
}
}
} catch (e: IOException) {
} finally {
releaseConnection(sock)
}
}
}
private fun releaseConnection(sock: Socket) {
try { sock.close() } catch (_: IOException) {}
val wasCurrent = synchronized(socketLock) {
if (sock === socket) {
socket = null
output = null
true
} else {
false
}
}
if (wasCurrent) {
hosting = false
onHostingEnded()
_connectionState.value = ConnectionState.DISCONNECTED
}
}
private fun startWriterIfNeeded() {
if (writerStarted) return
writerStarted = true
scope.launch(Dispatchers.IO) {
for (message in outbox) {
val currentSocket = socket
val currentOutput = output
if (currentSocket == null || currentOutput == null || currentSocket.isClosed) {
NetworkLog.log("LAN", "Message not sent: no connection")
continue
}
try {
currentOutput.write((message + "\n").toByteArray(Charsets.UTF_8))
currentOutput.flush()
} catch (e: IOException) {
releaseConnection(currentSocket)
}
}
}
}
private sealed class LineResult {
data class Line(val text: String) : LineResult()
object TooLong : LineResult()
object EndOfStream : LineResult()
}
private fun readBoundedLine(reader: BufferedReader): LineResult {
val builder = StringBuilder()
while (true) {
val ch = reader.read()
if (ch == -1) {
return if (builder.isEmpty()) LineResult.EndOfStream
else LineResult.Line(builder.toString())
}
if (ch == '\n'.code) return LineResult.Line(builder.toString())
if (ch == '\r'.code) continue
if (builder.length >= MAX_MESSAGE_CHARS) return LineResult.TooLong
builder.append(ch.toChar())
}
}
override fun send(message: GameMessage) {
val json = message.toJson()
val size = json.length
if (size > MAX_MESSAGE_CHARS) {
NetworkLog.reason("LAN", DisconnectReason.MESSAGE_TOO_LARGE, "$size chars")
return
}
outbox.trySend(json)
}
override fun disconnect() {
val wasHosting = hosting
hosting = false
if (wasHosting) onHostingEnded()
synchronized(socketLock) {
try { socket?.close() } catch (_: IOException) {}
socket = null
output = null
}
var dropped = 0
while (outbox.tryReceive().isSuccess) dropped++
if (dropped > 0) {
repeat(dropped) { NetworkMetrics.messageDropped() }
NetworkLog.log("LAN", "Dropped unsent messages: $dropped")
}
_connectionState.value = ConnectionState.DISCONNECTED
}
fun shutdown() {
stopDiscovery()
unregisterService()
disconnect()
try { serverSocket?.close() } catch (_: IOException) {}
synchronized(presenceLock) { serverSocket = null; presenceStarting = false }
}
private fun localIpAddress(): String? {
try {
val wifi = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
@Suppress("DEPRECATION")
val ip = wifi?.connectionInfo?.ipAddress ?: 0
if (ip != 0) {
return "${ip and 0xff}.${ip shr 8 and 0xff}.${ip shr 16 and 0xff}.${ip shr 24 and 0xff}"
}
} catch (_: Exception) { }
try {
for (nif in NetworkInterface.getNetworkInterfaces()) {
if (!nif.isUp || nif.isLoopback) continue
val name = nif.name.lowercase()
if (name.startsWith("tun") || name.startsWith("ppp") || name.startsWith("rmnet")) continue
for (addr in nif.inetAddresses) {
if (!addr.isLoopbackAddress && addr.hostAddress?.contains(':') == false) {
return addr.hostAddress
}
}
}
} catch (_: Exception) { }
return null
}
}
@@ -0,0 +1,25 @@
package cc.skysparkle.matewave.network
import java.util.concurrent.CopyOnWriteArrayList
class MessageDispatcher {
private val listeners = CopyOnWriteArrayList<(GameMessage) -> Unit>()
fun add(listener: (GameMessage) -> Unit): () -> Unit {
listeners.add(listener)
return { listeners.remove(listener) }
}
fun dispatch(message: GameMessage) {
for (listener in listeners) {
try {
listener(message)
} catch (_: Exception) {
}
}
}
fun clear() {
listeners.clear()
}
}
@@ -0,0 +1,53 @@
package cc.skysparkle.matewave.network
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
enum class DisconnectReason(val userText: String, val textRes: Int) {
NONE("—", cc.skysparkle.matewave.R.string.reason_unknown),
PERMISSION_DENIED("No nearby devices permission", cc.skysparkle.matewave.R.string.reason_permission_denied),
BLUETOOTH_OFF("Bluetooth is off", cc.skysparkle.matewave.R.string.reason_bluetooth_off),
WIFI_UNAVAILABLE("Phone is not connected to Wi-Fi", cc.skysparkle.matewave.R.string.reason_wifi_unavailable),
PEER_NOT_FOUND("Opponent not found", cc.skysparkle.matewave.R.string.reason_peer_not_found),
CONNECT_TIMEOUT("Opponent did not respond in time", cc.skysparkle.matewave.R.string.reason_connect_timeout),
SERVICE_NOT_FOUND("The other device does not have this app", cc.skysparkle.matewave.R.string.reason_service_not_found),
SUBSCRIBE_FAILED("Could not enable data reception", cc.skysparkle.matewave.R.string.reason_subscribe_failed),
WRITE_FAILED("Could not send data", cc.skysparkle.matewave.R.string.reason_write_failed),
MESSAGE_TOO_LARGE("Received message is too large", cc.skysparkle.matewave.R.string.reason_message_too_large),
REMOTE_DISCONNECTED("Opponent disconnected", cc.skysparkle.matewave.R.string.reason_remote_disconnected),
PORT_BUSY("Could not open port", cc.skysparkle.matewave.R.string.reason_port_busy),
UNKNOWN("Unknown connection error", cc.skysparkle.matewave.R.string.reason_unknown)
}
data class NetworkEvent(
val at: Long,
val transport: String,
val text: String
)
/** In-memory connection log, copied into the diagnostics report from Settings. */
object NetworkLog {
private const val CAPACITY = 60
private val _events = MutableStateFlow<List<NetworkEvent>>(emptyList())
private val _lastReason = MutableStateFlow(DisconnectReason.NONE)
val lastReason: StateFlow<DisconnectReason> = _lastReason
fun log(transport: String, text: String) {
val event = NetworkEvent(System.currentTimeMillis(), transport, text)
_events.update { (it + event).takeLast(CAPACITY) }
}
fun reason(transport: String, reason: DisconnectReason, detail: String? = null) {
_lastReason.value = reason
log(transport, reason.userText + (detail?.let { ": $it" } ?: ""))
}
fun asText(): String = _events.value.joinToString("\n") { e ->
val time = java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.getDefault())
.format(java.util.Date(e.at))
"$time [${e.transport}] ${e.text}"
}
}
@@ -0,0 +1,58 @@
package cc.skysparkle.matewave.network
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
object NetworkMetrics {
private val connectAttempts = AtomicInteger()
private val connectSuccesses = AtomicInteger()
private val chunksSent = AtomicLong()
private val chunkRetries = AtomicLong()
private val duplicateChunks = AtomicLong()
private val corruptedMessages = AtomicLong()
private val messagesDropped = AtomicLong()
private val lastConnectMillis = AtomicLong()
private val _updated = MutableStateFlow(0)
val updated: StateFlow<Int> = _updated
private fun touch() { _updated.update { it + 1 } }
fun connectStarted() { connectAttempts.incrementAndGet(); touch() }
fun connectSucceeded(millis: Long) {
connectSuccesses.incrementAndGet()
lastConnectMillis.set(millis)
touch()
}
fun chunkSent() { chunksSent.incrementAndGet() }
fun chunkRetried(count: Int) { chunkRetries.addAndGet(count.toLong()); touch() }
fun duplicateChunk() { duplicateChunks.incrementAndGet() }
fun messageCorrupted() { corruptedMessages.incrementAndGet(); touch() }
fun messageDropped() { messagesDropped.incrementAndGet(); touch() }
private fun retryPercent(): Int {
val sent = chunksSent.get()
if (sent == 0L) return 0
return ((chunkRetries.get() * 100) / sent).toInt()
}
fun asText(res: android.content.res.Resources): String = buildString {
appendLine(res.getString(cc.skysparkle.matewave.R.string.metrics_connect_attempts,
connectAttempts.get(), connectSuccesses.get()))
val last = lastConnectMillis.get()
if (last > 0) appendLine(res.getString(cc.skysparkle.matewave.R.string.metrics_last_connect, last))
appendLine(res.getString(cc.skysparkle.matewave.R.string.metrics_chunks,
chunksSent.get(), chunkRetries.get(), retryPercent()))
appendLine(res.getString(cc.skysparkle.matewave.R.string.metrics_duplicates, duplicateChunks.get()))
appendLine(res.getString(cc.skysparkle.matewave.R.string.metrics_corrupted, corruptedMessages.get()))
appendLine(res.getString(cc.skysparkle.matewave.R.string.metrics_dropped, messagesDropped.get()))
}
}
@@ -0,0 +1,60 @@
package cc.skysparkle.matewave.network
import android.bluetooth.BluetoothManager
import android.content.Context
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import androidx.core.content.ContextCompat
data class NetworkStatus(
val bluetoothSupported: Boolean,
val bluetoothOn: Boolean,
val wifiConnected: Boolean,
val permissionsGranted: Boolean,
val notificationsGranted: Boolean
) {
val bluetoothReady: Boolean
get() = bluetoothSupported && bluetoothOn && permissionsGranted
val lanReady: Boolean get() = wifiConnected
val anyChannelReady: Boolean get() = bluetoothReady || lanReady
val onlineBlocked: Boolean get() = !anyChannelReady
}
object NetworkRequirements {
fun check(context: Context): NetworkStatus {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
val adapter = manager?.adapter
val supported = adapter != null &&
context.packageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)
val on = try { adapter?.isEnabled == true } catch (e: SecurityException) { false }
return NetworkStatus(
bluetoothSupported = supported,
bluetoothOn = on,
wifiConnected = isWifiConnected(context),
permissionsGranted = AppPermissions.nearby.all { granted(context, it) },
notificationsGranted = notificationsGranted(context)
)
}
private fun isWifiConnected(context: Context): Boolean {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return false
val caps = cm.getNetworkCapabilities(cm.activeNetwork ?: return false) ?: return false
return caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
}
private fun notificationsGranted(context: Context): Boolean =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
granted(context, android.Manifest.permission.POST_NOTIFICATIONS)
} else true
private fun granted(context: Context, permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
}
@@ -0,0 +1,33 @@
package cc.skysparkle.matewave.network
import android.content.Context
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
/** Process-wide transport instances shared by presence and games. */
object Transports {
private val scope = CoroutineScope(SupervisorJob())
@Volatile private var ble: BleTransport? = null
@Volatile private var lan: LanTransport? = null
fun ble(context: Context): BleTransport =
ble ?: synchronized(this) {
ble ?: BleTransport(context.applicationContext, scope).also { ble = it }
}
fun lan(context: Context): LanTransport =
lan ?: synchronized(this) {
lan ?: LanTransport(context.applicationContext, scope).also { lan = it }
}
fun applyAnnounceSetting() {
lan?.applyAnnounceSetting()
ble?.applyAnnounceSetting()
}
fun releaseForGame() {
ble?.stopDiscovery()
lan?.stopDiscovery()
}
}
@@ -0,0 +1,96 @@
package cc.skysparkle.matewave.openings
import android.content.Context
data class OpeningName(val eco: String, val name: String)
/**
* Opening theory from the Lichess game database (May 2013, CC0), used to tell book moves from
* deviations, and opening names from lichess-org/chess-openings (CC0). Both are keyed by SAN move
* sequences from the standard starting position, so no position hashing is needed at runtime.
*
* explorer.txt: header "#games N", then one node per line "parentId san white draws black";
* node ids are line numbers (the root is 0) and parents always come before children. Only the
* tree shape is used here.
*/
object OpeningBook {
private class Data(
val san: Array<String>,
val children: Array<IntArray>,
val names: Map<String, OpeningName>,
/** Length in plies of the longest named line; longer prefixes are never looked up. */
val longestName: Int
)
@Volatile private var data: Data? = null
/** Reads both files; call from a background thread. Safe to call repeatedly. */
fun load(context: Context) {
if (data != null) return
synchronized(this) {
if (data != null) return
val assets = context.applicationContext.assets
val parents = ArrayList<Int>(40_000)
val sans = ArrayList<String>(40_000)
parents.add(-1); sans.add("")
assets.open("openings/explorer.txt").bufferedReader(Charsets.UTF_8).useLines { lines ->
for (line in lines) {
if (line.isBlank() || line.startsWith("#")) continue
val f = line.split(' ')
if (f.size < 2) continue
parents.add(f[0].toInt()); sans.add(f[1])
}
}
val childLists = Array(parents.size) { ArrayList<Int>(0) }
for (i in 1 until parents.size) childLists[parents[i]].add(i)
val names = HashMap<String, OpeningName>(4_000)
var longest = 0
assets.open("openings/names.tsv").bufferedReader(Charsets.UTF_8).useLines { lines ->
for (line in lines) {
val f = line.split('\t')
if (f.size < 3) continue
names.putIfAbsent(f[2], OpeningName(f[0], f[1]))
longest = maxOf(longest, f[2].count { it == ' ' } + 1)
}
}
data = Data(
san = sans.toTypedArray(),
children = Array(childLists.size) { childLists[it].toIntArray() },
names = names,
longestName = longest
)
}
}
/**
* Number of leading moves of [path] that are opening theory: played in the game database
* or part of a named opening line.
*/
fun bookDepth(path: List<String>): Int {
val d = data ?: return 0
var current = 0
var depth = 0
for (san in path) {
current = d.children[current].firstOrNull { d.san[it] == san } ?: break
depth++
}
for (len in minOf(path.size, d.longestName) downTo depth + 1) {
if (d.names.containsKey(path.subList(0, len).joinToString(" "))) return len
}
return depth
}
/** Deepest ply covered by the game database; beyond it "out of book" cannot be judged. */
const val MAX_TREE_PLY = 20
/** Name of the most specific known opening along [path]. */
fun openingName(path: List<String>): OpeningName? {
val d = data ?: return null
for (len in minOf(path.size, d.longestName) downTo 1) {
d.names[path.subList(0, len).joinToString(" ")]?.let { return it }
}
return null
}
}
@@ -0,0 +1,230 @@
package cc.skysparkle.matewave.openings
import android.content.Context
import cc.skysparkle.matewave.R
import cc.skysparkle.matewave.engine.Board
import cc.skysparkle.matewave.engine.ChessEngine
import cc.skysparkle.matewave.engine.Move
import cc.skysparkle.matewave.engine.Notation
import cc.skysparkle.matewave.engine.PieceColor
/** An opening line to learn, played from [side]'s point of view. */
data class OpeningCourse(
val id: String,
val side: PieceColor,
val eco: String,
val name: String,
val sans: List<String>,
val moves: List<Move>
) {
fun isLearnerMove(ply: Int): Boolean = (ply % 2 == 0) == (side == PieceColor.WHITE)
}
/** Artwork for the course card. */
fun OpeningCourse.artRes(): Int = when (id) {
"Sicilian Defense: Najdorf Variation" -> R.drawable.art_najdorf
"Sicilian Defense: Dragon Variation" -> R.drawable.art_dragon
"French Defense: Advance Variation" -> R.drawable.art_french_advance
"Caro-Kann Defense: Classical Variation" -> R.drawable.art_caro_kann_classical
"King's Indian Defense: Orthodox Variation" -> R.drawable.art_kings_indian
"Grünfeld Defense: Exchange Variation" -> R.drawable.art_grunfeld
"Italian Game: Giuoco Piano" -> R.drawable.art_italian
"Ruy Lopez: Closed" -> R.drawable.art_ruy_lopez
"King's Gambit Accepted: Kieseritzky Gambit" -> R.drawable.art_kings_gambit
"Queen's Gambit Declined: Orthodox Defense" -> R.drawable.art_qgd_orthodox
"London System" -> R.drawable.art_london
"Catalan Opening: Open Defense" -> R.drawable.art_catalan
"Caro-Kann Defense: Advance Variation" -> R.drawable.art_caro_kann_advance
"Scandinavian Defense: Main Line" -> R.drawable.art_scandinavian
"Pirc Defense: Classical Variation" -> R.drawable.art_pirc
"Petrov's Defense: Classical Attack" -> R.drawable.art_petrov
"Slav Defense: Czech Variation" -> R.drawable.art_slav
"Nimzo-Indian Defense: Classical Variation" -> R.drawable.art_nimzo_indian
"Dutch Defense: Leningrad Variation" -> R.drawable.art_dutch
"Queen's Gambit Declined: Tarrasch Defense" -> R.drawable.art_tarrasch
"Scotch Game" -> R.drawable.art_scotch
"Vienna Game: Vienna Gambit" -> R.drawable.art_vienna
"English Opening: King's English Variation" -> R.drawable.art_english
"Queen's Gambit Accepted: Classical Defense" -> R.drawable.art_qga
else -> R.drawable.ic_book
}
/**
* Curated main lines (assets/openings/courses.tsv: side, ECO, name, SAN line). Every line
* ends with the learner's move and was checked for legality when the file was built.
*/
object OpeningCourses {
@Volatile private var cached: List<OpeningCourse>? = null
fun load(context: Context): List<OpeningCourse> {
cached?.let { return it }
synchronized(this) {
cached?.let { return it }
val result = context.applicationContext.assets.open("openings/courses.tsv")
.bufferedReader(Charsets.UTF_8).useLines { lines ->
lines.filter { it.isNotBlank() }.mapNotNull { line ->
val f = line.split('\t')
if (f.size < 4) return@mapNotNull null
val sans = f[3].split(' ')
val moves = toMoves(sans) ?: return@mapNotNull null
OpeningCourse(
id = f[2],
side = if (f[0] == "W") PieceColor.WHITE else PieceColor.BLACK,
eco = f[1],
name = f[2],
sans = sans,
moves = moves
)
}.toList()
}
cached = result
return result
}
}
private fun toMoves(sans: List<String>): List<Move>? {
var board = Board.startPosition()
val moves = ArrayList<Move>(sans.size)
for (san in sans) {
val move = Notation.fromSan(board, san) ?: return null
moves.add(move)
board = ChessEngine.applyMove(board, move)
}
return moves
}
}
enum class CourseStatus { NEW, LOCKED, LEARNING, DUE, WAITING }
/** Stars before and after a review, and flawless reviews still needed for the next star. */
data class StarChange(val before: Int, val after: Int, val reviewsToNext: Int)
/** A course with its spaced-repetition state at a given moment. */
data class CourseInfo(
val course: OpeningCourse,
val status: CourseStatus,
/** Learner moves already learned (level 1 or higher). */
val learned: Int,
val total: Int,
val dueCards: Int,
val nextDueAt: Long?,
/** 0..3: 1 = fully learned, then one star per [OpeningSrs.REVIEWS_PER_STAR] flawless reviews. */
val stars: Int,
/** Flawless reviews still needed for the next star; 0 at three stars. */
val reviewsToNext: Int = 0
) {
val mastered: Boolean get() = stars >= 3
}
/**
* Spaced repetition for opening lines. Every learner move of a course is a card with a level
* and a due time. A correct first answer on a due card raises its level; a wrong answer sends
* that card back to level 1. Intervals grow from 4 hours to 90 days.
*/
class OpeningSrs(private val store: CardStore) {
/** Where cards are kept: SharedPreferences in the app, a map in tests. */
interface CardStore {
fun get(key: String): String?
fun put(key: String, value: String)
}
constructor(context: Context) : this(object : CardStore {
private val prefs = context.applicationContext.getSharedPreferences("opening_srs", Context.MODE_PRIVATE)
override fun get(key: String): String? = prefs.getString(key, null)
override fun put(key: String, value: String) { prefs.edit().putString(key, value).apply() }
})
private fun key(course: OpeningCourse, ply: Int) = "${course.id}|$ply"
fun level(course: OpeningCourse, ply: Int): Int = read(course, ply).first
fun isDue(course: OpeningCourse, ply: Int, now: Long): Boolean {
val (level, due) = read(course, ply)
return level > 0 && due <= now
}
private fun read(course: OpeningCourse, ply: Int): Pair<Int, Long> {
val raw = store.get(key(course, ply)) ?: return 0 to 0L
val parts = raw.split(',')
return (parts.getOrNull(0)?.toIntOrNull() ?: 0) to (parts.getOrNull(1)?.toLongOrNull() ?: 0L)
}
private fun write(course: OpeningCourse, ply: Int, level: Int, due: Long) {
store.put(key(course, ply), "$level,$due")
}
/** First correct play of a new move. */
fun learn(course: OpeningCourse, ply: Int, now: Long) {
if (level(course, ply) == 0) write(course, ply, 1, now + INTERVALS[1])
}
/** Correct first answer. Only a due card moves up: practising early does not skip reviews. */
fun success(course: OpeningCourse, ply: Int, now: Long) {
val (level, due) = read(course, ply)
if (level == 0 || due > now) return
val next = minOf(MAX_LEVEL, level + 1)
write(course, ply, next, now + INTERVALS[next])
}
fun fail(course: OpeningCourse, ply: Int, now: Long) {
if (level(course, ply) == 0) return
write(course, ply, 1, now + RELEARN_MS)
}
private fun reviewsKey(course: OpeningCourse) = "${course.id}|reviews"
private fun reviewCount(course: OpeningCourse): Int = store.get(reviewsKey(course))?.toIntOrNull() ?: 0
private fun starsFor(reviews: Int): Int = 1 + minOf(2, reviews / REVIEWS_PER_STAR)
private fun reviewsToNext(reviews: Int): Int =
if (reviews >= 2 * REVIEWS_PER_STAR) 0 else REVIEWS_PER_STAR - reviews % REVIEWS_PER_STAR
/**
* Records a finished review of due moves. A flawless one counts towards the next star; a
* mistake or hint takes one star away (the first star, "learned", is never lost) and the
* lost star has to be earned again from the start.
*/
fun recordReview(course: OpeningCourse, flawless: Boolean): StarChange {
val reviews = reviewCount(course)
val before = starsFor(reviews)
val after = if (flawless) {
minOf(reviews + 1, 2 * REVIEWS_PER_STAR)
} else {
// Back to the start of the previous star.
maxOf(0, (before - 2) * REVIEWS_PER_STAR)
}
store.put(reviewsKey(course), after.toString())
return StarChange(before, starsFor(after), reviewsToNext(after))
}
fun info(course: OpeningCourse, now: Long, anotherInProgress: Boolean): CourseInfo {
val plies = course.moves.indices.filter { course.isLearnerMove(it) }
val cards = plies.map { read(course, it) }
val learned = cards.count { it.first > 0 }
val due = cards.count { it.first > 0 && it.second <= now }
val nextDue = cards.filter { it.first > 0 }.minOfOrNull { it.second }
val reviews = reviewCount(course)
val stars = if (learned < plies.size) 0 else starsFor(reviews)
val status = when {
learned == 0 && anotherInProgress -> CourseStatus.LOCKED
learned == 0 -> CourseStatus.NEW
learned < plies.size -> CourseStatus.LEARNING
due > 0 -> CourseStatus.DUE
else -> CourseStatus.WAITING
}
return CourseInfo(course, status, learned, plies.size, due, nextDue, stars, if (stars == 0) 0 else reviewsToNext(reviews))
}
companion object {
const val MAX_LEVEL = 7
/** Flawless reviews needed for each of the second and third stars. */
const val REVIEWS_PER_STAR = 3
private const val HOUR = 3_600_000L
private const val DAY = 24 * HOUR
/** Interval after reaching each level (index = level). */
val INTERVALS = longArrayOf(0, 4 * HOUR, DAY, 3 * DAY, 7 * DAY, 16 * DAY, 35 * DAY, 90 * DAY)
const val RELEARN_MS = 10 * 60_000L
}
}
@@ -0,0 +1,178 @@
package cc.skysparkle.matewave.presence
import android.content.Context
import cc.skysparkle.matewave.data.FriendsStore
import cc.skysparkle.matewave.network.LanPeer
import cc.skysparkle.matewave.network.NetworkLog
import cc.skysparkle.matewave.network.Transports
import cc.skysparkle.matewave.security.DeviceKeys
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
data class DiscoveredPeer(
val playerId: String,
val name: String,
val elo: Int,
val fingerprint: String,
val address: String
)
/**
* Shows who is nearby on the same network. Announcements carry an ECDSA signature so a
* known friend's name and rating cannot be overwritten by someone copying their ID.
*/
class LocalPresence(
private val context: Context,
private val scope: CoroutineScope
) {
private val _peers = MutableStateFlow<Map<String, DiscoveredPeer>>(emptyMap())
val peers: StateFlow<Map<String, DiscoveredPeer>> = _peers
private var job: Job? = null
private var gameWatchJob: Job? = null
private val knownPeers by lazy { cc.skysparkle.matewave.security.KnownPeers(context) }
/** Start of the current discovery session and time of its last result. */
@Volatile private var sessionStart = 0L
@Volatile private var lastResultAt = 0L
private var pruneJob: Job? = null
fun start() {
if (job != null) return
val lan = Transports.lan(context)
if (PresenceSettings(context).announceEnabled) lan.startPresence()
beginSession(lan)
job = scope.launch {
while (true) {
delay(WATCHDOG_INTERVAL_MS)
if (lanBusy(lan)) continue
runCatching { restartDiscovery(lan) }
}
}
gameWatchJob = scope.launch {
var wasBusy = false
lan.connectionState.collect { state ->
val busy = state == cc.skysparkle.matewave.network.ConnectionState.CONNECTED ||
state == cc.skysparkle.matewave.network.ConnectionState.CONNECTING
if (wasBusy && !busy) {
runCatching { restartDiscovery(lan) }
}
wasBusy = busy
}
}
NetworkLog.log("PRESENCE", "LAN presence started")
}
private fun lanBusy(lan: cc.skysparkle.matewave.network.LanTransport): Boolean {
val state = lan.connectionState.value
return state == cc.skysparkle.matewave.network.ConnectionState.CONNECTED ||
state == cc.skysparkle.matewave.network.ConnectionState.CONNECTING
}
/** Fresh discovery without clearing the list first, so statuses do not blink. */
fun refresh() {
val lan = Transports.lan(context)
if (lanBusy(lan)) return
scope.launch { runCatching { restartDiscovery(lan) } }
}
private suspend fun restartDiscovery(lan: cc.skysparkle.matewave.network.LanTransport) {
lan.stopDiscovery()
delay(RESTART_DELAY_MS)
if (PresenceSettings(context).announceEnabled) lan.startPresence()
beginSession(lan)
}
/**
* Starts a discovery session. A new session reports only what it finds; if it finds nobody
* it reports nothing at all, so without this check players who left stayed in the list.
*/
private fun beginSession(lan: cc.skysparkle.matewave.network.LanTransport) {
sessionStart = System.currentTimeMillis()
lan.discoverPeers { found -> replaceAll(found) }
pruneJob?.cancel()
pruneJob = scope.launch {
delay(PRUNE_AFTER_MS)
if (lastResultAt < sessionStart) _peers.value = emptyMap()
}
}
fun stop() {
job?.cancel()
job = null
gameWatchJob?.cancel()
gameWatchJob = null
pruneJob?.cancel()
pruneJob = null
runCatching {
val lan = Transports.lan(context)
val busy = lan.connectionState.value == cc.skysparkle.matewave.network.ConnectionState.CONNECTED ||
lan.connectionState.value == cc.skysparkle.matewave.network.ConnectionState.CONNECTING
if (busy) lan.stopDiscovery() else lan.shutdown()
}
_peers.value = emptyMap()
NetworkLog.log("PRESENCE", "LAN presence stopped")
}
private fun replaceAll(found: List<LanPeer>) {
lastResultAt = System.currentTimeMillis()
val mine = DeviceKeys.publicKeyFingerprint(context)
val result = LinkedHashMap<String, DiscoveredPeer>()
found.forEach { peer ->
val id = peer.playerId.ifEmpty { peer.fingerprint }
if (id.isEmpty()) return@forEach
if (peer.fingerprint.isNotEmpty() && peer.fingerprint == mine) return@forEach
val knownKey = knownPeers.publicKeyOf(id)
if (knownKey != null) {
val authentic = peer.signature.isNotEmpty() && DeviceKeys.verify(
knownKey,
cc.skysparkle.matewave.network.LanTransport.presencePayload(peer.playerId, peer.displayName, peer.elo),
peer.signature
)
if (!authentic) {
NetworkLog.log("PRESENCE", "Announcement for $id failed signature check, hidden")
return@forEach
}
FriendsStore.updateProfile(
userId = id,
name = peer.displayName.ifEmpty { peer.name },
elo = peer.elo
)
FriendsStore.markSeen(id, lastResultAt)
}
result[id] = DiscoveredPeer(
playerId = id,
name = peer.displayName.ifEmpty { peer.name },
elo = peer.elo,
fingerprint = peer.fingerprint,
address = peer.id
)
}
_peers.value = result
}
fun isOnline(playerId: String): Boolean = _peers.value.containsKey(playerId)
private companion object {
const val WATCHDOG_INTERVAL_MS = 30_000L
/** A session that found nobody within this time means nobody is around. */
const val PRUNE_AFTER_MS = 8_000L
const val RESTART_DELAY_MS = 600L
}
}
@@ -0,0 +1,37 @@
package cc.skysparkle.matewave.presence
import android.content.Context
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
/**
* The one [LocalPresence] of the process. The app's screens and the background service share it,
* so two discovery sessions never compete for the network service, and it runs while at least
* one of them holds it.
*/
object PresenceHub {
private val scope = CoroutineScope(SupervisorJob())
@Volatile private var instance: LocalPresence? = null
private var holders = 0
fun get(context: Context): LocalPresence =
instance ?: synchronized(this) {
instance ?: LocalPresence(context.applicationContext, scope).also { instance = it }
}
fun acquire(context: Context) {
val presence = get(context)
val first = synchronized(this) { holders++ == 0 }
if (first) presence.start()
}
fun release(context: Context) {
val presence = get(context)
val last = synchronized(this) {
if (holders == 0) return
--holders == 0
}
if (last) presence.stop()
}
}
@@ -0,0 +1,94 @@
package cc.skysparkle.matewave.presence
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import cc.skysparkle.matewave.MainActivity
import cc.skysparkle.matewave.R
import cc.skysparkle.matewave.network.NetworkLog
/** Optional foreground service that keeps the player visible while the app is in the background. */
class PresenceService : Service() {
private var holdsPresence = false
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (holdsPresence) return START_STICKY
createChannel()
val started = runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, buildNotification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE)
} else {
startForeground(NOTIFICATION_ID, buildNotification())
}
}.isSuccess
if (!started) {
NetworkLog.log("PRESENCE", "Background service not started: rejected by the system")
PresenceSettings(applicationContext).backgroundEnabled = false
stopSelf()
return START_NOT_STICKY
}
PresenceHub.acquire(applicationContext)
holdsPresence = true
NetworkLog.log("PRESENCE", "Background presence started")
return START_STICKY
}
override fun onDestroy() {
if (holdsPresence) {
holdsPresence = false
PresenceHub.release(applicationContext)
NetworkLog.log("PRESENCE", "Background presence stopped")
}
super.onDestroy()
}
private fun createChannel() {
val manager = getSystemService(NotificationManager::class.java) ?: return
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.presence_service_channel),
NotificationManager.IMPORTANCE_LOW
)
manager.createNotificationChannel(channel)
}
private fun buildNotification(): Notification {
val intent = Intent(this, MainActivity::class.java)
val pending = PendingIntent.getActivity(
this, 0, intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
return Notification.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.presence_service_title))
.setContentText(getString(R.string.presence_service_text))
.setSmallIcon(R.drawable.ic_stat_background)
.setContentIntent(pending)
.setOngoing(true)
.build()
}
companion object {
private const val CHANNEL_ID = "presence"
private const val NOTIFICATION_ID = 42
fun start(context: Context) {
runCatching { context.startForegroundService(Intent(context, PresenceService::class.java)) }
}
fun stop(context: Context) {
runCatching { context.stopService(Intent(context, PresenceService::class.java)) }
}
}
}
@@ -0,0 +1,21 @@
package cc.skysparkle.matewave.presence
import android.content.Context
class PresenceSettings(context: Context) {
private val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
var announceEnabled: Boolean
get() = prefs.getBoolean(KEY_ANNOUNCE, true)
set(value) { prefs.edit().putBoolean(KEY_ANNOUNCE, value).apply() }
var backgroundEnabled: Boolean
get() = prefs.getBoolean(KEY_BACKGROUND, false)
set(value) { prefs.edit().putBoolean(KEY_BACKGROUND, value).apply() }
private companion object {
const val PREFS = "presence_settings"
const val KEY_ANNOUNCE = "announce"
const val KEY_BACKGROUND = "background"
}
}
@@ -0,0 +1,96 @@
package cc.skysparkle.matewave.puzzles
import android.content.Context
import cc.skysparkle.matewave.R
import org.json.JSONArray
data class Puzzle(
val id: Int,
val theme: String,
val fen: String,
val solutionUci: List<String>,
val rating: Int,
val themes: List<String> = emptyList()
)
/** Localized name of the puzzle theme, shown as the screen title. */
fun Puzzle.themeTitleRes(): Int = when (theme) {
"tactic" -> R.string.puzzle_theme_tactic
"mate_in_1" -> R.string.puzzle_theme_mate_in_1
"mate_in_2" -> R.string.puzzle_theme_mate_in_2
"mate_in_3" -> R.string.puzzle_theme_mate_in_3
"fork" -> R.string.puzzle_theme_fork
"pin" -> R.string.puzzle_theme_pin
"discovered_attack" -> R.string.puzzle_theme_discovered_attack
"rook_endgame" -> R.string.puzzle_theme_rook_endgame
"pawn_endgame" -> R.string.puzzle_theme_pawn_endgame
"sacrifice" -> R.string.puzzle_theme_sacrifice
"deflection" -> R.string.puzzle_theme_deflection
"hanging_piece" -> R.string.puzzle_theme_hanging_piece
"skewer" -> R.string.puzzle_theme_skewer
"bishop_endgame" -> R.string.puzzle_theme_bishop_endgame
"zugzwang" -> R.string.puzzle_theme_zugzwang
"trapped_piece" -> R.string.puzzle_theme_trapped_piece
"knight_endgame" -> R.string.puzzle_theme_knight_endgame
"promotion" -> R.string.puzzle_theme_promotion
"attraction" -> R.string.puzzle_theme_attraction
"double_check" -> R.string.puzzle_theme_double_check
"clearance" -> R.string.puzzle_theme_clearance
"queen_endgame" -> R.string.puzzle_theme_queen_endgame
"en_passant" -> R.string.puzzle_theme_en_passant
"interference" -> R.string.puzzle_theme_interference
"xray" -> R.string.puzzle_theme_xray
else -> R.string.puzzle_title_fallback
}
/** Loads the bundled Lichess puzzle set (CC0) from assets and caches it in memory. */
object PuzzleRepository {
@Volatile
private var cached: List<Puzzle>? = null
fun load(context: Context): List<Puzzle> {
cached?.let { return it }
synchronized(this) {
cached?.let { return it }
val json = context.assets.open("puzzles.json").bufferedReader(Charsets.UTF_8).use { it.readText() }
val arr = JSONArray(json)
val result = ArrayList<Puzzle>(arr.length())
for (i in 0 until arr.length()) {
val o = arr.getJSONObject(i)
val solutionArr = o.getJSONArray("solution")
val solution = (0 until solutionArr.length()).map { solutionArr.getString(it) }
val themesArr = o.optJSONArray("themes")
val themes = themesArr?.let { t -> (0 until t.length()).map { t.getString(it) } } ?: emptyList()
result.add(
Puzzle(
id = o.getInt("id"),
theme = o.optString("theme", "tactic"),
fen = o.getString("fen"),
solutionUci = solution,
rating = o.getInt("rating"),
themes = themes
)
)
}
cached = result
return result
}
}
/**
* A puzzle close to [rating]: a random unsolved one within ±100, widening the window if
* needed. Once every nearby puzzle is solved, solved ones are offered again.
*/
fun pickForRating(context: Context, rating: Int, solved: Set<Int>, exclude: Int? = null): Puzzle? {
val all = load(context).filter { it.id != exclude }
if (all.isEmpty()) return null
val unsolved = all.filter { it.id !in solved }
for (pool in listOf(unsolved, all)) {
for (window in intArrayOf(100, 200, 400)) {
val near = pool.filter { kotlin.math.abs(it.rating - rating) <= window }
if (near.isNotEmpty()) return near.random()
}
}
return (unsolved.ifEmpty { all }).minByOrNull { kotlin.math.abs(it.rating - rating) }
}
}
@@ -0,0 +1,161 @@
package cc.skysparkle.matewave.security
import android.content.Context
import android.util.Base64
import java.security.KeyFactory
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.MessageDigest
import java.security.PrivateKey
import java.security.PublicKey
import java.security.Signature
import java.security.spec.ECGenParameterSpec
import java.security.spec.PKCS8EncodedKeySpec
import java.security.spec.X509EncodedKeySpec
/**
* Long-term ECDSA identity key (kept in Android Keystore) plus per-game ephemeral ECDH
* keys, so a leaked session secret does not expose other games.
*/
object DeviceKeys {
private const val KEYSTORE = "AndroidKeyStore"
// Kept from before the rename: a new alias would create a new identity and break pinned keys.
private const val KEY_ALIAS = "chessapp_identity"
private const val PREFS = "device_keys"
private const val KEY_PRIVATE = "private"
private const val KEY_PUBLIC = "public"
private const val ALGORITHM = "EC"
private const val CURVE = "secp256r1"
private const val SIGNATURE_ALGORITHM = "SHA256withECDSA"
@Volatile private var cached: KeyPair? = null
private fun keystoreKeyPair(): KeyPair? = try {
val store = java.security.KeyStore.getInstance(KEYSTORE).apply { load(null) }
val existing = store.getEntry(KEY_ALIAS, null) as? java.security.KeyStore.PrivateKeyEntry
if (existing != null) {
KeyPair(existing.certificate.publicKey, existing.privateKey)
} else {
val generator = KeyPairGenerator.getInstance(ALGORITHM, KEYSTORE)
generator.initialize(
android.security.keystore.KeyGenParameterSpec.Builder(
KEY_ALIAS,
android.security.keystore.KeyProperties.PURPOSE_SIGN or
android.security.keystore.KeyProperties.PURPOSE_VERIFY
)
.setAlgorithmParameterSpec(ECGenParameterSpec(CURVE))
.setDigests(android.security.keystore.KeyProperties.DIGEST_SHA256)
.build()
)
generator.generateKeyPair()
}
} catch (e: Exception) {
null
}
private fun keyPair(context: Context): KeyPair? {
cached?.let { return it }
synchronized(this) {
cached?.let { return it }
keystoreKeyPair()?.let {
cached = it
return it
}
val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val storedPrivate = prefs.getString(KEY_PRIVATE, null)
val storedPublic = prefs.getString(KEY_PUBLIC, null)
if (storedPrivate != null && storedPublic != null) {
val restored = try {
val factory = KeyFactory.getInstance(ALGORITHM)
KeyPair(
factory.generatePublic(X509EncodedKeySpec(decode(storedPublic))),
factory.generatePrivate(PKCS8EncodedKeySpec(decode(storedPrivate)))
)
} catch (e: Exception) {
null
}
if (restored != null) {
cached = restored
return restored
}
}
return try {
val generator = KeyPairGenerator.getInstance(ALGORITHM)
generator.initialize(ECGenParameterSpec(CURVE))
val fresh = generator.generateKeyPair()
prefs.edit()
.putString(KEY_PRIVATE, encode(fresh.private.encoded))
.putString(KEY_PUBLIC, encode(fresh.public.encoded))
.apply()
cached = fresh
fresh
} catch (e: Exception) {
null
}
}
}
fun publicKey(context: Context): String? =
keyPair(context)?.public?.encoded?.let { encode(it) }
fun publicKeyFingerprint(context: Context): String? =
publicKey(context)?.let { fingerprintOf(it) }
fun fingerprintOf(publicKey: String): String =
MessageDigest.getInstance("SHA-256")
.digest(publicKey.toByteArray(Charsets.UTF_8))
.take(16).joinToString("") { "%02x".format(it) }
fun sign(context: Context, data: String): String? {
val privateKey: PrivateKey = keyPair(context)?.private ?: return null
return try {
val signature = Signature.getInstance(SIGNATURE_ALGORITHM)
signature.initSign(privateKey)
signature.update(data.toByteArray(Charsets.UTF_8))
encode(signature.sign())
} catch (e: Exception) {
null
}
}
fun verify(publicKeyEncoded: String, data: String, signatureEncoded: String): Boolean {
return try {
val key: PublicKey = KeyFactory.getInstance(ALGORITHM)
.generatePublic(X509EncodedKeySpec(decode(publicKeyEncoded)))
val signature = Signature.getInstance(SIGNATURE_ALGORITHM)
signature.initVerify(key)
signature.update(data.toByteArray(Charsets.UTF_8))
signature.verify(decode(signatureEncoded))
} catch (e: Exception) {
false
}
}
fun generateEphemeral(): KeyPair? = try {
val generator = KeyPairGenerator.getInstance(ALGORITHM)
generator.initialize(ECGenParameterSpec(CURVE))
generator.generateKeyPair()
} catch (e: Exception) {
null
}
fun encodePublic(keyPair: KeyPair): String = encode(keyPair.public.encoded)
fun sharedSecretWith(ephemeral: KeyPair, peerEphemeralPublic: String): ByteArray? = try {
val peer = KeyFactory.getInstance(ALGORITHM)
.generatePublic(X509EncodedKeySpec(decode(peerEphemeralPublic)))
val agreement = javax.crypto.KeyAgreement.getInstance("ECDH")
agreement.init(ephemeral.private)
agreement.doPhase(peer, true)
MessageDigest.getInstance("SHA-256").digest(agreement.generateSecret())
} catch (e: Exception) {
null
}
private fun encode(bytes: ByteArray): String = Base64.encodeToString(bytes, Base64.NO_WRAP)
private fun decode(value: String): ByteArray = Base64.decode(value, Base64.NO_WRAP)
}
@@ -0,0 +1,95 @@
package cc.skysparkle.matewave.security
import android.content.Context
import cc.skysparkle.matewave.network.NetworkLog
import org.json.JSONObject
enum class TrustResult {
TRUSTED,
FIRST_SEEN,
IMPERSONATION,
BAD_SIGNATURE,
UNSIGNED
}
/**
* Trust-on-first-use key pinning: the first public key seen for a player ID is stored,
* and later profiles for that ID must be signed with the same key.
*/
class KnownPeers(context: Context) {
private val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
fun publicKeyOf(userId: String): String? = prefs.getString(key(userId), null)
fun verifyAndRemember(
userId: String,
publicKey: String?,
signedData: String,
signature: String?
): TrustResult {
if (publicKey.isNullOrBlank() || signature.isNullOrBlank()) {
if (publicKeyOf(userId) != null) {
NetworkLog.log("TRUST", "Unsigned profile for known $userId rejected as impersonation")
return TrustResult.IMPERSONATION
}
return TrustResult.UNSIGNED
}
if (!DeviceKeys.verify(publicKey, signedData, signature)) {
NetworkLog.log("TRUST", "Signature mismatch for $userId")
return TrustResult.BAD_SIGNATURE
}
val known = publicKeyOf(userId)
return when {
known == null -> {
prefs.edit().putString(key(userId), publicKey).apply()
TrustResult.FIRST_SEEN
}
known == publicKey -> TrustResult.TRUSTED
else -> {
NetworkLog.log("TRUST", "Impersonation attempt for $userId rejected")
TrustResult.IMPERSONATION
}
}
}
fun profilePayload(
userId: String,
name: String,
elo: Int,
avatarBase64: String? = null,
backgroundBase64: String? = null,
ephemeralKey: String = ""
): String =
JSONObject().apply {
put("userId", userId)
put("name", name)
put("elo", elo)
put("avatar", digestOf(avatarBase64))
put("background", digestOf(backgroundBase64))
put("ephemeral", ephemeralKey)
}.toString()
private fun digestOf(value: String?): String {
if (value.isNullOrEmpty()) return ""
return java.security.MessageDigest.getInstance("SHA-256")
.digest(value.toByteArray(Charsets.UTF_8))
.take(8).joinToString("") { "%02x".format(it) }
}
/** Forgets every pinned key, e.g. after opponents reinstalled the app. */
fun forgetAll() {
prefs.edit().clear().apply()
}
private fun key(userId: String) = "pk:$userId"
private companion object {
const val PREFS = "known_peers"
}
}
@@ -0,0 +1,108 @@
package cc.skysparkle.matewave.security
import android.util.Base64
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.Mac
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* Per-session message protection: AES-256-GCM for confidentiality and a truncated
* HMAC-SHA256 tag bound to the sender's direction. A sliding window of sequence numbers
* rejects replays while tolerating out-of-order delivery.
*/
class SessionAuth(private val secret: ByteArray) {
private var lastAcceptedSeq = 0
private var outgoingSeq = 0
private val seenInWindow = java.util.TreeSet<Int>()
fun nextSeq(): Int {
outgoingSeq += 1
return outgoingSeq
}
fun acceptSeq(seq: Int): Boolean {
if (seq <= 0) return false
if (seq > lastAcceptedSeq) {
lastAcceptedSeq = seq
seenInWindow.add(seq)
while (seenInWindow.isNotEmpty() && seenInWindow.first() < lastAcceptedSeq - REPLAY_WINDOW) {
seenInWindow.pollFirst()
}
return true
}
if (seq < lastAcceptedSeq - REPLAY_WINDOW) return false
return seenInWindow.add(seq)
}
fun tag(message: String): String? = try {
val mac = Mac.getInstance(MAC_ALGORITHM)
mac.init(SecretKeySpec(secret, MAC_ALGORITHM))
val full = mac.doFinal(message.toByteArray(Charsets.UTF_8))
Base64.encodeToString(full.copyOfRange(0, TAG_BYTES), Base64.NO_WRAP)
} catch (e: Exception) {
null
}
fun verify(message: String, providedTag: String?): Boolean {
if (providedTag == null) return false
val expected = tag(message) ?: return false
if (expected.length != providedTag.length) return false
var diff = 0
for (i in expected.indices) {
diff = diff or (expected[i].code xor providedTag[i].code)
}
return diff == 0
}
private val encryptionKey: ByteArray by lazy {
java.security.MessageDigest.getInstance("SHA-256")
.digest(secret + KDF_LABEL)
}
fun encrypt(plaintext: String): String? = try {
val nonce = ByteArray(GCM_NONCE_BYTES).also { SecureRandom().nextBytes(it) }
val cipher = Cipher.getInstance(CIPHER_ALGORITHM)
cipher.init(
Cipher.ENCRYPT_MODE,
SecretKeySpec(encryptionKey, "AES"),
GCMParameterSpec(GCM_TAG_BITS, nonce)
)
val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
Base64.encodeToString(nonce + ciphertext, Base64.NO_WRAP)
} catch (e: Exception) {
null
}
fun decrypt(payload: String): String? = try {
val raw = Base64.decode(payload, Base64.NO_WRAP)
if (raw.size <= GCM_NONCE_BYTES) null else {
val nonce = raw.copyOfRange(0, GCM_NONCE_BYTES)
val ciphertext = raw.copyOfRange(GCM_NONCE_BYTES, raw.size)
val cipher = Cipher.getInstance(CIPHER_ALGORITHM)
cipher.init(
Cipher.DECRYPT_MODE,
SecretKeySpec(encryptionKey, "AES"),
GCMParameterSpec(GCM_TAG_BITS, nonce)
)
String(cipher.doFinal(ciphertext), Charsets.UTF_8)
}
} catch (e: Exception) {
null
}
private companion object {
const val MAC_ALGORITHM = "HmacSHA256"
const val TAG_BYTES = 12
const val CIPHER_ALGORITHM = "AES/GCM/NoPadding"
const val GCM_NONCE_BYTES = 12
const val GCM_TAG_BITS = 128
const val REPLAY_WINDOW = 256
val KDF_LABEL = "chessapp-session-aes-gcm-v1".toByteArray(Charsets.UTF_8)
}
}
@@ -0,0 +1,19 @@
package cc.skysparkle.matewave.settings
import android.content.Context
import android.content.Intent
import android.net.Uri
/** External links of the app, kept in one place. */
object AppLinks {
const val REPOSITORY = "https://git.skysparkle.cc/kalarina/Matewave"
const val REPOSITORY_LABEL = "git.skysparkle.cc/kalarina/Matewave"
const val PRIVACY_POLICY = "https://skysparkle.cc/privacy?lang=en"
/** Opens a link in the browser; does nothing if no app can open it. */
fun open(context: Context, url: String) {
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
}
}
@@ -0,0 +1,50 @@
package cc.skysparkle.matewave.settings
import android.content.Context
import androidx.compose.ui.graphics.Color
enum class BoardTheme(val titleRes: Int, val light: Color, val dark: Color) {
CLASSIC(cc.skysparkle.matewave.R.string.board_theme_classic, Color(0xFFEDD6B0), Color(0xFFB88762)),
GREEN(cc.skysparkle.matewave.R.string.board_theme_green, Color(0xFFEEEED2), Color(0xFF769656)),
BLUE(cc.skysparkle.matewave.R.string.board_theme_blue, Color(0xFFDEE3E6), Color(0xFF8CA2AD)),
GREY(cc.skysparkle.matewave.R.string.board_theme_grey, Color(0xFFDCDCDC), Color(0xFF8F8F8F))
}
class BoardAppearance(context: Context) {
private val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
var theme: BoardTheme
get() = runCatching { BoardTheme.valueOf(prefs.getString(KEY_THEME, null) ?: "") }
.getOrDefault(BoardTheme.CLASSIC)
set(value) { prefs.edit().putString(KEY_THEME, value.name).apply() }
var showCoordinates: Boolean
get() = prefs.getBoolean(KEY_COORDS, true)
set(value) { prefs.edit().putBoolean(KEY_COORDS, value).apply() }
var useImagePieces: Boolean
get() = prefs.getBoolean(KEY_IMAGES, true)
set(value) { prefs.edit().putBoolean(KEY_IMAGES, value).apply() }
var showLastMove: Boolean
get() = prefs.getBoolean(KEY_LAST_MOVE, true)
set(value) { prefs.edit().putBoolean(KEY_LAST_MOVE, value).apply() }
var showCheckHighlight: Boolean
get() = prefs.getBoolean(KEY_CHECK, true)
set(value) { prefs.edit().putBoolean(KEY_CHECK, value).apply() }
var showOpening: Boolean
get() = prefs.getBoolean(KEY_OPENING, true)
set(value) { prefs.edit().putBoolean(KEY_OPENING, value).apply() }
private companion object {
const val KEY_OPENING = "opening_in_game"
const val KEY_LAST_MOVE = "last_move"
const val KEY_CHECK = "check_highlight"
const val PREFS = "board_appearance"
const val KEY_THEME = "theme"
const val KEY_COORDS = "coords"
const val KEY_IMAGES = "images"
}
}
@@ -0,0 +1,16 @@
package cc.skysparkle.matewave.settings
import android.content.Context
class FeedbackSettings(context: Context) {
private val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
var vibrationEnabled: Boolean
get() = prefs.getBoolean(KEY_VIBRATION, true)
set(value) { prefs.edit().putBoolean(KEY_VIBRATION, value).apply() }
private companion object {
const val PREFS = "feedback_settings"
const val KEY_VIBRATION = "vibration"
}
}
@@ -0,0 +1,50 @@
package cc.skysparkle.matewave.settings
import android.content.Context
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
/**
* Vibration feedback through the Vibrator service directly, so it works even when
* system touch feedback is disabled. Controlled by the in-app vibration switch.
*/
object Haptics {
@Volatile private var appContext: Context? = null
private var vibrator: Vibrator? = null
fun init(context: Context) {
val app = context.applicationContext
appContext = app
vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
(app.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator
} else {
@Suppress("DEPRECATION")
app.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
}
}
fun select() = play(Build.VERSION_CODES.Q, predefined = VibrationEffect.EFFECT_TICK, fallbackMs = 8)
fun move() = play(Build.VERSION_CODES.Q, predefined = VibrationEffect.EFFECT_CLICK, fallbackMs = 15)
fun capture() = play(Build.VERSION_CODES.Q, predefined = VibrationEffect.EFFECT_HEAVY_CLICK, fallbackMs = 25)
fun alert() = play(Build.VERSION_CODES.Q, predefined = VibrationEffect.EFFECT_DOUBLE_CLICK, fallbackMs = 40)
private fun play(minApiForPredefined: Int, predefined: Int, fallbackMs: Long) {
val context = appContext ?: return
val v = vibrator ?: return
if (!v.hasVibrator()) return
if (!FeedbackSettings(context).vibrationEnabled) return
runCatching {
val effect = if (Build.VERSION.SDK_INT >= minApiForPredefined) {
VibrationEffect.createPredefined(predefined)
} else {
VibrationEffect.createOneShot(fallbackMs, VibrationEffect.DEFAULT_AMPLITUDE)
}
v.vibrate(effect)
}
}
}
@@ -0,0 +1,31 @@
package cc.skysparkle.matewave.settings
import android.content.Context
import cc.skysparkle.matewave.ai.AiDifficulty
import cc.skysparkle.matewave.clock.TimeControl
import cc.skysparkle.matewave.engine.PieceColor
/** Remembers the last choices on setup screens, so a repeat game is one tap away. */
class LastGameSetup(context: Context) {
private val prefs = context.applicationContext.getSharedPreferences("last_game_setup", Context.MODE_PRIVATE)
var aiDifficulty: AiDifficulty
get() = AiDifficulty.entries.firstOrNull { it.name == prefs.getString("ai_difficulty", null) } ?: AiDifficulty.MEDIUM
set(value) { prefs.edit().putString("ai_difficulty", value.name).apply() }
/** null means a random colour. */
var aiColor: PieceColor?
get() = when (prefs.getString("ai_color", "W")) { "W" -> PieceColor.WHITE; "B" -> PieceColor.BLACK; else -> null }
set(value) { prefs.edit().putString("ai_color", when (value) { PieceColor.WHITE -> "W"; PieceColor.BLACK -> "B"; null -> "R" }).apply() }
var aiTime: TimeControl
get() = timeFor("ai_time", TimeControl.RAPID_10_0)
set(value) { prefs.edit().putString("ai_time", value.label).apply() }
var nearbyTime: TimeControl
get() = timeFor("nearby_time", TimeControl.BLITZ_5_0)
set(value) { prefs.edit().putString("nearby_time", value.label).apply() }
private fun timeFor(key: String, default: TimeControl): TimeControl =
TimeControl.presets.firstOrNull { it.label == prefs.getString(key, null) } ?: default
}
@@ -0,0 +1,128 @@
package cc.skysparkle.matewave.share
import java.io.BufferedOutputStream
import java.io.File
import java.io.IOException
import java.io.OutputStream
import java.net.Inet4Address
import java.net.NetworkInterface
import java.net.ServerSocket
import java.net.Socket
import java.util.Locale
import kotlin.concurrent.thread
class ApkShareServer(private val apk: File, private val appName: String) {
private val fileName = "$appName.apk"
@Volatile
private var server: ServerSocket? = null
fun start(): Int {
val socket = ServerSocket(0)
server = socket
thread(name = "apk-share-accept", isDaemon = true) {
while (!socket.isClosed) {
val client = try {
socket.accept()
} catch (e: IOException) {
break
}
thread(name = "apk-share-client", isDaemon = true) { serve(client) }
}
}
return socket.localPort
}
fun stop() {
runCatching { server?.close() }
server = null
}
private fun serve(client: Socket) {
try {
client.use { s ->
s.soTimeout = 15_000
val input = s.getInputStream().bufferedReader(Charsets.ISO_8859_1)
val requestLine = input.readLine() ?: return
while (true) {
val line = input.readLine() ?: break
if (line.isEmpty()) break
}
val parts = requestLine.split(' ')
val method = parts.getOrNull(0).orEmpty()
val path = parts.getOrNull(1).orEmpty().substringBefore('?')
val out = BufferedOutputStream(s.getOutputStream())
val withBody = method == "GET"
when {
method != "GET" && method != "HEAD" ->
sendText(out, 405, "Method Not Allowed", "Method Not Allowed", withBody)
path == "/" || path == "/index.html" -> {
val page = landingPage()
writeHeader(out, 200, "OK", "text/html; charset=utf-8", page.size.toLong())
if (withBody) out.write(page)
}
path == "/$fileName" -> {
writeHeader(
out, 200, "OK", "application/vnd.android.package-archive", apk.length(),
"Content-Disposition: attachment; filename=\"$fileName\"\r\n"
)
if (withBody) apk.inputStream().use { it.copyTo(out, 64 * 1024) }
}
else -> sendText(out, 404, "Not Found", "Not Found", withBody)
}
out.flush()
}
} catch (e: IOException) {
}
}
private fun sendText(out: OutputStream, code: Int, reason: String, text: String, withBody: Boolean) {
val body = text.toByteArray(Charsets.UTF_8)
writeHeader(out, code, reason, "text/plain; charset=utf-8", body.size.toLong())
if (withBody) out.write(body)
}
private fun writeHeader(
out: OutputStream, code: Int, reason: String, type: String, length: Long, extra: String = ""
) {
val header = "HTTP/1.1 $code $reason\r\n" +
"Content-Type: $type\r\n" +
"Content-Length: $length\r\n" +
"Cache-Control: no-store\r\n" +
"Connection: close\r\n" +
extra +
"\r\n"
out.write(header.toByteArray(Charsets.ISO_8859_1))
}
private fun landingPage(): ByteArray {
val size = String.format(Locale.US, "%.1f MB", apk.length() / 1_048_576.0)
val html = "<!doctype html><html><head><meta charset=\"utf-8\">" +
"<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">" +
"<title>$appName</title><style>" +
"body{font-family:sans-serif;background:#12181A;color:#fff;text-align:center;padding:48px 20px}" +
"a.b{display:inline-block;margin-top:28px;padding:16px 28px;border-radius:12px;" +
"background:#3E7C4A;color:#fff;font-size:18px;text-decoration:none}" +
"p{color:#b8c2c0}</style></head><body><h1>$appName</h1>" +
"<p>Android app \u00B7 APK $size</p>" +
"<a class=\"b\" href=\"/$fileName\">Download APK</a>" +
"<p>After downloading, open the file and allow installation from this source if asked.</p>" +
"</body></html>"
return html.toByteArray(Charsets.UTF_8)
}
}
object LocalNetwork {
private val WIFI_PREFIXES = listOf("wlan", "swlan", "ap", "softap", "eth")
fun wifiIpv4(): String? = runCatching {
NetworkInterface.getNetworkInterfaces()?.toList().orEmpty()
.filter { nif -> nif.isUp && !nif.isLoopback && WIFI_PREFIXES.any { nif.name.startsWith(it) } }
.flatMap { nif -> nif.inetAddresses.toList() }
.filterIsInstance<Inet4Address>()
.firstOrNull { it.isSiteLocalAddress }
?.hostAddress
}.getOrNull()
}
@@ -0,0 +1,22 @@
package cc.skysparkle.matewave.share
import android.content.Context
import android.net.Uri
import androidx.core.content.FileProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
object ApkSharing {
fun apkFile(context: Context): File = File(context.applicationInfo.sourceDir)
fun isSplitInstall(context: Context): Boolean =
!context.applicationInfo.splitSourceDirs.isNullOrEmpty()
suspend fun copyForSharing(context: Context, appName: String): Uri = withContext(Dispatchers.IO) {
val dir = File(context.cacheDir, "share").apply { mkdirs() }
val target = File(dir, "$appName.apk")
apkFile(context).copyTo(target, overwrite = true)
FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", target)
}
}
@@ -0,0 +1,76 @@
package cc.skysparkle.matewave.stats
import android.content.Context
object Stats {
private const val PREFS = "chess_stats_prefs"
private const val KEY_WINS = "wins_vs_ai"
private const val KEY_GAMES = "games_played_vs_ai"
private const val KEY_STREAK = "current_streak"
private const val KEY_RATINGS = "rating_history"
private const val KEY_SOLVED = "solved_puzzles"
private const val RATING_LIMIT = 40
private var appContext: Context? = null
fun init(context: Context) {
appContext = context.applicationContext
}
private fun prefs() = appContext?.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
fun wins(): Int = prefs()?.getInt(KEY_WINS, 0) ?: 0
fun gamesPlayed(): Int = prefs()?.getInt(KEY_GAMES, 0) ?: 0
fun winPercent(): Int {
val played = gamesPlayed()
return if (played == 0) 0 else ((wins() * 100f) / played).let { Math.round(it) }
}
fun streak(): Int = prefs()?.getInt(KEY_STREAK, 0) ?: 0
/** Result of a rated game against the AI or another player: 1.0 win, 0.5 draw, 0.0 loss. */
fun recordResult(result: Double) {
val p = prefs() ?: return
val win = result >= 1.0
p.edit()
.putInt(KEY_WINS, p.getInt(KEY_WINS, 0) + if (win) 1 else 0)
.putInt(KEY_GAMES, p.getInt(KEY_GAMES, 0) + 1)
.putInt(KEY_STREAK, if (win) p.getInt(KEY_STREAK, 0) + 1 else 0)
.apply()
}
/** Rating change after a rated game; the statistics graph shows this history. */
fun recordRating(before: Int, after: Int) {
val p = prefs() ?: return
val history = readRatings().ifEmpty { listOf(before) }
val ratings = (history + after).takeLast(RATING_LIMIT)
p.edit().putString(KEY_RATINGS, ratings.joinToString(",")).apply()
}
private fun readRatings(): List<Int> {
val raw = prefs()?.getString(KEY_RATINGS, "") ?: ""
return if (raw.isBlank()) emptyList() else raw.split(",").mapNotNull { it.toIntOrNull() }
}
fun solvedPuzzles(): Set<Int> =
prefs()?.getStringSet(KEY_SOLVED, emptySet())?.mapNotNull { it.toIntOrNull() }?.toSet() ?: emptySet()
fun markPuzzleSolved(id: Int) {
val p = prefs() ?: return
val set = (p.getStringSet(KEY_SOLVED, emptySet()) ?: emptySet()).toMutableSet()
set.add(id.toString())
p.edit().putStringSet(KEY_SOLVED, set).apply()
}
/** Rating history for the graph; with fewer than two games it starts from the current rating. */
fun ratingCurve(currentRating: Int): List<Int> {
val ratings = readRatings()
return when {
ratings.isEmpty() -> listOf(currentRating, currentRating)
ratings.size == 1 -> listOf(ratings.first(), ratings.first())
else -> ratings
}
}
}
@@ -0,0 +1,81 @@
package cc.skysparkle.matewave.ui.components
import android.content.Context
import android.graphics.BitmapFactory
import android.util.DisplayMetrics
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import cc.skysparkle.matewave.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
private object BackgroundAssetCache {
@Volatile private var bitmap: ImageBitmap? = null
fun cached(): ImageBitmap? = bitmap
fun load(context: Context): ImageBitmap? {
bitmap?.let { return it }
synchronized(this) {
bitmap?.let { return it }
val metrics: DisplayMetrics = context.resources.displayMetrics
val target = maxOf(metrics.widthPixels, metrics.heightPixels).coerceAtLeast(1)
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeResource(context.resources, R.drawable.bg_forest, bounds)
var sample = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / sample > target * 2) sample *= 2
val options = BitmapFactory.Options().apply {
inSampleSize = sample
inPreferredConfig = android.graphics.Bitmap.Config.RGB_565
}
val decoded = runCatching {
BitmapFactory.decodeResource(context.resources, R.drawable.bg_forest, options)
?.asImageBitmap()
}.getOrNull()
bitmap = decoded
return decoded
}
}
}
@Composable
fun AppBackgroundImage(modifier: Modifier = Modifier.fillMaxSize()) {
val context = LocalContext.current
var bitmap by remember { mutableStateOf(BackgroundAssetCache.cached()) }
LaunchedEffect(Unit) {
if (bitmap == null) {
bitmap = withContext(Dispatchers.IO) { BackgroundAssetCache.load(context) }
}
}
val image = bitmap
if (image != null) {
Image(
bitmap = image,
contentDescription = null,
modifier = modifier,
contentScale = ContentScale.Crop
)
} else {
Box(modifier = modifier.background(Color(0xFF101413)))
}
}
@@ -0,0 +1,153 @@
package cc.skysparkle.matewave.ui.components
import cc.skysparkle.matewave.ui.theme.ink
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.runtime.getValue
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import cc.skysparkle.matewave.R
import cc.skysparkle.matewave.ui.theme.UiCornerRadius
enum class BottomTab { HOME, FRIENDS, SETTINGS, ACCOUNT }
@Composable
fun AppBottomBar(
current: BottomTab,
onHomeClick: () -> Unit,
onFriendsClick: () -> Unit,
onSettingsClick: () -> Unit,
onAccountClick: () -> Unit,
accountAvatarBase64: String? = null
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 4.dp)
.background(cc.skysparkle.matewave.ui.theme.FloatingBarFill, RoundedCornerShape(UiCornerRadius))
.padding(vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
NavItem(iconRes = R.drawable.ic_home, label = stringResource(R.string.nav_home), active = current == BottomTab.HOME, onClick = onHomeClick)
NavItem(iconRes = R.drawable.ic_friends, label = stringResource(R.string.nav_friends), active = current == BottomTab.FRIENDS, onClick = onFriendsClick)
NavItem(iconRes = R.drawable.ic_settings, label = stringResource(R.string.nav_settings), active = current == BottomTab.SETTINGS, onClick = onSettingsClick)
AccountNavItem(
avatarBase64 = accountAvatarBase64,
active = current == BottomTab.ACCOUNT,
onClick = onAccountClick
)
}
}
@Composable
private fun RowScope.NavItem(iconRes: Int, label: String, active: Boolean, onClick: () -> Unit) {
val shape = RoundedCornerShape(UiCornerRadius)
val highlight by animateColorAsState(
if (active) ink.copy(alpha = 0.14f) else ink.copy(alpha = 0f),
animationSpec = tween(220),
label = "navHighlight"
)
val itemColor by animateColorAsState(
if (active) ink else ink.copy(alpha = 0.7f),
animationSpec = tween(220),
label = "navColor"
)
Column(
modifier = Modifier
.weight(1f)
.clip(shape)
.background(highlight, shape)
.clickable(onClick = onClick)
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
painter = painterResource(iconRes),
contentDescription = null,
colorFilter = ColorFilter.tint(itemColor),
modifier = Modifier.size(24.dp)
)
Spacer(Modifier.height(4.dp))
Text(
label,
color = if (active) itemColor else Color.Transparent,
style = MaterialTheme.typography.labelMedium
)
}
}
@Composable
private fun RowScope.AccountNavItem(avatarBase64: String?, active: Boolean, onClick: () -> Unit) {
val shape = RoundedCornerShape(UiCornerRadius)
val bitmap = rememberChatImage(avatarBase64, AVATAR_MAX_DIMENSION_PX)
val highlight by animateColorAsState(
if (active) ink.copy(alpha = 0.14f) else ink.copy(alpha = 0f),
animationSpec = tween(220),
label = "navHighlight"
)
val itemColor by animateColorAsState(
if (active) ink else ink.copy(alpha = 0.7f),
animationSpec = tween(220),
label = "navColor"
)
Column(
modifier = Modifier
.weight(1f)
.clip(shape)
.background(highlight, shape)
.clickable(onClick = onClick)
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (bitmap != null) {
Image(
bitmap = bitmap,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(24.dp).clip(CircleShape)
)
} else {
Image(
painter = painterResource(R.drawable.ic_default_avatar),
contentDescription = null,
colorFilter = ColorFilter.tint(itemColor),
modifier = Modifier.size(24.dp)
)
}
Spacer(Modifier.height(4.dp))
Text(
stringResource(R.string.account_title),
color = if (active) itemColor else Color.Transparent,
style = MaterialTheme.typography.labelMedium
)
}
}
@@ -0,0 +1,184 @@
package cc.skysparkle.matewave.ui.components
import android.os.Build
import android.view.WindowManager
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import cc.skysparkle.matewave.ui.theme.DialogScrimColor
import cc.skysparkle.matewave.ui.theme.ink
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
val DialogSurface = Color(0xFF262B29)
val DialogAccent = Color(0xFF93DBA3)
val DialogShape = RoundedCornerShape(28.dp)
@Composable
fun AppDialog(
onDismissRequest: () -> Unit,
title: String? = null,
actions: (@Composable RowScope.() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit = {}
) {
Dialog(
onDismissRequest = onDismissRequest,
properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false)
) {
val window = (LocalView.current.parent as? DialogWindowProvider)?.window
SideEffect {
window?.let { w ->
w.setDimAmount(0f)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
runCatching {
if (w.windowManager.isCrossWindowBlurEnabled) {
w.addFlags(WindowManager.LayoutParams.FLAG_BLUR_BEHIND)
w.attributes = w.attributes.apply { blurBehindRadius = 24 }
}
}
}
}
}
var shown by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { shown = true }
val progress by animateFloatAsState(
targetValue = if (shown) 1f else 0f,
animationSpec = tween(durationMillis = 240, easing = FastOutSlowInEasing),
label = "dialogAppear"
)
val scrim = DialogScrimColor
Box(
modifier = Modifier
.fillMaxSize()
.drawBehind { drawRect(scrim.copy(alpha = scrim.alpha * progress)) }
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onDismissRequest
),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 24.dp, vertical = 16.dp)
.fillMaxWidth()
.widthIn(max = 560.dp)
.graphicsLayer {
alpha = progress
val s = 0.92f + 0.08f * progress
scaleX = s
scaleY = s
translationY = (1f - progress) * 24.dp.toPx()
}
.clip(DialogShape)
.background(DialogSurface)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { }
.padding(start = 24.dp, end = 16.dp, top = 24.dp, bottom = 16.dp)
) {
if (title != null) {
Text(
title,
color = ink,
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(end = 8.dp)
)
Spacer(Modifier.height(16.dp))
}
// Long content scrolls between the fixed title and buttons.
Column(
modifier = Modifier
.weight(1f, fill = false)
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(end = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
content = content
)
if (actions != null) {
Spacer(Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.End),
verticalAlignment = Alignment.CenterVertically,
content = actions
)
}
}
}
}
}
@Composable
fun DialogText(text: String, modifier: Modifier = Modifier) {
Text(
text,
color = ink.copy(alpha = 0.78f),
style = MaterialTheme.typography.bodyLarge,
modifier = modifier
)
}
@Composable
fun DialogAction(
text: String,
onClick: () -> Unit,
enabled: Boolean = true,
color: Color = DialogAccent
) {
Text(
text,
color = if (enabled) color else color.copy(alpha = 0.4f),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier
.clip(RoundedCornerShape(50))
.then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier)
.padding(horizontal = 12.dp, vertical = 10.dp)
)
}
@@ -0,0 +1,35 @@
package cc.skysparkle.matewave.ui.components
import cc.skysparkle.matewave.ui.theme.ink
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun AppIcon(
resId: Int,
size: Dp = 20.dp,
tint: Color = ink,
modifier: Modifier = Modifier,
contentDescription: String? = null
) {
Image(
painter = painterResource(resId),
contentDescription = contentDescription,
colorFilter = ColorFilter.tint(tint),
modifier = modifier.size(size)
)
}
@Composable
fun ButtonIcon(resId: Int, tint: Color = ink, size: Dp = 18.dp) {
AppIcon(resId, size = size, tint = tint, modifier = Modifier.padding(end = 8.dp))
}
@@ -0,0 +1,57 @@
package cc.skysparkle.matewave.ui.components
import cc.skysparkle.matewave.ui.theme.ink
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import cc.skysparkle.matewave.engine.Piece
import cc.skysparkle.matewave.engine.PieceColor
import cc.skysparkle.matewave.engine.PieceType
@Composable
fun CapturedPiecesRow(
captured: List<PieceType>,
capturedColor: PieceColor,
advantage: Int,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier.fillMaxWidth().height(22.dp),
verticalAlignment = Alignment.CenterVertically
) {
Row(horizontalArrangement = Arrangement.spacedBy(3.dp), verticalAlignment = Alignment.CenterVertically) {
captured.groupBy { it }.forEach { (type, group) ->
Row(horizontalArrangement = Arrangement.spacedBy((-10).dp)) {
repeat(group.size) {
Image(
painter = painterResource(Piece(type, capturedColor).drawableRes()),
contentDescription = null,
modifier = Modifier.size(20.dp)
)
}
}
}
}
if (advantage > 0) {
Spacer(Modifier.width(6.dp))
Text(
"+$advantage",
color = ink.copy(alpha = 0.85f),
style = MaterialTheme.typography.labelLarge
)
}
}
}
@@ -0,0 +1,145 @@
package cc.skysparkle.matewave.ui.components
import cc.skysparkle.matewave.ui.theme.ink
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import cc.skysparkle.matewave.R
import cc.skysparkle.matewave.ui.theme.ChessGreen
import cc.skysparkle.matewave.ui.theme.whiteOutlinedTextFieldColors
import cc.skysparkle.matewave.viewmodel.ChatEntry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun ChatDialog(
messages: List<ChatEntry>,
onSendText: (String) -> Unit,
onSendImage: (String) -> Unit,
onDismiss: () -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var text by remember { mutableStateOf("") }
var sendingImage by remember { mutableStateOf(false) }
val scrollState = rememberScrollState()
val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
sendingImage = true
scope.launch {
val encoded = withContext(Dispatchers.IO) { encodeImageForChat(context, uri) }
sendingImage = false
if (encoded != null) onSendImage(encoded)
}
}
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) scrollState.animateScrollTo(scrollState.maxValue)
}
AppDialog(
onDismissRequest = onDismiss,
title = stringResource(R.string.chat_title),
actions = { DialogAction(stringResource(R.string.chat_close), onDismiss) }
) {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 320.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
if (messages.isEmpty()) {
DialogText(stringResource(R.string.chat_empty))
}
messages.forEach { entry -> ChatBubble(entry) }
}
if (sendingImage) {
GlassProgress(stringResource(R.string.chat_sending_image))
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
GlassOutlinedButton(onClick = { imagePicker.launch("image/*") }) {
AppIcon(R.drawable.ic_add_photo, size = 22.dp)
}
OutlinedTextField(
value = text,
onValueChange = { text = it },
modifier = Modifier.weight(1f),
placeholder = { Text(stringResource(R.string.chat_input_hint), color = ink.copy(alpha = 0.5f)) },
colors = whiteOutlinedTextFieldColors()
)
GlassButton(
onClick = {
if (text.isNotBlank()) {
onSendText(text.trim())
text = ""
}
},
enabled = text.isNotBlank()
) {
AppIcon(R.drawable.ic_arrow_forward, size = 20.dp)
}
}
}
}
@Composable
private fun ChatBubble(entry: ChatEntry) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (entry.fromMe) Arrangement.End else Arrangement.Start
) {
val shape = RoundedCornerShape(14.dp)
Column(
modifier = Modifier
.widthIn(max = 220.dp)
.background(if (entry.fromMe) ChessGreen.copy(alpha = 0.55f) else ink.copy(alpha = 0.15f), shape)
.padding(10.dp)
) {
entry.text?.let { Text(it, color = ink) }
entry.imageBase64?.let { b64 ->
val image = rememberChatImage(b64)
if (image != null) {
Image(
bitmap = image,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.size(180.dp)
)
}
}
}
}
}
@@ -0,0 +1,124 @@
package cc.skysparkle.matewave.ui.components
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.media.ExifInterface
import android.net.Uri
import android.util.Base64
import androidx.compose.runtime.Composable
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
private const val MAX_DIMENSION_PX = 640
private const val JPEG_QUALITY = 60
const val AVATAR_MAX_DIMENSION_PX = 320
const val AVATAR_JPEG_QUALITY = 75
fun encodeImageForChat(
context: Context,
uri: Uri,
maxDimension: Int = MAX_DIMENSION_PX,
quality: Int = JPEG_QUALITY
): String? {
return try {
val resolver = context.contentResolver
// Read the size first and decode a reduced copy: a full camera photo would not fit in memory.
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
val boundsStream = resolver.openInputStream(uri) ?: return null
boundsStream.use { BitmapFactory.decodeStream(it, null, bounds) }
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
var sample = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= maxDimension) sample *= 2
val options = BitmapFactory.Options().apply { inSampleSize = sample }
val decoded = resolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, options) } ?: return null
val rotation = runCatching {
resolver.openInputStream(uri)?.use { input ->
when (ExifInterface(input).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) {
ExifInterface.ORIENTATION_ROTATE_90 -> 90f
ExifInterface.ORIENTATION_ROTATE_180 -> 180f
ExifInterface.ORIENTATION_ROTATE_270 -> 270f
else -> 0f
}
}
}.getOrNull() ?: 0f
val scale = minOf(1f, maxDimension.toFloat() / maxOf(decoded.width, decoded.height))
val matrix = Matrix().apply {
if (scale < 1f) postScale(scale, scale)
if (rotation != 0f) postRotate(rotation)
}
val prepared = if (scale < 1f || rotation != 0f) {
Bitmap.createBitmap(decoded, 0, 0, decoded.width, decoded.height, matrix, true)
} else {
decoded
}
val output = ByteArrayOutputStream()
prepared.compress(Bitmap.CompressFormat.JPEG, quality, output)
Base64.encodeToString(output.toByteArray(), Base64.NO_WRAP)
} catch (e: Exception) {
null
} catch (e: OutOfMemoryError) {
null
}
}
private object DecodedImageCache {
private val cache = object : android.util.LruCache<String, Bitmap>(budgetBytes()) {
override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount
}
private fun budgetBytes(): Int =
(Runtime.getRuntime().maxMemory() / 16).toInt().coerceIn(4 shl 20, 32 shl 20)
fun key(base64: String, maxDimension: Int) = "$maxDimension:${base64.length}:${base64.hashCode()}"
fun get(key: String): Bitmap? = cache.get(key)
fun put(key: String, bitmap: Bitmap) { cache.put(key, bitmap) }
}
fun decodeChatImage(base64: String, maxDimension: Int = 0): Bitmap? {
return try {
val bytes = Base64.decode(base64, Base64.NO_WRAP)
if (maxDimension <= 0) {
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
} else {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
var sample = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= maxDimension) sample *= 2
val options = BitmapFactory.Options().apply { inSampleSize = sample }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
}
} catch (e: Exception) {
null
}
}
@Composable
fun rememberChatImage(base64: String?, maxDimension: Int = 0): ImageBitmap? {
if (base64 == null) return null
val key = remember(base64, maxDimension) { DecodedImageCache.key(base64, maxDimension) }
val state = produceState<ImageBitmap?>(
initialValue = DecodedImageCache.get(key)?.asImageBitmap(),
key1 = key
) {
if (value == null) {
value = withContext(Dispatchers.Default) {
val bitmap = decodeChatImage(base64, maxDimension)
if (bitmap != null) DecodedImageCache.put(key, bitmap)
bitmap?.asImageBitmap()
}
}
}
return state.value
}
@@ -0,0 +1,341 @@
package cc.skysparkle.matewave.ui.components
import android.content.Context
import android.graphics.BitmapFactory
import android.graphics.Paint
import android.graphics.Typeface
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import cc.skysparkle.matewave.engine.Board
import cc.skysparkle.matewave.engine.Move
import cc.skysparkle.matewave.engine.Piece
import cc.skysparkle.matewave.engine.PieceColor
import cc.skysparkle.matewave.engine.PieceType
import cc.skysparkle.matewave.settings.BoardAppearance
import cc.skysparkle.matewave.ui.theme.HighlightCheck
import cc.skysparkle.matewave.ui.theme.HighlightHint
import cc.skysparkle.matewave.ui.theme.HighlightLastMove
import cc.skysparkle.matewave.ui.theme.HighlightMove
import kotlin.math.floor
import kotlin.math.roundToInt
private object PieceImages {
@Volatile private var cache: Array<ImageBitmap>? = null
fun get(context: Context): Array<ImageBitmap> {
cache?.let { return it }
synchronized(this) {
cache?.let { return it }
val options = BitmapFactory.Options().apply { inScaled = false }
val types = PieceType.values()
val loaded = Array(PieceColor.values().size * types.size) { i ->
val piece = Piece(types[i % types.size], PieceColor.values()[i / types.size])
BitmapFactory.decodeResource(context.resources, piece.drawableRes(), options).asImageBitmap()
}
cache = loaded
return loaded
}
}
fun indexOf(piece: Piece): Int = piece.color.ordinal * PieceType.values().size + piece.type.ordinal
}
@Composable
fun ChessBoardView(
board: Board,
selectedSquare: Int?,
legalTargets: List<Move>,
kingInCheckSquare: Int?,
flipped: Boolean,
onSquareClick: (Int) -> Unit,
modifier: Modifier = Modifier,
lastMove: Move? = null,
hintSquare: Int? = null,
interactionEnabled: Boolean = true,
/** A square briefly tinted to confirm or reject a move (puzzles). */
flashSquare: Int? = null,
flashColor: Color = Color.Transparent
) {
val context = LocalContext.current
val appearance = remember { BoardAppearance(context) }
val theme = appearance.theme
val showCoords = appearance.showCoordinates
val imagePieces = appearance.useImagePieces
val showLastMove = appearance.showLastMove
val showCheck = appearance.showCheckHighlight
val pieceImages = if (imagePieces) remember(context) { PieceImages.get(context) } else null
val targetMask = remember(legalTargets) {
var mask = 0L
for (m in legalTargets) mask = mask or (1L shl m.to)
mask
}
val coordPaint = remember {
Paint().apply { isAntiAlias = true; typeface = Typeface.DEFAULT_BOLD }
}
val glyphPaint = remember {
Paint().apply { isAntiAlias = true; textAlign = Paint.Align.CENTER }
}
val currentOnSquareClick by rememberUpdatedState(onSquareClick)
val currentBoard by rememberUpdatedState(board)
val currentSelected by rememberUpdatedState(selectedSquare)
val currentTargetMask by rememberUpdatedState(targetMask)
var dragSquare by remember { mutableStateOf<Int?>(null) }
var dragPos by remember { mutableStateOf<Offset?>(null) }
Canvas(
modifier = modifier
.fillMaxWidth()
.aspectRatio(1f)
.pointerInput(flipped, interactionEnabled) {
if (interactionEnabled) {
detectTapGestures { tap ->
val cellPx = size.width / 8f
val col = floor(tap.x / cellPx).toInt().coerceIn(0, 7)
val row = floor(tap.y / cellPx).toInt().coerceIn(0, 7)
val displayRank = if (flipped) row else 7 - row
val displayFile = if (flipped) 7 - col else col
currentOnSquareClick(displayRank * 8 + displayFile)
}
}
}
.pointerInput(flipped, interactionEnabled) {
if (interactionEnabled) {
detectDragGesturesAfterLongPress(
onDragStart = { start ->
val src = squareAt(start.x, start.y, size.width.toFloat(), flipped)
val piece = if (src >= 0) currentBoard.pieceAt(src) else null
if (piece != null && piece.color == currentBoard.sideToMove) {
dragSquare = src
dragPos = start
if (currentSelected == src) cc.skysparkle.matewave.settings.Haptics.select()
if (currentSelected != src) currentOnSquareClick(src)
}
},
onDrag = { change, _ ->
if (dragSquare != null) dragPos = change.position
},
onDragEnd = {
val src = dragSquare
val pos = dragPos
dragSquare = null
dragPos = null
if (src != null && pos != null && currentSelected == src) {
val target = squareAt(pos.x, pos.y, size.width.toFloat(), flipped)
if (target >= 0 && target != src &&
((currentTargetMask shr target) and 1L) != 0L
) {
currentOnSquareClick(target)
}
}
},
onDragCancel = {
dragSquare = null
dragPos = null
}
)
}
}
) {
val cell = size.width / 8f
val lastFrom = if (showLastMove) (lastMove?.from ?: -1) else -1
val lastTo = if (showLastMove) (lastMove?.to ?: -1) else -1
val selected = selectedSquare ?: -1
val checkSquare = if (showCheck) (kingInCheckSquare ?: -1) else -1
val hint = hintSquare ?: -1
val flash = flashSquare ?: -1
val dSq = dragSquare
val dPos = dragPos
val liftedSq = if (dSq != null && dPos != null && selected == dSq) dSq else -1
val liftedAt: Offset? = if (liftedSq >= 0) dPos else null
for (row in 0 until 8) {
for (col in 0 until 8) {
val displayRank = if (flipped) row else 7 - row
val displayFile = if (flipped) 7 - col else col
val sqIndex = displayRank * 8 + displayFile
val isLight = (displayRank + displayFile) % 2 == 1
var color = if (isLight) theme.light else theme.dark
if (sqIndex == lastFrom || sqIndex == lastTo) color = HighlightLastMove
if (sqIndex == hint) color = HighlightHint
if (sqIndex == selected) color = HighlightMove
if (sqIndex == checkSquare) color = HighlightCheck
drawRect(
color = color,
topLeft = Offset(col * cell, row * cell),
size = Size(cell, cell)
)
if (sqIndex == flash) {
drawRect(color = flashColor, topLeft = Offset(col * cell, row * cell), size = Size(cell, cell))
}
if (showCoords) {
val labelColor = if (isLight) theme.dark else theme.light
if (row == 7) {
drawCoordinate(
('a' + displayFile).toString(),
Offset(col * cell + cell * 0.78f, row * cell + cell * 0.80f),
cell, labelColor, coordPaint
)
}
if (col == 0) {
drawCoordinate(
(displayRank + 1).toString(),
Offset(col * cell + cell * 0.10f, row * cell + cell * 0.22f),
cell, labelColor, coordPaint
)
}
}
if ((targetMask shr sqIndex) and 1L != 0L) {
drawCircle(
color = HighlightMove,
radius = cell * 0.14f,
center = Offset(col * cell + cell / 2, row * cell + cell / 2)
)
}
}
}
val pieceSize = cell * 0.86f
val inset = (cell - pieceSize) / 2f
val pieceDst = IntSize(pieceSize.roundToInt(), pieceSize.roundToInt())
if (pieceImages == null) {
glyphPaint.textSize = cell * 0.62f
glyphPaint.color = GLYPH_COLOR
}
for (row in 0 until 8) {
for (col in 0 until 8) {
val displayRank = if (flipped) row else 7 - row
val displayFile = if (flipped) 7 - col else col
val pieceSq = displayRank * 8 + displayFile
if (pieceSq == liftedSq) continue
val piece = board.pieceAt(pieceSq) ?: continue
if (pieceImages != null) {
drawImage(
image = pieceImages[PieceImages.indexOf(piece)],
dstOffset = IntOffset(
(col * cell + inset).roundToInt(),
(row * cell + inset).roundToInt()
),
dstSize = pieceDst,
filterQuality = FilterQuality.Medium
)
} else {
val metrics = glyphPaint.fontMetrics
drawContext.canvas.nativeCanvas.drawText(
piece.unicode(),
col * cell + cell / 2f,
row * cell + cell / 2f - (metrics.ascent + metrics.descent) / 2f,
glyphPaint
)
}
}
}
if (liftedAt != null) {
val liftedPiece = board.pieceAt(liftedSq)
if (liftedPiece != null) {
val hover = squareAt(liftedAt.x, liftedAt.y, size.width, flipped)
if (hover >= 0 && ((targetMask shr hover) and 1L) != 0L) {
val hRank = hover / 8
val hFile = hover % 8
val hRow = if (flipped) hRank else 7 - hRank
val hCol = if (flipped) 7 - hFile else hFile
drawRect(
color = Color.White.copy(alpha = 0.75f),
topLeft = Offset(hCol * cell, hRow * cell),
size = Size(cell, cell),
style = Stroke(width = cell * 0.07f)
)
}
val liftedSize = pieceSize * LIFT_SCALE
val cx = liftedAt.x
val cy = liftedAt.y - cell * 0.55f
drawOval(
color = Color.Black.copy(alpha = 0.30f),
topLeft = Offset(liftedAt.x - liftedSize * 0.36f, liftedAt.y + cell * 0.05f),
size = Size(liftedSize * 0.72f, liftedSize * 0.28f)
)
if (pieceImages != null) {
val liftedDst = IntSize(liftedSize.roundToInt(), liftedSize.roundToInt())
drawImage(
image = pieceImages[PieceImages.indexOf(liftedPiece)],
dstOffset = IntOffset(
(cx - liftedSize / 2f).roundToInt(),
(cy - liftedSize / 2f).roundToInt()
),
dstSize = liftedDst,
filterQuality = FilterQuality.Medium
)
} else {
glyphPaint.textSize = cell * 0.62f * LIFT_SCALE
val metrics = glyphPaint.fontMetrics
drawContext.canvas.nativeCanvas.drawText(
liftedPiece.unicode(),
cx,
cy - (metrics.ascent + metrics.descent) / 2f,
glyphPaint
)
}
}
}
}
}
private const val LIFT_SCALE = 1.6f
private fun squareAt(x: Float, y: Float, boardPx: Float, flipped: Boolean): Int {
if (x < 0f || y < 0f || x >= boardPx || y >= boardPx) return -1
val cellPx = boardPx / 8f
val col = floor(x / cellPx).toInt().coerceIn(0, 7)
val row = floor(y / cellPx).toInt().coerceIn(0, 7)
val displayRank = if (flipped) row else 7 - row
val displayFile = if (flipped) 7 - col else col
return displayRank * 8 + displayFile
}
private val GLYPH_COLOR = Color(0xFF1B1B1B).toArgb()
private fun DrawScope.drawCoordinate(
text: String,
position: Offset,
cell: Float,
color: Color,
paint: Paint
) {
paint.color = color.toArgb()
paint.textSize = cell * 0.20f
drawContext.canvas.nativeCanvas.drawText(text, position.x, position.y, paint)
}
@@ -0,0 +1,213 @@
package cc.skysparkle.matewave.ui.components
import cc.skysparkle.matewave.ui.theme.ink
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.graphicsLayer
import cc.skysparkle.matewave.ui.theme.LocalInk
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import cc.skysparkle.matewave.R
import cc.skysparkle.matewave.ui.theme.ChessGreen
import cc.skysparkle.matewave.ui.theme.PanelBorder
import cc.skysparkle.matewave.ui.theme.PanelFill
import cc.skysparkle.matewave.ui.theme.TabletMaxContentWidth
import cc.skysparkle.matewave.ui.theme.UiCornerRadius
import cc.skysparkle.matewave.ui.theme.glass
private val UiShape: Shape = RoundedCornerShape(UiCornerRadius)
@Composable
fun GlassScaffold(
title: String,
onBack: (() -> Unit)? = null,
actions: @Composable RowScope.() -> Unit = {},
scrollable: Boolean = true,
bottomBar: (@Composable () -> Unit)? = null,
/** Replaces the plain title, e.g. with the opening name and its status badge. */
titleContent: (@Composable RowScope.() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit
) {
Box(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
Column(modifier = Modifier.fillMaxSize().widthIn(max = TabletMaxContentWidth)) {
Row(
modifier = Modifier.fillMaxWidth().statusBarsPadding().padding(horizontal = 4.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (onBack != null) {
IconButton(onClick = onBack) {
AppIcon(
R.drawable.ic_arrow_back,
size = 24.dp,
contentDescription = stringResource(R.string.back)
)
}
} else {
Spacer(Modifier.width(48.dp))
}
if (titleContent != null) {
Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically, content = titleContent)
} else {
Text(
title,
color = ink,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.weight(1f)
)
}
actions()
}
val scrollState = rememberScrollState()
val contentModifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(horizontal = 16.dp)
.then(if (scrollable) Modifier.verticalScroll(scrollState) else Modifier)
.then(if (bottomBar == null) Modifier.navigationBarsPadding() else Modifier)
.padding(bottom = 16.dp)
Column(
modifier = contentModifier,
verticalArrangement = Arrangement.spacedBy(14.dp),
content = content
)
if (bottomBar != null) {
Box(modifier = Modifier.navigationBarsPadding()) { bottomBar() }
}
}
}
}
}
@Composable
private fun Modifier.pressScale(interactionSource: MutableInteractionSource): Modifier {
val pressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState(
targetValue = if (pressed) 0.97f else 1f,
animationSpec = spring(dampingRatio = 0.6f, stiffness = 900f),
label = "pressScale"
)
return this.graphicsLayer { scaleX = scale; scaleY = scale }
}
@Composable
fun GlassButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
content: @Composable RowScope.() -> Unit
) {
val interaction = remember { MutableInteractionSource() }
CompositionLocalProvider(LocalInk provides Color.White) {
Row(
modifier = modifier
.pressScale(interaction)
.alpha(if (enabled) 1f else 0.4f)
.clip(UiShape)
.background(ChessGreen)
.then(
if (enabled) Modifier.clickable(
interactionSource = interaction,
indication = LocalIndication.current,
onClick = onClick
) else Modifier
)
.padding(horizontal = 20.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
content = content
)
}
}
@Composable
fun GlassOutlinedButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
content: @Composable RowScope.() -> Unit
) {
val interaction = remember { MutableInteractionSource() }
Row(
modifier = modifier
.pressScale(interaction)
.alpha(if (enabled) 1f else 0.4f)
.clip(UiShape)
.background(PanelFill)
.border(1.dp, PanelBorder, UiShape)
.then(
if (enabled) Modifier.clickable(
interactionSource = interaction,
indication = LocalIndication.current,
onClick = onClick
) else Modifier
)
.padding(horizontal = 20.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
content = content
)
}
@Composable
fun GlassCard(modifier: Modifier = Modifier, shape: Shape = UiShape, content: @Composable ColumnScope.() -> Unit) {
Column(
modifier = modifier
.glass(shape)
.border(1.dp, PanelBorder, shape)
.padding(14.dp),
content = content
)
}
@Composable
fun GlassProgress(text: String) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
CircularProgressIndicator(color = ink, modifier = Modifier.padding(2.dp))
Text(text, color = ink, style = MaterialTheme.typography.bodyMedium)
}
}
@@ -0,0 +1,113 @@
package cc.skysparkle.matewave.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
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.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import cc.skysparkle.matewave.R
import cc.skysparkle.matewave.ui.theme.UiCornerRadius
import cc.skysparkle.matewave.ui.theme.glass
import cc.skysparkle.matewave.ui.theme.ink
/** Small uppercase caption above a group. */
@Composable
fun SectionCaption(text: String, modifier: Modifier = Modifier) {
Text(
text.uppercase(),
color = ink.copy(alpha = 0.5f),
style = MaterialTheme.typography.labelMedium,
modifier = modifier.padding(start = 4.dp, top = 6.dp)
)
}
/** Rows of one kind in a single block, instead of a card per row. Separate rows with [ListDivider]. */
@Composable
fun ListGroup(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
Column(
modifier = modifier
.fillMaxWidth()
.glass(RoundedCornerShape(UiCornerRadius))
.padding(vertical = 2.dp),
content = content
)
}
@Composable
fun ListDivider(inset: Int = 52) {
Box(
Modifier
.fillMaxWidth()
.padding(start = inset.dp)
.height(1.dp)
.background(ink.copy(alpha = 0.07f))
)
}
/**
* One row: optional icon or custom leading content, title with an optional subtitle, an
* optional grey value on the right, custom trailing content and a chevron when it navigates.
*/
@Composable
fun ListRow(
title: String,
modifier: Modifier = Modifier,
subtitle: String? = null,
icon: Int? = null,
iconTint: Color = ink,
titleColor: Color = ink,
subtitleColor: Color = ink.copy(alpha = 0.6f),
value: String? = null,
valueColor: Color = ink.copy(alpha = 0.55f),
chevron: Boolean = false,
leading: (@Composable () -> Unit)? = null,
trailing: (@Composable RowScope.() -> Unit)? = null,
onClick: (() -> Unit)? = null
) {
Row(
modifier = modifier
.fillMaxWidth()
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
when {
leading != null -> { leading(); Spacer(Modifier.width(12.dp)) }
icon != null -> { AppIcon(icon, size = 22.dp, tint = iconTint); Spacer(Modifier.width(14.dp)) }
}
Column(modifier = Modifier.weight(1f)) {
Text(title, color = titleColor, style = MaterialTheme.typography.bodyLarge, maxLines = 2, overflow = TextOverflow.Ellipsis)
if (subtitle != null) {
Text(subtitle, color = subtitleColor, style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
if (value != null) {
Spacer(Modifier.width(8.dp))
Text(value, color = valueColor, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
}
if (trailing != null) {
Spacer(Modifier.width(8.dp))
trailing()
}
if (chevron) {
Spacer(Modifier.width(8.dp))
AppIcon(R.drawable.ic_arrow_forward, size = 16.dp, tint = ink.copy(alpha = 0.5f))
}
}
}
@@ -0,0 +1,132 @@
package cc.skysparkle.matewave.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import cc.skysparkle.matewave.R
import cc.skysparkle.matewave.engine.Board
import cc.skysparkle.matewave.engine.PieceColor
import cc.skysparkle.matewave.openings.OpeningName
import cc.skysparkle.matewave.ui.theme.ChessGreen
import cc.skysparkle.matewave.ui.theme.ink
private data class MoveToken(val text: String, val moveIndex: Int?)
private val BookTint = androidx.compose.ui.graphics.Color(0xFF93DBA3)
private val OutOfBookTint = androidx.compose.ui.graphics.Color(0xFFFFB86B)
/**
* Compact move list that scrolls to the current move, with the opening name underneath.
* [currentIndex] is the number of moves played to reach the shown position.
*/
@Composable
fun MovesBar(
sans: List<String>,
startBoard: Board,
currentIndex: Int,
opening: OpeningName?,
modifier: Modifier = Modifier,
/** Leading moves that are opening theory; they are tinted in the list. */
bookPlies: Int = 0,
/** "Theory" or "Out of book from move N"; null hides the status. */
bookStatus: String? = null,
inBook: Boolean = true,
onMoveClick: ((Int) -> Unit)? = null
) {
if (sans.isEmpty() && opening == null) return
val tokens = remember(sans, startBoard) {
buildList {
sans.forEachIndexed { i, san ->
val whiteMove = (startBoard.sideToMove == PieceColor.WHITE) == (i % 2 == 0)
val number = startBoard.fullmoveNumber + (i + if (startBoard.sideToMove == PieceColor.BLACK) 1 else 0) / 2
if (whiteMove) add(MoveToken("$number.", null))
else if (i == 0) add(MoveToken("$number…", null))
add(MoveToken(san, i))
}
}
}
val listState = rememberLazyListState()
LaunchedEffect(currentIndex, tokens.size) {
val target = tokens.indexOfFirst { it.moveIndex == currentIndex - 1 }
if (target >= 0) listState.animateScrollToItem(maxOf(0, target - 3))
}
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
if (sans.isNotEmpty()) {
LazyRow(state = listState, horizontalArrangement = Arrangement.spacedBy(2.dp)) {
itemsIndexed(tokens) { _, token ->
val index = token.moveIndex
if (index == null) {
Text(
token.text,
color = ink.copy(alpha = 0.5f),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(start = 6.dp, top = 4.dp, bottom = 4.dp)
)
} else {
val current = index == currentIndex - 1
Text(
token.text,
color = if (index < bookPlies) BookTint else ink,
fontWeight = if (current) FontWeight.Bold else FontWeight.Medium,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.then(if (current) Modifier.background(ChessGreen.copy(alpha = 0.45f)) else Modifier)
.then(if (onMoveClick != null) Modifier.clickable { onMoveClick(index + 1) } else Modifier)
.padding(horizontal = 6.dp, vertical = 4.dp)
)
}
}
}
}
if (opening != null || bookStatus != null) {
Row(verticalAlignment = Alignment.CenterVertically) {
AppIcon(R.drawable.ic_book, size = 14.dp, tint = if (inBook) BookTint else ink.copy(alpha = 0.5f))
Spacer(Modifier.width(6.dp))
Text(
opening?.let { "${it.eco} · ${it.name}" } ?: "",
color = ink.copy(alpha = 0.85f),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false)
)
if (bookStatus != null) {
Spacer(Modifier.width(8.dp))
Text(
bookStatus,
color = if (inBook) BookTint else OutOfBookTint,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
modifier = Modifier
.clip(RoundedCornerShape(6.dp))
.background((if (inBook) BookTint else OutOfBookTint).copy(alpha = 0.15f))
.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
}
}
}
@@ -0,0 +1,130 @@
package cc.skysparkle.matewave.ui.components
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import cc.skysparkle.matewave.R
/**
* Asks for [permissions] after a short explanation. Once Android stops showing its own prompt
* (the user declined twice), the explanation leads to the app's system settings instead, so
* the button never silently does nothing.
*/
@Composable
fun rememberPermissionRequester(
permissions: List<String>,
rationaleTitle: String,
rationaleText: String,
onResult: (granted: Boolean) -> Unit
): () -> Unit {
val context = LocalContext.current
var showRationale by remember { mutableStateOf(false) }
var openSettings by remember { mutableStateOf(false) }
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { results ->
onResult(results.values.all { it })
}
if (showRationale) {
PermissionRationaleDialog(
title = rationaleTitle,
text = rationaleText,
confirmText = stringResource(if (openSettings) R.string.permission_open_settings else R.string.permission_allow),
onConfirm = {
showRationale = false
if (openSettings) {
openAppSettings(context)
onResult(false)
} else {
markRequested(context, permissions)
launcher.launch(permissions.toTypedArray())
}
},
onDismiss = {
showRationale = false
onResult(false)
}
)
}
return {
val allGranted = permissions.all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
if (allGranted) {
onResult(true)
} else {
openSettings = permanentlyDenied(context, permissions)
showRationale = true
}
}
}
private const val REQUESTS_PREFS = "permission_requests"
private fun markRequested(context: Context, permissions: List<String>) {
val editor = context.getSharedPreferences(REQUESTS_PREFS, Context.MODE_PRIVATE).edit()
permissions.forEach { editor.putBoolean(it, true) }
editor.apply()
}
/** Asked before, still denied, and Android would not show its prompt again. */
private fun permanentlyDenied(context: Context, permissions: List<String>): Boolean {
val activity = context.findActivity() ?: return false
val prefs = context.getSharedPreferences(REQUESTS_PREFS, Context.MODE_PRIVATE)
return permissions.any { permission ->
prefs.getBoolean(permission, false) &&
ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED &&
!ActivityCompat.shouldShowRequestPermissionRationale(activity, permission)
}
}
private fun openAppSettings(context: Context) {
runCatching {
context.startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:${context.packageName}"))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
}
private tailrec fun Context.findActivity(): Activity? = when (this) {
is Activity -> this
is ContextWrapper -> baseContext.findActivity()
else -> null
}
@Composable
private fun PermissionRationaleDialog(
title: String,
text: String,
confirmText: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AppDialog(
onDismissRequest = onDismiss,
title = title,
actions = {
DialogAction(stringResource(R.string.permission_not_now), onDismiss)
DialogAction(confirmText, onConfirm)
}
) {
DialogText(text)
}
}
@@ -0,0 +1,62 @@
package cc.skysparkle.matewave.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import cc.skysparkle.matewave.R
import cc.skysparkle.matewave.engine.Piece
import cc.skysparkle.matewave.engine.PieceColor
import cc.skysparkle.matewave.engine.PieceType
import cc.skysparkle.matewave.ui.theme.PanelBorder
import cc.skysparkle.matewave.ui.theme.UiCornerRadius
import cc.skysparkle.matewave.ui.theme.ink
/** The four promotion pieces, drawn with the same images as the board. */
@Composable
fun PromotionDialog(color: PieceColor, onChoose: (PieceType) -> Unit, onDismiss: () -> Unit) {
AppDialog(
onDismissRequest = onDismiss,
title = stringResource(R.string.promotion_title),
actions = {
DialogAction(stringResource(R.string.account_cancel), onDismiss, color = ink.copy(alpha = 0.75f))
}
) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf(PieceType.QUEEN, PieceType.ROOK, PieceType.BISHOP, PieceType.KNIGHT).forEach { type ->
val optionShape = RoundedCornerShape(UiCornerRadius)
Box(
modifier = Modifier
.weight(1f)
.aspectRatio(1f)
.clip(optionShape)
.background(ink.copy(alpha = 0.10f))
.border(1.dp, PanelBorder, optionShape)
.clickable { onChoose(type) }
.padding(8.dp),
contentAlignment = Alignment.Center
) {
Image(
painter = painterResource(Piece(type, color).drawableRes()),
contentDescription = null,
modifier = Modifier.fillMaxWidth().aspectRatio(1f)
)
}
}
}
}
}
@@ -0,0 +1,58 @@
package cc.skysparkle.matewave.ui.components
import cc.skysparkle.matewave.ui.theme.ink
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import cc.skysparkle.matewave.ui.theme.ChessGreen
@Composable
fun SetupSectionLabel(text: String, modifier: Modifier = Modifier) {
Text(
text.uppercase(),
color = ink.copy(alpha = 0.5f),
style = MaterialTheme.typography.labelMedium,
modifier = modifier
)
}
@Composable
fun SetupSegmented(options: List<String>, selectedIndex: Int, onSelect: (Int) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(50))
.background(ink.copy(alpha = 0.08f))
.padding(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
options.forEachIndexed { index, label ->
val selected = index == selectedIndex
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(50))
.background(if (selected) ChessGreen.copy(alpha = 0.35f) else Color.Transparent)
.clickable { onSelect(index) }
.padding(vertical = 10.dp),
contentAlignment = Alignment.Center
) {
Text(label, color = ink, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
@@ -0,0 +1,71 @@
package cc.skysparkle.matewave.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import cc.skysparkle.matewave.R
import cc.skysparkle.matewave.clock.TimeControl
import cc.skysparkle.matewave.ui.theme.ChessGreen
import cc.skysparkle.matewave.ui.theme.ink
/** "10+0" from "Rapid 10+0"; the infinity sign for no limit. */
fun TimeControl.shortLabel(): String = if (this == TimeControl.NO_LIMIT) "∞" else label.substringAfterLast(' ')
@Composable
fun TimeControl.displayName(): String =
if (this == TimeControl.NO_LIMIT) stringResource(R.string.time_no_limit) else shortLabel()
/** Every time control visible at once, grouped by category, instead of a clipped scrolling row. */
@Composable
fun TimeControlGrid(selected: TimeControl, onSelect: (TimeControl) -> Unit) {
val groups = listOf(
R.string.time_cat_bullet to listOf(TimeControl.BULLET_1_0, TimeControl.BULLET_2_1),
R.string.time_cat_blitz to listOf(TimeControl.BLITZ_3_0, TimeControl.BLITZ_5_0, TimeControl.BLITZ_5_3),
R.string.time_cat_rapid to listOf(TimeControl.RAPID_10_0, TimeControl.RAPID_15_10),
R.string.time_cat_classical to listOf(TimeControl.CLASSICAL_30_0, TimeControl.CLASSICAL_30_20),
R.string.time_no_limit to listOf(TimeControl.NO_LIMIT)
)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
groups.forEach { (title, options) ->
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
stringResource(title),
color = ink.copy(alpha = 0.6f),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.width(110.dp)
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
options.forEach { tc ->
val active = tc == selected
val shape = RoundedCornerShape(50)
Text(
tc.shortLabel(),
color = ink,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.clip(shape)
.background(if (active) ChessGreen.copy(alpha = 0.35f) else ink.copy(alpha = 0.08f))
.border(1.dp, if (active) ChessGreen else ink.copy(alpha = 0.12f), shape)
.clickable { onSelect(tc) }
.padding(horizontal = 14.dp, vertical = 7.dp)
)
}
}
}
}
}
}
@@ -0,0 +1,510 @@
package cc.skysparkle.matewave.ui.screens
import cc.skysparkle.matewave.ui.components.ListDivider
import cc.skysparkle.matewave.ui.components.ListGroup
import cc.skysparkle.matewave.ui.components.ListRow
import cc.skysparkle.matewave.ui.components.SectionCaption
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import cc.skysparkle.matewave.ui.theme.ink
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import cc.skysparkle.matewave.R
import cc.skysparkle.matewave.ui.components.DialogText
import cc.skysparkle.matewave.ui.components.DialogAction
import cc.skysparkle.matewave.ui.components.AppDialog
import cc.skysparkle.matewave.ui.components.AppBottomBar
import cc.skysparkle.matewave.ui.components.BottomTab
import cc.skysparkle.matewave.data.ProfileStore
import cc.skysparkle.matewave.ui.components.AppIcon
import cc.skysparkle.matewave.ui.components.GlassOutlinedButton
import cc.skysparkle.matewave.ui.components.GlassScaffold
import cc.skysparkle.matewave.ui.components.rememberChatImage
import cc.skysparkle.matewave.ui.components.encodeImageForChat
import cc.skysparkle.matewave.ui.theme.ChessGreen
import cc.skysparkle.matewave.ui.theme.PanelBorder
import cc.skysparkle.matewave.ui.theme.whiteOutlinedTextFieldColors
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun AccountScreen(
studyViewModel: cc.skysparkle.matewave.viewmodel.OpeningStudyViewModel,
onHomeClick: () -> Unit,
onFriendsClick: () -> Unit,
onSettingsClick: () -> Unit,
onStudyClick: () -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var profile by remember { mutableStateOf(ProfileStore.get()) }
var showLevelPicker by remember { mutableStateOf(false) }
var showForgetConfirm by remember { mutableStateOf(false) }
var editingName by remember { mutableStateOf(false) }
var copied by remember { mutableStateOf(false) }
val backgroundPicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
scope.launch {
val encoded = withContext(Dispatchers.IO) { encodeImageForChat(context, uri) }
if (encoded != null) {
ProfileStore.setBackground(encoded)
profile = ProfileStore.get()
}
}
}
val avatarPicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
scope.launch {
val encoded = withContext(Dispatchers.IO) {
encodeImageForChat(
context, uri,
cc.skysparkle.matewave.ui.components.AVATAR_MAX_DIMENSION_PX,
cc.skysparkle.matewave.ui.components.AVATAR_JPEG_QUALITY
)
}
if (encoded != null) {
ProfileStore.setAvatar(encoded)
profile = ProfileStore.get()
}
}
}
val myFingerprint = remember {
cc.skysparkle.matewave.security.DeviceKeys.publicKeyFingerprint(context)
}
LaunchedEffect(Unit) { studyViewModel.init(context) }
val study by studyViewModel.overview.collectAsState()
var showStats by remember { mutableStateOf(false) }
var showSecurity by remember { mutableStateOf(false) }
GlassScaffold(
title = stringResource(R.string.account_title),
onBack = null,
bottomBar = {
AppBottomBar(
current = BottomTab.ACCOUNT,
onHomeClick = onHomeClick,
onFriendsClick = onFriendsClick,
onSettingsClick = onSettingsClick,
onAccountClick = { },
accountAvatarBase64 = profile.avatarBase64
)
}
) {
androidx.compose.runtime.CompositionLocalProvider(cc.skysparkle.matewave.ui.theme.LocalInk provides Color.White) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(168.dp)
.clip(RoundedCornerShape(20.dp))
.clickable { backgroundPicker.launch("image/*") }
) {
val bgBitmap = rememberChatImage(profile.backgroundBase64)
if (bgBitmap != null) {
Image(
bitmap = bgBitmap,
contentDescription = null,
modifier = Modifier.fillMaxWidth().height(168.dp),
contentScale = ContentScale.Crop
)
} else {
Image(
painter = painterResource(R.drawable.bg_profile_default),
contentDescription = null,
modifier = Modifier.fillMaxWidth().height(168.dp),
contentScale = ContentScale.Crop
)
}
Box(
modifier = Modifier.fillMaxWidth().height(168.dp).background(
Brush.verticalGradient(
0.0f to Color.Black.copy(alpha = 0.30f),
0.55f to Color.Black.copy(alpha = 0.45f),
1.0f to Color.Black.copy(alpha = 0.72f)
)
)
)
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(10.dp)
.size(36.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.45f))
.clickable { backgroundPicker.launch("image/*") },
contentAlignment = Alignment.Center
) {
AppIcon(R.drawable.ic_add_photo, size = 20.dp)
}
Column(
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.padding(horizontal = 18.dp, vertical = 16.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Box {
AvatarCircle(
base64 = profile.avatarBase64,
size = 76.dp,
modifier = Modifier.clickable { avatarPicker.launch("image/*") }
)
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.size(28.dp)
.clip(CircleShape)
.background(ChessGreen)
.clickable { avatarPicker.launch("image/*") },
contentAlignment = Alignment.Center
) {
AppIcon(R.drawable.ic_edit, size = 14.dp)
}
}
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
profile.name,
color = ink,
fontSize = 23.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
AppIcon(
R.drawable.ic_edit,
size = 18.dp,
tint = ink.copy(alpha = 0.75f),
modifier = Modifier.clickable { editingName = true }
)
}
Text(
stringResource(R.string.account_header_summary, profile.elo, cc.skysparkle.matewave.stats.Stats.gamesPlayed()),
color = ink.copy(alpha = 0.72f),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
}
if (profile.avatarBase64 != null || profile.backgroundBase64 != null) {
Text(
stringResource(R.string.account_reset_avatar),
color = ink.copy(alpha = 0.55f),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.clickable {
ProfileStore.resetAvatarToDefault()
ProfileStore.setBackground(null)
profile = ProfileStore.get()
}
)
}
if (!profile.levelChosen) {
GlassOutlinedButton(onClick = { showLevelPicker = true }, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.account_choose_level), color = ink)
}
}
// Progress: the reason to open a profile. The rating is shown here once, with its trend.
SectionCaption(stringResource(R.string.account_progress))
ListGroup {
val curve = remember(profile.elo) { cc.skysparkle.matewave.stats.Stats.ratingCurve(profile.elo) }
ListRow(
title = stringResource(R.string.account_rating_row),
icon = R.drawable.ic_license,
iconTint = ChessGreen,
value = "${profile.elo}",
valueColor = ink,
trailing = { MiniRatingGraph(points = curve, modifier = Modifier.width(64.dp).height(24.dp)) },
chevron = true,
onClick = { showStats = true }
)
ListDivider()
ListRow(
title = stringResource(R.string.study_title),
icon = R.drawable.ic_crown,
value = "${study.learnedCount}/${study.courses.size} · ★${study.masteredCount}",
chevron = true,
onClick = onStudyClick
)
ListDivider()
ListRow(
title = stringResource(R.string.puzzles_title),
icon = R.drawable.ic_puzzle,
value = stringResource(R.string.puzzle_solved_count, cc.skysparkle.matewave.stats.Stats.solvedPuzzles().size)
)
}
// Security folded into one row; the details live in a dialog.
if (myFingerprint != null) {
SectionCaption(stringResource(R.string.account_section_security))
ListGroup {
ListRow(
title = stringResource(R.string.account_device_key),
icon = R.drawable.ic_key,
value = myFingerprint.take(4).uppercase() + "…" + myFingerprint.takeLast(4).uppercase(),
chevron = true,
onClick = { showSecurity = true }
)
}
}
}
if (showStats) StatsDialog(onDismiss = { showStats = false })
if (showSecurity && myFingerprint != null) {
AppDialog(
onDismissRequest = { showSecurity = false; copied = false },
title = stringResource(R.string.account_section_security),
actions = { DialogAction(stringResource(R.string.chat_close), { showSecurity = false; copied = false }) }
) {
Text(stringResource(R.string.account_fingerprint), color = ink.copy(alpha = 0.6f), style = MaterialTheme.typography.labelMedium)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Text(groupFingerprint(myFingerprint), color = ink, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
AppIcon(
R.drawable.ic_copy,
size = 20.dp,
modifier = Modifier.clickable {
val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("fingerprint", myFingerprint))
copied = true
}
)
AppIcon(
R.drawable.ic_share,
size = 20.dp,
modifier = Modifier.clickable {
val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(android.content.Intent.EXTRA_TEXT, groupFingerprint(myFingerprint))
}
context.startActivity(android.content.Intent.createChooser(send, null))
}
)
}
if (copied) {
Text(stringResource(R.string.account_fingerprint_copied), color = ChessGreen, style = MaterialTheme.typography.bodySmall)
}
DialogText(stringResource(R.string.account_fingerprint_explain))
DialogText(stringResource(R.string.account_trusted_keys_hint))
Text(
stringResource(R.string.account_forget_keys),
color = Color(0xFFFF9B9B),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.clickable { showSecurity = false; showForgetConfirm = true }
.padding(vertical = 6.dp)
)
}
}
if (editingName) {
EditNameDialog(
initial = profile.name,
onSave = { newName ->
ProfileStore.setName(newName)
profile = ProfileStore.get()
// Nearby players should see the new name right away.
cc.skysparkle.matewave.network.Transports.lan(context).republishProfile()
cc.skysparkle.matewave.network.Transports.ble(context).republishProfile()
editingName = false
},
onDismiss = { editingName = false }
)
}
if (showForgetConfirm) {
ConfirmDialog(
title = stringResource(R.string.account_forget_keys),
text = stringResource(R.string.account_forget_keys_confirm),
onConfirm = {
cc.skysparkle.matewave.security.KnownPeers(context).forgetAll()
showForgetConfirm = false
},
onDismiss = { showForgetConfirm = false }
)
}
if (showLevelPicker) {
LevelPickerDialog(
onChoose = { elo ->
ProfileStore.setStartingElo(elo)
profile = ProfileStore.get()
cc.skysparkle.matewave.network.Transports.lan(context).republishProfile()
showLevelPicker = false
},
onDismiss = { showLevelPicker = false }
)
}
}
private const val MAX_NAME_LENGTH = 20
@Composable
private fun EditNameDialog(initial: String, onSave: (String) -> Unit, onDismiss: () -> Unit) {
var value by remember { mutableStateOf(initial) }
AppDialog(
onDismissRequest = onDismiss,
title = stringResource(R.string.account_name),
actions = {
DialogAction(stringResource(R.string.account_cancel), onDismiss, color = ink.copy(alpha = 0.75f))
DialogAction(
stringResource(R.string.account_save_name),
onClick = { if (value.isNotBlank()) onSave(value.trim()) },
enabled = value.isNotBlank()
)
}
) {
OutlinedTextField(
value = value,
onValueChange = { if (it.length <= MAX_NAME_LENGTH) value = it },
singleLine = true,
colors = whiteOutlinedTextFieldColors(),
modifier = Modifier.fillMaxWidth()
)
Text(
"${value.length} / $MAX_NAME_LENGTH",
color = ink.copy(alpha = 0.45f),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.End
)
}
}
private fun groupFingerprint(fingerprint: String): String =
fingerprint.uppercase().chunked(4).joinToString(" ")
@Composable
private fun ConfirmDialog(title: String, text: String, onConfirm: () -> Unit, onDismiss: () -> Unit) {
AppDialog(
onDismissRequest = onDismiss,
title = title,
actions = {
DialogAction(stringResource(R.string.account_cancel), onDismiss, color = ink.copy(alpha = 0.75f))
DialogAction(stringResource(R.string.account_confirm), onConfirm)
}
) {
DialogText(text)
}
}
private data class SkillLevel(val labelRes: Int, val elo: Int)
private val SKILL_LEVELS = listOf(
SkillLevel(R.string.skill_novice, 800),
SkillLevel(R.string.skill_amateur, 1200),
SkillLevel(R.string.skill_pro, 1800),
SkillLevel(R.string.skill_master, 2200)
)
@Composable
private fun LevelPickerDialog(onChoose: (Int) -> Unit, onDismiss: () -> Unit) {
AppDialog(
onDismissRequest = onDismiss,
title = stringResource(R.string.account_level_dialog_title),
actions = {
DialogAction(stringResource(R.string.account_cancel), onDismiss, color = ink.copy(alpha = 0.75f))
}
) {
SKILL_LEVELS.forEach { level ->
val rowShape = RoundedCornerShape(12.dp)
Row(
modifier = Modifier
.fillMaxWidth()
.clip(rowShape)
.background(ink.copy(alpha = 0.07f))
.clickable { onChoose(level.elo) }
.padding(horizontal = 16.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(stringResource(level.labelRes), color = ink, style = MaterialTheme.typography.titleMedium)
Text("~${level.elo}", color = ink.copy(alpha = 0.7f), style = MaterialTheme.typography.titleMedium)
}
}
}
}
@Composable
fun AvatarCircle(base64: String?, size: Dp, modifier: Modifier = Modifier) {
val bitmap = rememberChatImage(base64, cc.skysparkle.matewave.ui.components.AVATAR_MAX_DIMENSION_PX)
Box(
modifier = modifier
.size(size)
.clip(CircleShape)
.background(ChessGreen.copy(alpha = 0.6f))
.border(2.dp, PanelBorder, CircleShape),
contentAlignment = Alignment.Center
) {
if (bitmap != null) {
Image(
bitmap = bitmap,
contentDescription = null,
modifier = Modifier.size(size).clip(CircleShape),
contentScale = ContentScale.Crop
)
} else {
Image(
painter = painterResource(R.drawable.ic_default_avatar),
contentDescription = null,
colorFilter = ColorFilter.tint(ink),
modifier = Modifier.size(size * 0.8f)
)
}
}
}
@@ -0,0 +1,184 @@
package cc.skysparkle.matewave.ui.screens
import cc.skysparkle.matewave.ui.theme.ink
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
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
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.Image
import cc.skysparkle.matewave.R
import cc.skysparkle.matewave.settings.BoardAppearance
import cc.skysparkle.matewave.settings.BoardTheme
import cc.skysparkle.matewave.ui.components.GlassCard
import cc.skysparkle.matewave.ui.components.GlassScaffold
import cc.skysparkle.matewave.ui.theme.GlassBorder
@Composable
fun AppearanceScreen(onBack: () -> Unit) {
val context = LocalContext.current
val appearance = remember { BoardAppearance(context) }
var theme by remember { mutableStateOf(appearance.theme) }
var coords by remember { mutableStateOf(appearance.showCoordinates) }
var images by remember { mutableStateOf(appearance.useImagePieces) }
var lastMove by remember { mutableStateOf(appearance.showLastMove) }
var checkGlow by remember { mutableStateOf(appearance.showCheckHighlight) }
var openingBar by remember { mutableStateOf(appearance.showOpening) }
GlassScaffold(title = stringResource(R.string.appearance_title), onBack = onBack) {
Text(
stringResource(R.string.appearance_board_theme),
color = ink,
style = MaterialTheme.typography.titleMedium
)
BoardTheme.entries.forEach { candidate ->
GlassCard(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable {
theme = candidate
appearance.theme = candidate
}
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
ThemePreview(candidate, showPieces = images)
Text(
stringResource(candidate.titleRes),
color = ink,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
if (candidate == theme) {
cc.skysparkle.matewave.ui.components.AppIcon(
R.drawable.ic_check_box, size = 20.dp, tint = Color(0xFF7BE495)
)
}
}
}
}
AppearanceToggle(
title = stringResource(R.string.appearance_coordinates),
subtitle = stringResource(R.string.appearance_coordinates_hint),
checked = coords
) { coords = it; appearance.showCoordinates = it }
AppearanceToggle(
title = stringResource(R.string.appearance_image_pieces),
subtitle = stringResource(R.string.appearance_image_pieces_hint),
checked = images
) { images = it; appearance.useImagePieces = it }
AppearanceToggle(
title = stringResource(R.string.appearance_last_move),
subtitle = stringResource(R.string.appearance_last_move_hint),
checked = lastMove
) { lastMove = it; appearance.showLastMove = it }
AppearanceToggle(
title = stringResource(R.string.appearance_check),
subtitle = stringResource(R.string.appearance_check_hint),
checked = checkGlow
) { checkGlow = it; appearance.showCheckHighlight = it }
AppearanceToggle(
title = stringResource(R.string.appearance_opening_in_game),
subtitle = stringResource(R.string.appearance_opening_in_game_hint),
checked = openingBar
) { openingBar = it; appearance.showOpening = it }
Text(
stringResource(R.string.appearance_restart_hint),
color = ink.copy(alpha = 0.6f),
style = MaterialTheme.typography.bodySmall
)
}
}
@Composable
private fun ThemePreview(theme: BoardTheme, showPieces: Boolean) {
val shape = RoundedCornerShape(6.dp)
Column(modifier = Modifier.size(56.dp).clip(shape).border(1.dp, GlassBorder, shape)) {
repeat(2) { row ->
Row(modifier = Modifier.weight(1f)) {
repeat(2) { col ->
val light = (row + col) % 2 == 0
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.background(if (light) theme.light else theme.dark),
contentAlignment = Alignment.Center
) {
if (showPieces && row == 0 && col == 0) {
Image(
painter = painterResource(R.drawable.piece_white_king),
contentDescription = null,
modifier = Modifier.size(22.dp)
)
}
if (showPieces && row == 1 && col == 1) {
Image(
painter = painterResource(R.drawable.piece_black_knight),
contentDescription = null,
modifier = Modifier.size(22.dp)
)
}
}
}
}
}
}
}
@Composable
private fun AppearanceToggle(
title: String,
subtitle: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit
) {
GlassCard(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(title, color = ink, style = MaterialTheme.typography.titleMedium)
Text(subtitle, color = ink.copy(alpha = 0.7f), style = MaterialTheme.typography.bodySmall)
}
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = Color(0xFF4F8A5B)
)
)
}
}
}
@@ -0,0 +1,315 @@
package cc.skysparkle.matewave.ui.screens
import androidx.compose.animation.core.animateFloat
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import cc.skysparkle.matewave.ui.components.ListDivider
import cc.skysparkle.matewave.ui.components.ListGroup
import cc.skysparkle.matewave.ui.components.ListRow
import cc.skysparkle.matewave.ui.components.SectionCaption
import cc.skysparkle.matewave.ui.components.TimeControlGrid
import cc.skysparkle.matewave.ui.components.shortLabel
import cc.skysparkle.matewave.ui.components.AppDialog
import cc.skysparkle.matewave.ui.components.DialogAction
import cc.skysparkle.matewave.ui.components.GlassOutlinedButton
import cc.skysparkle.matewave.ui.theme.ink
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
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
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import cc.skysparkle.matewave.R
import cc.skysparkle.matewave.ui.components.ButtonIcon
import cc.skysparkle.matewave.clock.TimeControl
import cc.skysparkle.matewave.data.FriendsStore
import android.bluetooth.BluetoothAdapter
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.LaunchedEffect
import cc.skysparkle.matewave.network.AppPermissions
import cc.skysparkle.matewave.network.NetworkRequirements
import kotlinx.coroutines.delay
import cc.skysparkle.matewave.network.NearbyPlayer
import cc.skysparkle.matewave.network.Transports
import cc.skysparkle.matewave.network.TransportType
import cc.skysparkle.matewave.presence.LocalPresence
import cc.skysparkle.matewave.ui.components.AppIcon
import cc.skysparkle.matewave.ui.components.GlassButton
import cc.skysparkle.matewave.ui.components.GlassScaffold
import cc.skysparkle.matewave.ui.components.rememberPermissionRequester
import cc.skysparkle.matewave.ui.theme.ChessGreen
private enum class Hint { BLUETOOTH_PERMISSION, BLUETOOTH_OFF, WIFI_OFF }
private val WarningAmber = Color(0xFFFFC46B)
/** A player found over either transport. */
private data class NearbyEntry(
val name: String,
val elo: Int?,
val type: TransportType,
val address: String,
val avatar: String?
)
@Composable
fun ConnectionScreen(
presence: LocalPresence,
onStartHost: (TimeControl) -> Unit,
onJoin: (TransportType, TimeControl, targetId: String) -> Unit,
onBack: () -> Unit
) {
val context = LocalContext.current
val last = remember { cc.skysparkle.matewave.settings.LastGameSetup(context) }
var timeControl by remember { mutableStateOf(last.nearbyTime) }
var pickTime by remember { mutableStateOf(false) }
var btPlayers by remember { mutableStateOf<List<NearbyPlayer>>(emptyList()) }
var btAllowed by remember { mutableStateOf(AppPermissions.allGranted(context, AppPermissions.nearby)) }
val profile = remember { cc.skysparkle.matewave.data.ProfileStore.get() }
val visible = remember { cc.skysparkle.matewave.presence.PresenceSettings(context).announceEnabled }
var network by remember { mutableStateOf(NetworkRequirements.check(context)) }
fun startBluetooth() {
val ble = Transports.ble(context)
runCatching {
ble.startPresence()
ble.discoverNearby { found -> btPlayers = found }
}
}
// Wi-Fi and Bluetooth can be switched on while the screen is open: notice it and search again.
LaunchedEffect(Unit) {
while (true) {
delay(3_000)
val now = NetworkRequirements.check(context)
if (btAllowed && now.bluetoothOn && !network.bluetoothOn) startBluetooth()
if (!now.bluetoothOn) btPlayers = emptyList()
if (now.wifiConnected && !network.wifiConnected) presence.refresh()
network = now
}
}
val enableBluetooth = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { }
val requestNearby = rememberPermissionRequester(
permissions = AppPermissions.nearby,
rationaleTitle = stringResource(R.string.mesh_permission_title),
rationaleText = stringResource(R.string.mesh_permission_text),
onResult = { granted -> btAllowed = granted }
)
// One search over both transports: Wi-Fi presence runs app-wide, Bluetooth while this screen is open.
DisposableEffect(btAllowed) {
if (btAllowed) startBluetooth()
onDispose {
if (btAllowed) runCatching {
val ble = Transports.ble(context)
ble.stopDiscovery()
ble.stopPresenceIfIdle()
}
}
}
val peers by presence.peers.collectAsState()
val friends = remember(peers) { FriendsStore.list() }
val entries = remember(peers, btPlayers, friends) {
val lan = peers.values.map { peer ->
NearbyEntry(
name = peer.name,
elo = peer.elo,
type = TransportType.LAN,
address = peer.address,
avatar = friends.firstOrNull { it.userId == peer.playerId }?.avatarBase64
)
}
val lanFingerprints = peers.values.map { it.fingerprint }.filter { it.isNotEmpty() }
// The same phone seen over both channels is listed once, over Wi-Fi. Bluetooth carries
// a shorter fingerprint: the first bytes of the same key hash.
val bt = btPlayers.filter { player ->
val short = player.fingerprint
short.isNullOrEmpty() || lanFingerprints.none { it.startsWith(short) }
}.map {
NearbyEntry(name = it.name, elo = null, type = TransportType.BLUETOOTH, address = it.address, avatar = null)
}
(lan + bt).sortedBy { it.name.lowercase() }
}
GlassScaffold(
title = stringResource(R.string.vsplayer_nearby_title),
onBack = onBack,
bottomBar = {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
GlassButton(
onClick = { last.nearbyTime = timeControl; onStartHost(timeControl) },
modifier = Modifier.weight(1f)
) {
ButtonIcon(R.drawable.ic_add, size = 20.dp)
Text(stringResource(R.string.nearby_create), color = ink, style = MaterialTheme.typography.titleMedium, maxLines = 1)
}
GlassOutlinedButton(onClick = { pickTime = true }) {
Text(timeControl.shortLabel() + " ▾", color = ink, maxLines = 1)
}
}
}
) {
SearchPulse()
Text(
if (visible) stringResource(R.string.nearby_visible_as, profile.name) else stringResource(R.string.nearby_hidden),
color = ink.copy(alpha = 0.65f),
style = MaterialTheme.typography.bodySmall,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
// What keeps others from being found, each with the fix one tap away.
val hints = listOfNotNull(
Hint.BLUETOOTH_PERMISSION.takeIf { !btAllowed && network.bluetoothSupported },
Hint.BLUETOOTH_OFF.takeIf { btAllowed && network.bluetoothSupported && !network.bluetoothOn },
Hint.WIFI_OFF.takeIf { !network.wifiConnected }
)
if (hints.isNotEmpty()) {
ListGroup {
hints.forEachIndexed { index, hint ->
if (index > 0) ListDivider()
when (hint) {
Hint.BLUETOOTH_PERMISSION -> ListRow(
title = stringResource(R.string.nearby_enable_bt),
icon = R.drawable.ic_bluetooth,
chevron = true,
onClick = { requestNearby() }
)
Hint.BLUETOOTH_OFF -> ListRow(
title = stringResource(R.string.reason_bluetooth_off),
subtitle = stringResource(R.string.nearby_bt_off_hint),
icon = R.drawable.ic_bluetooth,
iconTint = WarningAmber,
chevron = true,
onClick = {
runCatching { enableBluetooth.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) }
}
)
Hint.WIFI_OFF -> ListRow(
title = stringResource(R.string.reason_wifi_unavailable),
subtitle = stringResource(R.string.nearby_wifi_hint),
icon = R.drawable.ic_wifi,
iconTint = WarningAmber
)
}
}
}
}
if (entries.isEmpty()) {
Text(
stringResource(R.string.nearby_empty),
color = ink.copy(alpha = 0.6f),
style = MaterialTheme.typography.bodyMedium,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
)
} else {
SectionCaption(stringResource(R.string.nearby_players))
ListGroup {
entries.forEachIndexed { index, entry ->
if (index > 0) ListDivider(inset = 68)
val transport = stringResource(if (entry.type == TransportType.LAN) R.string.transport_wifi else R.string.transport_bluetooth)
ListRow(
title = entry.name.ifBlank { stringResource(R.string.players_unknown_name) },
subtitle = listOfNotNull(entry.elo?.toString(), transport).joinToString(" · "),
leading = { AvatarCircle(base64 = entry.avatar, size = 40.dp) },
trailing = {
AppIcon(
if (entry.type == TransportType.LAN) R.drawable.ic_wifi_local else R.drawable.ic_bluetooth,
size = 16.dp,
tint = ink.copy(alpha = 0.5f)
)
Spacer(Modifier.width(8.dp))
Text(
stringResource(R.string.players_play),
color = Color.White,
style = MaterialTheme.typography.labelLarge,
modifier = Modifier
.clip(RoundedCornerShape(50))
.background(ChessGreen)
.clickable { onJoin(entry.type, timeControl, entry.address) }
.padding(horizontal = 14.dp, vertical = 6.dp)
)
}
)
}
}
}
}
if (pickTime) {
AppDialog(
onDismissRequest = { pickTime = false },
title = stringResource(R.string.time_control_label),
actions = { DialogAction(stringResource(R.string.chat_close), { pickTime = false }) }
) {
TimeControlGrid(selected = timeControl, onSelect = { timeControl = it; last.nearbyTime = it; pickTime = false })
}
}
}
/** Concentric rings pulsing around the knight: the search is always running. */
@Composable
private fun SearchPulse() {
val transition = androidx.compose.animation.core.rememberInfiniteTransition(label = "pulse")
val phase by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = androidx.compose.animation.core.infiniteRepeatable(
androidx.compose.animation.core.tween(1800, easing = androidx.compose.animation.core.LinearEasing)
),
label = "phase"
)
Column(modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Box(modifier = Modifier.size(120.dp), contentAlignment = Alignment.Center) {
androidx.compose.foundation.Canvas(modifier = Modifier.size(120.dp)) {
for (k in 0 until 3) {
val p = (phase + k / 3f) % 1f
drawCircle(
color = ChessGreen.copy(alpha = (1f - p) * 0.45f),
radius = size.minDimension / 2f * (0.3f + 0.7f * p),
style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2.dp.toPx())
)
}
}
androidx.compose.foundation.Image(
painter = androidx.compose.ui.res.painterResource(R.drawable.piece_white_knight),
contentDescription = null,
modifier = Modifier.size(40.dp)
)
}
Text(stringResource(R.string.nearby_searching), color = ink, style = MaterialTheme.typography.titleMedium)
}
}
@@ -0,0 +1,330 @@
package cc.skysparkle.matewave.ui.screens
import cc.skysparkle.matewave.ui.components.ListDivider
import cc.skysparkle.matewave.ui.components.ListGroup
import cc.skysparkle.matewave.ui.components.ListRow
import cc.skysparkle.matewave.ui.components.SectionCaption
import cc.skysparkle.matewave.ui.components.SetupSegmented
import cc.skysparkle.matewave.ui.components.GlassButton
import androidx.compose.foundation.layout.Spacer
import cc.skysparkle.matewave.ui.theme.ink
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.layout.height
import androidx.compose.ui.window.Dialog
import cc.skysparkle.matewave.R
import cc.skysparkle.matewave.data.FriendEntry
import cc.skysparkle.matewave.data.FriendsStore
import cc.skysparkle.matewave.presence.LocalPresence
import cc.skysparkle.matewave.ui.components.AppBottomBar
import cc.skysparkle.matewave.ui.components.AppIcon
import cc.skysparkle.matewave.ui.components.BottomTab
import cc.skysparkle.matewave.ui.components.GlassScaffold
import cc.skysparkle.matewave.ui.theme.ChessGreen
@Composable
fun FriendsScreen(
presence: LocalPresence,
onHomeClick: () -> Unit,
onSettingsClick: () -> Unit,
onAccountClick: () -> Unit,
onPlay: () -> Unit = {}
) {
var onlineOnly by remember { mutableStateOf(false) }
var version by remember { mutableStateOf(0) }
var opened by remember { mutableStateOf<FriendEntry?>(null) }
val myProfile = remember { cc.skysparkle.matewave.data.ProfileStore.get() }
val peers by presence.peers.collectAsState()
val friends = remember(peers, version) { FriendsStore.list() }
// Fresh search on opening, then refresh "5 min ago" labels while the screen is visible.
androidx.compose.runtime.LaunchedEffect(Unit) {
presence.refresh()
while (true) {
kotlinx.coroutines.delay(30_000)
version++
}
}
GlassScaffold(
title = stringResource(R.string.friends_title),
onBack = null,
bottomBar = {
AppBottomBar(
current = BottomTab.FRIENDS,
onHomeClick = onHomeClick,
onFriendsClick = { },
onSettingsClick = onSettingsClick,
onAccountClick = onAccountClick,
accountAvatarBase64 = myProfile.avatarBase64
)
}
) {
val savedIds = friends.map { it.userId }.toSet()
val strangers = peers.values.filter { it.playerId !in savedIds }
if (friends.isEmpty() && strangers.isEmpty()) {
// Empty state with the action that fills the list.
Spacer(Modifier.height(24.dp))
Text(stringResource(R.string.friends_nobody_title), color = ink, style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.friends_nobody_hint), color = ink.copy(alpha = 0.65f), style = MaterialTheme.typography.bodyMedium)
GlassButton(onClick = onPlay, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.friends_play_nearby), color = ink)
}
return@GlassScaffold
}
SetupSegmented(
options = listOf(stringResource(R.string.friends_filter_all), stringResource(R.string.friends_filter_online)),
selectedIndex = if (onlineOnly) 1 else 0
) { onlineOnly = it == 1 }
val visible = friends
.filter { !onlineOnly || presence.isOnline(it.userId) }
.sortedWith(compareByDescending<FriendEntry> { presence.isOnline(it.userId) }.thenByDescending { it.lastPlayedMs })
val favorites = visible.filter { it.favorite }
val others = visible.filter { !it.favorite }
FriendGroup(
title = stringResource(R.string.friends_group_favorites),
list = favorites,
isOnline = presence::isOnline,
onOpen = { opened = it },
onToggleFavorite = { FriendsStore.toggleFavorite(it.userId); version++ },
onPlay = onPlay
)
FriendGroup(
title = if (favorites.isNotEmpty()) stringResource(R.string.friends_group_others) else null,
list = others,
isOnline = presence::isOnline,
onOpen = { opened = it },
onToggleFavorite = { FriendsStore.toggleFavorite(it.userId); version++ },
onPlay = onPlay
)
if (strangers.isNotEmpty()) {
SectionCaption(stringResource(R.string.friends_group_nearby))
ListGroup {
strangers.sortedBy { it.name }.forEachIndexed { index, peer ->
if (index > 0) ListDivider(inset = 68)
ListRow(
title = displayName(peer.name, peer.playerId),
subtitle = stringResource(R.string.friends_rating, peer.elo),
leading = { AvatarCircle(base64 = null, size = 40.dp) },
trailing = { PlayChip(onPlay) }
)
}
}
}
if (visible.isEmpty() && strangers.isEmpty()) {
Text(stringResource(R.string.friends_filter_empty), color = ink.copy(alpha = 0.6f), style = MaterialTheme.typography.bodyMedium)
}
opened?.let { friend ->
FriendCard(friend = friend, online = presence.isOnline(friend.userId), onDismiss = { opened = null })
}
}
}
@Composable
private fun FriendGroup(
title: String?,
list: List<FriendEntry>,
isOnline: (String) -> Boolean,
onOpen: (FriendEntry) -> Unit,
onToggleFavorite: (FriendEntry) -> Unit,
onPlay: () -> Unit
) {
if (list.isEmpty()) return
if (title != null) SectionCaption(title)
ListGroup {
list.forEachIndexed { index, friend ->
if (index > 0) ListDivider(inset = 68)
FriendRow(
friend = friend,
online = isOnline(friend.userId),
onOpen = { onOpen(friend) },
onToggleFavorite = { onToggleFavorite(friend) },
onPlay = onPlay
)
}
}
}
/**
* Players who never set a name are all called "Player"; a short piece of their id tells them
* apart in the list.
*/
@Composable
private fun displayName(name: String, id: String): String {
val default = stringResource(R.string.player_default_name)
val generic = name.isBlank() || name == default || name == "Player"
return if (generic) "${name.ifBlank { default }} · ${id.filter { it.isLetterOrDigit() }.take(4).uppercase()}" else name
}
@Composable
private fun PlayChip(onClick: () -> Unit) {
Text(
stringResource(R.string.friends_play),
color = Color.White,
style = MaterialTheme.typography.labelLarge,
modifier = Modifier
.clip(RoundedCornerShape(50))
.background(ChessGreen)
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 6.dp)
)
}
@Composable
private fun FriendCard(friend: FriendEntry, online: Boolean, onDismiss: () -> Unit) {
Dialog(onDismissRequest = onDismiss) {
Column(
modifier = Modifier
.clip(cc.skysparkle.matewave.ui.components.DialogShape)
.background(cc.skysparkle.matewave.ui.components.DialogSurface)
) {
androidx.compose.runtime.CompositionLocalProvider(cc.skysparkle.matewave.ui.theme.LocalInk provides Color.White) {
Box(modifier = Modifier.fillMaxWidth().height(150.dp)) {
val bg = cc.skysparkle.matewave.ui.components.rememberChatImage(friend.backgroundBase64)
if (bg != null) {
androidx.compose.foundation.Image(
bitmap = bg,
contentDescription = null,
modifier = Modifier.fillMaxWidth().height(150.dp),
contentScale = androidx.compose.ui.layout.ContentScale.Crop
)
} else {
androidx.compose.foundation.Image(
painter = androidx.compose.ui.res.painterResource(R.drawable.bg_profile_default),
contentDescription = null,
modifier = Modifier.fillMaxWidth().height(150.dp),
contentScale = androidx.compose.ui.layout.ContentScale.Crop
)
}
Box(
modifier = Modifier.fillMaxWidth().height(150.dp).background(
androidx.compose.ui.graphics.Brush.verticalGradient(
0f to Color.Black.copy(alpha = 0.15f),
1f to Color.Black.copy(alpha = 0.75f)
)
)
)
Row(
modifier = Modifier.align(Alignment.BottomStart).padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
AvatarCircle(base64 = friend.avatarBase64, size = 64.dp)
Column {
Text(friend.name, color = ink, style = MaterialTheme.typography.titleLarge)
Text(
stringResource(R.string.friends_rating, friend.elo),
color = ink.copy(alpha = 0.75f),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
if (online) stringResource(R.string.friends_online)
else lastSeenText(maxOf(friend.lastPlayedMs, friend.lastSeenMs)),
color = if (online) Color(0xFF7BE495) else ink.copy(alpha = 0.6f),
style = MaterialTheme.typography.bodyMedium
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
cc.skysparkle.matewave.ui.components.DialogAction(
stringResource(R.string.chat_close),
onClick = onDismiss
)
}
}
}
}
}
@Composable
private fun FriendRow(
friend: FriendEntry,
online: Boolean,
onOpen: () -> Unit,
onToggleFavorite: () -> Unit,
onPlay: () -> Unit
) {
ListRow(
title = displayName(friend.name, friend.userId),
subtitle = stringResource(R.string.friends_rating, friend.elo) + " · " +
(if (online) stringResource(R.string.friends_online) else lastSeenText(maxOf(friend.lastPlayedMs, friend.lastSeenMs))),
subtitleColor = if (online) ChessGreen else ink.copy(alpha = 0.55f),
leading = {
Box {
AvatarCircle(base64 = friend.avatarBase64, size = 40.dp)
if (online) {
Box(
Modifier
.align(Alignment.BottomEnd)
.size(11.dp)
.clip(CircleShape)
.background(ChessGreen)
)
}
}
},
trailing = {
if (online) PlayChip(onPlay)
AppIcon(
R.drawable.ic_star,
size = 20.dp,
tint = if (friend.favorite) StarGold else ink.copy(alpha = 0.25f),
modifier = Modifier
.padding(start = 6.dp)
.clip(CircleShape)
.clickable(onClick = onToggleFavorite)
.padding(6.dp)
)
},
onClick = onOpen
)
}
@Composable
private fun lastSeenText(lastSeenMs: Long): String {
if (lastSeenMs <= 0) return stringResource(R.string.friends_offline)
val minutes = (System.currentTimeMillis() - lastSeenMs) / 60_000
return when {
minutes < 60 -> stringResource(R.string.friends_minutes_ago, minutes.coerceAtLeast(1).toInt())
minutes < 60 * 24 -> stringResource(R.string.friends_hours_ago, (minutes / 60).toInt())
minutes < 60 * 48 -> stringResource(R.string.friends_yesterday)
else -> stringResource(R.string.friends_days_ago, (minutes / (60 * 24)).toInt())
}
}
@@ -0,0 +1,570 @@
package cc.skysparkle.matewave.ui.screens
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.padding
import cc.skysparkle.matewave.ui.theme.ink
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.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import cc.skysparkle.matewave.R
import cc.skysparkle.matewave.ui.components.DialogText
import cc.skysparkle.matewave.ui.components.DialogAction
import cc.skysparkle.matewave.ui.components.AppDialog
import cc.skysparkle.matewave.ui.components.AppIcon
import cc.skysparkle.matewave.ui.components.ButtonIcon
import cc.skysparkle.matewave.engine.ChessEngine
import cc.skysparkle.matewave.engine.GameStatus
import cc.skysparkle.matewave.engine.PieceColor
import cc.skysparkle.matewave.network.ConnectionState
import cc.skysparkle.matewave.ui.components.ChatDialog
import cc.skysparkle.matewave.ui.components.ChessBoardView
import cc.skysparkle.matewave.ui.components.GlassOutlinedButton
import cc.skysparkle.matewave.ui.components.GlassScaffold
import cc.skysparkle.matewave.ui.components.PromotionDialog
import cc.skysparkle.matewave.ui.theme.ChessGreen
import cc.skysparkle.matewave.viewmodel.GameMode
import cc.skysparkle.matewave.viewmodel.GameUiState
import cc.skysparkle.matewave.viewmodel.GameViewModel
@Composable
fun GameScreen(viewModel: GameViewModel, onExit: () -> Unit) {
val exitGame = {
viewModel.leaveNetworkGame()
onExit()
}
val state by viewModel.uiState.collectAsState()
var showChat by remember { mutableStateOf(false) }
val context = androidx.compose.ui.platform.LocalContext.current
val showOpeningBar = remember { cc.skysparkle.matewave.settings.BoardAppearance(context).showOpening }
// Leaving an ongoing network game counts as a loss, so ask first; a game against the AI is saved.
var confirmLeave by remember { mutableStateOf(false) }
var confirmResign by remember { mutableStateOf(false) }
val requestExit: () -> Unit = {
val ongoing = state.status == GameStatus.ONGOING && state.gameStarted
if (ongoing && state.mode == GameMode.VS_PLAYER_NETWORK) {
confirmLeave = true
} else {
exitGame()
}
}
androidx.activity.compose.BackHandler(onBack = requestExit)
var gameOverHidden by remember(state.status, state.opponentLeft) { mutableStateOf(false) }
// Opening shown in the top bar: "A40 · Englund Gambit" with a THEORY / out-of-book badge.
val start = state.startBoard
val sans = state.moveSans
val showOpening = showOpeningBar && state.mode != GameMode.PUZZLE
val fromStart = remember(start) { cc.skysparkle.matewave.engine.Notation.isStandardStart(start) }
val opening = remember(sans, fromStart) {
if (fromStart) cc.skysparkle.matewave.openings.OpeningBook.openingName(sans) else null
}
val bookPlies = remember(sans, fromStart) {
if (fromStart) cc.skysparkle.matewave.openings.OpeningBook.bookDepth(sans) else 0
}
val inBook = sans.isNotEmpty() && bookPlies >= sans.size
val bookStatus = when {
!fromStart || sans.isEmpty() -> null
inBook -> stringResource(R.string.opening_in_theory)
bookPlies < cc.skysparkle.matewave.openings.OpeningBook.MAX_TREE_PLY ->
stringResource(R.string.opening_out_of_book, bookPlies / 2 + 1)
else -> null
}
GlassScaffold(
title = "",
onBack = requestExit,
titleContent = if (showOpening && opening != null) {
{ OpeningTitle("${opening.eco} · ${opening.name}", bookStatus, inBook) }
} else null
) {
if (state.mode == GameMode.VS_PLAYER_NETWORK) ConnectionBanner(state)
TrustBanners(state)
val material = remember(state.board) { cc.skysparkle.matewave.engine.Material.balance(state.board) }
val bottomColor = state.myColor
val topColor = if (bottomColor == PieceColor.WHITE) PieceColor.BLACK else PieceColor.WHITE
val kingInCheckSquare = if (ChessEngine.isInCheck(state.board, state.board.sideToMove)) {
state.board.kingSquare(state.board.sideToMove)
} else null
PlayerRow(state, topColor, isMe = false, material = material)
ChessBoardView(
board = state.board,
selectedSquare = state.selectedSquare,
legalTargets = state.legalTargetsFromSelected,
kingInCheckSquare = kingInCheckSquare,
flipped = state.myColor == PieceColor.BLACK,
onSquareClick = viewModel::onSquareClicked,
lastMove = state.lastMove,
interactionEnabled = state.status == GameStatus.ONGOING && state.pendingPromotion == null &&
(state.mode != GameMode.VS_PLAYER_NETWORK ||
(state.connectionState == ConnectionState.CONNECTED &&
state.handshake == cc.skysparkle.matewave.viewmodel.HandshakeState.PLAYING))
)
PlayerRow(state, bottomColor, isMe = true, material = material)
// The move ribbon: only moves here, the opening name lives in the top bar.
if (showOpening) {
cc.skysparkle.matewave.ui.components.MovesBar(
sans = sans,
startBoard = start,
currentIndex = sans.size,
opening = null,
bookPlies = bookPlies
)
}
androidx.compose.animation.AnimatedVisibility(
visible = state.opponentNotice == cc.skysparkle.matewave.viewmodel.OpponentNotice.DRAW_DECLINED,
enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(),
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically()
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AppIcon(R.drawable.ic_draw, size = 18.dp, tint = Color(0xFFFFB86B))
Text(stringResource(R.string.draw_declined_notice), color = ink, style = MaterialTheme.typography.bodyMedium)
}
}
androidx.compose.runtime.LaunchedEffect(state.opponentNotice) {
if (state.opponentNotice == cc.skysparkle.matewave.viewmodel.OpponentNotice.DRAW_DECLINED) {
kotlinx.coroutines.delay(NOTICE_VISIBLE_MS)
viewModel.dismissOpponentNotice()
}
}
if (state.mode == GameMode.VS_PLAYER_NETWORK && state.status == GameStatus.ONGOING) {
// Both actions neutral: resigning must not look like the main action.
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
GlassOutlinedButton(
onClick = { viewModel.offerDraw() },
modifier = Modifier.weight(1f),
enabled = !state.drawOfferSentByMe
) {
Text(
if (state.drawOfferSentByMe) stringResource(R.string.draw_offer_waiting) else stringResource(R.string.offer_draw),
color = ink,
maxLines = 1
)
}
GlassOutlinedButton(onClick = { confirmResign = true }, modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.resign), color = ink, maxLines = 1)
}
}
ChatButton(
unreadCount = state.unreadChatCount,
onClick = { viewModel.markChatRead(); showChat = true }
)
}
// The result dialog was closed to look at the board: one tap brings it back.
if (state.status != GameStatus.ONGOING && gameOverHidden) {
GlassOutlinedButton(onClick = { gameOverHidden = false }, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.game_over_title), color = ink, maxLines = 1)
}
}
}
if (confirmLeave) {
AppDialog(
onDismissRequest = { confirmLeave = false },
title = stringResource(R.string.game_leave_title),
actions = {
DialogAction(stringResource(R.string.account_cancel), { confirmLeave = false }, color = ink.copy(alpha = 0.75f))
DialogAction(stringResource(R.string.game_leave_confirm), { confirmLeave = false; exitGame() })
}
) {
DialogText(stringResource(R.string.game_leave_network))
}
}
if (confirmResign) {
AppDialog(
onDismissRequest = { confirmResign = false },
title = stringResource(R.string.game_resign_title),
actions = {
DialogAction(stringResource(R.string.account_cancel), { confirmResign = false }, color = ink.copy(alpha = 0.75f))
DialogAction(stringResource(R.string.resign), { confirmResign = false; viewModel.resign() })
}
) {
DialogText(stringResource(R.string.game_resign_text))
}
}
state.pendingPromotion?.let {
PromotionDialog(
color = state.board.sideToMove,
onChoose = viewModel::choosePromotion,
onDismiss = viewModel::cancelPromotion
)
}
if (state.drawOfferFromOpponent) {
IncomingDrawOfferDialog(
onAccept = { viewModel.respondToDrawOffer(true) },
onDecline = { viewModel.respondToDrawOffer(false) }
)
}
if (showChat) {
ChatDialog(
messages = state.chatMessages,
onSendText = viewModel::sendChatMessage,
onSendImage = viewModel::sendChatImage,
onDismiss = { showChat = false }
)
}
if (state.status != GameStatus.ONGOING && !gameOverHidden) {
GameOverDialog(
state.status, state,
onRematch = {
viewModel.rematchVsAi()
},
onExit = exitGame,
onDismiss = { gameOverHidden = true },
onOfferRematch = { viewModel.offerRematch() }
)
}
if (state.rematchOfferFromOpponent) {
RematchOfferDialog(
onAccept = { viewModel.acceptRematch() },
onDecline = { viewModel.declineRematch() }
)
}
}
@Composable
private fun RematchOfferDialog(onAccept: () -> Unit, onDecline: () -> Unit) {
AppDialog(
onDismissRequest = onDecline,
title = stringResource(R.string.rematch_offer_title),
actions = {
DialogAction(stringResource(R.string.rematch_decline), onDecline, color = ink.copy(alpha = 0.75f))
DialogAction(stringResource(R.string.rematch_accept), onAccept)
}
) {
DialogText(stringResource(R.string.rematch_offer_text))
}
}
@Composable
private fun ChatButton(unreadCount: Int, onClick: () -> Unit) {
Box {
GlassOutlinedButton(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
ButtonIcon(R.drawable.ic_chat)
Text(stringResource(R.string.chat_button), color = ink)
}
if (unreadCount > 0) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.size(20.dp)
.clip(CircleShape)
.background(ChessGreen),
contentAlignment = Alignment.Center
) {
Text(unreadCount.toString(), color = Color.White, fontSize = 11.sp)
}
}
}
}
@Composable
private fun IncomingDrawOfferDialog(onAccept: () -> Unit, onDecline: () -> Unit) {
AppDialog(
onDismissRequest = onDecline,
title = stringResource(R.string.draw_offer_received_title),
actions = {
DialogAction(stringResource(R.string.decline), onDecline, color = ink.copy(alpha = 0.75f))
DialogAction(stringResource(R.string.accept), onAccept)
}
) {
DialogText(stringResource(R.string.draw_offer_received_text))
}
}
@Composable
private fun GameOverDialog(
status: GameStatus,
state: GameUiState,
onRematch: () -> Unit,
onExit: () -> Unit,
onDismiss: () -> Unit,
onOfferRematch: () -> Unit = {}
) {
val colorWhite = stringResource(R.string.color_white)
val colorBlack = stringResource(R.string.color_black)
val message = when (status) {
GameStatus.CHECKMATE -> {
val winner = if (state.board.sideToMove == PieceColor.WHITE) colorBlack else colorWhite
stringResource(R.string.checkmate_winner, winner)
}
GameStatus.STALEMATE -> stringResource(R.string.stalemate)
GameStatus.DRAW_50_MOVES -> stringResource(R.string.draw_50_moves)
GameStatus.DRAW_REPETITION -> stringResource(R.string.draw_repetition)
GameStatus.DRAW_MATERIAL -> stringResource(R.string.draw_material)
GameStatus.TIMEOUT -> {
val winner = if (state.timedOutSide == PieceColor.WHITE) colorBlack else colorWhite
stringResource(R.string.timeout_winner, winner)
}
GameStatus.TIMEOUT_DRAW -> stringResource(R.string.timeout_draw)
GameStatus.RESIGNATION -> {
val winner = if (state.resignedSide == PieceColor.WHITE) colorBlack else colorWhite
stringResource(R.string.resignation_winner, winner)
}
GameStatus.DRAW_AGREEMENT -> stringResource(R.string.draw_agreement)
GameStatus.ONGOING -> return
}
val waitingForRematch = state.mode == GameMode.VS_PLAYER_NETWORK && !state.opponentLeft && state.rematchOfferedByMe
val shownMessage = if (state.opponentLeftMidGame) stringResource(R.string.opponent_left_win) else message
AppDialog(
onDismissRequest = onDismiss,
title = stringResource(R.string.game_over_title),
actions = {
DialogAction(stringResource(R.string.back_to_menu_short), onExit, color = ink.copy(alpha = 0.75f))
if (state.mode == GameMode.VS_AI) {
DialogAction(stringResource(R.string.play_again), onRematch)
} else if (state.mode == GameMode.VS_PLAYER_NETWORK && !state.opponentLeft && !waitingForRematch) {
DialogAction(stringResource(R.string.rematch_offer), onOfferRematch)
}
}
) {
DialogText(shownMessage)
if (waitingForRematch) {
DialogText(stringResource(R.string.rematch_waiting))
}
if (state.opponentNotice == cc.skysparkle.matewave.viewmodel.OpponentNotice.REMATCH_DECLINED) {
Text(
stringResource(R.string.rematch_declined_notice),
color = Color(0xFFFFB86B),
style = MaterialTheme.typography.bodyMedium
)
}
if (state.opponentLeft && !state.opponentLeftMidGame) {
Text(
stringResource(R.string.opponent_left_after_game),
color = Color(0xFFFFB86B),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
private const val NO_LIMIT_THRESHOLD_SECONDS = 365L * 24 * 3600
private const val NOTICE_VISIBLE_MS = 4_000L
@Composable
private fun SideCaptured(material: cc.skysparkle.matewave.engine.MaterialBalance, side: PieceColor) {
cc.skysparkle.matewave.ui.components.CapturedPiecesRow(
captured = if (side == PieceColor.WHITE) material.capturedByWhite else material.capturedByBlack,
capturedColor = if (side == PieceColor.WHITE) PieceColor.BLACK else PieceColor.WHITE,
advantage = if (side == PieceColor.WHITE) material.advantage else -material.advantage
)
}
/** Opening name in the top bar with a small status badge underneath. */
@Composable
private fun OpeningTitle(name: String, status: String?, inBook: Boolean) {
Column(modifier = Modifier.padding(start = 4.dp)) {
Text(
name,
color = ink,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
)
if (status != null) {
val tint = if (inBook) ChessGreen else Color(0xFFFFB86B)
Text(
status.uppercase(),
color = tint,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
modifier = Modifier
.padding(top = 2.dp)
.clip(RoundedCornerShape(4.dp))
.background(tint.copy(alpha = 0.15f))
.padding(horizontal = 6.dp, vertical = 1.dp)
)
}
}
}
/**
* A player strip: activity dot, name and rating, captured pieces, and a large clock. The side to
* move gets a bright clock on a soft plate; the other side is dimmed. No cards around it.
*/
@Composable
private fun PlayerRow(
state: GameUiState,
side: PieceColor,
isMe: Boolean,
material: cc.skysparkle.matewave.engine.MaterialBalance
) {
val active = state.status == GameStatus.ONGOING && state.board.sideToMove == side
// Re-read after the game: the rating changes when it ends.
val profile = remember(state.status) { cc.skysparkle.matewave.data.ProfileStore.get() }
val (name, elo, avatar) = when {
isMe -> Triple(profile.name, profile.elo, profile.avatarBase64)
state.mode == GameMode.VS_AI -> Triple(
stringResource(R.string.game_ai_name, stringResource(difficultyName(state.aiDifficulty))),
cc.skysparkle.matewave.data.ProfileStore.aiNominalElo(state.aiDifficulty),
null
)
else -> Triple(
state.opponentProfile?.name ?: stringResource(R.string.game_opponent),
state.opponentProfile?.elo,
state.opponentProfile?.avatarBase64
)
}
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(if (active) ChessGreen else ink.copy(alpha = 0.15f))
)
Spacer(Modifier.width(10.dp))
AvatarCircle(base64 = avatar, size = 34.dp)
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
name,
color = if (active) ink else ink.copy(alpha = 0.75f),
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
)
if (!isMe && state.mode == GameMode.VS_PLAYER_NETWORK &&
state.handshake == cc.skysparkle.matewave.viewmodel.HandshakeState.PLAYING &&
!state.opponentUntrusted && !state.opponentUnverified
) {
AppIcon(R.drawable.ic_encrypted, size = 14.dp, tint = ChessGreen)
}
}
if (elo != null) {
Text(stringResource(R.string.friends_elo_value, elo), color = ink.copy(alpha = 0.55f), style = MaterialTheme.typography.labelSmall)
}
SideCaptured(material, side)
}
state.clockState?.let { cs ->
BigClock(if (side == PieceColor.WHITE) cs.whiteMillis else cs.blackMillis, active)
}
}
}
@Composable
private fun BigClock(millis: Long, active: Boolean) {
val totalSeconds = millis / 1000
val low = active && totalSeconds < 20
val color = when {
low -> Color(0xFFFF6B6B)
active -> ink
else -> ink.copy(alpha = 0.45f)
}
Box(
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.background(if (active) ink.copy(alpha = 0.12f) else Color.Transparent)
.padding(horizontal = 12.dp, vertical = 6.dp),
contentAlignment = Alignment.Center
) {
if (totalSeconds >= NO_LIMIT_THRESHOLD_SECONDS) {
AppIcon(R.drawable.ic_infinity, size = 28.dp, tint = color)
} else {
Text(
"%d:%02d".format(totalSeconds / 60, totalSeconds % 60),
color = color,
style = MaterialTheme.typography.headlineSmall,
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold
)
}
}
}
@Composable
private fun ConnectionBanner(state: GameUiState) {
when (state.connectionState) {
ConnectionState.CONNECTING -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CircularProgressIndicator(color = ink, strokeWidth = 2.dp, modifier = Modifier.size(16.dp))
Text(stringResource(R.string.connecting_to_opponent), color = ink, style = MaterialTheme.typography.bodyMedium)
}
ConnectionState.FAILED -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
AppIcon(R.drawable.ic_error, size = 18.dp, tint = Color(0xFFFF6B6B))
Text(stringResource(R.string.connection_failed), color = Color(0xFFFF6B6B), style = MaterialTheme.typography.bodyMedium)
}
ConnectionState.DISCONNECTED -> if (state.gameStarted) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
AppIcon(R.drawable.ic_signal_disconnected, size = 18.dp, tint = Color(0xFFFF6B6B))
Text(stringResource(R.string.connection_lost), color = Color(0xFFFF6B6B), style = MaterialTheme.typography.bodyMedium)
}
}
ConnectionState.CONNECTED -> Unit
}
}
@Composable
private fun TrustBanners(state: GameUiState) {
val (icon, text, color) = when {
state.opponentUntrusted -> Triple(R.drawable.ic_no_encryption, stringResource(R.string.trust_impersonation), Color(0xFFFF6B6B))
state.opponentOutdated -> Triple(R.drawable.ic_error, stringResource(R.string.trust_outdated), Color(0xFFFF6B6B))
state.opponentUnverified -> Triple(R.drawable.ic_no_encryption, stringResource(R.string.trust_unverified), Color(0xFFFFC46B))
else -> Triple(0, null, Color.Unspecified)
}
if (text != null) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
AppIcon(icon, size = 16.dp, tint = color)
Text(text, color = color, style = MaterialTheme.typography.bodySmall)
}
}
state.newPeerFingerprint?.let { fingerprint ->
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
AppIcon(R.drawable.ic_key, size = 16.dp, tint = Color(0xFFFFC46B))
Text(stringResource(R.string.trust_first_meeting, fingerprint), color = Color(0xFFFFC46B), style = MaterialTheme.typography.bodySmall)
}
}
}
/** Name of an AI level, shared by the setup, game and home screens. */
internal fun difficultyName(d: cc.skysparkle.matewave.ai.AiDifficulty): Int = when (d) {
cc.skysparkle.matewave.ai.AiDifficulty.EASY -> R.string.difficulty_easy
cc.skysparkle.matewave.ai.AiDifficulty.MEDIUM -> R.string.difficulty_medium
cc.skysparkle.matewave.ai.AiDifficulty.HARD -> R.string.difficulty_hard
cc.skysparkle.matewave.ai.AiDifficulty.EXPERT -> R.string.difficulty_expert
}
@@ -0,0 +1,210 @@
package cc.skysparkle.matewave.ui.screens
import cc.skysparkle.matewave.ui.theme.ink
import android.content.Context
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
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
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import cc.skysparkle.matewave.R
import cc.skysparkle.matewave.ui.components.AppDialog
import cc.skysparkle.matewave.ui.components.AppIcon
import cc.skysparkle.matewave.ui.components.ButtonIcon
import cc.skysparkle.matewave.ui.components.DialogAccent
import cc.skysparkle.matewave.ui.components.DialogAction
import cc.skysparkle.matewave.ui.components.GlassCard
import cc.skysparkle.matewave.ui.components.GlassOutlinedButton
import cc.skysparkle.matewave.ui.components.GlassScaffold
import cc.skysparkle.matewave.ui.components.SetupSectionLabel
private data class LicenseEntry(
val name: String,
val author: String,
val version: String?,
val license: String,
val url: String,
val noteRes: Int? = null
)
private const val APACHE = "Apache License 2.0"
private const val AOSP = "The Android Open Source Project"
private val LIBRARIES = listOf(
LicenseEntry(
"Jetpack Compose (UI, Foundation, Material 3)", AOSP, "BOM 2026.09.00", APACHE,
"https://developer.android.com/jetpack/androidx/releases/compose"
),
LicenseEntry(
"AndroidX Activity Compose", AOSP, "1.13.0", APACHE,
"https://developer.android.com/jetpack/androidx/releases/activity"
),
LicenseEntry(
"AndroidX Core KTX", AOSP, "1.18.0", APACHE,
"https://developer.android.com/jetpack/androidx/releases/core"
),
LicenseEntry(
"AndroidX Navigation Compose", AOSP, "2.9.7", APACHE,
"https://developer.android.com/jetpack/androidx/releases/navigation"
),
LicenseEntry(
"AndroidX Lifecycle ViewModel Compose", AOSP, "2.11.0", APACHE,
"https://developer.android.com/jetpack/androidx/releases/lifecycle"
),
LicenseEntry(
"AndroidX \u2014 other libraries (pulled in by the ones above)", AOSP, null, APACHE,
"https://developer.android.com/jetpack/androidx"
),
LicenseEntry(
"ZXing Core (QR codes)", "ZXing authors", "3.5.4", APACHE,
"https://github.com/zxing/zxing"
),
LicenseEntry(
"kotlinx.coroutines", "JetBrains", "1.11.0", APACHE,
"https://github.com/Kotlin/kotlinx.coroutines"
),
LicenseEntry(
"Kotlin Standard Library", "JetBrains", null, APACHE,
"https://github.com/JetBrains/kotlin"
)
)
private val DATA = listOf(
LicenseEntry(
"Lichess Puzzle Database", "Lichess", null, "CC0 1.0",
"https://database.lichess.org/#puzzles", R.string.licenses_note_puzzles
),
LicenseEntry(
"Lichess Standard Games Database (2013-05)", "Lichess", null, "CC0 1.0",
"https://database.lichess.org/#standard_games", R.string.licenses_note_games
),
LicenseEntry(
"lichess-org/chess-openings", "Lichess contributors", null, "CC0 1.0",
"https://github.com/lichess-org/chess-openings", R.string.licenses_note_openings
)
)
private val ASSETS = listOf(
LicenseEntry(
"Material Symbols", "Google", null, APACHE,
"https://iconbuddy.com/material-symbols", R.string.licenses_note_icons
),
LicenseEntry(
"Vector Chess Pieces", "RhosGFX", null, "CC0 1.0",
"https://rhosgfx.itch.io/vector-chess-pieces", R.string.licenses_note_pieces
)
)
private val RADIO = listOf(
LicenseEntry(
"Radio Swiss Jazz", "SRG SSR", null, "\u00A9 SRG SSR",
"https://www.radioswissjazz.ch/en/reception/internet", R.string.licenses_note_swissjazz
)
)
@Composable
fun LicensesScreen(onBack: () -> Unit) {
val context = LocalContext.current
var showApache by remember { mutableStateOf(false) }
GlassScaffold(title = stringResource(R.string.licenses_title), onBack = onBack) {
Text(
stringResource(R.string.licenses_intro),
color = ink.copy(alpha = 0.75f),
style = MaterialTheme.typography.bodyMedium
)
SetupSectionLabel(stringResource(R.string.licenses_group_libraries))
LIBRARIES.forEach { entry -> LicenseCard(entry) { openUrl(context, entry.url) } }
GlassOutlinedButton(onClick = { showApache = true }, modifier = Modifier.fillMaxWidth()) {
ButtonIcon(R.drawable.ic_copyright)
Text(stringResource(R.string.licenses_apache_full), color = ink)
}
SetupSectionLabel(stringResource(R.string.licenses_group_data))
DATA.forEach { entry -> LicenseCard(entry) { openUrl(context, entry.url) } }
SetupSectionLabel(stringResource(R.string.licenses_group_assets))
ASSETS.forEach { entry -> LicenseCard(entry) { openUrl(context, entry.url) } }
SetupSectionLabel(stringResource(R.string.licenses_group_radio))
Text(
stringResource(R.string.licenses_radio_hint),
color = ink.copy(alpha = 0.6f),
style = MaterialTheme.typography.bodySmall
)
RADIO.forEach { entry -> LicenseCard(entry) { openUrl(context, entry.url) } }
}
if (showApache) {
ApacheLicenseDialog(onDismiss = { showApache = false })
}
}
@Composable
private fun LicenseCard(entry: LicenseEntry, onClick: () -> Unit) {
GlassCard(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(entry.name, color = ink, style = MaterialTheme.typography.titleMedium)
Text(
listOfNotNull(entry.author, entry.version).joinToString(" \u00B7 "),
color = ink.copy(alpha = 0.65f),
style = MaterialTheme.typography.bodySmall
)
Text(entry.license, color = DialogAccent, style = MaterialTheme.typography.bodySmall)
entry.noteRes?.let { note ->
Text(
stringResource(note),
color = ink.copy(alpha = 0.7f),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 6.dp)
)
}
}
AppIcon(R.drawable.ic_arrow_forward, size = 16.dp, tint = ink.copy(alpha = 0.6f))
}
}
}
@Composable
private fun ApacheLicenseDialog(onDismiss: () -> Unit) {
val context = LocalContext.current
val text = remember {
runCatching {
context.resources.openRawResource(R.raw.license_apache_2_0)
.bufferedReader().use { it.readText() }
}.getOrDefault("https://www.apache.org/licenses/LICENSE-2.0")
.trim()
.split(Regex("\n\\s*\n"))
.joinToString("\n\n") { paragraph -> paragraph.lines().joinToString(" ") { it.trim() } }
}
AppDialog(
onDismissRequest = onDismiss,
title = APACHE,
actions = { DialogAction(stringResource(R.string.chat_close), onDismiss) }
) {
Column(modifier = Modifier.fillMaxWidth().heightIn(max = 380.dp).verticalScroll(rememberScrollState())) {
Text(text, color = ink.copy(alpha = 0.78f), style = MaterialTheme.typography.bodySmall)
}
}
}
private fun openUrl(context: Context, url: String) = cc.skysparkle.matewave.settings.AppLinks.open(context, url)
@@ -0,0 +1,371 @@
package cc.skysparkle.matewave.ui.screens
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import cc.skysparkle.matewave.ui.components.ListDivider
import cc.skysparkle.matewave.ui.components.ListGroup
import cc.skysparkle.matewave.ui.components.ListRow
import cc.skysparkle.matewave.ui.components.SectionCaption
import cc.skysparkle.matewave.ui.theme.ink
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.foundation.Image
import cc.skysparkle.matewave.R
import cc.skysparkle.matewave.ui.components.DialogText
import cc.skysparkle.matewave.ui.components.DialogAction
import cc.skysparkle.matewave.ui.components.AppDialog
import cc.skysparkle.matewave.stats.Stats
import cc.skysparkle.matewave.ui.components.AppBottomBar
import cc.skysparkle.matewave.ui.components.BottomTab
import cc.skysparkle.matewave.ui.theme.ChessGreen
import cc.skysparkle.matewave.ui.theme.TabletMaxContentWidth
import cc.skysparkle.matewave.ui.theme.UiCornerRadius
import cc.skysparkle.matewave.ui.theme.glass
import cc.skysparkle.matewave.viewmodel.GameViewModel
import cc.skysparkle.matewave.viewmodel.ResumeInfo
import cc.skysparkle.matewave.ui.components.displayName
@Composable
fun MainMenuScreen(
viewModel: GameViewModel,
studyViewModel: cc.skysparkle.matewave.viewmodel.OpeningStudyViewModel,
onContinueGame: () -> Unit,
onQuickGame: () -> Unit,
onVsAiClick: () -> Unit,
onPuzzlesClick: () -> Unit,
onStudyClick: () -> Unit,
onVsPlayerClick: () -> Unit,
onSettingsClick: () -> Unit,
onAccountClick: () -> Unit,
onFriendsClick: () -> Unit
) {
val context = LocalContext.current
val resumable by viewModel.resumable.collectAsState()
val ongoing = resumable
val hasOngoingGame = ongoing != null
val profile = remember { cc.skysparkle.matewave.data.ProfileStore.get() }
val lastSetup = remember { cc.skysparkle.matewave.settings.LastGameSetup(context) }
LaunchedEffect(Unit) { studyViewModel.init(context) }
val study by studyViewModel.overview.collectAsState()
var showRatingGate by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
Column(modifier = Modifier.fillMaxSize().widthIn(max = TabletMaxContentWidth)) {
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(top = 16.dp, start = 20.dp, end = 20.dp, bottom = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(R.drawable.logo_knight),
contentDescription = null,
modifier = Modifier.size(width = 30.dp, height = 44.dp)
)
Spacer(Modifier.width(12.dp))
Greeting(modifier = Modifier.weight(1f))
// The rating is always in sight.
Text(
"${profile.elo}",
color = ink,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.clip(RoundedCornerShape(50))
.background(ink.copy(alpha = 0.10f))
.padding(horizontal = 12.dp, vertical = 6.dp)
)
}
Column(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 20.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
// The most urgent thing first: an unfinished game.
if (ongoing != null) ContinueCard(ongoing, onContinueGame)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
ModeTile(
iconRes = R.drawable.ic_ai_app,
title = stringResource(R.string.mode_ai_title),
subtitle = stringResource(R.string.mode_ai_subtitle),
modifier = Modifier.weight(1f),
onClick = { if (profile.levelChosen) onVsAiClick() else showRatingGate = true }
)
ModeTile(
iconRes = R.drawable.ic_profile_photo,
title = stringResource(R.string.mode_online_title),
subtitle = stringResource(R.string.mode_online_subtitle),
modifier = Modifier.weight(1f),
onClick = { if (profile.levelChosen) onVsPlayerClick() else showRatingGate = true }
)
}
SectionCaption(stringResource(R.string.home_today))
ListGroup {
if (!hasOngoingGame) {
ListRow(
title = stringResource(R.string.quick_game_title),
subtitle = stringResource(
R.string.quick_game_subtitle,
stringResource(difficultyName(lastSetup.aiDifficulty)),
lastSetup.aiTime.displayName()
),
icon = R.drawable.ic_quick_game,
iconTint = ChessGreen,
chevron = true,
onClick = { if (profile.levelChosen) onQuickGame() else showRatingGate = true }
)
ListDivider()
}
if (study.dueCards > 0) {
ListRow(
title = stringResource(R.string.study_review_title),
subtitle = stringResource(R.string.study_review_summary, study.dueCards, study.dueCourses),
icon = R.drawable.ic_review,
iconTint = cc.skysparkle.matewave.ui.screens.StarGold,
chevron = true,
onClick = onStudyClick
)
} else {
ListRow(
title = stringResource(R.string.study_title),
subtitle = stringResource(R.string.study_menu_subtitle),
icon = R.drawable.ic_crown,
chevron = true,
onClick = onStudyClick
)
}
ListDivider()
ListRow(
title = stringResource(R.string.puzzles_title),
subtitle = stringResource(R.string.puzzles_subtitle),
icon = R.drawable.ic_puzzle,
chevron = true,
onClick = onPuzzlesClick
)
}
}
Box(modifier = Modifier.navigationBarsPadding()) {
AppBottomBar(
current = BottomTab.HOME,
onHomeClick = { },
onFriendsClick = onFriendsClick,
onSettingsClick = onSettingsClick,
onAccountClick = onAccountClick,
accountAvatarBase64 = profile.avatarBase64
)
}
}
}
if (showRatingGate) {
RatingGateDialog(
onOpenAccount = { showRatingGate = false; onAccountClick() },
onDismiss = { showRatingGate = false }
)
}
}
@Composable
private fun Greeting(modifier: Modifier = Modifier) {
val hour = remember { java.util.Calendar.getInstance().get(java.util.Calendar.HOUR_OF_DAY) }
val greeting = when (hour) {
in 5..11 -> stringResource(R.string.greeting_morning)
in 12..17 -> stringResource(R.string.greeting_afternoon)
in 18..22 -> stringResource(R.string.greeting_evening)
else -> stringResource(R.string.greeting_night)
}
Column(modifier = modifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(greeting, color = ink, style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.width(6.dp))
Text("👋", fontSize = 20.sp)
}
Text(stringResource(R.string.greeting_ready), color = ink.copy(alpha = 0.7f), style = MaterialTheme.typography.bodyMedium)
}
}
/** An unfinished game against the AI: move number, whose turn and the clock, one tap to resume. */
@Composable
private fun ContinueCard(info: ResumeInfo, onClick: () -> Unit) {
val turn = stringResource(if (info.myTurn) R.string.home_your_move else R.string.home_ai_move)
val clock = info.myMillis?.let { ms ->
val s = ms.coerceAtLeast(0) / 1000
"%d:%02d".format(s / 60, s % 60)
}
val details = stringResource(R.string.home_continue_details, info.moveNumber, turn) + (clock?.let { " · $it" } ?: "")
Row(
modifier = Modifier
.fillMaxWidth()
.glass(RoundedCornerShape(UiCornerRadius))
.border(1.dp, ChessGreen.copy(alpha = 0.6f), RoundedCornerShape(UiCornerRadius))
.clickable(onClick = onClick)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.continue_game_title), color = ink, style = MaterialTheme.typography.titleMedium)
Text(details, color = ink.copy(alpha = 0.7f), style = MaterialTheme.typography.bodySmall)
}
Box(
modifier = Modifier.size(44.dp).clip(CircleShape).background(ChessGreen),
contentAlignment = Alignment.Center
) {
cc.skysparkle.matewave.ui.components.AppIcon(R.drawable.ic_arrow_forward, size = 20.dp, tint = Color.White)
}
}
}
/** One of the two equal ways to start a game. */
@Composable
private fun ModeTile(iconRes: Int, title: String, subtitle: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
Column(
modifier = modifier
.glass(RoundedCornerShape(UiCornerRadius))
.clickable(onClick = onClick)
.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Image(
painter = painterResource(iconRes),
contentDescription = null,
colorFilter = ColorFilter.tint(ink),
modifier = Modifier.size(24.dp)
)
Text(title, color = ink, style = MaterialTheme.typography.titleMedium)
Text(subtitle, color = ink.copy(alpha = 0.65f), style = MaterialTheme.typography.bodySmall)
}
}
@Composable
private fun RatingGateDialog(onOpenAccount: () -> Unit, onDismiss: () -> Unit) {
AppDialog(
onDismissRequest = onDismiss,
title = stringResource(R.string.rating_gate_title),
actions = {
DialogAction(stringResource(R.string.account_cancel), onDismiss, color = ink.copy(alpha = 0.75f))
DialogAction(stringResource(R.string.rating_gate_open), onOpenAccount)
}
) {
DialogText(stringResource(R.string.rating_gate_text))
}
}
@Composable
internal fun StatsDialog(onDismiss: () -> Unit) {
val curve = remember { Stats.ratingCurve(cc.skysparkle.matewave.data.ProfileStore.get().elo) }
AppDialog(
onDismissRequest = onDismiss,
title = stringResource(R.string.stats_dialog_title),
actions = { DialogAction(stringResource(R.string.chat_close), onDismiss) }
) {
DialogText(stringResource(R.string.stats_dialog_summary, Stats.wins(), Stats.winPercent(), Stats.streak()))
MiniRatingGraph(points = curve, modifier = Modifier.fillMaxWidth().height(140.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(stringResource(R.string.stats_min, curve.min()), color = ink.copy(alpha = 0.6f), style = MaterialTheme.typography.labelMedium)
Text(stringResource(R.string.stats_now, curve.last()), color = ink, style = MaterialTheme.typography.labelMedium)
Text(stringResource(R.string.stats_max, curve.max()), color = ink.copy(alpha = 0.6f), style = MaterialTheme.typography.labelMedium)
}
Text(
stringResource(R.string.stats_graph_caption),
color = ink.copy(alpha = 0.6f),
style = MaterialTheme.typography.bodySmall
)
}
}
@Composable
internal fun MiniRatingGraph(points: List<Int>, modifier: Modifier = Modifier) {
val lineInk = ink
Canvas(modifier = modifier) {
if (points.size < 2) return@Canvas
val minV = points.min().toFloat()
val maxV = points.max().toFloat()
val range = (maxV - minV).coerceAtLeast(1f)
val stepX = size.width / (points.size - 1)
fun yOf(v: Int) = size.height - ((v - minV) / range) * size.height
val gridLines = 4
for (i in 0..gridLines) {
val y = size.height / gridLines * i
drawLine(lineInk.copy(alpha = 0.08f), Offset(0f, y), Offset(size.width, y), strokeWidth = 1f)
}
// Reference line: the rating at the start of the shown history.
val zeroY = yOf(points.first())
drawLine(lineInk.copy(alpha = 0.3f), Offset(0f, zeroY), Offset(size.width, zeroY), strokeWidth = 1.5f)
val line = Path().apply {
moveTo(0f, yOf(points[0]))
points.forEachIndexed { i, v -> if (i > 0) lineTo(i * stepX, yOf(v)) }
}
val fill = Path().apply {
addPath(line)
lineTo(size.width, size.height)
lineTo(0f, size.height)
close()
}
drawPath(
fill,
brush = Brush.verticalGradient(listOf(ChessGreen.copy(alpha = 0.35f), ChessGreen.copy(alpha = 0f)))
)
drawPath(line, color = ChessGreen, style = Stroke(width = 4f, cap = StrokeCap.Round))
points.forEachIndexed { i, v ->
val center = Offset(i * stepX, yOf(v))
drawCircle(color = lineInk, radius = 5f, center = center)
drawCircle(color = ChessGreen, radius = 3f, center = center)
}
}
}
@@ -0,0 +1,301 @@
package cc.skysparkle.matewave.ui.screens
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import cc.skysparkle.matewave.R
import cc.skysparkle.matewave.engine.PieceColor
import cc.skysparkle.matewave.openings.CourseInfo
import cc.skysparkle.matewave.openings.CourseStatus
import cc.skysparkle.matewave.openings.artRes
import cc.skysparkle.matewave.ui.components.AppIcon
import cc.skysparkle.matewave.ui.components.GlassButton
import cc.skysparkle.matewave.ui.components.GlassScaffold
import cc.skysparkle.matewave.ui.components.SetupSegmented
import cc.skysparkle.matewave.ui.theme.ChessGreen
import cc.skysparkle.matewave.ui.theme.UiCornerRadius
import cc.skysparkle.matewave.ui.theme.glass
import cc.skysparkle.matewave.ui.theme.ink
import cc.skysparkle.matewave.viewmodel.OpeningStudyViewModel
import cc.skysparkle.matewave.viewmodel.StudyOverview
val StarGold = Color(0xFFFFC857)
@Composable
fun OpeningStudyScreen(viewModel: OpeningStudyViewModel, onBack: () -> Unit, onOpenTrainer: () -> Unit) {
val context = LocalContext.current
LaunchedEffect(Unit) { viewModel.init(context) }
val overview by viewModel.overview.collectAsState()
var side by rememberSaveable { mutableIntStateOf(0) }
GlassScaffold(
title = stringResource(R.string.study_title),
onBack = onBack,
actions = { HeaderProgress(overview) }
) {
val total = overview.courses.size.coerceAtLeast(1)
ProgressBar(overview.courses.sumOf { it.stars }.toFloat() / (total * 3), height = 4)
NextAction(overview, viewModel, onOpenTrainer)
SetupSegmented(
options = listOf(stringResource(R.string.study_for_white), stringResource(R.string.study_for_black)),
selectedIndex = side
) { side = it }
val lockedMessage = overview.learning?.let { stringResource(R.string.study_locked_toast, it.course.name) }
val sideColor = if (side == 0) PieceColor.WHITE else PieceColor.BLACK
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val columns = when {
maxWidth < 360.dp -> 2
maxWidth < 600.dp -> 3
else -> 4
}
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
overview.courses.filter { it.course.side == sideColor }.chunked(columns).forEach { row ->
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
row.forEach { info ->
CourseCard(info, Modifier.weight(1f)) {
if (viewModel.open(info.course)) onOpenTrainer()
else if (lockedMessage != null) Toast.makeText(context, lockedMessage, Toast.LENGTH_SHORT).show()
}
}
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
}
}
}
}
}
}
/** "3/24 ★ 1" in the top bar. */
@Composable
private fun HeaderProgress(overview: StudyOverview) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 12.dp)) {
Text("${overview.learnedCount}/${overview.courses.size}", color = ink.copy(alpha = 0.8f), style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.width(10.dp))
AppIcon(R.drawable.ic_star_badge, size = 16.dp, tint = StarGold)
Spacer(Modifier.width(4.dp))
Text("${overview.masteredCount}", color = ink.copy(alpha = 0.8f), style = MaterialTheme.typography.titleSmall)
}
}
/**
* The one thing to do now, shown only when there is something to do: review, continue the
* course in progress, or start the first course. Otherwise just the time of the next review.
*/
@Composable
private fun NextAction(overview: StudyOverview, viewModel: OpeningStudyViewModel, onOpenTrainer: () -> Unit) {
val firstNew = overview.courses.firstOrNull { it.status == CourseStatus.NEW }
val learning = overview.learning
val nextDueAt = overview.nextDueAt
when {
overview.dueCards > 0 -> ActionCard(
icon = R.drawable.ic_review,
title = stringResource(R.string.study_review_title),
subtitle = stringResource(R.string.study_review_summary, overview.dueCards, overview.dueCourses),
button = stringResource(R.string.study_review_button)
) { if (viewModel.reviewNext()) onOpenTrainer() }
learning != null -> ActionCard(
icon = R.drawable.ic_book,
title = stringResource(R.string.study_continue_title),
subtitle = learning.course.name,
button = stringResource(R.string.study_continue_button),
progress = learning.learned.toFloat() / learning.total.coerceAtLeast(1)
) { if (viewModel.open(learning.course)) onOpenTrainer() }
overview.learnedCount == 0 && firstNew != null -> ActionCard(
icon = R.drawable.ic_book,
title = stringResource(R.string.study_start_with, firstNew.course.name),
subtitle = null,
button = stringResource(R.string.study_start_button)
) { if (viewModel.open(firstNew.course)) onOpenTrainer() }
nextDueAt != null -> Row(verticalAlignment = Alignment.CenterVertically) {
AppIcon(R.drawable.ic_review, size = 16.dp, tint = ink.copy(alpha = 0.55f))
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.study_next_review, formatDueIn(nextDueAt)), color = ink.copy(alpha = 0.65f), style = MaterialTheme.typography.bodyMedium)
}
}
}
@Composable
private fun ActionCard(icon: Int, title: String, subtitle: String?, button: String, progress: Float? = null, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.glass(RoundedCornerShape(UiCornerRadius))
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppIcon(icon, size = 22.dp, tint = StarGold)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(title, color = ink, style = MaterialTheme.typography.titleSmall, maxLines = 2, overflow = TextOverflow.Ellipsis)
if (subtitle != null) {
Text(subtitle, color = ink.copy(alpha = 0.65f), style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
if (progress != null) {
Spacer(Modifier.height(6.dp))
ProgressBar(progress, height = 4)
}
}
Spacer(Modifier.width(12.dp))
GlassButton(onClick = onClick) { Text(button, color = ink, maxLines = 1) }
}
}
/** A course card without a frame: the knight on a soft glow, name, size and one status line. */
@Composable
private fun CourseCard(info: CourseInfo, modifier: Modifier, onClick: () -> Unit) {
val locked = info.status == CourseStatus.LOCKED
val glow = when {
info.mastered -> StarGold.copy(alpha = 0.20f)
info.status == CourseStatus.DUE -> ChessGreen.copy(alpha = 0.22f)
else -> ink.copy(alpha = 0.08f)
}
val family = info.course.name.substringBefore(':').trim()
val variation = info.course.name.substringAfter(':', "").trim()
Column(
modifier = modifier
.alpha(if (locked) 0.4f else 1f)
.clip(RoundedCornerShape(UiCornerRadius))
.clickable(onClick = onClick)
.padding(4.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(0.85f)
.background(Brush.radialGradient(listOf(glow, Color.Transparent))),
contentAlignment = Alignment.BottomCenter
) {
Image(
painter = painterResource(info.course.artRes()),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize().padding(6.dp)
)
when {
info.mastered -> AppIcon(R.drawable.ic_star_badge, size = 22.dp, tint = StarGold, modifier = Modifier.align(Alignment.TopEnd))
info.status == CourseStatus.DUE -> AppIcon(R.drawable.ic_review, size = 20.dp, tint = ChessGreen, modifier = Modifier.align(Alignment.TopEnd))
}
}
Spacer(Modifier.height(6.dp))
Text(family, color = ink, style = MaterialTheme.typography.labelLarge, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
variation.ifEmpty { " " },
color = ink.copy(alpha = 0.65f),
style = MaterialTheme.typography.labelSmall,
minLines = 2,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
AppIcon(R.drawable.ic_timer, size = 12.dp, tint = ink.copy(alpha = 0.55f))
Spacer(Modifier.width(4.dp))
Text(
LocalContext.current.resources.getQuantityString(R.plurals.study_moves_count, info.total, info.total),
color = ink.copy(alpha = 0.55f),
style = MaterialTheme.typography.labelSmall,
maxLines = 1
)
}
// One status line of fixed height, so rows stay even; empty for a new course.
Box(modifier = Modifier.fillMaxWidth().height(20.dp), contentAlignment = Alignment.CenterStart) {
when (info.status) {
CourseStatus.NEW -> Unit
CourseStatus.LOCKED -> Text(stringResource(R.string.study_locked_short), color = ink.copy(alpha = 0.7f), style = MaterialTheme.typography.labelSmall)
CourseStatus.LEARNING -> ProgressBar(info.learned.toFloat() / info.total.coerceAtLeast(1), height = 4)
CourseStatus.DUE -> Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(7.dp).clip(CircleShape).background(ChessGreen))
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.study_status_due, info.dueCards), color = ChessGreen, style = MaterialTheme.typography.labelSmall, maxLines = 1)
}
CourseStatus.WAITING -> Row(verticalAlignment = Alignment.CenterVertically) {
if (info.stars > 0) Stars(info.stars, size = 12.dp)
Spacer(Modifier.width(6.dp))
info.nextDueAt?.let {
Text(formatDueIn(it), color = ink.copy(alpha = 0.55f), style = MaterialTheme.typography.labelSmall, maxLines = 1)
}
}
}
}
}
}
@Composable
fun Stars(count: Int, size: androidx.compose.ui.unit.Dp = 16.dp) {
Row {
repeat(3) { i ->
AppIcon(R.drawable.ic_star, size = size, tint = if (i < count) StarGold else ink.copy(alpha = 0.2f))
}
}
}
@Composable
fun ProgressBar(fraction: Float, height: Int = 8) {
val f = fraction.coerceIn(0f, 1f)
Box(
modifier = Modifier
.fillMaxWidth()
.height(height.dp)
.clip(RoundedCornerShape((height / 2).dp))
.background(ink.copy(alpha = 0.12f))
) {
if (f > 0f) Box(Modifier.fillMaxWidth(f).fillMaxHeight().clip(RoundedCornerShape((height / 2).dp)).background(ChessGreen))
}
}
/** "5 min", "4 h", "3 d" until [at]; "now" if already due. */
@Composable
fun formatDueIn(at: Long): String {
val diff = at - System.currentTimeMillis()
val minute = 60_000L
val hour = 60 * minute
val day = 24 * hour
return when {
diff <= 0 -> stringResource(R.string.study_due_now)
diff < hour -> stringResource(R.string.study_minutes, ((diff + minute - 1) / minute).toInt())
diff < day -> stringResource(R.string.study_hours, ((diff + hour - 1) / hour).toInt())
else -> stringResource(R.string.study_days, ((diff + day - 1) / day).toInt())
}
}
@@ -0,0 +1,175 @@
package cc.skysparkle.matewave.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import cc.skysparkle.matewave.R
import cc.skysparkle.matewave.engine.Board
import cc.skysparkle.matewave.engine.PieceColor
import cc.skysparkle.matewave.openings.OpeningBook
import cc.skysparkle.matewave.ui.components.ButtonIcon
import cc.skysparkle.matewave.ui.components.ChessBoardView
import cc.skysparkle.matewave.ui.components.GlassButton
import cc.skysparkle.matewave.ui.components.GlassCard
import cc.skysparkle.matewave.ui.components.GlassOutlinedButton
import cc.skysparkle.matewave.ui.components.GlassScaffold
import cc.skysparkle.matewave.ui.components.MovesBar
import cc.skysparkle.matewave.ui.theme.ChessGreen
import cc.skysparkle.matewave.ui.theme.ink
import cc.skysparkle.matewave.viewmodel.OpeningStudyViewModel
import cc.skysparkle.matewave.viewmodel.SessionMode
import cc.skysparkle.matewave.viewmodel.TrainerPhase
@Composable
fun OpeningTrainerScreen(viewModel: OpeningStudyViewModel, onBack: () -> Unit) {
val state by viewModel.state.collectAsState()
DisposableEffect(viewModel) { onDispose { viewModel.leave() } }
val course = state.course ?: return
val startBoard = remember { Board.startPosition() }
val sans = state.sans
val opening = remember(sans) { OpeningBook.openingName(sans) }
GlassScaffold(title = course.name, onBack = onBack) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
stringResource(
when (state.mode) {
SessionMode.LEARN -> R.string.study_mode_learn
SessionMode.REVIEW -> R.string.study_mode_review
SessionMode.PRACTICE -> R.string.study_mode_practice
}
),
color = ChessGreen,
style = MaterialTheme.typography.labelLarge
)
Spacer(Modifier.weight(1f))
val asked = state.asked.size
if (asked > 0 && state.phase != TrainerPhase.COMPLETE) {
Text(
stringResource(R.string.study_progress, minOf(state.answered + 1, asked), asked),
color = ink.copy(alpha = 0.7f),
style = MaterialTheme.typography.bodySmall
)
}
}
ProgressBar(state.answered.toFloat() / state.asked.size.coerceAtLeast(1))
MovesBar(sans = sans, startBoard = startBoard, currentIndex = sans.size, opening = opening)
ChessBoardView(
board = state.board,
selectedSquare = state.selectedSquare,
legalTargets = state.legalTargets,
kingInCheckSquare = null,
flipped = course.side == PieceColor.BLACK,
onSquareClick = { viewModel.onSquareClick(it) },
lastMove = state.lastMove,
hintSquare = state.hintSquare,
interactionEnabled = state.phase == TrainerPhase.YOUR_MOVE || state.phase == TrainerPhase.WRONG
)
when (state.phase) {
TrainerPhase.YOUR_MOVE -> Text(
stringResource(if (state.mode == SessionMode.LEARN) R.string.study_learn_prompt else R.string.study_recall_prompt),
color = ink,
style = MaterialTheme.typography.titleMedium
)
TrainerPhase.AUTO_MOVE -> Text(
stringResource(R.string.study_opponent),
color = ink.copy(alpha = 0.6f),
style = MaterialTheme.typography.titleMedium
)
TrainerPhase.WRONG -> Text(
stringResource(R.string.study_wrong),
color = Color(0xFFFF8A80),
style = MaterialTheme.typography.titleMedium
)
TrainerPhase.COMPLETE -> {
val result = state.result
GlassCard(modifier = Modifier.fillMaxWidth()) {
Text(
stringResource(if (state.mode == SessionMode.LEARN) R.string.study_result_learned else R.string.study_result_review),
color = ink,
style = MaterialTheme.typography.titleLarge
)
Spacer(Modifier.height(6.dp))
Stars(result?.stars ?: 0, size = 22.dp)
Spacer(Modifier.height(6.dp))
val change = state.starChange
val stars = result?.stars ?: 0
val (starText, starColor) = when {
change != null && change.after > change.before ->
stringResource(R.string.study_star_gained) to StarGold
change != null && change.after < change.before ->
stringResource(R.string.study_star_lost) to Color(0xFFFF8A80)
stars >= 3 -> stringResource(R.string.study_all_stars) to StarGold
state.mode == SessionMode.PRACTICE -> stringResource(R.string.study_practice_no_stars) to ink.copy(alpha = 0.65f)
stars > 0 -> stringResource(R.string.study_reviews_to_next, result?.reviewsToNext ?: 0) to ink.copy(alpha = 0.8f)
else -> "" to ink
}
if (starText.isNotEmpty()) {
Text(starText, color = starColor, style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(4.dp))
}
if (state.asked.isNotEmpty()) {
Text(
stringResource(R.string.study_result_first_try, state.firstTry, state.asked.size),
color = ink.copy(alpha = 0.8f),
style = MaterialTheme.typography.bodyMedium
)
}
result?.nextDueAt?.let {
Text(
stringResource(R.string.study_next_review, formatDueIn(it)),
color = ink.copy(alpha = 0.65f),
style = MaterialTheme.typography.bodySmall
)
}
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
GlassOutlinedButton(onClick = { viewModel.practiceAgain() }, modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.study_again), color = ink, maxLines = 1)
}
GlassButton(onClick = { if (!viewModel.continueStudy()) onBack() }, modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.study_next), color = ink, maxLines = 1)
}
}
}
}
if (state.phase != TrainerPhase.COMPLETE) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (state.mode != SessionMode.LEARN) {
GlassOutlinedButton(
onClick = { viewModel.showHint() },
enabled = state.phase == TrainerPhase.YOUR_MOVE,
modifier = Modifier.weight(1f)
) {
ButtonIcon(R.drawable.ic_hint)
Text(stringResource(R.string.puzzle_hint), color = ink, maxLines = 1)
}
}
// A review is not restarted: it would only replay the moves still due.
if (state.mode != SessionMode.REVIEW) {
GlassOutlinedButton(onClick = { viewModel.restart() }, modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.study_restart), color = ink, maxLines = 1)
}
}
}
}
}
}
@@ -0,0 +1,294 @@
package cc.skysparkle.matewave.ui.screens
import cc.skysparkle.matewave.ui.theme.ink
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import cc.skysparkle.matewave.R
import cc.skysparkle.matewave.settings.FeedbackSettings
import cc.skysparkle.matewave.network.AppPermissions
import cc.skysparkle.matewave.network.DisconnectReason
import cc.skysparkle.matewave.network.NetworkLog
import cc.skysparkle.matewave.network.NetworkRequirements
import cc.skysparkle.matewave.ui.components.GlassCard
import cc.skysparkle.matewave.ui.components.GlassOutlinedButton
import cc.skysparkle.matewave.ui.components.GlassScaffold
import cc.skysparkle.matewave.ui.components.rememberPermissionRequester
@Composable
fun PermissionsScreen(onBack: () -> Unit) {
val context = LocalContext.current
var tick by remember { mutableStateOf(0) }
val status = remember(tick) { NetworkRequirements.check(context) }
LaunchedEffect(Unit) {
while (true) { kotlinx.coroutines.delay(2_000); tick++ }
}
val requestNearby = rememberPermissionRequester(
permissions = AppPermissions.nearby,
rationaleTitle = stringResource(R.string.mesh_permission_title),
rationaleText = stringResource(R.string.mesh_permission_text),
onResult = { tick++ }
)
GlassScaffold(title = stringResource(R.string.permissions_card_title), onBack = onBack) {
PermissionItem(
iconRes = R.drawable.ic_communication,
granted = status.permissionsGranted,
title = stringResource(R.string.perm_nearby_title),
subtitle = stringResource(R.string.perm_nearby_text),
onGrant = { requestNearby() }
)
val presenceSettings = remember { cc.skysparkle.matewave.presence.PresenceSettings(context) }
var announce by remember { mutableStateOf(presenceSettings.announceEnabled) }
GlassCard(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
cc.skysparkle.matewave.ui.components.ButtonIcon(R.drawable.ic_online, size = 24.dp)
Column(modifier = Modifier.weight(1f)) {
Text(
stringResource(R.string.perm_announce_title),
color = ink,
style = MaterialTheme.typography.titleMedium
)
Text(
stringResource(R.string.perm_announce_text),
color = ink.copy(alpha = 0.7f),
style = MaterialTheme.typography.bodySmall
)
}
Switch(
checked = announce,
onCheckedChange = {
announce = it
presenceSettings.announceEnabled = it
cc.skysparkle.matewave.network.Transports.applyAnnounceSetting()
},
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = Color(0xFF4F8A5B)
)
)
}
}
val feedbackSettings = remember { FeedbackSettings(context) }
var vibration by remember { mutableStateOf(feedbackSettings.vibrationEnabled) }
GlassCard(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
cc.skysparkle.matewave.ui.components.ButtonIcon(R.drawable.ic_vibration, size = 24.dp)
Column(modifier = Modifier.weight(1f)) {
Text(
stringResource(R.string.perm_vibration_title),
color = ink,
style = MaterialTheme.typography.titleMedium
)
Text(
stringResource(R.string.perm_vibration_text),
color = ink.copy(alpha = 0.7f),
style = MaterialTheme.typography.bodySmall
)
}
Switch(
checked = vibration,
onCheckedChange = {
vibration = it
feedbackSettings.vibrationEnabled = it
},
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = Color(0xFF4F8A5B)
)
)
}
}
var background by remember { mutableStateOf(presenceSettings.backgroundEnabled) }
val requestNotifications = rememberPermissionRequester(
permissions = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
listOf(android.Manifest.permission.POST_NOTIFICATIONS)
} else emptyList(),
rationaleTitle = stringResource(R.string.perm_background_title),
rationaleText = stringResource(R.string.perm_background_notification),
onResult = { granted ->
if (granted) {
cc.skysparkle.matewave.presence.PresenceService.start(context)
} else {
background = false
presenceSettings.backgroundEnabled = false
}
}
)
GlassCard(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
cc.skysparkle.matewave.ui.components.ButtonIcon(R.drawable.ic_background_activity, size = 24.dp)
Column(modifier = Modifier.weight(1f)) {
Text(
stringResource(R.string.perm_background_title),
color = ink,
style = MaterialTheme.typography.titleMedium
)
Text(
stringResource(R.string.perm_background_text),
color = ink.copy(alpha = 0.7f),
style = MaterialTheme.typography.bodySmall
)
}
Switch(
checked = background,
onCheckedChange = {
background = it
presenceSettings.backgroundEnabled = it
if (it) {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU &&
!cc.skysparkle.matewave.network.AppPermissions.allGranted(
context, listOf(android.Manifest.permission.POST_NOTIFICATIONS)
)
) {
requestNotifications()
} else {
cc.skysparkle.matewave.presence.PresenceService.start(context)
}
} else {
cc.skysparkle.matewave.presence.PresenceService.stop(context)
}
},
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = Color(0xFF4F8A5B)
)
)
}
if (background) {
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
cc.skysparkle.matewave.ui.components.ButtonIcon(
R.drawable.ic_notification, tint = Color(0xFFFFC46B), size = 16.dp
)
Text(
stringResource(R.string.perm_background_notification),
color = ink.copy(alpha = 0.7f),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f)
)
}
}
if (background && !cc.skysparkle.matewave.network.BatteryOptimization.isIgnoring(context)) {
Spacer(Modifier.height(8.dp))
Text(
stringResource(R.string.perm_battery_needed),
color = Color(0xFFFFC46B),
style = MaterialTheme.typography.bodySmall
)
GlassOutlinedButton(
onClick = { cc.skysparkle.matewave.network.BatteryOptimization.openSettings(context) },
modifier = Modifier.fillMaxWidth()
) {
cc.skysparkle.matewave.ui.components.ButtonIcon(R.drawable.ic_go_settings)
Text(stringResource(R.string.perm_battery_open), color = ink)
}
}
}
val lastReason by NetworkLog.lastReason.collectAsState()
if (lastReason != DisconnectReason.NONE) {
Row(verticalAlignment = Alignment.CenterVertically) {
cc.skysparkle.matewave.ui.components.ButtonIcon(
R.drawable.ic_error, tint = Color(0xFFFFC46B), size = 16.dp
)
Text(
stringResource(R.string.diag_last_reason, stringResource(lastReason.textRes)),
color = Color(0xFFFFC46B),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f)
)
}
}
val metricsTick by cc.skysparkle.matewave.network.NetworkMetrics.updated.collectAsState()
val metricsText = remember(metricsTick) {
cc.skysparkle.matewave.network.NetworkMetrics.asText(context.resources)
}
Text(
metricsText.trim(),
color = ink.copy(alpha = 0.7f),
style = MaterialTheme.typography.bodySmall
)
GlassOutlinedButton(
onClick = {
val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE)
as android.content.ClipboardManager
val report = buildString {
appendLine(context.getString(R.string.diag_report_counters))
append(cc.skysparkle.matewave.network.NetworkMetrics.asText(context.resources))
appendLine()
appendLine(context.getString(R.string.diag_report_events))
append(NetworkLog.asText().ifBlank { context.getString(R.string.diag_log_empty) })
}
clipboard.setPrimaryClip(
android.content.ClipData.newPlainText("network report", report)
)
},
modifier = Modifier.fillMaxWidth()
) {
cc.skysparkle.matewave.ui.components.ButtonIcon(R.drawable.ic_copy)
Text(stringResource(R.string.diag_copy_log), color = ink)
}
}
}
@Composable
private fun PermissionItem(
granted: Boolean,
title: String,
subtitle: String,
onGrant: () -> Unit,
iconRes: Int? = null
) {
GlassCard(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
cc.skysparkle.matewave.ui.components.AppIcon(
resId = if (granted) R.drawable.ic_approve else R.drawable.ic_cancel,
size = 20.dp,
tint = if (granted) Color(0xFF7BE495) else Color(0xFFFF6B6B)
)
if (iconRes != null) {
cc.skysparkle.matewave.ui.components.AppIcon(iconRes, size = 22.dp)
}
Text(title, color = ink, style = MaterialTheme.typography.titleMedium)
}
Text(subtitle, color = ink.copy(alpha = 0.75f), style = MaterialTheme.typography.bodySmall)
if (!granted) {
GlassOutlinedButton(onClick = onGrant, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.perm_grant), color = ink)
}
}
}
}
@@ -0,0 +1,218 @@
package cc.skysparkle.matewave.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import cc.skysparkle.matewave.R
import cc.skysparkle.matewave.engine.ChessEngine
import cc.skysparkle.matewave.engine.Notation
import cc.skysparkle.matewave.engine.PieceColor
import cc.skysparkle.matewave.puzzles.Puzzle
import cc.skysparkle.matewave.puzzles.PuzzleRepository
import cc.skysparkle.matewave.puzzles.themeTitleRes
import cc.skysparkle.matewave.stats.Stats
import cc.skysparkle.matewave.ui.components.ButtonIcon
import cc.skysparkle.matewave.ui.components.ChessBoardView
import cc.skysparkle.matewave.ui.components.GlassButton
import cc.skysparkle.matewave.ui.components.GlassOutlinedButton
import cc.skysparkle.matewave.ui.components.GlassScaffold
import cc.skysparkle.matewave.ui.components.PromotionDialog
import cc.skysparkle.matewave.ui.theme.ChessGreen
import cc.skysparkle.matewave.ui.theme.ink
import cc.skysparkle.matewave.viewmodel.GameViewModel
import kotlinx.coroutines.delay
private val WrongRed = Color(0xFFFF6B6B)
@Composable
fun PuzzleSolveScreen(viewModel: GameViewModel, onBack: () -> Unit, onNextPuzzle: (Puzzle) -> Unit) {
val context = LocalContext.current
val state by viewModel.uiState.collectAsState()
val puzzle = state.puzzle
// Confirmation on the board: the destination of a correct move flashes green; a wrong
// move stays red until it is taken back.
var greenFlash by remember { mutableStateOf<Int?>(null) }
LaunchedEffect(state.puzzleMoveIndex, state.puzzleSolved) {
if ((state.puzzleMoveIndex > 0 || state.puzzleSolved) && !state.puzzleFailedAttempt) {
greenFlash = state.lastMove?.to
delay(450)
greenFlash = null
}
}
val redSquare = if (state.puzzleFailedAttempt && state.puzzleAutoMoving) state.lastMove?.to else null
GlassScaffold(
title = puzzle?.let { stringResource(R.string.puzzle_title_rating, it.rating) } ?: stringResource(R.string.puzzle_title_fallback),
onBack = onBack,
actions = {
if (puzzle != null) {
Text(
stringResource(puzzle.themeTitleRes()).uppercase(),
color = ChessGreen,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
modifier = Modifier
.padding(end = 12.dp)
.clip(RoundedCornerShape(4.dp))
.background(ChessGreen.copy(alpha = 0.15f))
.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
) {
// On tablets the board is capped and centred, so the task and buttons fit without scrolling.
ChessBoardView(
board = state.board,
selectedSquare = state.selectedSquare,
legalTargets = state.legalTargetsFromSelected,
kingInCheckSquare = if (ChessEngine.isInCheck(state.board, state.board.sideToMove)) {
state.board.kingSquare(state.board.sideToMove)
} else null,
flipped = state.myColor == PieceColor.BLACK,
onSquareClick = viewModel::onSquareClicked,
modifier = Modifier.align(Alignment.CenterHorizontally).widthIn(max = 560.dp),
lastMove = state.lastMove,
hintSquare = state.puzzleHintFrom,
interactionEnabled = state.puzzleStarted && !state.puzzleAutoMoving && !state.puzzleSolved && state.pendingPromotion == null,
flashSquare = redSquare ?: greenFlash,
flashColor = if (redSquare != null) WrongRed.copy(alpha = 0.55f) else ChessGreen.copy(alpha = 0.55f)
)
// The task, in the same format as the status in a game: a dot in the side's colour.
val (text, color) = when {
state.puzzleSolved -> "✓ " + stringResource(R.string.puzzle_solved) to ChessGreen
state.puzzleFailedAttempt -> "✗ " + stringResource(R.string.puzzle_wrong_move) to WrongRed
state.puzzleAutoMoving -> stringResource(R.string.opponent_turn) to ink.copy(alpha = 0.7f)
state.myColor == PieceColor.WHITE -> stringResource(R.string.puzzle_find_best_white) to ink
else -> stringResource(R.string.puzzle_find_best_black) to ink
}
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier
.size(10.dp)
.clip(CircleShape)
.background(if (state.myColor == PieceColor.WHITE) Color(0xFFF0F0F0) else Color(0xFF222222))
.border(1.dp, ink.copy(alpha = 0.5f), CircleShape)
)
Spacer(Modifier.width(10.dp))
Text(text, color = color, style = MaterialTheme.typography.titleMedium)
}
// Progress through multi-move puzzles: one dot per move of the solver.
if (puzzle != null) {
val total = (puzzle.solutionUci.size + 1) / 2
if (total > 1) {
val done = if (state.puzzleSolved) total else state.puzzleMoveIndex / 2
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
repeat(total) { i ->
Box(
Modifier
.size(8.dp)
.clip(CircleShape)
.background(if (i < done) ChessGreen else ink.copy(alpha = 0.2f))
)
}
Spacer(Modifier.width(4.dp))
Text(
stringResource(R.string.puzzle_move_of, minOf(done + 1, total), total),
color = ink.copy(alpha = 0.55f),
style = MaterialTheme.typography.labelSmall
)
}
}
}
if (state.puzzleSolutionRevealed && !state.puzzleSolved) {
val expectedUci = puzzle?.solutionUci?.getOrNull(state.puzzleMoveIndex)
val san = expectedUci?.let { uci ->
ChessEngine.legalMoves(state.board).firstOrNull { it.toUci() == uci }?.let { Notation.toSan(state.board, it, withCheck = true) }
}
Text(
text = if (san != null) stringResource(R.string.puzzle_solution_prefix, san) else stringResource(R.string.puzzle_solution_unavailable),
color = ink.copy(alpha = 0.85f),
style = MaterialTheme.typography.bodyMedium
)
}
if (state.puzzleSolved) {
val next = remember(puzzle?.id) {
PuzzleRepository.pickForRating(
context,
cc.skysparkle.matewave.data.ProfileStore.get().elo,
Stats.solvedPuzzles(),
exclude = puzzle?.id
)
}
if (next != null) {
GlassButton(onClick = { onNextPuzzle(next) }, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.puzzle_next), color = ink, style = MaterialTheme.typography.titleMedium)
}
}
Text(
stringResource(R.string.puzzle_solved_count, Stats.solvedPuzzles().size),
color = ink.copy(alpha = 0.55f),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
} else if (state.puzzleStarted) {
// The hint is the action; showing the solution is giving up, so it is a quiet link.
GlassOutlinedButton(
onClick = { viewModel.showPuzzleHint() },
modifier = Modifier.fillMaxWidth(),
enabled = !state.puzzleAutoMoving
) {
ButtonIcon(R.drawable.ic_hint)
Text(stringResource(R.string.puzzle_hint), color = ink)
}
Text(
stringResource(R.string.puzzle_show_solution),
color = ink.copy(alpha = if (state.puzzleAutoMoving) 0.3f else 0.6f),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable(enabled = !state.puzzleAutoMoving) { viewModel.revealPuzzleSolution() }
.padding(vertical = 8.dp)
)
}
}
state.pendingPromotion?.let {
PromotionDialog(
color = state.board.sideToMove,
onChoose = viewModel::choosePromotion,
onDismiss = viewModel::cancelPromotion
)
}
}
@@ -0,0 +1,275 @@
package cc.skysparkle.matewave.ui.screens
import cc.skysparkle.matewave.ui.components.ListDivider
import cc.skysparkle.matewave.ui.components.ListGroup
import cc.skysparkle.matewave.ui.components.ListRow
import cc.skysparkle.matewave.ui.components.SectionCaption
import cc.skysparkle.matewave.ui.theme.ink
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.LaunchedEffect
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
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.background
import androidx.compose.ui.draw.clip
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import cc.skysparkle.matewave.R
import cc.skysparkle.matewave.ui.components.DialogAccent
import cc.skysparkle.matewave.ui.components.DialogAction
import cc.skysparkle.matewave.ui.components.AppDialog
import cc.skysparkle.matewave.audio.RadioState
import cc.skysparkle.matewave.audio.SoundManager
import cc.skysparkle.matewave.ui.components.AppBottomBar
import cc.skysparkle.matewave.ui.components.BottomTab
import cc.skysparkle.matewave.ui.components.GlassScaffold
import cc.skysparkle.matewave.ui.theme.ChessGreen
@Composable
fun SettingsScreen(
onHomeClick: () -> Unit,
onFriendsClick: () -> Unit,
onAccountClick: () -> Unit,
onOpenPermissions: () -> Unit,
onOpenAppearance: () -> Unit,
onOpenLicenses: () -> Unit,
onOpenShare: () -> Unit
) {
var musicOn by remember { mutableStateOf(SoundManager.musicEnabled) }
var sfxOn by remember { mutableStateOf(SoundManager.sfxEnabled) }
val context = LocalContext.current
val myProfile = remember { cc.skysparkle.matewave.data.ProfileStore.get() }
val versionName = remember {
runCatching {
val pm = context.packageManager
val info = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
pm.getPackageInfo(context.packageName, android.content.pm.PackageManager.PackageInfoFlags.of(0))
} else {
@Suppress("DEPRECATION")
pm.getPackageInfo(context.packageName, 0)
}
info.versionName
}.getOrNull() ?: "—"
}
var showAbout by remember { mutableStateOf(false) }
var statusTick by remember { mutableStateOf(0) }
val status = remember(statusTick) {
cc.skysparkle.matewave.network.NetworkRequirements.check(context)
}
LaunchedEffect(Unit) {
while (true) { kotlinx.coroutines.delay(3_000); statusTick++ }
}
val feedback = remember { cc.skysparkle.matewave.settings.FeedbackSettings(context) }
var vibrationOn by remember { mutableStateOf(feedback.vibrationEnabled) }
val radioState by SoundManager.radioState.collectAsState()
GlassScaffold(
title = stringResource(R.string.nav_settings),
onBack = null,
bottomBar = {
AppBottomBar(
current = BottomTab.SETTINGS,
onHomeClick = onHomeClick,
onFriendsClick = onFriendsClick,
onSettingsClick = { },
onAccountClick = onAccountClick,
accountAvatarBase64 = myProfile.avatarBase64
)
}
) {
SectionCaption(stringResource(R.string.settings_section_sound))
ListGroup {
ListRow(
title = stringResource(R.string.settings_radio),
subtitle = stringResource(R.string.settings_radio_subtitle),
icon = R.drawable.ic_music,
// Radio status sits in the row instead of a stray line under it.
value = if (!musicOn) null else when (radioState) {
RadioState.CONNECTING -> stringResource(R.string.settings_radio_connecting)
RadioState.ERROR -> stringResource(R.string.settings_radio_error)
else -> null
},
valueColor = if (radioState == RadioState.ERROR) Color(0xFFFF6B6B) else ink.copy(alpha = 0.55f),
trailing = { SettingSwitch(musicOn) { musicOn = it; SoundManager.setMusicEnabled(it) } }
)
ListDivider()
ListRow(
title = stringResource(R.string.settings_sfx),
subtitle = stringResource(R.string.settings_sfx_subtitle),
icon = R.drawable.ic_sound_effect,
trailing = { SettingSwitch(sfxOn) { sfxOn = it; SoundManager.setSfxEnabled(it) } }
)
ListDivider()
ListRow(
title = stringResource(R.string.perm_vibration_title),
icon = R.drawable.ic_vibration,
trailing = { SettingSwitch(vibrationOn) { vibrationOn = it; feedback.vibrationEnabled = it } }
)
}
SectionCaption(stringResource(R.string.settings_section_game))
ListGroup {
ListRow(
title = stringResource(R.string.appearance_title),
subtitle = stringResource(R.string.appearance_subtitle),
icon = R.drawable.ic_board_view,
chevron = true,
onClick = onOpenAppearance
)
}
SectionCaption(stringResource(R.string.settings_section_system))
ListGroup {
ListRow(
title = stringResource(R.string.permissions_card_title),
icon = if (status.onlineBlocked) R.drawable.ic_permissions else R.drawable.ic_check_permission,
iconTint = if (status.onlineBlocked) Color(0xFFFF6B6B) else ChessGreen,
value = stringResource(if (status.onlineBlocked) R.string.permissions_card_blocked_short else R.string.permissions_card_ok_short),
valueColor = if (status.onlineBlocked) Color(0xFFFF6B6B) else ink.copy(alpha = 0.55f),
chevron = true,
onClick = onOpenPermissions
)
ListDivider()
ListRow(
title = stringResource(R.string.privacy_policy),
icon = R.drawable.ic_policy,
chevron = true,
onClick = { cc.skysparkle.matewave.settings.AppLinks.open(context, cc.skysparkle.matewave.settings.AppLinks.PRIVACY_POLICY) }
)
ListDivider()
ListRow(
title = stringResource(R.string.about_section),
icon = R.drawable.ic_info,
value = versionName,
chevron = true,
onClick = { showAbout = true }
)
}
}
if (showAbout) {
AppDialog(
onDismissRequest = { showAbout = false },
title = stringResource(R.string.about_section),
actions = { DialogAction(stringResource(R.string.chat_close), onClick = { showAbout = false }) }
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Image(
painter = painterResource(R.drawable.ic_launcher_card),
contentDescription = null,
modifier = Modifier.size(72.dp).clip(RoundedCornerShape(16.dp))
)
Text(
stringResource(R.string.app_title),
color = ink,
style = MaterialTheme.typography.headlineSmall
)
Text(
stringResource(R.string.settings_version, versionName),
color = DialogAccent,
style = MaterialTheme.typography.bodySmall
)
Text(
stringResource(R.string.settings_description),
color = ink.copy(alpha = 0.78f),
style = MaterialTheme.typography.bodyMedium,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
AboutRow(
iconRes = R.drawable.ic_share,
title = stringResource(R.string.share_row_title),
subtitle = stringResource(R.string.share_row_subtitle),
onClick = {
showAbout = false
onOpenShare()
}
)
AboutRow(
iconRes = R.drawable.ic_copyright,
title = stringResource(R.string.licenses_title),
subtitle = stringResource(R.string.licenses_subtitle),
onClick = {
showAbout = false
onOpenLicenses()
}
)
AboutRow(
iconRes = R.drawable.ic_repo,
title = stringResource(R.string.settings_repo_title),
subtitle = cc.skysparkle.matewave.settings.AppLinks.REPOSITORY_LABEL,
onClick = { cc.skysparkle.matewave.settings.AppLinks.open(context, cc.skysparkle.matewave.settings.AppLinks.REPOSITORY) }
)
}
}
}
@Composable
private fun AboutRow(iconRes: Int, title: String, subtitle: String, onClick: () -> Unit) {
val rowShape = RoundedCornerShape(12.dp)
Row(
modifier = Modifier
.fillMaxWidth()
.clip(rowShape)
.background(ink.copy(alpha = 0.07f))
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
cc.skysparkle.matewave.ui.components.AppIcon(iconRes, size = 28.dp)
Spacer(Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Text(title, color = ink, style = MaterialTheme.typography.titleMedium)
Text(subtitle, color = ink.copy(alpha = 0.65f), style = MaterialTheme.typography.bodySmall)
}
cc.skysparkle.matewave.ui.components.AppIcon(
R.drawable.ic_arrow_forward, size = 16.dp, tint = ink.copy(alpha = 0.6f)
)
}
}
@Composable
private fun SettingSwitch(checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
colors = SwitchDefaults.colors(
checkedTrackColor = ChessGreen,
checkedThumbColor = Color.White,
uncheckedTrackColor = ink.copy(alpha = 0.15f),
uncheckedThumbColor = Color.White.copy(alpha = 0.7f)
)
)
}
@@ -0,0 +1,238 @@
package cc.skysparkle.matewave.ui.screens
import cc.skysparkle.matewave.ui.theme.ink
import android.content.Intent
import android.text.format.Formatter
import androidx.compose.foundation.Canvas
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import cc.skysparkle.matewave.R
import cc.skysparkle.matewave.share.ApkShareServer
import cc.skysparkle.matewave.share.ApkSharing
import cc.skysparkle.matewave.share.LocalNetwork
import cc.skysparkle.matewave.ui.components.AppIcon
import cc.skysparkle.matewave.ui.components.ButtonIcon
import cc.skysparkle.matewave.ui.components.DialogAccent
import cc.skysparkle.matewave.ui.components.GlassCard
import cc.skysparkle.matewave.ui.components.GlassOutlinedButton
import cc.skysparkle.matewave.ui.components.GlassScaffold
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
import kotlinx.coroutines.launch
@Composable
fun ShareApkScreen(onBack: () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val view = LocalView.current
val appName = stringResource(R.string.app_title)
val apk = remember { ApkSharing.apkFile(context) }
var url by remember { mutableStateOf<String?>(null) }
var preparing by remember { mutableStateOf(false) }
var failed by remember { mutableStateOf(false) }
DisposableEffect(Unit) {
val server = ApkShareServer(apk, appName)
val ip = LocalNetwork.wifiIpv4()
if (ip != null && apk.exists()) {
val port = runCatching { server.start() }.getOrNull()
if (port != null) url = "http://$ip:$port/"
}
view.keepScreenOn = true
onDispose {
server.stop()
view.keepScreenOn = false
}
}
GlassScaffold(title = stringResource(R.string.share_row_title), onBack = onBack) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
AppIcon(R.drawable.ic_wifi, size = 44.dp, tint = DialogAccent)
Text(
stringResource(R.string.share_wifi_title),
color = ink,
style = MaterialTheme.typography.headlineSmall
)
Text(
stringResource(R.string.share_wifi_subtitle),
color = ink.copy(alpha = 0.75f),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
val address = url
if (address != null) {
Box(
modifier = Modifier
.size(240.dp)
.clip(RoundedCornerShape(20.dp))
.background(Color.White)
.padding(18.dp)
) {
QrCode(address, Modifier.size(204.dp))
}
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(ink.copy(alpha = 0.10f))
.padding(vertical = 14.dp, horizontal = 12.dp),
contentAlignment = Alignment.Center
) {
Text(address, color = ink.copy(alpha = 0.85f), style = MaterialTheme.typography.bodyMedium)
}
} else {
Text(
stringResource(R.string.share_no_wifi),
color = Color(0xFFFFC46B),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
}
Text(
stringResource(R.string.share_apk_size, Formatter.formatShortFileSize(context, apk.length())),
color = ink.copy(alpha = 0.7f),
style = MaterialTheme.typography.bodyMedium
)
if (ApkSharing.isSplitInstall(context)) {
Text(
stringResource(R.string.share_split_warning),
color = Color(0xFFFFC46B),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center
)
}
}
GlassCard(modifier = Modifier.fillMaxWidth()) {
Text(
stringResource(R.string.share_instructions_title),
color = ink,
style = MaterialTheme.typography.titleMedium
)
Column(modifier = Modifier.padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
listOf(
R.string.share_step_1, R.string.share_step_2, R.string.share_step_3, R.string.share_step_4
).forEachIndexed { index, step ->
Text(
"${index + 1}. ${stringResource(step)}",
color = ink.copy(alpha = 0.85f),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
HorizontalDivider(color = ink.copy(alpha = 0.15f))
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
stringResource(R.string.share_other_title),
color = ink,
style = MaterialTheme.typography.titleMedium
)
Text(
stringResource(R.string.share_other_hint),
color = ink.copy(alpha = 0.7f),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
}
GlassOutlinedButton(
onClick = {
if (!preparing) {
preparing = true
failed = false
scope.launch {
val uri = runCatching { ApkSharing.copyForSharing(context, appName) }.getOrNull()
preparing = false
if (uri == null) {
failed = true
} else {
val send = Intent(Intent.ACTION_SEND).apply {
type = "application/vnd.android.package-archive"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(send, null))
}
}
}
},
modifier = Modifier.fillMaxWidth()
) {
ButtonIcon(R.drawable.ic_share)
Text(
stringResource(if (preparing) R.string.share_preparing else R.string.share_button),
color = ink
)
}
if (failed) {
Text(
stringResource(R.string.share_failed),
color = Color(0xFFFF6B6B),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable
private fun QrCode(text: String, modifier: Modifier = Modifier) {
val matrix = remember(text) {
QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, 0, 0, mapOf(EncodeHintType.MARGIN to 0))
}
Canvas(modifier = modifier) {
val modules = matrix.width
val cell = size.width / modules
for (y in 0 until modules) {
for (x in 0 until modules) {
if (matrix.get(x, y)) {
drawRect(Color.Black, Offset(x * cell, y * cell), Size(cell + 0.6f, cell + 0.6f))
}
}
}
}
}
@@ -0,0 +1,164 @@
package cc.skysparkle.matewave.ui.screens
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import cc.skysparkle.matewave.ui.components.AppIcon
import cc.skysparkle.matewave.ui.components.SectionCaption
import cc.skysparkle.matewave.ui.components.TimeControlGrid
import cc.skysparkle.matewave.ui.components.displayName
import cc.skysparkle.matewave.ui.theme.ink
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
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.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.clip
import cc.skysparkle.matewave.ui.components.SetupSegmented
import cc.skysparkle.matewave.ui.theme.ChessGreen
import cc.skysparkle.matewave.ui.theme.PanelBorder
import cc.skysparkle.matewave.R
import cc.skysparkle.matewave.ai.AiDifficulty
import cc.skysparkle.matewave.clock.TimeControl
import cc.skysparkle.matewave.engine.PieceColor
import cc.skysparkle.matewave.ui.components.GlassButton
import cc.skysparkle.matewave.ui.components.GlassScaffold
@Composable
private fun difficultyLabel(difficulty: AiDifficulty): String = stringResource(difficultyName(difficulty))
@Composable
fun VsAiSetupScreen(
onBack: () -> Unit,
onStart: (AiDifficulty, PieceColor?, TimeControl) -> Unit
) {
val context = androidx.compose.ui.platform.LocalContext.current
val last = remember { cc.skysparkle.matewave.settings.LastGameSetup(context) }
var difficulty by remember { mutableStateOf(last.aiDifficulty) }
var color by remember { mutableStateOf(last.aiColor) }
var timeControl by remember { mutableStateOf(last.aiTime) }
val levels = AiDifficulty.entries
GlassScaffold(
title = stringResource(R.string.vsai_title),
onBack = onBack,
// The start button stays at the bottom, in reach of the thumb, with a summary of the choice.
bottomBar = {
Box(modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp)) {
GlassButton(
onClick = {
last.aiDifficulty = difficulty
last.aiColor = color
last.aiTime = timeControl
onStart(difficulty, color, timeControl)
},
modifier = Modifier.fillMaxWidth()
) {
Text(
stringResource(R.string.vsai_play_summary, difficultyLabel(difficulty), timeControl.displayName()),
color = ink,
style = MaterialTheme.typography.titleMedium,
maxLines = 1
)
}
}
}
) {
// Who you are playing: changes with the level.
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier.size(52.dp).clip(CircleShape).background(ink.copy(alpha = 0.10f)),
contentAlignment = Alignment.Center
) {
AppIcon(R.drawable.ic_ai_app, size = 28.dp)
}
Spacer(Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
stringResource(R.string.game_ai_name, difficultyLabel(difficulty)) + " ≈" +
cc.skysparkle.matewave.data.ProfileStore.aiNominalElo(difficulty),
color = ink,
style = MaterialTheme.typography.titleMedium
)
Text(stringResource(aiDescription(difficulty)), color = ink.copy(alpha = 0.65f), style = MaterialTheme.typography.bodySmall)
}
}
SectionCaption(stringResource(R.string.difficulty_label))
SetupSegmented(
options = levels.map { difficultyLabel(it) },
selectedIndex = levels.indexOf(difficulty)
) { difficulty = levels[it] }
Row(modifier = Modifier.fillMaxWidth()) {
levels.forEach {
Text(
"≈" + cc.skysparkle.matewave.data.ProfileStore.aiNominalElo(it),
color = ink.copy(alpha = if (it == difficulty) 0.8f else 0.45f),
style = MaterialTheme.typography.labelSmall,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
modifier = Modifier.weight(1f)
)
}
}
SectionCaption(stringResource(R.string.your_color))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ColorChip(R.drawable.ic_white_colour, stringResource(R.string.color_white), color == PieceColor.WHITE) { color = PieceColor.WHITE }
ColorChip(R.drawable.ic_black_colour, stringResource(R.string.color_black), color == PieceColor.BLACK) { color = PieceColor.BLACK }
ColorChip(R.drawable.ic_random_colour, stringResource(R.string.color_random), color == null) { color = null }
}
SectionCaption(stringResource(R.string.time_control_label))
TimeControlGrid(selected = timeControl, onSelect = { timeControl = it })
}
}
private fun aiDescription(d: AiDifficulty): Int = when (d) {
AiDifficulty.EASY -> R.string.ai_desc_easy
AiDifficulty.MEDIUM -> R.string.ai_desc_medium
AiDifficulty.HARD -> R.string.ai_desc_hard
AiDifficulty.EXPERT -> R.string.ai_desc_expert
}
@Composable
private fun ColorChip(iconRes: Int, text: String, selected: Boolean, onClick: () -> Unit) {
val shape = RoundedCornerShape(50)
Row(
modifier = Modifier
.clip(shape)
.background(if (selected) ChessGreen.copy(alpha = 0.35f) else ink.copy(alpha = 0.10f))
.border(1.dp, if (selected) ChessGreen else PanelBorder, shape)
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Image(
painter = painterResource(iconRes),
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Text(text, color = ink, style = MaterialTheme.typography.bodySmall)
}
}
@@ -0,0 +1,39 @@
package cc.skysparkle.matewave.ui.theme
import androidx.compose.foundation.background
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
val PanelFill = Color(0xFF14171C).copy(alpha = 0.86f)
val PanelBorder = Color.White.copy(alpha = 0.10f)
val UiCornerRadius = 8.dp
val GlassShape: Shape = RoundedCornerShape(UiCornerRadius)
val TabletMaxContentWidth = 600.dp
fun Modifier.glass(shape: Shape = GlassShape): Modifier = this
.clip(shape)
.background(PanelFill)
val GlassBorder = PanelBorder
val PhotoScrim = Brush.verticalGradient(
0.00f to Color.Black.copy(alpha = 0.84f),
0.10f to Color.Black.copy(alpha = 0.72f),
0.22f to Color.Black.copy(alpha = 0.58f),
0.38f to Color.Black.copy(alpha = 0.48f),
0.55f to Color.Black.copy(alpha = 0.47f),
0.72f to Color.Black.copy(alpha = 0.55f),
0.88f to Color.Black.copy(alpha = 0.68f),
1.00f to Color.Black.copy(alpha = 0.80f)
)
val DialogScrimColor = Color.Black.copy(alpha = 0.62f)
val FloatingBarFill = Color.Black.copy(alpha = 0.25f)
@@ -0,0 +1,13 @@
package cc.skysparkle.matewave.ui.theme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
val LocalInk = compositionLocalOf { Color.White }
val ink: Color
@Composable
@ReadOnlyComposable
get() = LocalInk.current
@@ -0,0 +1,41 @@
package cc.skysparkle.matewave.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.Color
val ChessGreen = Color(0xFF3E7C4A)
val ChessGreenDark = Color(0xFF1B4D2E)
val HighlightMove = Color(0x8858A6FF)
val HighlightCheck = Color(0x88E53935)
val HighlightLastMove = Color(0x664FC3F7)
val HighlightHint = Color(0x88FFC107)
private val DarkScheme = darkColorScheme(
primary = ChessGreen,
secondary = ChessGreenDark,
background = Color(0xFF121212),
surface = Color(0xFF1E1E1E)
)
/** The app has a single dark theme; system bars are set up in MainActivity. */
@Composable
fun MatewaveTheme(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = DarkScheme) {
CompositionLocalProvider(LocalInk provides Color.White, content = content)
}
}
@Composable
fun whiteOutlinedTextFieldColors() = OutlinedTextFieldDefaults.colors(
focusedTextColor = ink,
unfocusedTextColor = ink,
focusedBorderColor = ink.copy(alpha = 0.6f),
unfocusedBorderColor = ink.copy(alpha = 0.3f),
focusedLabelColor = ink.copy(alpha = 0.8f),
unfocusedLabelColor = ink.copy(alpha = 0.6f),
cursorColor = ink
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,305 @@
package cc.skysparkle.matewave.viewmodel
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import cc.skysparkle.matewave.audio.SoundManager
import cc.skysparkle.matewave.engine.Board
import cc.skysparkle.matewave.engine.ChessEngine
import cc.skysparkle.matewave.engine.Move
import cc.skysparkle.matewave.engine.PieceType
import cc.skysparkle.matewave.openings.CourseInfo
import cc.skysparkle.matewave.openings.CourseStatus
import cc.skysparkle.matewave.openings.OpeningBook
import cc.skysparkle.matewave.openings.OpeningCourse
import cc.skysparkle.matewave.openings.OpeningCourses
import cc.skysparkle.matewave.openings.OpeningSrs
import cc.skysparkle.matewave.settings.Haptics
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** LEARN: new moves with the piece highlighted. REVIEW: only due moves. PRACTICE: every move. */
enum class SessionMode { LEARN, REVIEW, PRACTICE }
enum class TrainerPhase { YOUR_MOVE, AUTO_MOVE, WRONG, COMPLETE }
data class TrainerState(
val course: OpeningCourse? = null,
val mode: SessionMode = SessionMode.LEARN,
val board: Board = Board.startPosition(),
val ply: Int = 0,
val phase: TrainerPhase = TrainerPhase.AUTO_MOVE,
val selectedSquare: Int? = null,
val legalTargets: List<Move> = emptyList(),
val lastMove: Move? = null,
val hintSquare: Int? = null,
/** Plies the learner has to answer in this session. */
val asked: List<Int> = emptyList(),
val answered: Int = 0,
val firstTry: Int = 0,
val wrongOnThisMove: Boolean = false,
val result: CourseInfo? = null,
/** Set after a review of due moves: stars gained, kept or lost. */
val starChange: cc.skysparkle.matewave.openings.StarChange? = null
) {
val sans: List<String> get() = course?.sans?.take(ply) ?: emptyList()
}
data class StudyOverview(
val courses: List<CourseInfo> = emptyList(),
val dueCards: Int = 0,
val dueCourses: Int = 0,
val nextDueAt: Long? = null,
val learning: CourseInfo? = null
) {
val learnedCount: Int get() = courses.count { it.total > 0 && it.learned == it.total }
val masteredCount: Int get() = courses.count { it.mastered }
}
class OpeningStudyViewModel : ViewModel() {
private val _overview = MutableStateFlow(StudyOverview())
val overview: StateFlow<StudyOverview> = _overview
private val _state = MutableStateFlow(TrainerState())
val state: StateFlow<TrainerState> = _state
private var srs: OpeningSrs? = null
private var courses: List<OpeningCourse> = emptyList()
private var autoJob: Job? = null
fun init(context: Context) {
if (srs != null) { refresh(); return }
srs = OpeningSrs(context)
viewModelScope.launch {
courses = withContext(Dispatchers.IO) {
runCatching { OpeningBook.load(context) }
runCatching { OpeningCourses.load(context) }.getOrDefault(emptyList())
}
refresh()
}
}
/** Recomputes statuses; due times change with the clock, so call it whenever the list is shown. */
fun refresh() {
val s = srs ?: return
val now = System.currentTimeMillis()
val inProgress = courses.firstOrNull { s.info(it, now, false).status == CourseStatus.LEARNING }
val infos = courses.map { s.info(it, now, anotherInProgress = inProgress != null && inProgress != it) }
_overview.value = StudyOverview(
courses = infos,
dueCards = infos.sumOf { it.dueCards },
dueCourses = infos.count { it.status == CourseStatus.DUE },
nextDueAt = infos.filter { it.status == CourseStatus.WAITING }.mapNotNull { it.nextDueAt }.minOrNull(),
learning = infos.firstOrNull { it.status == CourseStatus.LEARNING }
)
}
fun infoOf(course: OpeningCourse): CourseInfo? = _overview.value.courses.firstOrNull { it.course == course }
/** Opens a course in the mode its state calls for. Returns false for a locked course. */
fun open(course: OpeningCourse): Boolean {
val info = infoOf(course) ?: return false
val mode = when (info.status) {
CourseStatus.LOCKED -> return false
CourseStatus.NEW, CourseStatus.LEARNING -> SessionMode.LEARN
CourseStatus.DUE -> SessionMode.REVIEW
CourseStatus.WAITING -> SessionMode.PRACTICE
}
start(course, mode)
return true
}
fun reviewNext(): Boolean {
val next = _overview.value.courses.firstOrNull { it.status == CourseStatus.DUE } ?: return false
start(next.course, SessionMode.REVIEW)
return true
}
fun practiceAgain() {
_state.value.course?.let { start(it, SessionMode.PRACTICE) }
}
/** Next thing to do after a session: a due review, then continuing or starting a course. */
fun continueStudy(): Boolean {
refresh()
val o = _overview.value
val current = _state.value.course
val target = o.courses.firstOrNull { it.status == CourseStatus.DUE }
?: o.learning
?: o.courses.firstOrNull { it.status == CourseStatus.NEW && it.course != current }
?: return false
return open(target.course)
}
private fun start(course: OpeningCourse, mode: SessionMode) {
autoJob?.cancel()
val s = srs ?: return
val now = System.currentTimeMillis()
val learnerPlies = course.moves.indices.filter { course.isLearnerMove(it) }
val asked = when (mode) {
SessionMode.LEARN -> learnerPlies.filter { s.level(course, it) == 0 }
SessionMode.REVIEW -> learnerPlies.filter { s.isDue(course, it, now) }
SessionMode.PRACTICE -> learnerPlies
}
// Learning resumes where it stopped: everything before the first new move is set up instantly.
val resumePly = if (mode == SessionMode.LEARN) (asked.firstOrNull() ?: 0) else 0
var board = Board.startPosition()
for (i in 0 until resumePly) board = ChessEngine.applyMove(board, course.moves[i])
_state.value = TrainerState(
course = course,
mode = mode,
board = board,
ply = resumePly,
lastMove = course.moves.getOrNull(resumePly - 1),
asked = asked
)
continueLine()
}
fun restart() {
val s = _state.value
val course = s.course ?: return
start(course, s.mode)
}
fun showHint() {
val s = _state.value
val course = s.course ?: return
if (s.phase != TrainerPhase.YOUR_MOVE && s.phase != TrainerPhase.WRONG) return
// A hint counts like a mistake for scheduling: the move was not recalled.
if (!s.wrongOnThisMove && s.mode != SessionMode.LEARN) {
srs?.fail(course, s.ply, System.currentTimeMillis())
}
_state.value = s.copy(hintSquare = course.moves[s.ply].from, wrongOnThisMove = true)
}
fun onSquareClick(square: Int) {
val s = _state.value
val course = s.course ?: return
if (s.phase != TrainerPhase.YOUR_MOVE && s.phase != TrainerPhase.WRONG) return
if (s.selectedSquare != null) {
val candidates = s.legalTargets.filter { it.to == square }
if (candidates.isNotEmpty()) {
val move = candidates.firstOrNull { it.promotion == null || it.promotion == PieceType.QUEEN } ?: candidates.first()
answer(course, move)
return
}
}
val piece = s.board.squares[square]
if (piece != null && piece.color == s.board.sideToMove) {
SoundManager.playSelect()
Haptics.select()
_state.value = s.copy(selectedSquare = square, legalTargets = ChessEngine.legalMovesFrom(s.board, square))
} else {
_state.value = s.copy(selectedSquare = null, legalTargets = emptyList())
}
}
private fun answer(course: OpeningCourse, move: Move) {
val s = _state.value
val expected = course.moves[s.ply]
val now = System.currentTimeMillis()
if (move.from != expected.from || move.to != expected.to || move.promotion != expected.promotion) {
Haptics.alert()
if (!s.wrongOnThisMove && s.mode != SessionMode.LEARN) srs?.fail(course, s.ply, now)
_state.value = s.copy(
phase = TrainerPhase.WRONG,
wrongOnThisMove = true,
// After a mistake the right piece is shown, so the line can be finished.
hintSquare = expected.from,
selectedSquare = null,
legalTargets = emptyList()
)
return
}
when (s.mode) {
SessionMode.LEARN -> srs?.learn(course, s.ply, now)
else -> if (!s.wrongOnThisMove) srs?.success(course, s.ply, now)
}
_state.value = s.copy(answered = s.answered + 1, firstTry = s.firstTry + if (s.wrongOnThisMove) 0 else 1)
play(expected)
continueLine()
}
private fun play(move: Move) {
val s = _state.value
val capture = s.board.squares[move.to] != null || move.isEnPassant
if (capture) SoundManager.playCapture() else SoundManager.playMove()
if (capture) Haptics.capture() else Haptics.move()
_state.value = s.copy(
board = ChessEngine.applyMove(s.board, move),
ply = s.ply + 1,
lastMove = move,
selectedSquare = null,
legalTargets = emptyList(),
hintSquare = null,
wrongOnThisMove = false
)
}
/** Auto-plays the opponent's moves and the learner's moves that are not asked this session. */
private fun continueLine() {
val s = _state.value
val course = s.course ?: return
when {
s.ply >= course.moves.size -> finish()
course.isLearnerMove(s.ply) && s.ply in s.asked -> {
val hint = if (s.mode == SessionMode.LEARN) course.moves[s.ply].from else null
_state.value = s.copy(phase = TrainerPhase.YOUR_MOVE, hintSquare = hint)
}
else -> {
_state.value = s.copy(phase = TrainerPhase.AUTO_MOVE)
val ply = s.ply
autoJob = viewModelScope.launch {
delay(if (course.isLearnerMove(ply)) KNOWN_MOVE_DELAY_MS else REPLY_DELAY_MS)
val now = _state.value
if (now.course != course || now.ply != ply) return@launch
play(course.moves[ply])
continueLine()
}
}
}
}
/**
* The trainer screen was closed. A review left after a mistake counts as failed, so quitting
* cannot save a star that a finished review would have cost.
*/
fun leave() {
autoJob?.cancel()
val s = _state.value
val course = s.course ?: return
if (s.phase == TrainerPhase.COMPLETE) return
val mistake = s.firstTry < s.answered || s.wrongOnThisMove
if (s.mode == SessionMode.REVIEW && s.asked.isNotEmpty() && mistake) {
val change = srs?.recordReview(course, flawless = false)
refresh()
_state.value = s.copy(phase = TrainerPhase.COMPLETE, hintSquare = null, result = infoOf(course), starChange = change)
} else {
refresh()
}
}
private fun finish() {
val s = _state.value
val course = s.course ?: return
// Only reviews of due moves change stars: they are spaced by the schedule, so stars
// cannot be farmed by repeating a line. Practice sessions leave stars alone.
val change = if (s.mode == SessionMode.REVIEW && s.asked.isNotEmpty()) {
srs?.recordReview(course, flawless = s.firstTry == s.asked.size)
} else null
refresh()
_state.value = s.copy(phase = TrainerPhase.COMPLETE, hintSquare = null, result = infoOf(course), starChange = change)
}
private companion object {
const val REPLY_DELAY_MS = 500L
const val KNOWN_MOVE_DELAY_MS = 300L
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Some files were not shown because too many files have changed in this diff Show More