Compare commits
39 Commits
7acbe54f46
...
edge
| Author | SHA1 | Date | |
|---|---|---|---|
| 63bdf824be | |||
| 96aae7239b | |||
| 341c13a98c | |||
| 89c317f694 | |||
| edcbc0727b | |||
| 09eb101c52 | |||
| adf9547e01 | |||
| 640b423dfa | |||
| 8e3c62d966 | |||
| f695eb6c45 | |||
| 59d955a11d | |||
| a079bd481e | |||
| 4878b74e09 | |||
| 37a828736e | |||
| 4b22ab3756 | |||
| daf483f514 | |||
| a6677d5bf9 | |||
| a6b6c92c33 | |||
| ac2d75c3f9 | |||
| aa055469a8 | |||
| f3d90b189d | |||
| e10e1f1617 | |||
| 80441c3367 | |||
| d3bf4853de | |||
| 21b6ddc29b | |||
| d393cf022e | |||
| 21e235a07f | |||
| c55ee7f47e | |||
| 6cbfdb1e65 | |||
| b84a973559 | |||
| c14342aa51 | |||
| aeda919376 | |||
| 15a4d44c3f | |||
| fbe8b0bab6 | |||
| 94a5c26d6c | |||
| b95fc45548 | |||
| 3edd45d5f4 | |||
| 0051e97bc7 | |||
| a19fdd3582 |
3
.gitattributes
vendored
3
.gitattributes
vendored
@@ -17,3 +17,6 @@ gradlew text eol=lf
|
|||||||
*.png binary
|
*.png binary
|
||||||
*.webp binary
|
*.webp binary
|
||||||
*.ico binary
|
*.ico binary
|
||||||
|
# Bundled type families (res/font). `text=auto` already detects these as binary,
|
||||||
|
# but a font is too easy to corrupt silently to leave to a heuristic.
|
||||||
|
*.ttf binary
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Gate every pull request into `main` on lint + unit tests + a debug build, so a
|
# Gate every pull request into `main` or `edge` on lint + unit tests + a debug
|
||||||
# broken build can't reach the deployable branch. Debug builds are auto-signed,
|
# build, so a broken build can't reach the deployable branch. Debug builds are auto-signed,
|
||||||
# so this gate needs no secrets. The signed *release* APK + Gitea release come
|
# so this gate needs no secrets. The signed *release* APK + Gitea release come
|
||||||
# later (release.yml, M6). See docs/android/PLAN.md §12.
|
# later (release.yml, M6). See docs/android/PLAN.md §12.
|
||||||
#
|
#
|
||||||
@@ -18,9 +18,14 @@
|
|||||||
|
|
||||||
name: PR Checks
|
name: PR Checks
|
||||||
|
|
||||||
|
# `edge` is here because a workstream that lands ten phase PRs onto it before one
|
||||||
|
# cutover PR into `main` otherwise gets NO CI at all until the cutover — which is
|
||||||
|
# exactly what happened to all nine M12 phase PRs, and would have happened again
|
||||||
|
# to engagement Phase 8 (ENGAGEMENT.md §7.1 Q8). A phase should fail on its own
|
||||||
|
# PR, not inside the cutover window with a whole workstream's diff to bisect.
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main, edge]
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: pr-checks-${{ github.ref }}
|
group: pr-checks-${{ github.ref }}
|
||||||
@@ -40,8 +45,15 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# `packages: ''` is load-bearing, not tidying. The action's own default is
|
||||||
|
# `tools` -- a package Google has REMOVED from the SDK repository -- so the
|
||||||
|
# default makes `sdkmanager tools` exit 1 and the step fails before a line
|
||||||
|
# of this repo is compiled. It is redundant here regardless: the next step
|
||||||
|
# installs exactly what the build targets.
|
||||||
- name: Set up Android SDK
|
- name: Set up Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v3
|
||||||
|
with:
|
||||||
|
packages: ''
|
||||||
|
|
||||||
# Install exactly what the build targets so it never depends on AGP's
|
# Install exactly what the build targets so it never depends on AGP's
|
||||||
# build-time auto-download. `yes |` accepts any license prompts; `set
|
# build-time auto-download. `yes |` accepts any license prompts; `set
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -48,10 +48,15 @@ any shard's website — there is no compiled-in API host.
|
|||||||
|
|
||||||
## CI
|
## CI
|
||||||
|
|
||||||
`.gitea/workflows/pr-checks.yml` gates PRs into `main` with `./gradlew lint test assembleDebug` on the
|
`.gitea/workflows/pr-checks.yml` gates PRs into `main` **and `edge`** with
|
||||||
org's self-hosted runner (JDK 17 + Android SDK). Debug builds are auto-signed, so the gate needs no
|
`./gradlew lint test assembleDebug` on the org's self-hosted runner (JDK 17 + Android SDK). Debug
|
||||||
secrets. **This pipeline is verified green end-to-end on the runner** (M0). A signed **release** APK
|
builds are auto-signed, so the gate needs no secrets. **This pipeline is verified green end-to-end on
|
||||||
attached to a Gitea release comes at M6.
|
the runner** (M0). A signed **release** APK attached to a Gitea release comes at M6.
|
||||||
|
|
||||||
|
**`edge` is in the trigger deliberately**: a workstream that lands its phases on a working branch
|
||||||
|
before one cutover PR into `main` otherwise gets no CI at all until the cutover — which is what
|
||||||
|
happened to all nine M12 phase PRs (`docs/website/ENGAGEMENT.md` §7.1 Q8). `sonarqube.yml` is
|
||||||
|
unaffected: it is a push-on-`main` analysis, not a PR gate.
|
||||||
|
|
||||||
The workflow carries a few runner-specific accommodations (each explained in comments in the file),
|
The workflow carries a few runner-specific accommodations (each explained in comments in the file),
|
||||||
because this self-hosted runner differs from a stock GitHub runner:
|
because this self-hosted runner differs from a stock GitHub runner:
|
||||||
|
|||||||
93
app/licenses/EBGaramond-OFL.txt
Normal file
93
app/licenses/EBGaramond-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2017 The EB Garamond Project Authors (https://github.com/octaviopardo/EBGaramond12)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://openfontlicense.org
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
93
app/licenses/IMFellEnglish-OFL.txt
Normal file
93
app/licenses/IMFellEnglish-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright (c) 2010, Igino Marini (mail@iginomarini.com)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
93
app/licenses/Inter-OFL.txt
Normal file
93
app/licenses/Inter-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
93
app/licenses/Merriweather-OFL.txt
Normal file
93
app/licenses/Merriweather-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2020 The Merriweather Project Authors (https://github.com/EbenSorkin/Merriweather4) with Reserved Font Name "Merriweather".
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://openfontlicense.org
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
93
app/licenses/PlayfairDisplay-OFL.txt
Normal file
93
app/licenses/PlayfairDisplay-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2017 The Playfair Display Project Authors (https://github.com/clauseggers/Playfair-Display), with Reserved Font Name "Playfair Display"
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
93
app/licenses/SourceSans3-OFL.txt
Normal file
93
app/licenses/SourceSans3-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2010-2020 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries.
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
|
||||||
|
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
93
app/licenses/WorkSans-OFL.txt
Normal file
93
app/licenses/WorkSans-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2019 The Work Sans Project Authors (https://github.com/weiweihuanghuang/Work-Sans)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -58,9 +58,16 @@ class MainActivity : ComponentActivity() {
|
|||||||
// consumed once by RunicApp which navigates to the stream's screen.
|
// consumed once by RunicApp which navigates to the stream's screen.
|
||||||
private var pendingStream by mutableStateOf<String?>(null)
|
private var pendingStream by mutableStateOf<String?>(null)
|
||||||
|
|
||||||
|
// The tickle's other half: an opaque ref, carried since M7 and read since
|
||||||
|
// ENGAGEMENT.md phase 8, where a `notification:<id>` ref means the engine wrote
|
||||||
|
// an inbox row and the tap should land there. Never rendered — it is a hint that
|
||||||
|
// something exists, and the app pulls the real item over the authenticated API.
|
||||||
|
private var pendingRef by mutableStateOf<String?>(null)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
pendingStream = intent?.getStringExtra(PushNotifier.EXTRA_STREAM)
|
pendingStream = intent?.getStringExtra(PushNotifier.EXTRA_STREAM)
|
||||||
|
pendingRef = intent?.getStringExtra(PushNotifier.EXTRA_REF)
|
||||||
handleSsoCallback(intent)
|
handleSsoCallback(intent)
|
||||||
// Dark-only app (M5): force light system-bar icons over the transparent bars so
|
// Dark-only app (M5): force light system-bar icons over the transparent bars so
|
||||||
// they stay legible on the deep blue-black surfaces regardless of system theme.
|
// they stay legible on the deep blue-black surfaces regardless of system theme.
|
||||||
@@ -96,10 +103,14 @@ class MainActivity : ComponentActivity() {
|
|||||||
ConnectScreen(onConnected = appViewModel::onConnected)
|
ConnectScreen(onConnected = appViewModel::onConnected)
|
||||||
is AppState.Ready ->
|
is AppState.Ready ->
|
||||||
RunicApp(
|
RunicApp(
|
||||||
brand = s.appearance.brand,
|
appearance = s.appearance,
|
||||||
onChangeServer = appViewModel::changeServer,
|
onChangeServer = appViewModel::changeServer,
|
||||||
deepLinkStream = pendingStream,
|
deepLinkStream = pendingStream,
|
||||||
onDeepLinkConsumed = { pendingStream = null },
|
deepLinkRef = pendingRef,
|
||||||
|
onDeepLinkConsumed = {
|
||||||
|
pendingStream = null
|
||||||
|
pendingRef = null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,7 +127,13 @@ class MainActivity : ComponentActivity() {
|
|||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
setIntent(intent)
|
setIntent(intent)
|
||||||
intent.getStringExtra(PushNotifier.EXTRA_STREAM)?.let { pendingStream = it }
|
intent.getStringExtra(PushNotifier.EXTRA_STREAM)?.let {
|
||||||
|
pendingStream = it
|
||||||
|
// Cleared alongside, not conditionally: a tickle with no ref arriving
|
||||||
|
// after one with a ref must not inherit the earlier ref and land on the
|
||||||
|
// inbox instead of its own screen.
|
||||||
|
pendingRef = intent.getStringExtra(PushNotifier.EXTRA_REF)
|
||||||
|
}
|
||||||
handleSsoCallback(intent)
|
handleSsoCallback(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.inbox
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationItemDto
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
private val Context.inboxDataStore: DataStore<Preferences> by preferencesDataStore(name = "inbox")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [InboxCache] over the same plain DataStore the push state uses. Not secret —
|
||||||
|
* tokens stay in the encrypted store — but an inbox body is a person's own
|
||||||
|
* notifications, which is why the snapshot is owner-scoped and cleared on
|
||||||
|
* sign-out rather than left lying about.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class DataStoreInboxCache @Inject constructor(
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
private val json: Json,
|
||||||
|
) : InboxCache {
|
||||||
|
|
||||||
|
private val store = context.inboxDataStore
|
||||||
|
|
||||||
|
override suspend fun read(owner: String): InboxCache.Snapshot? {
|
||||||
|
val raw = store.data.first()[KEY_SNAPSHOT] ?: return null
|
||||||
|
val stored = try {
|
||||||
|
json.decodeFromString(Stored.serializer(), raw)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// A snapshot this build can't parse is a snapshot from an older one;
|
||||||
|
// dropping it silently is right — it will be rewritten on the next pull.
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (stored.owner != owner) return null
|
||||||
|
return InboxCache.Snapshot(items = stored.items, unread = stored.unread, savedAt = stored.savedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun write(owner: String, items: List<NotificationItemDto>, unread: Int) {
|
||||||
|
val payload = Stored(
|
||||||
|
owner = owner,
|
||||||
|
items = items.take(InboxCache.MAX_ITEMS),
|
||||||
|
unread = unread,
|
||||||
|
savedAt = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
store.edit { it[KEY_SNAPSHOT] = json.encodeToString(Stored.serializer(), payload) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clear() {
|
||||||
|
store.edit { it.remove(KEY_SNAPSHOT) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class Stored(
|
||||||
|
val owner: String,
|
||||||
|
val items: List<NotificationItemDto>,
|
||||||
|
val unread: Int,
|
||||||
|
val savedAt: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val KEY_SNAPSHOT = stringPreferencesKey("snapshot")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.inbox
|
||||||
|
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationItemDto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The inbox's offline snapshot (ENGAGEMENT.md phase 8).
|
||||||
|
*
|
||||||
|
* **PLAN.md §7 decided the app ships no Room cache, and that decision stands** —
|
||||||
|
* this is its one named exception, settled with the org lead 2026-08-31. The
|
||||||
|
* inbox is a short, read-only, newest-first list with a server-side cursor and no
|
||||||
|
* joins, so what "works offline" needs is the newest page and the badge, not a
|
||||||
|
* database: one JSON blob in the DataStore the push code already uses. Nothing
|
||||||
|
* here is a source of truth — a successful pull always replaces it, and the
|
||||||
|
* screen says out loud when it is showing this instead.
|
||||||
|
*
|
||||||
|
* **The [owner] key is the security property, not a convenience.** A snapshot is
|
||||||
|
* written under the base URL *and* the account id that produced it and is only
|
||||||
|
* ever handed back to that exact pair, so a cache cannot survive into another
|
||||||
|
* account or another shard — including the sign-out paths that never reach
|
||||||
|
* [clear] at all (a dead refresh token, a server switch). Clearing on logout is
|
||||||
|
* the tidy-up; this is what makes it safe.
|
||||||
|
*
|
||||||
|
* An interface for the same reason [com.runicgateway.app.core.auth.TokenStore] is
|
||||||
|
* one: the storage needs a `Context` and the view models that use it should be
|
||||||
|
* testable without one.
|
||||||
|
*/
|
||||||
|
interface InboxCache {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What was cached for [owner], or null when nothing was — including when the
|
||||||
|
* stored snapshot belongs to a different account or shard, which is the same
|
||||||
|
* answer on purpose.
|
||||||
|
*/
|
||||||
|
suspend fun read(owner: String): Snapshot?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the snapshot with the newest page.
|
||||||
|
*
|
||||||
|
* Only the FIRST page is ever cached, capped at [MAX_ITEMS]: an offline inbox
|
||||||
|
* is there so the last things you were told are still readable on a train, not
|
||||||
|
* so the whole history is. Later pages come from the server or not at all.
|
||||||
|
*/
|
||||||
|
suspend fun write(owner: String, items: List<NotificationItemDto>, unread: Int)
|
||||||
|
|
||||||
|
/** Forget everything. Called on sign-out, alongside the push deregistration. */
|
||||||
|
suspend fun clear()
|
||||||
|
|
||||||
|
/** What the screen renders from while offline, with the time it was captured. */
|
||||||
|
data class Snapshot(
|
||||||
|
val items: List<NotificationItemDto>,
|
||||||
|
val unread: Int,
|
||||||
|
val savedAt: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The server's own default page size — caching more than it sends is pointless. */
|
||||||
|
const val MAX_ITEMS = 30
|
||||||
|
|
||||||
|
/** The (shard, account) a snapshot belongs to. */
|
||||||
|
fun ownerKey(baseUrl: String?, userId: Long): String = "${baseUrl.orEmpty()}|$userId"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.push
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationItemDto
|
||||||
|
import com.runicgateway.app.data.repository.NotificationsRepository
|
||||||
|
import com.runicgateway.app.ui.navigation.Routes
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The inbox id a tickle's `notification:<id>` ref names, or null.
|
||||||
|
*
|
||||||
|
* Prefix-exact, the same rule [Routes.forTickle] reads the ref by, and digits
|
||||||
|
* only. The relay is untrusted, so a ref is a hint, and anything that is not
|
||||||
|
* exactly a positive id is treated as no hint at all.
|
||||||
|
*/
|
||||||
|
internal fun inboxIdFromRef(ref: String?): Long? {
|
||||||
|
if (ref == null || !ref.startsWith(Routes.INBOX_REF_PREFIX)) return null
|
||||||
|
val digits = ref.substring(Routes.INBOX_REF_PREFIX.length)
|
||||||
|
if (digits.isEmpty() || digits.length > MAX_ID_DIGITS || !digits.all { it in '0'..'9' }) return null
|
||||||
|
return digits.toLongOrNull()?.takeIf { it > 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The item from [page] that [tickle] is about, or null.
|
||||||
|
*
|
||||||
|
* The item must be the one the ref names **and** come from the trigger the tickle
|
||||||
|
* names. Only the user's own inbox can be read, so a forged pair could only put
|
||||||
|
* one of their own items on their own phone. A tickle that disagrees with the row
|
||||||
|
* it points at is still not one to title from.
|
||||||
|
*/
|
||||||
|
internal fun itemForTickle(tickle: PushTickle, page: List<NotificationItemDto>): NotificationItemDto? {
|
||||||
|
val id = inboxIdFromRef(tickle.ref) ?: return null
|
||||||
|
return page.firstOrNull { it.id == id && it.triggerId == tickle.stream }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulls the inbox row a tickle points at, so the notification can say what
|
||||||
|
* happened (`docs/modules/rust/PLAN.md` D70).
|
||||||
|
*
|
||||||
|
* This is the wake-and-pull contract core's `pushChannel.js` describes: the relay
|
||||||
|
* carries `{ stream, ref }` and nothing else, and the content comes over the
|
||||||
|
* authenticated, ownership-checked inbox API. Core has no single-item read, so
|
||||||
|
* this reads the first page. The row was written moments ago, so it is on that
|
||||||
|
* page, and if it is not the caller falls back to the per-stream title.
|
||||||
|
*
|
||||||
|
* **Every failure is null**: no ref, a signed-out app, a dead network, a timeout,
|
||||||
|
* an item not on the page. A GET of the inbox marks nothing read, so the badge
|
||||||
|
* is left alone.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class PushContentResolver @Inject constructor(
|
||||||
|
private val notifications: NotificationsRepository,
|
||||||
|
) {
|
||||||
|
suspend fun itemFor(tickle: PushTickle): NotificationItemDto? {
|
||||||
|
if (inboxIdFromRef(tickle.ref) == null) return null
|
||||||
|
val page = withTimeoutOrNull(PULL_TIMEOUT_MS) {
|
||||||
|
(notifications.inbox() as? ApiResult.Ok)?.data?.items
|
||||||
|
} ?: return null
|
||||||
|
return itemForTickle(tickle, page)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/**
|
||||||
|
* Long enough for a phone waking on a slow network, and short enough that a
|
||||||
|
* notification is never held back noticeably for a title.
|
||||||
|
*/
|
||||||
|
const val PULL_TIMEOUT_MS = 5_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A `BIGINT UNSIGNED` never runs past 20 digits. */
|
||||||
|
private const val MAX_ID_DIGITS = 20
|
||||||
@@ -13,6 +13,7 @@ import androidx.core.app.NotificationCompat
|
|||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import com.runicgateway.app.MainActivity
|
import com.runicgateway.app.MainActivity
|
||||||
import com.runicgateway.app.R
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationItemDto
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -20,10 +21,18 @@ import javax.inject.Singleton
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the notification channels and posts a notification for a received tickle
|
* Builds the notification channels and posts a notification for a received tickle
|
||||||
* (PLAN.md §11, M7 Part 2 work items 2/3/7). v1 shows a **generic per-stream**
|
* (PLAN.md §11, M7 Part 2 work items 2/3/7). Tapping deep-links into [MainActivity],
|
||||||
* notification titled from the fixed [PushStreams] catalog — the content-free tickle
|
* which fetches fresh over the authenticated API.
|
||||||
* carries nothing to render, so nothing is fetched to display the notification; tapping
|
*
|
||||||
* deep-links into [MainActivity] (which fetches fresh over the authenticated API).
|
* **The title comes from the inbox row when there is one** (`docs/modules/rust/PLAN.md`
|
||||||
|
* D70). The tickle carries nothing to render. When its ref names an inbox row,
|
||||||
|
* [PushContentResolver] has already pulled that row over the authenticated API, and
|
||||||
|
* the notification says what the row says. Every other tickle keeps the M7 behaviour:
|
||||||
|
* a **generic per-stream** title from the fixed [PushStreams] catalog. Until D70 that
|
||||||
|
* was every tickle, and "New notification" is all a Rust raid alert ever said.
|
||||||
|
*
|
||||||
|
* **The lock screen shows only the generic title.** The row's text is content, and
|
||||||
|
* a locked phone on a table is not the place for "a door was destroyed in S16".
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class PushNotifier @Inject constructor(
|
class PushNotifier @Inject constructor(
|
||||||
@@ -65,17 +74,32 @@ class PushNotifier @Inject constructor(
|
|||||||
.setContentIntent(deepLinkIntent(stream = null, ref = null))
|
.setContentIntent(deepLinkIntent(stream = null, ref = null))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
/** Post a notification for a tickle, deep-linking to the stream's screen on tap. */
|
/**
|
||||||
fun notify(tickle: PushTickle) {
|
* Post a notification for a tickle, deep-linking to the stream's screen on tap.
|
||||||
|
*
|
||||||
|
* [item] is the inbox row the tickle points at, when it could be pulled; null
|
||||||
|
* falls back to the per-stream title.
|
||||||
|
*/
|
||||||
|
fun notify(tickle: PushTickle, item: NotificationItemDto? = null) {
|
||||||
if (!manager.areNotificationsEnabled()) return // POST_NOTIFICATIONS not granted
|
if (!manager.areNotificationsEnabled()) return // POST_NOTIFICATIONS not granted
|
||||||
val title = context.getString(PushStreams.titleRes(tickle.stream))
|
val generic = context.getString(PushStreams.titleRes(tickle.stream))
|
||||||
val notification = NotificationCompat.Builder(context, CHANNEL_MESSAGES)
|
val content = notificationText(item, generic)
|
||||||
.setContentTitle(title)
|
val redacted = NotificationCompat.Builder(context, CHANNEL_MESSAGES)
|
||||||
|
.setContentTitle(generic)
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_name)
|
||||||
|
.build()
|
||||||
|
val builder = NotificationCompat.Builder(context, CHANNEL_MESSAGES)
|
||||||
|
.setContentTitle(content.title)
|
||||||
.setSmallIcon(R.drawable.ic_stat_name)
|
.setSmallIcon(R.drawable.ic_stat_name)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||||
|
.setPublicVersion(redacted)
|
||||||
.setContentIntent(deepLinkIntent(tickle.stream, tickle.ref))
|
.setContentIntent(deepLinkIntent(tickle.stream, tickle.ref))
|
||||||
.build()
|
content.body?.let { body ->
|
||||||
|
builder.setContentText(body).setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||||
|
}
|
||||||
|
val notification = builder.build()
|
||||||
try {
|
try {
|
||||||
manager.notify(nextId.getAndIncrement(), notification)
|
manager.notify(nextId.getAndIncrement(), notification)
|
||||||
} catch (_: SecurityException) {
|
} catch (_: SecurityException) {
|
||||||
@@ -108,3 +132,18 @@ class PushNotifier @Inject constructor(
|
|||||||
const val EXTRA_REF = "com.runicgateway.app.push.REF"
|
const val EXTRA_REF = "com.runicgateway.app.push.REF"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a posted notification says: a title always, and a body when the row had one. */
|
||||||
|
internal data class NotificationText(val title: String, val body: String?)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The text for a notification about [item], falling back to [generic] (D70).
|
||||||
|
*
|
||||||
|
* A row with a blank title is treated as no row: a notification with an empty
|
||||||
|
* headline is worse than one that says only that something happened.
|
||||||
|
*/
|
||||||
|
internal fun notificationText(item: NotificationItemDto?, generic: String): NotificationText {
|
||||||
|
val title = item?.title?.trim().orEmpty()
|
||||||
|
if (title.isEmpty()) return NotificationText(generic, null)
|
||||||
|
return NotificationText(title, item?.body?.trim()?.takeIf { it.isNotEmpty() })
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -33,6 +32,7 @@ class PushService : Service() {
|
|||||||
|
|
||||||
@Inject lateinit var streamClient: NtfyStreamClient
|
@Inject lateinit var streamClient: NtfyStreamClient
|
||||||
@Inject lateinit var notifier: PushNotifier
|
@Inject lateinit var notifier: PushNotifier
|
||||||
|
@Inject lateinit var content: PushContentResolver
|
||||||
@Inject lateinit var prefs: PushPreferences
|
@Inject lateinit var prefs: PushPreferences
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
@@ -67,8 +67,15 @@ class PushService : Service() {
|
|||||||
stopSelf()
|
stopSelf()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
streamClient.events(snapshot.ntfyUrl, topic).collectLatest { event ->
|
// Each tickle in its own job (D70). Titling one from its inbox row
|
||||||
if (event is NtfyStreamClient.Event.Message) notifier.notify(event.tickle)
|
// suspends on a network read, and inside `collectLatest` the next tickle
|
||||||
|
// would cancel it. A burst (two owners' raid alerts, then a restart) would
|
||||||
|
// then post only the last.
|
||||||
|
streamClient.events(snapshot.ntfyUrl, topic).collect { event ->
|
||||||
|
if (event is NtfyStreamClient.Event.Message) {
|
||||||
|
val tickle = event.tickle
|
||||||
|
scope.launch { notifier.notify(tickle, content.itemFor(tickle)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
41
app/src/main/java/com/runicgateway/app/core/time/Instants.kt
Normal file
41
app/src/main/java/com/runicgateway/app/core/time/Instants.kt
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.time
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a timestamp off the wire, in either shape the backend sends.
|
||||||
|
*
|
||||||
|
* **Which one arrives is not the app's to decide.** Express serializes a `Date`
|
||||||
|
* to ISO-8601 with a `Z`, but these values start life as MariaDB `DATETIME`
|
||||||
|
* columns, and one read back as a string reaches the wire as
|
||||||
|
* `2026-08-31 07:13:50` with no zone at all. A zoneless stamp is read as **UTC**,
|
||||||
|
* because that is what the server stores — reading it as local time would
|
||||||
|
* silently shift every timestamp by the device's offset, which is a bug that
|
||||||
|
* looks right on the machine it was written on.
|
||||||
|
*
|
||||||
|
* Anything unparseable answers null, and every caller is expected to render
|
||||||
|
* *something* without it: a notification with an odd date is still worth reading,
|
||||||
|
* and an event with one is still worth listing.
|
||||||
|
*
|
||||||
|
* Lives here rather than beside either caller because the trap is the wire's, not
|
||||||
|
* one screen's — the inbox found it (ENGAGEMENT.md phase 8) and the event screens
|
||||||
|
* inherit it (EVENTS.md §I).
|
||||||
|
*/
|
||||||
|
fun parseWireInstant(raw: String?): Instant? {
|
||||||
|
val text = raw?.trim().orEmpty()
|
||||||
|
if (text.isEmpty()) return null
|
||||||
|
return try {
|
||||||
|
Instant.parse(text)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
try {
|
||||||
|
LocalDateTime.parse(text.replace(' ', 'T')).atZone(ZoneId.of("UTC")).toInstant()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
84
app/src/main/java/com/runicgateway/app/data/api/EventsApi.kt
Normal file
84
app/src/main/java/com/runicgateway/app/data/api/EventsApi.kt
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api
|
||||||
|
|
||||||
|
import com.runicgateway.app.data.api.dto.EventCalendarDto
|
||||||
|
import com.runicgateway.app.data.api.dto.EventHistoryDto
|
||||||
|
import com.runicgateway.app.data.api.dto.EventSeriesResponse
|
||||||
|
import com.runicgateway.app.data.api.dto.PublicEventResponse
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Path
|
||||||
|
import retrofit2.http.Query
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event surface (PLAN.md §9 M13, `docs/website/EVENTS.md` § API surface).
|
||||||
|
*
|
||||||
|
* **These are CORE routes, not a module's**, which is why they live here rather
|
||||||
|
* than beside the shard reads in [PublicApi]: they exist on a backend
|
||||||
|
* with no game module installed at all, and they are gated by core's own `events`
|
||||||
|
* capability rather than by a module's. Nothing here is under `/shard`.
|
||||||
|
*
|
||||||
|
* The three public reads and the one player read share an interface for the same
|
||||||
|
* reason the website mounts them in one feature: the history row's whole purpose
|
||||||
|
* is to link back to the public page. The player call carries a bearer through
|
||||||
|
* [com.runicgateway.app.core.net.AuthInterceptor] like every other authenticated
|
||||||
|
* call; there is one Retrofit.
|
||||||
|
*/
|
||||||
|
interface EventsApi {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public calendar. Defaults to now through 31 days out when neither end
|
||||||
|
* is named; the window may span at most 92 days and the server 400s past it.
|
||||||
|
*
|
||||||
|
* Rehearsals and unlisted events are absent — that filtering is in SQL, not
|
||||||
|
* in the answer, so there is nothing here to re-check.
|
||||||
|
*/
|
||||||
|
@GET("api/v1/public/events")
|
||||||
|
suspend fun getCalendar(
|
||||||
|
@Query("from") from: String? = null,
|
||||||
|
@Query("to") to: String? = null,
|
||||||
|
@Query("seriesId") seriesId: Long? = null,
|
||||||
|
): EventCalendarDto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One event.
|
||||||
|
*
|
||||||
|
* **[run] selects which occurrence the results table is about**, and is what
|
||||||
|
* an announcement's link carries: the page lives at the definition's slug, so
|
||||||
|
* a weekly event has one address that survives a retitle, while every
|
||||||
|
* `event.` trigger is about one occurrence. A run belonging to some other
|
||||||
|
* event is ignored rather than refused, so a stale link in a months-old mail
|
||||||
|
* still opens the page it was about.
|
||||||
|
*
|
||||||
|
* A draft, an archived definition and an unlisted one all answer 404,
|
||||||
|
* indistinguishable from a slug that never existed.
|
||||||
|
*/
|
||||||
|
@GET("api/v1/public/events/{slug}")
|
||||||
|
suspend fun getEvent(
|
||||||
|
@Path("slug") slug: String,
|
||||||
|
@Query("run") run: String? = null,
|
||||||
|
): PublicEventResponse
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One arc. A series with no listed events answers 404 rather than an empty
|
||||||
|
* page — an arc is a label on its definitions, so a page for an empty one
|
||||||
|
* would publish that an operator has named something they have not announced.
|
||||||
|
*/
|
||||||
|
@GET("api/v1/public/events/series/{slug}")
|
||||||
|
suspend fun getSeries(@Path("slug") slug: String): EventSeriesResponse
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The caller's own participation history. Self-scoped on the session's user
|
||||||
|
* id server-side; there is deliberately no id parameter here, because there
|
||||||
|
* is none on the route.
|
||||||
|
*
|
||||||
|
* [before] is a participation row id, not an offset — the list gains rows at
|
||||||
|
* the top as the reader attends things.
|
||||||
|
*/
|
||||||
|
@GET("api/v1/player/events/history")
|
||||||
|
suspend fun getHistory(
|
||||||
|
@Query("limit") limit: Int? = null,
|
||||||
|
@Query("before") before: Long? = null,
|
||||||
|
): EventHistoryDto
|
||||||
|
}
|
||||||
@@ -3,8 +3,13 @@
|
|||||||
*/
|
*/
|
||||||
package com.runicgateway.app.data.api
|
package com.runicgateway.app.data.api
|
||||||
|
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsUpdateDto
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationInboxDto
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationReadResultDto
|
||||||
import com.runicgateway.app.data.api.dto.NotificationStreamsDto
|
import com.runicgateway.app.data.api.dto.NotificationStreamsDto
|
||||||
import com.runicgateway.app.data.api.dto.NotificationSubscriptionsDto
|
import com.runicgateway.app.data.api.dto.NotificationSubscriptionsDto
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationUnreadDto
|
||||||
import com.runicgateway.app.data.api.dto.PushDeviceDto
|
import com.runicgateway.app.data.api.dto.PushDeviceDto
|
||||||
import com.runicgateway.app.data.api.dto.RegisterDeviceRequest
|
import com.runicgateway.app.data.api.dto.RegisterDeviceRequest
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
@@ -13,10 +18,13 @@ import retrofit2.http.GET
|
|||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
import retrofit2.http.PUT
|
import retrofit2.http.PUT
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
|
import retrofit2.http.Query
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The opt-in push surface under `/auth/me` (PLAN.md §11, M7 Part 2): device
|
* The notification surface under `/auth/me` (PLAN.md §11): device (endpoint)
|
||||||
* (endpoint) registration and per-user stream subscriptions. Every call rides the
|
* registration, per-user stream subscriptions, the per-channel preferences that
|
||||||
|
* supersede them (ENGAGEMENT.md phase 3), and the in-app **inbox** — the first
|
||||||
|
* of these that carries content rather than a preference (phase 7/8). Every call rides the
|
||||||
* main client, so [com.runicgateway.app.core.net.AuthInterceptor] attaches the
|
* main client, so [com.runicgateway.app.core.net.AuthInterceptor] attaches the
|
||||||
* bearer and [com.runicgateway.app.core.net.TokenAuthenticator] refreshes on 401 —
|
* bearer and [com.runicgateway.app.core.net.TokenAuthenticator] refreshes on 401 —
|
||||||
* registration only ever succeeds while signed in.
|
* registration only ever succeeds while signed in.
|
||||||
@@ -40,4 +48,41 @@ interface NotificationsApi {
|
|||||||
|
|
||||||
@PUT("api/v1/auth/me/notifications/subscriptions")
|
@PUT("api/v1/auth/me/notifications/subscriptions")
|
||||||
suspend fun putSubscriptions(@Body body: NotificationSubscriptionsDto): NotificationSubscriptionsDto
|
suspend fun putSubscriptions(@Body body: NotificationSubscriptionsDto): NotificationSubscriptionsDto
|
||||||
|
|
||||||
|
// ── Per-channel preferences (ENGAGEMENT.md phase 3) ────────────────────
|
||||||
|
//
|
||||||
|
// The superset of the two calls above: `notification_subscriptions` is now
|
||||||
|
// the push projection of this table and the server fans every write to
|
||||||
|
// either one into the other, so the two cannot disagree.
|
||||||
|
|
||||||
|
@GET("api/v1/auth/me/notifications/channels")
|
||||||
|
suspend fun channelPrefs(): NotificationChannelPrefsDto
|
||||||
|
|
||||||
|
/** SPARSE — send only the pairs that changed; everything unnamed is untouched. */
|
||||||
|
@PUT("api/v1/auth/me/notifications/channels")
|
||||||
|
suspend fun putChannelPrefs(
|
||||||
|
@Body body: NotificationChannelPrefsUpdateDto,
|
||||||
|
): NotificationChannelPrefsDto
|
||||||
|
|
||||||
|
// ── The inbox (ENGAGEMENT.md phase 7/8) ────────────────────────────────
|
||||||
|
//
|
||||||
|
// Keyset-paged on `before`, never an offset. There is no way to name another
|
||||||
|
// user on any of these: the caller is the only account they can read or write.
|
||||||
|
|
||||||
|
@GET("api/v1/auth/me/notifications")
|
||||||
|
suspend fun inbox(
|
||||||
|
@Query("limit") limit: Int? = null,
|
||||||
|
@Query("before") before: Long? = null,
|
||||||
|
@Query("unread") unread: Boolean? = null,
|
||||||
|
): NotificationInboxDto
|
||||||
|
|
||||||
|
@GET("api/v1/auth/me/notifications/unread-count")
|
||||||
|
suspend fun unreadCount(): NotificationUnreadDto
|
||||||
|
|
||||||
|
/** Idempotent; 404 both for a missing item and for another account's. */
|
||||||
|
@POST("api/v1/auth/me/notifications/{id}/read")
|
||||||
|
suspend fun markRead(@Path("id") id: Long): NotificationReadResultDto
|
||||||
|
|
||||||
|
@POST("api/v1/auth/me/notifications/read-all")
|
||||||
|
suspend fun markAllRead(): NotificationReadResultDto
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api
|
||||||
|
|
||||||
|
import com.runicgateway.app.data.api.dto.RustLinkListDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustLinkRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.RustLinkResultDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustUnlinkResultDto
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.DELETE
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Path
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A player's own Rust identity and what it earns them
|
||||||
|
* (`docs/modules/rust/PLAN.md` §19, §20, §22; M15), over the bearer-gated
|
||||||
|
* `/player/rust/…` surface.
|
||||||
|
*
|
||||||
|
* Its own interface beside [RustApi] rather than four more methods on it, for
|
||||||
|
* the same reason [PlayerShardApi] is separate from [PublicApi]: these need a
|
||||||
|
* session and those do not, and one interface holding both makes the tier a
|
||||||
|
* property of the method name instead of the type.
|
||||||
|
*
|
||||||
|
* **The paths are hardcoded, which is the contract and not a shortcut.**
|
||||||
|
* `MODULE_API.md` §2.9 forbids a client inferring a route from a capability, so
|
||||||
|
* the app cannot build `/<module id>/links` from what `GET /public/modules`
|
||||||
|
* reports. A capability answers *is the module there*; these four addresses are
|
||||||
|
* knowledge the app has because someone read the module's router.
|
||||||
|
*/
|
||||||
|
interface PlayerRustApi {
|
||||||
|
|
||||||
|
/** The Steam accounts the caller holds. Fleet-wide: a link is not per server. */
|
||||||
|
@GET("api/v1/player/rust/links")
|
||||||
|
suspend fun links(): RustLinkListDto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redeem the code `/link` handed the player in game.
|
||||||
|
*
|
||||||
|
* **The refusals are not interchangeable and the screen must not flatten
|
||||||
|
* them.** A 400 is a code that is unknown or expired — go and get another;
|
||||||
|
* a 409 is a Steam account another website account holds — run `/unlink` in
|
||||||
|
* game; a 503 is a server that could not be reached, where the code is still
|
||||||
|
* good and the only right advice is to wait a minute. A player told to run
|
||||||
|
* `/link` again when the server their code came from was merely down will
|
||||||
|
* get another code from the same down server. The server sends a sentence
|
||||||
|
* for each; this leg renders it rather than writing one of its own.
|
||||||
|
*
|
||||||
|
* Rate-limited server-side (ten per quarter-hour per IP), so a 429 is an
|
||||||
|
* ordinary answer here rather than a bug.
|
||||||
|
*/
|
||||||
|
@POST("api/v1/player/rust/link")
|
||||||
|
suspend fun link(@Body body: RustLinkRequest): RustLinkResultDto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release a link the caller holds.
|
||||||
|
*
|
||||||
|
* Scoped to the caller inside the server's statement, so a Steam id that is
|
||||||
|
* somebody else's answers the same 404 as one that is nobody's.
|
||||||
|
*/
|
||||||
|
@DELETE("api/v1/player/rust/links/{steamId}")
|
||||||
|
suspend fun unlink(@Path("steamId") steamId: String): RustUnlinkResultDto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the site has given this player in game — ranks and direct grants,
|
||||||
|
* each already resolved to the servers its scope reaches.
|
||||||
|
*
|
||||||
|
* Read-only by construction: everything that authors one of these rows is an
|
||||||
|
* admin route.
|
||||||
|
*/
|
||||||
|
@GET("api/v1/player/rust/permissions")
|
||||||
|
suspend fun permissions(): RustPlayerPermissionsDto
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import com.runicgateway.app.data.api.dto.HouseDto
|
|||||||
import com.runicgateway.app.data.api.dto.MarketMetaDto
|
import com.runicgateway.app.data.api.dto.MarketMetaDto
|
||||||
import com.runicgateway.app.data.api.dto.MarketPageDto
|
import com.runicgateway.app.data.api.dto.MarketPageDto
|
||||||
import com.runicgateway.app.data.api.dto.MarketVendorDto
|
import com.runicgateway.app.data.api.dto.MarketVendorDto
|
||||||
|
import com.runicgateway.app.data.api.dto.ModulesDto
|
||||||
import com.runicgateway.app.data.api.dto.OnlineStaffDto
|
import com.runicgateway.app.data.api.dto.OnlineStaffDto
|
||||||
import com.runicgateway.app.data.api.dto.PageDto
|
import com.runicgateway.app.data.api.dto.PageDto
|
||||||
import com.runicgateway.app.data.api.dto.PointsBoardDto
|
import com.runicgateway.app.data.api.dto.PointsBoardDto
|
||||||
@@ -67,6 +68,18 @@ interface PublicApi {
|
|||||||
@GET("api/v1/public/settings")
|
@GET("api/v1/public/settings")
|
||||||
suspend fun getSettings(): SettingsDto
|
suspend fun getSettings(): SettingsDto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which modules this backend is serving, and the capabilities each declares
|
||||||
|
* (§5, M13). Read together with the `version` block's own `capabilities` —
|
||||||
|
* core's list and a module's are separate lists on purpose.
|
||||||
|
*
|
||||||
|
* This is what lets the app tell a module that is **not installed** from a
|
||||||
|
* lookup that failed: `/public/shard/features` 404s in both cases, and only
|
||||||
|
* this call distinguishes them.
|
||||||
|
*/
|
||||||
|
@GET("api/v1/public/modules")
|
||||||
|
suspend fun getModules(): ModulesDto
|
||||||
|
|
||||||
// ── News & content ───────────────────────────────────────────────────
|
// ── News & content ───────────────────────────────────────────────────
|
||||||
@GET("api/v1/public/posts/{category}")
|
@GET("api/v1/public/posts/{category}")
|
||||||
suspend fun getPosts(@Path("category") category: String): List<PostDto>
|
suspend fun getPosts(@Path("category") category: String): List<PostDto>
|
||||||
|
|||||||
111
app/src/main/java/com/runicgateway/app/data/api/RustApi.kt
Normal file
111
app/src/main/java/com/runicgateway/app/data/api/RustApi.kt
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api
|
||||||
|
|
||||||
|
import com.runicgateway.app.data.api.dto.RustEventListDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustLeaderboardDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustMapDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustMapLiveDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustOnlineDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustServerListDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustServerResponse
|
||||||
|
import com.runicgateway.app.data.api.dto.RustWipeListDto
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Path
|
||||||
|
import retrofit2.http.Query
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `module-rust`'s public read path (PLAN.md §9 M14; `docs/modules/rust/PLAN.md`
|
||||||
|
* §17).
|
||||||
|
*
|
||||||
|
* **These paths are hardcoded, and that is the contract rather than a shortcut.**
|
||||||
|
* `MODULE_API.md` §2.9 forbids a client inferring a route from a capability, so
|
||||||
|
* the app cannot build `/<module id>/servers` from what `GET /public/modules`
|
||||||
|
* reports. A capability answers one question — *is the module there* — and these
|
||||||
|
* five addresses are knowledge the app has because someone read the module's
|
||||||
|
* router, exactly as the nine `/uo/` paths in [NavPaths] are.
|
||||||
|
*
|
||||||
|
* Its own interface, not a section of [PublicApi], for the reason [EventsApi] is
|
||||||
|
* its own: these exist only where the Rust module is installed, and a backend
|
||||||
|
* running a different game answers none of them.
|
||||||
|
*/
|
||||||
|
interface RustApi {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every Rust server this site follows.
|
||||||
|
*
|
||||||
|
* Answers from the module's own tables and never from a live call to a game
|
||||||
|
* host, so it succeeds while every server in the fleet is off — a server
|
||||||
|
* nobody can reach comes back `online: false, stale: true` with everything it
|
||||||
|
* last said still attached. There is no failure case here for the game being
|
||||||
|
* down, only for the website being down.
|
||||||
|
*/
|
||||||
|
@GET("api/v1/public/rust/servers")
|
||||||
|
suspend fun getServers(): RustServerListDto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One server, or a **404**.
|
||||||
|
*
|
||||||
|
* The only route under `/servers/{id}` that can say a server is not there:
|
||||||
|
* the four below answer an empty list for an id nobody configured, because an
|
||||||
|
* unknown server genuinely has no events and nobody online. A server an
|
||||||
|
* operator **disabled** answers the same 404 — switching one off is not
|
||||||
|
* switching it into a refusal.
|
||||||
|
*/
|
||||||
|
@GET("api/v1/public/rust/servers/{id}")
|
||||||
|
suspend fun getServer(@Path("id") id: String): RustServerResponse
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The feed, newest first.
|
||||||
|
*
|
||||||
|
* [kind] is comma-separated and [wipe] a wipe id; both are optional, and an
|
||||||
|
* **absent one must be absent rather than empty** — `?wipe=` asks for a wipe
|
||||||
|
* whose id is the empty string and answers nothing, with no error to notice.
|
||||||
|
* Retrofit drops a null `@Query` entirely, which is why these are nullable
|
||||||
|
* and never defaulted to `""`.
|
||||||
|
*
|
||||||
|
* The server serves a default-deny allowlist: moderation events, login
|
||||||
|
* attempts and anything carrying an IP address are stored and never returned
|
||||||
|
* here, whatever is asked for.
|
||||||
|
*/
|
||||||
|
@GET("api/v1/public/rust/servers/{id}/events")
|
||||||
|
suspend fun getEvents(
|
||||||
|
@Path("id") id: String,
|
||||||
|
@Query("kind") kind: String? = null,
|
||||||
|
@Query("wipe") wipe: String? = null,
|
||||||
|
@Query("limit") limit: Int? = null,
|
||||||
|
): RustEventListDto
|
||||||
|
|
||||||
|
/** Per wipe when [wipe] is given, all-time otherwise — the same rows summed. */
|
||||||
|
@GET("api/v1/public/rust/servers/{id}/leaderboard")
|
||||||
|
suspend fun getLeaderboard(
|
||||||
|
@Path("id") id: String,
|
||||||
|
@Query("wipe") wipe: String? = null,
|
||||||
|
@Query("sort") sort: String? = null,
|
||||||
|
@Query("limit") limit: Int? = null,
|
||||||
|
): RustLeaderboardDto
|
||||||
|
|
||||||
|
/** Every wipe this server has had, newest first. */
|
||||||
|
@GET("api/v1/public/rust/servers/{id}/wipes")
|
||||||
|
suspend fun getWipes(@Path("id") id: String): RustWipeListDto
|
||||||
|
|
||||||
|
/** The presence board, which an unreachable server does not clear. */
|
||||||
|
@GET("api/v1/public/rust/servers/{id}/online")
|
||||||
|
suspend fun getOnline(@Path("id") id: String): RustOnlineDto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The map: where its picture is, the frame to draw it in, and which layers
|
||||||
|
* this viewer gets (Rust phase 14). A module older than phase 14 answers 404.
|
||||||
|
*/
|
||||||
|
@GET("api/v1/public/rust/servers/{id}/map")
|
||||||
|
suspend fun getMap(@Path("id") id: String): RustMapDto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What moves, already cut down to this viewer on the server: a layer they
|
||||||
|
* may not see is absent. The module asks the game at most once per five
|
||||||
|
* seconds per server, however many viewers there are (D111).
|
||||||
|
*/
|
||||||
|
@GET("api/v1/public/rust/servers/{id}/map/live")
|
||||||
|
suspend fun getMapLive(@Path("id") id: String): RustMapLiveDto
|
||||||
|
}
|
||||||
217
app/src/main/java/com/runicgateway/app/data/api/dto/EventsDto.kt
Normal file
217
app/src/main/java/com/runicgateway/app/data/api/dto/EventsDto.kt
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire shapes for the public event surface (`docs/website/EVENTS.md` §I, events
|
||||||
|
* Phase 14a; the app's half is M13). Field names match
|
||||||
|
* `server/src/model/events/eventPublic.model.js` exactly.
|
||||||
|
*
|
||||||
|
* **That model is a PROJECTION, and these DTOs must not out-grow it.** Nothing on
|
||||||
|
* the server side is spread into a public entry — a field reaches one because a
|
||||||
|
* line put it there — so three things are absent from every shape below and each
|
||||||
|
* absence is a decision core made: the **spec** (phases, steps, actions and their
|
||||||
|
* params are the operator's plan for changing a live world; a visitor gets the
|
||||||
|
* phase LABEL while a run is live and nothing else), **health, cleanup, claims
|
||||||
|
* and errors** (facts about the deployment's plumbing, not about the event), and
|
||||||
|
* **`member_key`** (module-opaque, so core cannot say what publishing one would
|
||||||
|
* disclose). Adding a field here that the server does not send would decode to a
|
||||||
|
* default and render as a fact.
|
||||||
|
*
|
||||||
|
* Every DTO ignores unknown keys (NetworkModule's lenient Json), so an additive
|
||||||
|
* backend field is safe.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One calendar entry. [kind] is `run` or `projected` and the two are drawn
|
||||||
|
* differently on purpose.
|
||||||
|
*
|
||||||
|
* A **run** is a materialised occurrence: a row exists, it can be cancelled, and
|
||||||
|
* what it says is committed to. A **projected** entry is arithmetic past the
|
||||||
|
* materialisation horizon — a forecast with nothing behind it — so the screen
|
||||||
|
* labels it rather than drawing it as a booking. [adjusted] and [shiftMinutes]
|
||||||
|
* only ever arrive on a projection, and say a DST shift moved it.
|
||||||
|
*
|
||||||
|
* [scheduledFor] is a UTC instant and [timezone] is the EVENT's own zone, never
|
||||||
|
* the reader's. See [com.runicgateway.app.ui.events.eventTime].
|
||||||
|
*
|
||||||
|
* [runId] is on a run and never on a projection, which has nothing committed to
|
||||||
|
* it. Core added it for Rust D125, so a map marker naming a run can find its
|
||||||
|
* event; a core older than that omits it, and the marker stays unlinked.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class EventCalendarEntryDto(
|
||||||
|
val kind: String = "run",
|
||||||
|
val runId: Long? = null,
|
||||||
|
val title: String = "",
|
||||||
|
val slug: String = "",
|
||||||
|
val seriesName: String? = null,
|
||||||
|
val seriesSlug: String? = null,
|
||||||
|
val scheduledFor: String = "",
|
||||||
|
val timezone: String? = null,
|
||||||
|
val status: String = "scheduled",
|
||||||
|
val live: Boolean = false,
|
||||||
|
val adjusted: Boolean = false,
|
||||||
|
val shiftMinutes: Int = 0,
|
||||||
|
) {
|
||||||
|
/** True for a forecast the server has committed nothing to. */
|
||||||
|
val isProjected: Boolean get() = kind == "projected"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /public/events` — the calendar for a window, ascending by instant. */
|
||||||
|
@Serializable
|
||||||
|
data class EventCalendarDto(
|
||||||
|
val entries: List<EventCalendarEntryDto> = emptyList(),
|
||||||
|
/** True when the server capped the answer; the screen says so rather than lying by omission. */
|
||||||
|
val truncated: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One occurrence on an event's page.
|
||||||
|
*
|
||||||
|
* [phase] is the label of the phase a live run is in, resolved from the version
|
||||||
|
* that run PINNED — so an edit since does not relabel a run in flight. It is null
|
||||||
|
* on anything that is not live, which is why the screen only ever shows it there.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class EventOccurrenceDto(
|
||||||
|
val runId: Long = 0,
|
||||||
|
val scheduledFor: String = "",
|
||||||
|
val timezone: String? = null,
|
||||||
|
val startedAt: String? = null,
|
||||||
|
val endedAt: String? = null,
|
||||||
|
val status: String = "scheduled",
|
||||||
|
val live: Boolean = false,
|
||||||
|
val scope: String? = null,
|
||||||
|
val phase: String? = null,
|
||||||
|
val resultsPublishedAt: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of a published results table.
|
||||||
|
*
|
||||||
|
* [name] is whatever the module put in its participation `meta`, and there is
|
||||||
|
* genuinely nothing else to render when it is absent: core has no name for a
|
||||||
|
* character and the member key is not published, so the screen says "Unnamed"
|
||||||
|
* rather than inventing one.
|
||||||
|
*
|
||||||
|
* **[score] is fractional, and it has to be.** `event_run_participants.score` is
|
||||||
|
* `DECIMAL(18,4)`, and a module scoring by distance, time or a weighted tally
|
||||||
|
* writes a fraction — the live walk found `318.5` in the first row it read.
|
||||||
|
* Declaring it `Long` does not merely round: kotlinx REFUSES the body, the whole
|
||||||
|
* response fails to decode, and the screen reports a server error for a `200`.
|
||||||
|
* See [com.runicgateway.app.ui.events.scoreText] for how it is rendered.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class EventParticipantDto(
|
||||||
|
val name: String? = null,
|
||||||
|
val score: Double = 0.0,
|
||||||
|
val rank: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The results table for ONE occurrence, present only once it has been published. */
|
||||||
|
@Serializable
|
||||||
|
data class EventResultsDto(
|
||||||
|
val runId: Long = 0,
|
||||||
|
val scheduledFor: String = "",
|
||||||
|
val publishedAt: String? = null,
|
||||||
|
val participants: List<EventParticipantDto> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The arc an event belongs to, as its own page names it. */
|
||||||
|
@Serializable
|
||||||
|
data class EventSeriesRefDto(
|
||||||
|
val name: String = "",
|
||||||
|
val slug: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `GET /public/events/:slug` — the event. */
|
||||||
|
@Serializable
|
||||||
|
data class PublicEventDto(
|
||||||
|
val title: String = "",
|
||||||
|
val slug: String = "",
|
||||||
|
val summary: String? = null,
|
||||||
|
/** Sanitized HTML, written the way a wiki page and a forum post are. */
|
||||||
|
val body: String? = null,
|
||||||
|
val imageUrl: String? = null,
|
||||||
|
val timezone: String? = null,
|
||||||
|
val series: EventSeriesRefDto? = null,
|
||||||
|
val live: Boolean = false,
|
||||||
|
val current: EventOccurrenceDto? = null,
|
||||||
|
/**
|
||||||
|
* The next occurrence — **narrower than the first of [upcoming]**, and the
|
||||||
|
* server decides which. A cancelled occurrence still appears under what is
|
||||||
|
* coming, because "next Friday is off" is what somebody checking a calendar
|
||||||
|
* came to find out; it is not what "next" means.
|
||||||
|
*/
|
||||||
|
val next: EventOccurrenceDto? = null,
|
||||||
|
val upcoming: List<EventOccurrenceDto> = emptyList(),
|
||||||
|
val past: List<EventOccurrenceDto> = emptyList(),
|
||||||
|
val results: EventResultsDto? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The envelope `GET /public/events/:slug` answers with. */
|
||||||
|
@Serializable
|
||||||
|
data class PublicEventResponse(val event: PublicEventDto = PublicEventDto())
|
||||||
|
|
||||||
|
/** One event as an arc lists it — the editor's order, so no dates. */
|
||||||
|
@Serializable
|
||||||
|
data class EventSeriesEntryDto(
|
||||||
|
val title: String = "",
|
||||||
|
val slug: String = "",
|
||||||
|
val summary: String? = null,
|
||||||
|
val imageUrl: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `GET /public/events/series/:slug` — one arc and the listed events in it. */
|
||||||
|
@Serializable
|
||||||
|
data class EventSeriesDto(
|
||||||
|
val name: String = "",
|
||||||
|
val slug: String = "",
|
||||||
|
val description: String? = null,
|
||||||
|
val events: List<EventSeriesEntryDto> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The envelope `GET /public/events/series/:slug` answers with. */
|
||||||
|
@Serializable
|
||||||
|
data class EventSeriesResponse(val series: EventSeriesDto = EventSeriesDto())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of the caller's own participation history.
|
||||||
|
*
|
||||||
|
* [rank] is null until `core.results.publish` ran for that occurrence, and that
|
||||||
|
* is a real state rather than an error — the screen says "not published" rather
|
||||||
|
* than rendering a dash that reads as a bug.
|
||||||
|
*
|
||||||
|
* [id] is the participation row's own id and is what the keyset page walks back
|
||||||
|
* on: the list gains a row every time the reader attends something, so an offset
|
||||||
|
* would skip and repeat.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class EventHistoryEntryDto(
|
||||||
|
val id: Long = 0,
|
||||||
|
val runId: Long = 0,
|
||||||
|
val title: String = "",
|
||||||
|
val slug: String = "",
|
||||||
|
val seriesName: String? = null,
|
||||||
|
val seriesSlug: String? = null,
|
||||||
|
val scheduledFor: String = "",
|
||||||
|
val startedAt: String? = null,
|
||||||
|
val endedAt: String? = null,
|
||||||
|
val timezone: String? = null,
|
||||||
|
val status: String = "scheduled",
|
||||||
|
val joinedAt: String? = null,
|
||||||
|
// Fractional, for the reason [EventParticipantDto.score] gives.
|
||||||
|
val score: Double = 0.0,
|
||||||
|
val rank: Int? = null,
|
||||||
|
val resultsPublishedAt: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `GET /player/events/history` — self-scoped, one page. */
|
||||||
|
@Serializable
|
||||||
|
data class EventHistoryDto(
|
||||||
|
val entries: List<EventHistoryEntryDto> = emptyList(),
|
||||||
|
)
|
||||||
@@ -72,3 +72,139 @@ data class NotificationStreamsDto(
|
|||||||
data class NotificationSubscriptionsDto(
|
data class NotificationSubscriptionsDto(
|
||||||
val streams: List<String>,
|
val streams: List<String>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── The in-app channel (ENGAGEMENT.md phase 7/8) ───────────────────────────
|
||||||
|
//
|
||||||
|
// The inbox is the first notification surface that carries CONTENT. Everything
|
||||||
|
// above is a preference or a content-free tickle; these four shapes are the
|
||||||
|
// items themselves, pulled over the authenticated API after a tickle wakes the
|
||||||
|
// app. The wire names come from `userNotifications.db.js`'s `toItem`.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One inbox item. [read] is the flag and [readAt] the stamp, sent side by side so
|
||||||
|
* a client renders one without parsing the other.
|
||||||
|
*
|
||||||
|
* [url] is where the item points on the site (rendered from the template's
|
||||||
|
* `email.button` block) and is **null on most items** — an inbox row is complete
|
||||||
|
* on its own. [triggerId] is the event that produced it, in §7.2's ONE namespace,
|
||||||
|
* so it is the same vocabulary a push tickle's `stream` speaks.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class NotificationItemDto(
|
||||||
|
val id: Long = 0,
|
||||||
|
val triggerId: String = "",
|
||||||
|
val title: String = "",
|
||||||
|
val body: String? = null,
|
||||||
|
val url: String? = null,
|
||||||
|
val read: Boolean = false,
|
||||||
|
val readAt: String? = null,
|
||||||
|
val createdAt: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /auth/me/notifications` — one page, newest first.
|
||||||
|
*
|
||||||
|
* Keyset-paged: the next page is `?before=<the last item's id>`, not an offset,
|
||||||
|
* because the list gains rows at the top while it is being read. [hasMore] comes
|
||||||
|
* from the server's take+1, so "is there another page" costs no second query.
|
||||||
|
* [unread] counts the WHOLE inbox, not the page — it rides along so a screen
|
||||||
|
* rendering both a badge and a list from one response cannot show the two
|
||||||
|
* disagreeing.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class NotificationInboxDto(
|
||||||
|
val items: List<NotificationItemDto> = emptyList(),
|
||||||
|
val hasMore: Boolean = false,
|
||||||
|
val unread: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `GET /auth/me/notifications/unread-count` — the badge, on its own. */
|
||||||
|
@Serializable
|
||||||
|
data class NotificationUnreadDto(
|
||||||
|
val unread: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What both mark-read routes answer with. [unread] is the count AFTER the write,
|
||||||
|
* so the badge follows from the response rather than from a second call.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class NotificationReadResultDto(
|
||||||
|
val ok: Boolean = false,
|
||||||
|
val changed: Int = 0,
|
||||||
|
val unread: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Per-channel preferences (ENGAGEMENT.md phase 3) ────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One delivery channel from the registry. [modes] is what this channel accepts —
|
||||||
|
* `["off","instant"]` for push and in-app, `["off","instant","digest"]` for email
|
||||||
|
* — and the UI renders its control from THIS, never from a hardcoded set, so a
|
||||||
|
* channel added server-side arrives without an app release.
|
||||||
|
*
|
||||||
|
* [carriesContent] is the tickle invariant stated on the wire: push is `false`,
|
||||||
|
* which is why a push item's title never leaves the server.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class NotificationChannelDto(
|
||||||
|
val id: String = "",
|
||||||
|
val label: String = "",
|
||||||
|
val carriesContent: Boolean = false,
|
||||||
|
val defaultMode: String = "off",
|
||||||
|
val supportsDigest: Boolean = false,
|
||||||
|
val modes: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One subscribable id, from `GET /auth/me/notifications/channels`. The list is the
|
||||||
|
* UNION of push streams and event triggers in one namespace (§7.2), so an id may
|
||||||
|
* be a stream, a trigger, or both.
|
||||||
|
*
|
||||||
|
* [channels] is which channels apply to THIS id — a trigger-only id carries no
|
||||||
|
* `push` because nothing is registered to push it — and [modes] is the EFFECTIVE
|
||||||
|
* mode per channel: where the user has expressed nothing the server has already
|
||||||
|
* substituted that channel's default, and the client must not re-implement the
|
||||||
|
* defaulting.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class NotificationChannelItemDto(
|
||||||
|
val id: String = "",
|
||||||
|
val label: String = "",
|
||||||
|
val description: String = "",
|
||||||
|
val personal: Boolean = false,
|
||||||
|
val requiresLinkedAccount: Boolean = false,
|
||||||
|
val ceiling: String? = null,
|
||||||
|
val channels: List<String> = emptyList(),
|
||||||
|
val modes: Map<String, String> = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `GET · PUT /auth/me/notifications/channels` — the whole stored truth. */
|
||||||
|
@Serializable
|
||||||
|
data class NotificationChannelPrefsDto(
|
||||||
|
val channels: List<NotificationChannelDto> = emptyList(),
|
||||||
|
val items: List<NotificationChannelItemDto> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One (id, channel) → mode pair of a sparse update. */
|
||||||
|
@Serializable
|
||||||
|
data class NotificationChannelPrefDto(
|
||||||
|
val id: String,
|
||||||
|
val channel: String,
|
||||||
|
val mode: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `PUT /auth/me/notifications/channels` body — a SPARSE update: only the pairs
|
||||||
|
* named are written and every other pair is left alone, so one toggle saves
|
||||||
|
* without the screen holding the whole table.
|
||||||
|
*
|
||||||
|
* [prefs] has no default for the same reason [NotificationSubscriptionsDto.streams]
|
||||||
|
* has none — kotlinx omits a property equal to its default, and the validator
|
||||||
|
* requires the field. Unlike that DTO there is no empty-set case to get wrong
|
||||||
|
* here: `off` is a mode, never an omission.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class NotificationChannelPrefsUpdateDto(
|
||||||
|
val prefs: List<NotificationChannelPrefDto>,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTOs for a player's own half of `module-rust` (`docs/modules/rust/PLAN.md`
|
||||||
|
* §19, §20, §22; M15).
|
||||||
|
*
|
||||||
|
* Two surfaces, and they are deliberately separate reads rather than one:
|
||||||
|
*
|
||||||
|
* * **the links** — which Steam accounts this website account holds. A link is
|
||||||
|
* fleet-wide, because a Steam account is one person on every server an
|
||||||
|
* operator runs, while stats are per server and per wipe.
|
||||||
|
* * **what the site has given them in game** — groups and direct grants, each
|
||||||
|
* already resolved to the servers its scope reaches. It is its own read
|
||||||
|
* because an entitlement is authored against the *website* account, so it
|
||||||
|
* exists before a Steam id does; the person who has just been given something
|
||||||
|
* and has not linked yet is exactly the one who needs to see both halves at
|
||||||
|
* once.
|
||||||
|
*
|
||||||
|
* Nothing here is a write except the code redemption. A grant a player could
|
||||||
|
* change would not be a grant.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** `GET /player/rust/links` — the Steam accounts the caller holds. */
|
||||||
|
@Serializable
|
||||||
|
data class RustLinkListDto(
|
||||||
|
val links: List<RustLinkDto> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One linked Steam account.
|
||||||
|
*
|
||||||
|
* [name] is what the player was called in game when they linked — a display
|
||||||
|
* name only, and a Rust name changes on a whim. [serverId] is where the code was
|
||||||
|
* minted, which is not part of the identity but is where a support conversation
|
||||||
|
* starts.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class RustLinkDto(
|
||||||
|
val steamId: String = "",
|
||||||
|
val name: String? = null,
|
||||||
|
val serverId: String? = null,
|
||||||
|
val linkedAt: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `POST /player/rust/link` body — the six-character code `/link` gives in game. */
|
||||||
|
@Serializable
|
||||||
|
data class RustLinkRequest(val code: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /player/rust/link` result.
|
||||||
|
*
|
||||||
|
* [already] is a second press of the button rather than an error: the code was
|
||||||
|
* good and that Steam id was already this caller's.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class RustLinkResultDto(
|
||||||
|
val linked: Boolean = false,
|
||||||
|
val link: RustLinkDto? = null,
|
||||||
|
val already: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `DELETE /player/rust/links/{steamId}` result. */
|
||||||
|
@Serializable
|
||||||
|
data class RustUnlinkResultDto(
|
||||||
|
val unlinked: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /player/rust/permissions` — what the site has given this player in game.
|
||||||
|
*
|
||||||
|
* [accounts] is how many Steam accounts they have linked, and it is on the
|
||||||
|
* envelope for one reason: zero is why an entitlement can be authored and reach
|
||||||
|
* nobody, and the screen has to be able to say that without inferring it.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class RustPlayerPermissionsDto(
|
||||||
|
val accounts: Int = 0,
|
||||||
|
val groups: List<RustPlayerGroupDto> = emptyList(),
|
||||||
|
val grants: List<RustPlayerGrantDto> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A rank the site holds for this player, and what it carries. */
|
||||||
|
@Serializable
|
||||||
|
data class RustPlayerGroupDto(
|
||||||
|
val name: String = "",
|
||||||
|
val title: String = "",
|
||||||
|
val scope: String = "*",
|
||||||
|
val since: String? = null,
|
||||||
|
val permissions: List<String> = emptyList(),
|
||||||
|
val reach: List<RustReachDto> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One permission held directly, without a rank. */
|
||||||
|
@Serializable
|
||||||
|
data class RustPlayerGrantDto(
|
||||||
|
val permission: String = "",
|
||||||
|
val scope: String = "*",
|
||||||
|
val source: String = "admin",
|
||||||
|
val note: String? = null,
|
||||||
|
val since: String? = null,
|
||||||
|
val reach: List<RustReachDto> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One server an entitlement's scope reaches, and whether it is there yet.
|
||||||
|
*
|
||||||
|
* **The scope arithmetic is the server's.** A client handed `scope: "*"` would
|
||||||
|
* have to know what the fleet is to say anything useful, and then the rule
|
||||||
|
* exists in two places; the website resolves it and marks each server instead.
|
||||||
|
*
|
||||||
|
* [live] is the pushed ledger rather than the authored row: a grant is not a
|
||||||
|
* privilege in a game until a sync confirmed it. `false` covers every way it has
|
||||||
|
* not arrived — the server is offline, no loaded plugin registered the name, the
|
||||||
|
* store has never seen the account — and telling those apart is an operator's
|
||||||
|
* diagnosis, not a player's.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class RustReachDto(
|
||||||
|
val id: String = "",
|
||||||
|
val name: String = "",
|
||||||
|
val live: Boolean = false,
|
||||||
|
)
|
||||||
@@ -20,6 +20,47 @@ data class VersionDto(
|
|||||||
val service: String = "",
|
val service: String = "",
|
||||||
val api: String = "",
|
val api: String = "",
|
||||||
val server: String = "",
|
val server: String = "",
|
||||||
|
/**
|
||||||
|
* What CORE serves beyond the baseline every backend has (events Phase 14a;
|
||||||
|
* `MODULE_API.md` §2.9). Opaque strings, the same word a module uses on
|
||||||
|
* `GET /public/modules` so a client feature-detects one way, and a **separate
|
||||||
|
* list** because core is not a module.
|
||||||
|
*
|
||||||
|
* **The value is in what is absent**, which is why the default is empty
|
||||||
|
* rather than something meaningful: a backend released before a capability
|
||||||
|
* existed omits the key entirely, and that is how the app tells an older site
|
||||||
|
* from one that simply has nothing to show. An unknown string is absent, and
|
||||||
|
* no route may be inferred from one.
|
||||||
|
*/
|
||||||
|
val capabilities: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One installed, **started** module on `GET /public/modules`.
|
||||||
|
*
|
||||||
|
* A module that is disabled or failed to load is absent rather than listed with a
|
||||||
|
* state — its routes and its nav are absent too, so a client renders a site
|
||||||
|
* without that capability rather than one advertising a capability that 503s.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class InstalledModuleDto(
|
||||||
|
val id: String = "",
|
||||||
|
val name: String = "",
|
||||||
|
val version: String = "",
|
||||||
|
val capabilities: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /public/modules` — what this backend is serving beyond core.
|
||||||
|
*
|
||||||
|
* Database-free and never gated by site mode, so the app can feature-detect
|
||||||
|
* during maintenance. A **500** is the one answer that is not an answer: core
|
||||||
|
* refuses to return `[]` for a list read before its loader ran, because a caller
|
||||||
|
* cannot tell an empty list from a mis-ordered boot.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ModulesDto(
|
||||||
|
val modules: List<InstalledModuleDto> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** `GET /public/status` — site mode + version for the first-run probe (§3). */
|
/** `GET /public/status` — site mode + version for the first-run probe (§3). */
|
||||||
|
|||||||
248
app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt
Normal file
248
app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTOs for `module-rust`'s public read path (`docs/modules/rust/PLAN.md` §17,
|
||||||
|
* §18; M14).
|
||||||
|
*
|
||||||
|
* **Every one of these renders while the game is off**, which is the module's own
|
||||||
|
* promise and therefore this leg's: the website never calls a game server from a
|
||||||
|
* page, it answers from its own tables, and a server nobody can reach answers
|
||||||
|
* `online: false` with everything it last said still attached. Nothing here has
|
||||||
|
* an "unavailable" shape, because there is no such answer on this wire.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** `GET /public/rust/servers` — every server this site follows. */
|
||||||
|
@Serializable
|
||||||
|
data class RustServerListDto(
|
||||||
|
val servers: List<RustServerDto> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `GET /public/rust/servers/{id}` — one of them, or a 404. */
|
||||||
|
@Serializable
|
||||||
|
data class RustServerResponse(
|
||||||
|
val server: RustServerDto = RustServerDto(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One Rust server and what it last reported.
|
||||||
|
*
|
||||||
|
* **[online] and [stale] are not the same fact and the screen needs both.**
|
||||||
|
* `online` is what the last frame said; `stale` is whether anything has arrived
|
||||||
|
* recently enough to believe it. The server computes `online` as *"the row says
|
||||||
|
* up AND the row is fresh"*, so a stale row can never claim a server is up — but
|
||||||
|
* `stale` still has to come through, because a fresh row saying "down" and a row
|
||||||
|
* nobody has written in an hour are different things to say to a reader.
|
||||||
|
*
|
||||||
|
* **[lastSeenAt] is what a page means by "last reported", and [updatedAt] is
|
||||||
|
* not.** The module shipped a defect on exactly this in phase 3 and fixed it in
|
||||||
|
* phase 4: `updatedAt` moves on every poll including a FAILED one, so reading it
|
||||||
|
* as "last reported" made an offline server claim it had just checked in, every
|
||||||
|
* thirty seconds, for as long as it stayed down. Only a frame moves
|
||||||
|
* `lastSeenAt`. The app must not repeat the mistake one tier along.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class RustServerDto(
|
||||||
|
val id: String = "",
|
||||||
|
val name: String = "",
|
||||||
|
val online: Boolean = false,
|
||||||
|
val players: Int = 0,
|
||||||
|
val maxPlayers: Int = 0,
|
||||||
|
val hostname: String? = null,
|
||||||
|
val level: String? = null,
|
||||||
|
val worldSize: Int? = null,
|
||||||
|
val seed: Long? = null,
|
||||||
|
/** The CURRENT wipe, from the state row rather than the newest ingested wipe. */
|
||||||
|
val wipeId: String? = null,
|
||||||
|
val wipedAt: String? = null,
|
||||||
|
/** When a frame last arrived. What "last reported" means. */
|
||||||
|
val lastSeenAt: String? = null,
|
||||||
|
/** When this module last wrote the row — a failed poll moves it too. */
|
||||||
|
val updatedAt: String? = null,
|
||||||
|
val stale: Boolean = false,
|
||||||
|
/**
|
||||||
|
* When the server wipes next (module-rust phase 16, D130), or null. Null
|
||||||
|
* both when the operator set no schedule and against a module older than
|
||||||
|
* phase 16, which does not send the field — either way nothing is drawn.
|
||||||
|
*/
|
||||||
|
val nextWipe: RustNextWipeDto? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The next wipe, as the module computed it from the operator's schedule on this
|
||||||
|
* read. [source] is `forced` (Facepunch's monthly forced wipe), `rule` (the
|
||||||
|
* server's own weekly or biweekly day) or `once` (a one-off date the operator
|
||||||
|
* set — a delayed or an extra wipe). An unknown word is shown like `rule`.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class RustNextWipeDto(
|
||||||
|
val at: String? = null,
|
||||||
|
val source: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /public/rust/servers/{id}/events` — the killfeed and everything else public.
|
||||||
|
*
|
||||||
|
* **Nothing names who is online by default** (org lead, 2026-09-22). Below the
|
||||||
|
* operator's presence audience — staff unless widened — the server withholds
|
||||||
|
* every item that says a named player was on (joins, deaths, chat, tallies) and
|
||||||
|
* says so with [presenceHidden]; [presenceAudience] is who CAN see them. The
|
||||||
|
* screen says it, so a thin feed reads as withheld rather than as a quiet server.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class RustEventListDto(
|
||||||
|
val events: List<RustEventDto> = emptyList(),
|
||||||
|
val presenceHidden: Boolean = false,
|
||||||
|
val presenceAudience: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One stored frame.
|
||||||
|
*
|
||||||
|
* **[frame] is deliberately untyped.** The module stores the whole frame the
|
||||||
|
* bridge plugin emitted and indexes only the columns it serves, so the fields
|
||||||
|
* differ per [kind] and a later protocol adds more. A sealed hierarchy here would
|
||||||
|
* have to be extended in this repo before a server running a newer plugin could
|
||||||
|
* say anything new, and the module's own rule is the opposite: an unknown kind
|
||||||
|
* renders as itself rather than being dropped. [RustFeed] is the one place that
|
||||||
|
* knows the field names.
|
||||||
|
*
|
||||||
|
* [t] is epoch milliseconds — the stamp the plugin put on the frame, not a
|
||||||
|
* database column, so it is a number here and an ISO string everywhere else on
|
||||||
|
* this wire.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class RustEventDto(
|
||||||
|
val id: Long = 0,
|
||||||
|
val kind: String = "",
|
||||||
|
val t: Long = 0,
|
||||||
|
val wipeId: String? = null,
|
||||||
|
val steamId: String? = null,
|
||||||
|
val frame: JsonObject = JsonObject(emptyMap()),
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* One frame field as text, or null.
|
||||||
|
*
|
||||||
|
* **A JSON `null` answers null, not the four letters.** The plugin writes
|
||||||
|
* explicit nulls — `reason` on a clean disconnect, `weapon` on a fall — and a
|
||||||
|
* primitive's `content` is the string `"null"` for every one of them, which
|
||||||
|
* would put the word into a killfeed line. An empty string answers null too:
|
||||||
|
* the callers here all mean "is there something to show".
|
||||||
|
*/
|
||||||
|
fun str(key: String): String? = primitive(key)?.content?.takeIf { it.isNotEmpty() }
|
||||||
|
|
||||||
|
/** One frame field as a number, or null when it is absent, null or not one. */
|
||||||
|
fun num(key: String): Double? = primitive(key)?.content?.toDoubleOrNull()
|
||||||
|
|
||||||
|
/** One frame field as a flag. Absent, null and anything non-boolean are all false. */
|
||||||
|
fun flag(key: String): Boolean = primitive(key)?.content == "true"
|
||||||
|
|
||||||
|
/** The raw element, for a caller that wants to decide for itself. */
|
||||||
|
fun raw(key: String): JsonElement? = frame[key]
|
||||||
|
|
||||||
|
private fun primitive(key: String): JsonPrimitive? =
|
||||||
|
(frame[key] as? JsonPrimitive)?.takeIf { it !is JsonNull }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /public/rust/servers/{id}/leaderboard` — per wipe, or all-time. */
|
||||||
|
@Serializable
|
||||||
|
data class RustLeaderboardDto(
|
||||||
|
val leaderboard: List<RustLeaderboardRowDto> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One player's standing.
|
||||||
|
*
|
||||||
|
* All-time is these same per-wipe rows summed rather than a second set of
|
||||||
|
* counters, so the two can never disagree — which is why a player who appears
|
||||||
|
* only in an older wipe **drops out** of the current one rather than reading
|
||||||
|
* zero. The screen must not fill that gap in with zeroes.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class RustLeaderboardRowDto(
|
||||||
|
val steamId: String = "",
|
||||||
|
val name: String? = null,
|
||||||
|
val kills: Int = 0,
|
||||||
|
val deaths: Int = 0,
|
||||||
|
val npcKills: Int = 0,
|
||||||
|
val structures: Int = 0,
|
||||||
|
val playtimeSec: Long = 0,
|
||||||
|
val lastSeen: String? = null,
|
||||||
|
/**
|
||||||
|
* The chat titles this player holds now (module-rust phase 17, M19) — the
|
||||||
|
* ones the game shows in chat, after the server's mode. They rank the
|
||||||
|
* CURRENT wipe whichever wipe the board is showing. Absent from an older
|
||||||
|
* module, which is why it defaults to empty: nothing is drawn.
|
||||||
|
*/
|
||||||
|
val titles: List<RustTitleDto> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One chat title: its text, and the operator's `#rrggbb` colour for it. */
|
||||||
|
@Serializable
|
||||||
|
data class RustTitleDto(
|
||||||
|
val text: String = "",
|
||||||
|
val color: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `GET /public/rust/servers/{id}/wipes` — every wipe this server has had, newest first. */
|
||||||
|
@Serializable
|
||||||
|
data class RustWipeListDto(
|
||||||
|
val wipes: List<RustWipeDto> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One wipe.
|
||||||
|
*
|
||||||
|
* [wipeId] is derived by the bridge plugin from the save's creation time and
|
||||||
|
* stamped on every frame, so it is the same id the feed and the leaderboard are
|
||||||
|
* filtered by — which is what makes the per-wipe view navigable at all.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class RustWipeDto(
|
||||||
|
val wipeId: String = "",
|
||||||
|
val saveCreatedAt: String? = null,
|
||||||
|
val firstSeen: String? = null,
|
||||||
|
val lastSeen: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /public/rust/servers/{id}/online` — who is on right now.
|
||||||
|
*
|
||||||
|
* Below the operator's presence audience the names are withheld: [hidden] is
|
||||||
|
* true, [players] is empty and [count] is still the real number — a count names
|
||||||
|
* nobody, and it is already on the server line. An empty list with [hidden] set
|
||||||
|
* must never render as "nobody is on".
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class RustOnlineDto(
|
||||||
|
val players: List<RustPresenceDto> = emptyList(),
|
||||||
|
val hidden: Boolean = false,
|
||||||
|
val count: Int = 0,
|
||||||
|
val audience: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of the presence board.
|
||||||
|
*
|
||||||
|
* Read from the board the bridge re-sends on every connect and every minute,
|
||||||
|
* rather than counted from connect and disconnect events — so it is right even
|
||||||
|
* after the website has missed one. **An unreachable server does not clear it**,
|
||||||
|
* deliberately: these rows are still the best answer anybody has. Presented bare
|
||||||
|
* they read as *who is on right now*, which is the one thing an offline server
|
||||||
|
* cannot be saying, so the screen has to say which it is.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class RustPresenceDto(
|
||||||
|
val steamId: String = "",
|
||||||
|
val name: String? = null,
|
||||||
|
val sleeping: Boolean = false,
|
||||||
|
val connectedAt: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTOs for `module-rust`'s map (`docs/modules/rust/PLAN.md` §30, §31; M17).
|
||||||
|
*
|
||||||
|
* **Who may see what is decided on the server, and these shapes only carry the
|
||||||
|
* answer.** Each of the four layers has its own audience, and the module removes
|
||||||
|
* a layer the viewer may not see before it answers: the layer is *absent*, not
|
||||||
|
* empty and not flagged. That is why every layer on [RustMapLiveDto] is nullable.
|
||||||
|
* A null list is "not yours"; an empty one is "yours, and nothing is there".
|
||||||
|
* The app has no gate of its own, so it has none to get wrong.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** `GET /public/rust/servers/{id}/map`: the picture, the frame, and which layers this viewer gets. */
|
||||||
|
@Serializable
|
||||||
|
data class RustMapDto(
|
||||||
|
val serverId: String = "",
|
||||||
|
/** Changes on a wipe or a new seed. A live answer naming another key means a new map. */
|
||||||
|
val mapKey: String? = null,
|
||||||
|
/** Null when the game has no picture: the layers are drawn on [RustMapGeometryDto.background]. */
|
||||||
|
val picture: RustMapPictureDto? = null,
|
||||||
|
/** Null when the server has never described its map. Nothing can be placed without it. */
|
||||||
|
val geometry: RustMapGeometryDto? = null,
|
||||||
|
/** Present only when the viewer may see the world layer. */
|
||||||
|
val monuments: List<RustMonumentDto>? = null,
|
||||||
|
val layers: RustMapLayersDto = RustMapLayersDto(),
|
||||||
|
val mates: RustMapMatesDto = RustMapMatesDto(),
|
||||||
|
val pollMs: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the picture is.
|
||||||
|
*
|
||||||
|
* [path] is relative to `/api/v1` and carries the picture's hash, so it is
|
||||||
|
* immutable: a hash that is no longer current is a 404, never the new bytes.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class RustMapPictureDto(
|
||||||
|
val path: String = "",
|
||||||
|
val source: String? = null,
|
||||||
|
val fetchedAt: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a world position reaches a pixel (§30.3). [oceanMargin] is in pixels and is
|
||||||
|
* not scaled; [gridCells] and [gridCellSize] are the game's own grid (D119).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class RustMapGeometryDto(
|
||||||
|
val worldSize: Double = 0.0,
|
||||||
|
val oceanMargin: Double = 0.0,
|
||||||
|
val width: Double = 0.0,
|
||||||
|
val height: Double = 0.0,
|
||||||
|
val gridCells: Int = 0,
|
||||||
|
val gridCellSize: Double = 0.0,
|
||||||
|
val background: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RustMonumentDto(
|
||||||
|
val value: String = "",
|
||||||
|
val kind: String = "",
|
||||||
|
val label: String = "",
|
||||||
|
val grid: String? = null,
|
||||||
|
val x: Double = 0.0,
|
||||||
|
val z: Double = 0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RustMapLayersDto(
|
||||||
|
val world: RustMapLayerDto = RustMapLayerDto(),
|
||||||
|
val events: RustMapLayerDto = RustMapLayerDto(),
|
||||||
|
val players: RustMapLayerDto = RustMapLayerDto(),
|
||||||
|
val bases: RustMapLayerDto = RustMapLayerDto(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this viewer gets one layer, and who does. A hidden layer never says
|
||||||
|
* what it holds. [cappedByPresence] is the players layer's alone: it is narrower
|
||||||
|
* than its own switch because who may see who is online is narrower (D113).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class RustMapLayerDto(
|
||||||
|
val visible: Boolean = false,
|
||||||
|
val audience: String? = null,
|
||||||
|
val cappedByPresence: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Whether this viewer gets their own position and their online clan mates' (D115). */
|
||||||
|
@Serializable
|
||||||
|
data class RustMapMatesDto(
|
||||||
|
val visible: Boolean = false,
|
||||||
|
/** The server's switch. */
|
||||||
|
val on: Boolean = false,
|
||||||
|
val linked: Boolean = false,
|
||||||
|
val signedIn: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /public/rust/servers/{id}/map/live`: what moves, cut down to this viewer.
|
||||||
|
*
|
||||||
|
* [live] false means the game did not answer and [reason] says why; the picture
|
||||||
|
* stays up. Positions are never stored by the site, so there is no last-known
|
||||||
|
* answer to fall back on here the way the presence board has one.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class RustMapLiveDto(
|
||||||
|
val live: Boolean = false,
|
||||||
|
val reason: String? = null,
|
||||||
|
val mapKey: String? = null,
|
||||||
|
val world: List<RustMapWorldDto>? = null,
|
||||||
|
val events: List<RustMapEventDto>? = null,
|
||||||
|
val players: List<RustMapPlayerDto>? = null,
|
||||||
|
val playersTruncated: Boolean = false,
|
||||||
|
val bases: List<RustMapBaseDto>? = null,
|
||||||
|
val basesTruncated: Boolean = false,
|
||||||
|
val mates: List<RustMapPlayerDto>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A world event: `cargo`, `heli`, `chinook`, `bradley`, `supply` or `crate`. */
|
||||||
|
@Serializable
|
||||||
|
data class RustMapWorldDto(
|
||||||
|
val kind: String = "",
|
||||||
|
val x: Double = 0.0,
|
||||||
|
val z: Double = 0.0,
|
||||||
|
/** A locked crate being hacked: seconds left. */
|
||||||
|
val hackLeftSec: Int? = null,
|
||||||
|
val hacked: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What one of this site's events placed: a `zone`, `crate` or `npc` (phase 13a).
|
||||||
|
*
|
||||||
|
* [runId] is core's run id **as a string**, because the plugin holds it as one.
|
||||||
|
* Core's own shapes carry it as a number, so the two are matched as text.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class RustMapEventDto(
|
||||||
|
val kind: String = "",
|
||||||
|
val runId: String? = null,
|
||||||
|
val x: Double = 0.0,
|
||||||
|
val z: Double = 0.0,
|
||||||
|
val radius: Double? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
val prefab: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A player on the players layer, or a mate. [self] is only ever set on a mate:
|
||||||
|
* one of the viewer's own accounts.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class RustMapPlayerDto(
|
||||||
|
val steamId: String = "",
|
||||||
|
val name: String? = null,
|
||||||
|
val x: Double = 0.0,
|
||||||
|
val z: Double = 0.0,
|
||||||
|
val sleeping: Boolean = false,
|
||||||
|
val online: Boolean = false,
|
||||||
|
val self: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A base: `tc` or `vending`. Positions only: no owner, no authorised list, no shop name. */
|
||||||
|
@Serializable
|
||||||
|
data class RustMapBaseDto(
|
||||||
|
val kind: String = "",
|
||||||
|
val x: Double = 0.0,
|
||||||
|
val z: Double = 0.0,
|
||||||
|
)
|
||||||
@@ -6,6 +6,7 @@ package com.runicgateway.app.data.repository
|
|||||||
import com.runicgateway.app.core.auth.DeviceNameProvider
|
import com.runicgateway.app.core.auth.DeviceNameProvider
|
||||||
import com.runicgateway.app.core.auth.SessionManager
|
import com.runicgateway.app.core.auth.SessionManager
|
||||||
import com.runicgateway.app.core.auth.TrustTokenStore
|
import com.runicgateway.app.core.auth.TrustTokenStore
|
||||||
|
import com.runicgateway.app.core.inbox.InboxCache
|
||||||
import com.runicgateway.app.core.push.PushManager
|
import com.runicgateway.app.core.push.PushManager
|
||||||
import com.runicgateway.app.data.api.AuthApi
|
import com.runicgateway.app.data.api.AuthApi
|
||||||
import com.runicgateway.app.data.api.SsoApi
|
import com.runicgateway.app.data.api.SsoApi
|
||||||
@@ -35,6 +36,7 @@ class AuthRepository @Inject constructor(
|
|||||||
private val ssoApi: SsoApi,
|
private val ssoApi: SsoApi,
|
||||||
private val sessionManager: SessionManager,
|
private val sessionManager: SessionManager,
|
||||||
private val pushManager: PushManager,
|
private val pushManager: PushManager,
|
||||||
|
private val inboxCache: InboxCache,
|
||||||
private val trustTokenStore: TrustTokenStore,
|
private val trustTokenStore: TrustTokenStore,
|
||||||
private val deviceNameProvider: DeviceNameProvider,
|
private val deviceNameProvider: DeviceNameProvider,
|
||||||
private val json: Json,
|
private val json: Json,
|
||||||
@@ -183,6 +185,18 @@ class AuthRepository @Inject constructor(
|
|||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// Ignore — local session teardown proceeds regardless.
|
// Ignore — local session teardown proceeds regardless.
|
||||||
}
|
}
|
||||||
|
// Drop the cached inbox with it: those are one person's notifications, and
|
||||||
|
// they have finished with this device. This is the tidy-up, not the
|
||||||
|
// safeguard — InboxCache scopes every snapshot to (base URL, user id), so
|
||||||
|
// the paths that never reach here (a dead refresh, a server switch) cannot
|
||||||
|
// surface one account's items under another's session either.
|
||||||
|
try {
|
||||||
|
inboxCache.clear()
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Ignore — same reason.
|
||||||
|
}
|
||||||
val refreshToken = sessionManager.currentRefreshToken()
|
val refreshToken = sessionManager.currentRefreshToken()
|
||||||
try {
|
try {
|
||||||
authApi.logout(MobileLogoutRequest(refreshToken = refreshToken, all = allDevices))
|
authApi.logout(MobileLogoutRequest(refreshToken = refreshToken, all = allDevices))
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class ConnectionRepository @Inject constructor(
|
|||||||
private val sessionManager: SessionManager,
|
private val sessionManager: SessionManager,
|
||||||
private val trustTokenStore: TrustTokenStore,
|
private val trustTokenStore: TrustTokenStore,
|
||||||
private val shardFeaturesRepository: ShardFeaturesRepository,
|
private val shardFeaturesRepository: ShardFeaturesRepository,
|
||||||
|
private val siteCapabilitiesRepository: SiteCapabilitiesRepository,
|
||||||
private val pushManager: com.runicgateway.app.core.push.PushManager,
|
private val pushManager: com.runicgateway.app.core.push.PushManager,
|
||||||
private val config: com.runicgateway.app.core.AppConfig,
|
private val config: com.runicgateway.app.core.AppConfig,
|
||||||
) {
|
) {
|
||||||
@@ -116,6 +117,10 @@ class ConnectionRepository @Inject constructor(
|
|||||||
// a switch between two signed-out hosts changes no session, so nothing else
|
// a switch between two signed-out hosts changes no session, so nothing else
|
||||||
// invalidates the cache and the new shard would inherit the old one's menu.
|
// invalidates the cache and the new shard would inherit the old one's menu.
|
||||||
shardFeaturesRepository.invalidate()
|
shardFeaturesRepository.invalidate()
|
||||||
|
// Same argument, one layer up: what the OLD host served says nothing about
|
||||||
|
// the new one, and a stale "this backend has no game module" would hide the
|
||||||
|
// new host's shard rows until its first successful read.
|
||||||
|
siteCapabilitiesRepository.invalidate()
|
||||||
prefs.clear()
|
prefs.clear()
|
||||||
baseUrlHolder.set(null)
|
baseUrlHolder.set(null)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.repository
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.core.result.map
|
||||||
|
import com.runicgateway.app.core.result.safeApiCall
|
||||||
|
import com.runicgateway.app.data.api.EventsApi
|
||||||
|
import com.runicgateway.app.data.api.dto.EventCalendarDto
|
||||||
|
import com.runicgateway.app.data.api.dto.EventHistoryEntryDto
|
||||||
|
import com.runicgateway.app.data.api.dto.EventSeriesDto
|
||||||
|
import com.runicgateway.app.data.api.dto.PublicEventDto
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event calendar, event pages, arcs and the caller's own participation
|
||||||
|
* history (PLAN.md §6.1, §9 M13).
|
||||||
|
*
|
||||||
|
* The two single-object reads unwrap their envelope here rather than in a view
|
||||||
|
* model, so a screen never holds a `…Response` whose only job was to carry one
|
||||||
|
* field. The calendar and the history keep theirs: `truncated` is a fact about
|
||||||
|
* the answer that the screen renders, and the history's page is a list the pager
|
||||||
|
* appends to.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class EventsRepository @Inject constructor(
|
||||||
|
private val api: EventsApi,
|
||||||
|
) {
|
||||||
|
/** The public calendar. Both ends optional; the server's default window is 31 days. */
|
||||||
|
suspend fun calendar(
|
||||||
|
from: String? = null,
|
||||||
|
to: String? = null,
|
||||||
|
seriesId: Long? = null,
|
||||||
|
): ApiResult<EventCalendarDto> = safeApiCall { api.getCalendar(from, to, seriesId) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One event, optionally about one occurrence.
|
||||||
|
*
|
||||||
|
* [run] is passed through untouched — including a run that belongs to some
|
||||||
|
* other event, which the server ignores rather than refusing. Filtering it
|
||||||
|
* here would turn a stale link into a dead end instead of a page about the
|
||||||
|
* thing the link was about.
|
||||||
|
*/
|
||||||
|
suspend fun event(slug: String, run: String? = null): ApiResult<PublicEventDto> =
|
||||||
|
safeApiCall { api.getEvent(slug, run?.takeIf { it.isNotBlank() }) }.map { it.event }
|
||||||
|
|
||||||
|
/** One arc. A series with nothing listed in it answers 404, not an empty page. */
|
||||||
|
suspend fun series(slug: String): ApiResult<EventSeriesDto> =
|
||||||
|
safeApiCall { api.getSeries(slug) }.map { it.series }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One page of the caller's own participation history, newest first.
|
||||||
|
*
|
||||||
|
* [before] is the id of the last row already shown — a keyset page, not an
|
||||||
|
* offset, because the list gains rows at the top as the reader attends things.
|
||||||
|
*/
|
||||||
|
suspend fun history(limit: Int, before: Long? = null): ApiResult<List<EventHistoryEntryDto>> =
|
||||||
|
safeApiCall { api.getHistory(limit, before) }.map { it.entries }
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.repository
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** A game module whose player surface can say whether this user has linked an account. */
|
||||||
|
enum class LinkSource { RUST, SHARD }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which module's link read answers for this site (`docs/modules/rust/PLAN.md` D69).
|
||||||
|
*
|
||||||
|
* **Read off the capabilities, never assumed.** Before phase 11 the answer was
|
||||||
|
* always `module-uo`'s `/player/shard/accounts`, which does not exist on a Rust
|
||||||
|
* site. That call failed, the failure read as "not linked", and the raid alert
|
||||||
|
* was locked in every channel.
|
||||||
|
*
|
||||||
|
* A host that has **never** answered asks both, and a failed read is simply no
|
||||||
|
* link. A site runs one module (§24.5), so at most one of the two answers. That
|
||||||
|
* is the same fail-open direction [canUse] takes. A host that answered and named
|
||||||
|
* neither module has no link to ask about.
|
||||||
|
*/
|
||||||
|
fun linkSourcesFor(capabilities: SiteCapabilities?): List<LinkSource> {
|
||||||
|
if (capabilities == null) return LinkSource.entries
|
||||||
|
return buildList {
|
||||||
|
if (Capability.RUST in capabilities) add(LinkSource.RUST)
|
||||||
|
if (Capability.SHARD in capabilities) add(LinkSource.SHARD)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the signed-in user holds at least one linked game account on this site.
|
||||||
|
*
|
||||||
|
* It exists for the settings screen's one courtesy gate: a `requiresLinkedAccount`
|
||||||
|
* stream cannot have push switched **on** without a link. It is a courtesy and not
|
||||||
|
* a boundary. The server enforces no such flag, and who is actually alerted is the
|
||||||
|
* module's recipient computation (Rust D59), so this never needs to be more than
|
||||||
|
* a best guess.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class LinkedAccountRepository @Inject constructor(
|
||||||
|
private val capabilities: SiteCapabilitiesRepository,
|
||||||
|
private val rust: PlayerRustRepository,
|
||||||
|
private val shard: PlayerShardRepository,
|
||||||
|
) {
|
||||||
|
suspend fun hasLinkedAccount(): Boolean =
|
||||||
|
linkSourcesFor(capabilities.capabilities.value).any { source ->
|
||||||
|
when (source) {
|
||||||
|
LinkSource.RUST -> (rust.links() as? ApiResult.Ok)?.data?.isNotEmpty() == true
|
||||||
|
LinkSource.SHARD -> (shard.accounts() as? ApiResult.Ok)?.data?.isNotEmpty() == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,16 +6,23 @@ package com.runicgateway.app.data.repository
|
|||||||
import com.runicgateway.app.core.result.ApiResult
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
import com.runicgateway.app.core.result.safeApiCall
|
import com.runicgateway.app.core.result.safeApiCall
|
||||||
import com.runicgateway.app.data.api.NotificationsApi
|
import com.runicgateway.app.data.api.NotificationsApi
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationChannelPrefDto
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsUpdateDto
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationInboxDto
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationReadResultDto
|
||||||
import com.runicgateway.app.data.api.dto.NotificationStreamsDto
|
import com.runicgateway.app.data.api.dto.NotificationStreamsDto
|
||||||
import com.runicgateway.app.data.api.dto.NotificationSubscriptionsDto
|
import com.runicgateway.app.data.api.dto.NotificationSubscriptionsDto
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationUnreadDto
|
||||||
import com.runicgateway.app.data.api.dto.PushDeviceDto
|
import com.runicgateway.app.data.api.dto.PushDeviceDto
|
||||||
import com.runicgateway.app.data.api.dto.RegisterDeviceRequest
|
import com.runicgateway.app.data.api.dto.RegisterDeviceRequest
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Device registration + per-user stream subscriptions over the opt-in push surface
|
* Device registration, stream subscriptions, per-channel preferences and the
|
||||||
* (PLAN.md §11, M7 Part 2). Every call returns a typed [ApiResult] so the screen
|
* in-app inbox — the whole `/auth/me` notification surface (PLAN.md §11,
|
||||||
|
* ENGAGEMENT.md phases 3 and 7/8). Every call returns a typed [ApiResult] so the screen
|
||||||
* and the [com.runicgateway.app.core.push.PushManager] degrade gracefully — a `400`
|
* and the [com.runicgateway.app.core.push.PushManager] degrade gracefully — a `400`
|
||||||
* (endpoint off the shard's allow-set) or a down backend never throws (§7).
|
* (endpoint off the shard's allow-set) or a down backend never throws (§7).
|
||||||
*/
|
*/
|
||||||
@@ -37,4 +44,33 @@ class NotificationsRepository @Inject constructor(
|
|||||||
|
|
||||||
suspend fun setSubscriptions(streams: List<String>): ApiResult<NotificationSubscriptionsDto> =
|
suspend fun setSubscriptions(streams: List<String>): ApiResult<NotificationSubscriptionsDto> =
|
||||||
safeApiCall { api.putSubscriptions(NotificationSubscriptionsDto(streams)) }
|
safeApiCall { api.putSubscriptions(NotificationSubscriptionsDto(streams)) }
|
||||||
|
|
||||||
|
// ── Per-channel preferences (phase 3) ──────────────────────────────────
|
||||||
|
|
||||||
|
suspend fun channelPrefs(): ApiResult<NotificationChannelPrefsDto> =
|
||||||
|
safeApiCall { api.channelPrefs() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write ONE (id, channel) → mode pair. The endpoint is sparse, so a screen
|
||||||
|
* saving a single toggle sends a single row and cannot disturb the others —
|
||||||
|
* including the ones it does not render.
|
||||||
|
*/
|
||||||
|
suspend fun setChannelMode(id: String, channel: String, mode: String): ApiResult<NotificationChannelPrefsDto> =
|
||||||
|
safeApiCall {
|
||||||
|
api.putChannelPrefs(
|
||||||
|
NotificationChannelPrefsUpdateDto(listOf(NotificationChannelPrefDto(id, channel, mode))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The inbox (phase 7/8) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** One page, newest first. [before] is the previous page's last id, never an offset. */
|
||||||
|
suspend fun inbox(before: Long? = null, unreadOnly: Boolean = false): ApiResult<NotificationInboxDto> =
|
||||||
|
safeApiCall { api.inbox(before = before, unread = if (unreadOnly) true else null) }
|
||||||
|
|
||||||
|
suspend fun unreadCount(): ApiResult<NotificationUnreadDto> = safeApiCall { api.unreadCount() }
|
||||||
|
|
||||||
|
suspend fun markRead(id: Long): ApiResult<NotificationReadResultDto> = safeApiCall { api.markRead(id) }
|
||||||
|
|
||||||
|
suspend fun markAllRead(): ApiResult<NotificationReadResultDto> = safeApiCall { api.markAllRead() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.repository
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.core.result.map
|
||||||
|
import com.runicgateway.app.core.result.safeApiCall
|
||||||
|
import com.runicgateway.app.data.api.PlayerRustApi
|
||||||
|
import com.runicgateway.app.data.api.dto.RustLinkDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustLinkRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.RustLinkResultDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A player's own Rust identity and what it earns them (PLAN.md §9 M15), over the
|
||||||
|
* bearer-gated `/player/rust/…` surface.
|
||||||
|
*
|
||||||
|
* Every call returns a typed [ApiResult] rather than throwing, like every other
|
||||||
|
* repository here — and on this surface the **status is the message**: the
|
||||||
|
* website answers a refused code `400`, a Steam account somebody else holds
|
||||||
|
* `409`, an unreachable server `503` and a capped attempt `429`, precisely so a
|
||||||
|
* client can tell a player what to do next without reading prose. The view model
|
||||||
|
* is where that mapping lives.
|
||||||
|
*
|
||||||
|
* Nothing is cached. The entitlement read in particular is a picture of what the
|
||||||
|
* site has confirmed into a game, and a stale copy of that would be the one kind
|
||||||
|
* of wrong answer this whole surface exists to avoid.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class PlayerRustRepository @Inject constructor(
|
||||||
|
private val api: PlayerRustApi,
|
||||||
|
) {
|
||||||
|
/** The Steam accounts the caller holds, newest first. */
|
||||||
|
suspend fun links(): ApiResult<List<RustLinkDto>> =
|
||||||
|
safeApiCall { api.links() }.map { it.links }
|
||||||
|
|
||||||
|
/** Redeem a code from `/link` in game. */
|
||||||
|
suspend fun link(code: String): ApiResult<RustLinkResultDto> =
|
||||||
|
safeApiCall { api.link(RustLinkRequest(code)) }
|
||||||
|
|
||||||
|
/** Release one of the caller's own links. */
|
||||||
|
suspend fun unlink(steamId: String): ApiResult<Boolean> =
|
||||||
|
safeApiCall { api.unlink(steamId) }.map { it.unlinked }
|
||||||
|
|
||||||
|
/** Ranks and grants the site holds for the caller, resolved per server. */
|
||||||
|
suspend fun permissions(): ApiResult<RustPlayerPermissionsDto> =
|
||||||
|
safeApiCall { api.permissions() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.repository
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.core.result.map
|
||||||
|
import com.runicgateway.app.core.result.safeApiCall
|
||||||
|
import com.runicgateway.app.data.api.RustApi
|
||||||
|
import com.runicgateway.app.data.api.dto.RustEventListDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustMapDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustMapLiveDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustOnlineDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustServerDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustWipeDto
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Rust module's public read path (PLAN.md §9 M14).
|
||||||
|
*
|
||||||
|
* Every read unwraps its envelope here rather than in a view model, so no screen
|
||||||
|
* holds a `…Dto` whose only job was to carry one list. Nothing is cached and
|
||||||
|
* nothing is merged: the module's tables are already the cache — the site's whole
|
||||||
|
* premise is that it answers from what a server last said rather than from the
|
||||||
|
* server — so a second copy in the app would only add a way for the two to
|
||||||
|
* disagree.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class RustRepository @Inject constructor(
|
||||||
|
private val api: RustApi,
|
||||||
|
) {
|
||||||
|
/** Every server this site follows, with what each last reported. */
|
||||||
|
suspend fun servers(): ApiResult<List<RustServerDto>> =
|
||||||
|
safeApiCall { api.getServers() }.map { it.servers }
|
||||||
|
|
||||||
|
/** One server. A 404 here means no such server, or one an operator disabled. */
|
||||||
|
suspend fun server(id: String): ApiResult<RustServerDto> =
|
||||||
|
safeApiCall { api.getServer(id) }.map { it.server }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The feed.
|
||||||
|
*
|
||||||
|
* [kinds] is joined here rather than by a caller, so the query string this
|
||||||
|
* app sends exists in one place — and an **empty** list is sent as no `kind`
|
||||||
|
* parameter at all, which asks for the whole allowlist. Sending `kind=` would
|
||||||
|
* ask for a kind named the empty string.
|
||||||
|
*/
|
||||||
|
suspend fun events(
|
||||||
|
id: String,
|
||||||
|
kinds: List<String> = emptyList(),
|
||||||
|
wipe: String? = null,
|
||||||
|
limit: Int? = null,
|
||||||
|
): ApiResult<RustEventListDto> = safeApiCall {
|
||||||
|
api.getEvents(
|
||||||
|
id = id,
|
||||||
|
kind = kinds.takeIf { it.isNotEmpty() }?.joinToString(","),
|
||||||
|
wipe = wipe?.takeIf { it.isNotBlank() },
|
||||||
|
limit = limit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The leaderboard: per wipe when [wipe] is given, all-time otherwise. */
|
||||||
|
suspend fun leaderboard(
|
||||||
|
id: String,
|
||||||
|
wipe: String? = null,
|
||||||
|
sort: String? = null,
|
||||||
|
limit: Int? = null,
|
||||||
|
): ApiResult<List<RustLeaderboardRowDto>> = safeApiCall {
|
||||||
|
api.getLeaderboard(
|
||||||
|
id = id,
|
||||||
|
wipe = wipe?.takeIf { it.isNotBlank() },
|
||||||
|
sort = sort?.takeIf { it.isNotBlank() },
|
||||||
|
limit = limit,
|
||||||
|
)
|
||||||
|
}.map { it.leaderboard }
|
||||||
|
|
||||||
|
/** Every wipe this server has had, newest first. */
|
||||||
|
suspend fun wipes(id: String): ApiResult<List<RustWipeDto>> =
|
||||||
|
safeApiCall { api.getWipes(id) }.map { it.wipes }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The presence board. Rows survive an unreachable server, by design.
|
||||||
|
*
|
||||||
|
* Answered whole rather than as its rows: `hidden` and `count` are what let
|
||||||
|
* the screen tell "withheld from you" from "nobody is on".
|
||||||
|
*/
|
||||||
|
suspend fun online(id: String): ApiResult<RustOnlineDto> =
|
||||||
|
safeApiCall { api.getOnline(id) }
|
||||||
|
|
||||||
|
/** The map's picture, frame and layer gates for this viewer. */
|
||||||
|
suspend fun map(id: String): ApiResult<RustMapDto> =
|
||||||
|
safeApiCall { api.getMap(id) }
|
||||||
|
|
||||||
|
/** What moves on the map, as this viewer may see it. */
|
||||||
|
suspend fun mapLive(id: String): ApiResult<RustMapLiveDto> =
|
||||||
|
safeApiCall { api.getMapLive(id) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.repository
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.core.result.safeApiCall
|
||||||
|
import com.runicgateway.app.data.api.PublicApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What this BACKEND serves — core's own capabilities and every started module's
|
||||||
|
* (PLAN.md §5, §9 M13; `docs/website/MODULE_API.md` §2.9).
|
||||||
|
*
|
||||||
|
* ## Why this exists at all, and why it is not [ShardFeaturesRepository]
|
||||||
|
*
|
||||||
|
* The two answer different questions and neither can answer the other's:
|
||||||
|
*
|
||||||
|
* - **Capability — is this module installed at all?** Per HOST. It changes when
|
||||||
|
* an operator installs or removes a module, so it is resolved beside the
|
||||||
|
* appearance and invalidated on a server switch.
|
||||||
|
* - **Feature — does this shard publish this surface to this viewer?** Per
|
||||||
|
* VIEWER. It changes on sign-in, which is why it is resolved on every session
|
||||||
|
* change.
|
||||||
|
*
|
||||||
|
* Without the first, the app cannot tell a module that is **not installed** from
|
||||||
|
* a lookup that failed: `GET /public/shard/features` 404s in both cases, and
|
||||||
|
* [ShardFeaturesRepository] maps every failure to "unknown", which [canSee]
|
||||||
|
* treats as visible. On a site running a different game that renders every shard
|
||||||
|
* row in the drawer and every one of them 404s when tapped.
|
||||||
|
*
|
||||||
|
* ## Absence of an answer is not an answer of absence
|
||||||
|
*
|
||||||
|
* The distinction this class exists to make, and the reason [SiteCapabilities]
|
||||||
|
* carries no "unknown" member of its own — the *absence of the whole value* is
|
||||||
|
* the unknown state:
|
||||||
|
*
|
||||||
|
* - a **successful** read that does not name a capability is an answer, and
|
||||||
|
* [canUse] hides what needs it;
|
||||||
|
* - a **failed** read keeps the last answer this host gave, because a moment
|
||||||
|
* with no connectivity is not an uninstall;
|
||||||
|
* - a host that has **never** answered leaves the value null, and [canUse]
|
||||||
|
* passes — the drawer renders as it did before this existed rather than
|
||||||
|
* flickering its rows in on every cold start.
|
||||||
|
*
|
||||||
|
* The last one is deliberately the same fail-open direction [canSee] takes, for
|
||||||
|
* the same reason: the server gates every call regardless, so the cost of
|
||||||
|
* guessing wrong is a link that briefly 404s.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class SiteCapabilitiesRepository @Inject constructor(
|
||||||
|
private val api: PublicApi,
|
||||||
|
) {
|
||||||
|
private val _capabilities = MutableStateFlow<SiteCapabilities?>(null)
|
||||||
|
|
||||||
|
/** The current answer, or `null` while this host has never given one. */
|
||||||
|
val capabilities: StateFlow<SiteCapabilities?> = _capabilities.asStateFlow()
|
||||||
|
|
||||||
|
// Serializes concurrent refreshes: the shell refreshes on resume and the
|
||||||
|
// connect flow refreshes on first load, and two overlapping reads would race
|
||||||
|
// to publish.
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-resolve what this backend serves.
|
||||||
|
*
|
||||||
|
* **Two calls, and one failing is not the same as both failing.** Core's list
|
||||||
|
* and a module's are separate lists (§2.9), so they are merged from separate
|
||||||
|
* reads and each is kept only if it answered. A backend released before
|
||||||
|
* events omits `capabilities` from its `version` block entirely, which is an
|
||||||
|
* answer — the empty list — and not a failure.
|
||||||
|
*/
|
||||||
|
suspend fun refresh() = mutex.withLock {
|
||||||
|
val status = safeApiCall { api.getStatus() }
|
||||||
|
val modules = safeApiCall { api.getModules() }
|
||||||
|
|
||||||
|
// Neither call answered: keep whatever this host said last, which for a
|
||||||
|
// host that has never answered is still null.
|
||||||
|
if (status !is ApiResult.Ok && modules !is ApiResult.Ok) return@withLock
|
||||||
|
|
||||||
|
val previous = _capabilities.value
|
||||||
|
val core = (status as? ApiResult.Ok)?.data?.version?.capabilities?.toSet()
|
||||||
|
?: previous?.core
|
||||||
|
?: emptySet()
|
||||||
|
val installed = (modules as? ApiResult.Ok)?.data?.modules
|
||||||
|
?.flatMap { it.capabilities }
|
||||||
|
?.toSet()
|
||||||
|
?: previous?.modules
|
||||||
|
?: emptySet()
|
||||||
|
|
||||||
|
_capabilities.value = SiteCapabilities(core = core, modules = installed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop the answer. Called on a Settings → Server switch: capabilities belong
|
||||||
|
* to the host that reported them, and the new host must not inherit them —
|
||||||
|
* a switch between two signed-out hosts changes no session, so nothing else
|
||||||
|
* would invalidate this.
|
||||||
|
*/
|
||||||
|
fun invalidate() {
|
||||||
|
_capabilities.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What one backend serves, as two lists rather than one.
|
||||||
|
*
|
||||||
|
* They are kept apart because core is not a module: merging them would leave the
|
||||||
|
* app unable to tell *"this backend has events"* from *"a module called core
|
||||||
|
* happens to be installed"*, which is exactly the distinction
|
||||||
|
* `GET /public/modules` exists to make. [canUse] looks in both, because a menu
|
||||||
|
* entry does not care which half serves it — but the halves stay separable, so a
|
||||||
|
* future caller that does care still can.
|
||||||
|
*/
|
||||||
|
data class SiteCapabilities(
|
||||||
|
/** Core's own, from the `version` block. Empty on a backend that predates them. */
|
||||||
|
val core: Set<String>,
|
||||||
|
/** Every started module's, flattened. Two modules may declare the same string. */
|
||||||
|
val modules: Set<String>,
|
||||||
|
) {
|
||||||
|
/** True when either half names [capability]. */
|
||||||
|
operator fun contains(capability: String): Boolean =
|
||||||
|
capability in core || capability in modules
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when [capability] may be relied on — **or when this host has not answered
|
||||||
|
* yet**.
|
||||||
|
*
|
||||||
|
* The null case is the fail-open one and it is not the same as the empty one: a
|
||||||
|
* [SiteCapabilities] that names nothing is a backend that told us it serves
|
||||||
|
* nothing extra, and that hides. See the class doc above.
|
||||||
|
*
|
||||||
|
* `null` [capability] means the caller declared none, which always passes.
|
||||||
|
*/
|
||||||
|
fun canUse(capabilities: SiteCapabilities?, capability: String?): Boolean =
|
||||||
|
capability == null || capabilities == null || capability in capabilities
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The capability strings the app gates on.
|
||||||
|
*
|
||||||
|
* **Deliberately few.** `module-uo` declares eight, and gating each shard row on
|
||||||
|
* its own would be a second, worse copy of what the per-viewer feature flags
|
||||||
|
* already decide — and one that drifts, because a capability is opaque to core
|
||||||
|
* and nothing checks the two agree. One string answers the only question a
|
||||||
|
* capability can: is the module there.
|
||||||
|
*/
|
||||||
|
object Capability {
|
||||||
|
/**
|
||||||
|
* A game module serving a live shard. Declared by `module-uo`; a different
|
||||||
|
* game's module that serves the same surfaces would declare it too, which is
|
||||||
|
* the point of an opaque string.
|
||||||
|
*/
|
||||||
|
const val SHARD = "shard"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Rust module (`docs/modules/rust/PLAN.md` D16, phase 5).
|
||||||
|
*
|
||||||
|
* A second game module, and therefore a second string rather than a second
|
||||||
|
* meaning for [SHARD]: a Rust site is a **fleet of servers** with a list
|
||||||
|
* above them, where a shard is one place — the surfaces are not the same
|
||||||
|
* shape and a client cannot render one as the other.
|
||||||
|
*
|
||||||
|
* `module-rust` also declares `servers`, `killfeed`, `leaderboard`,
|
||||||
|
* `presence` and `wipes`, and this gates on none of them. Every one of those
|
||||||
|
* names a SURFACE, and core flattens all modules' capabilities into one list
|
||||||
|
* — so `servers` is a word another module could declare tomorrow, which would
|
||||||
|
* silently reveal these rows on a site that does not run Rust. `rust` is the
|
||||||
|
* string only that module can mean, which is the same job [SHARD] does for
|
||||||
|
* `module-uo`.
|
||||||
|
*/
|
||||||
|
const val RUST = "rust"
|
||||||
|
|
||||||
|
/** Core's event system (events Phase 14a). Never a module's. */
|
||||||
|
const val EVENTS = "events"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Rust module's live map (`docs/modules/rust/PLAN.md` phase 14, D122).
|
||||||
|
*
|
||||||
|
* **A surface name, which [RUST]'s note warns against for a menu row** — and
|
||||||
|
* that warning is about a row reachable on any site. This gates one tab on
|
||||||
|
* the Rust server screen, which is reachable only where [RUST] already
|
||||||
|
* answered, so another module declaring `map` cannot reveal it anywhere the
|
||||||
|
* Rust module is absent. What it does answer is the one question that
|
||||||
|
* matters here: a module older than phase 14 does not declare it.
|
||||||
|
*/
|
||||||
|
const val MAP = "map"
|
||||||
|
}
|
||||||
@@ -15,11 +15,14 @@ import com.runicgateway.app.core.net.TokenAuthenticator
|
|||||||
import com.runicgateway.app.core.net.UserAgentInterceptor
|
import com.runicgateway.app.core.net.UserAgentInterceptor
|
||||||
import com.runicgateway.app.data.api.AuthApi
|
import com.runicgateway.app.data.api.AuthApi
|
||||||
import com.runicgateway.app.data.api.AuthRefreshApi
|
import com.runicgateway.app.data.api.AuthRefreshApi
|
||||||
|
import com.runicgateway.app.data.api.EventsApi
|
||||||
import com.runicgateway.app.data.api.MeApi
|
import com.runicgateway.app.data.api.MeApi
|
||||||
import com.runicgateway.app.data.api.AdminApi
|
import com.runicgateway.app.data.api.AdminApi
|
||||||
import com.runicgateway.app.data.api.NotificationsApi
|
import com.runicgateway.app.data.api.NotificationsApi
|
||||||
|
import com.runicgateway.app.data.api.PlayerRustApi
|
||||||
import com.runicgateway.app.data.api.PlayerShardApi
|
import com.runicgateway.app.data.api.PlayerShardApi
|
||||||
import com.runicgateway.app.data.api.PublicApi
|
import com.runicgateway.app.data.api.PublicApi
|
||||||
|
import com.runicgateway.app.data.api.RustApi
|
||||||
import com.runicgateway.app.data.api.SsoApi
|
import com.runicgateway.app.data.api.SsoApi
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
@@ -122,6 +125,37 @@ object NetworkModule {
|
|||||||
fun providePlayerShardApi(retrofit: Retrofit): PlayerShardApi =
|
fun providePlayerShardApi(retrofit: Retrofit): PlayerShardApi =
|
||||||
retrofit.create(PlayerShardApi::class.java)
|
retrofit.create(PlayerShardApi::class.java)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event surface (§9 M13). Three public reads and one bearer-authed player
|
||||||
|
* read on one interface — they are all CORE routes, so none of them is a
|
||||||
|
* module path and none is under `/shard`.
|
||||||
|
*/
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideEventsApi(retrofit: Retrofit): EventsApi = retrofit.create(EventsApi::class.java)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `module-rust`'s public read path (§9 M14).
|
||||||
|
*
|
||||||
|
* A MODULE's routes, unlike [provideEventsApi] beside it — they exist only on
|
||||||
|
* a backend where an operator installed the Rust module, and the drawer rows
|
||||||
|
* that lead to them are gated on its `rust` capability. Provided
|
||||||
|
* unconditionally all the same: a Retrofit interface costs nothing until
|
||||||
|
* something calls it, and there is nowhere at injection time to ask.
|
||||||
|
*/
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideRustApi(retrofit: Retrofit): RustApi = retrofit.create(RustApi::class.java)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A player's own Rust identity and entitlements (§9 M15) — bearer-authed on
|
||||||
|
* the main client, like [providePlayerShardApi] one game along.
|
||||||
|
*/
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun providePlayerRustApi(retrofit: Retrofit): PlayerRustApi =
|
||||||
|
retrofit.create(PlayerRustApi::class.java)
|
||||||
|
|
||||||
/** Opt-in push devices + subscriptions (§11, M7) — bearer-authed on the main client. */
|
/** Opt-in push devices + subscriptions (§11, M7) — bearer-authed on the main client. */
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
|
|||||||
@@ -11,13 +11,16 @@ import com.runicgateway.app.core.auth.TokenStore
|
|||||||
import com.runicgateway.app.core.auth.TrustTokenStore
|
import com.runicgateway.app.core.auth.TrustTokenStore
|
||||||
import com.runicgateway.app.core.auth.sso.EncryptedPendingSsoStore
|
import com.runicgateway.app.core.auth.sso.EncryptedPendingSsoStore
|
||||||
import com.runicgateway.app.core.auth.sso.PendingSsoStore
|
import com.runicgateway.app.core.auth.sso.PendingSsoStore
|
||||||
|
import com.runicgateway.app.core.inbox.DataStoreInboxCache
|
||||||
|
import com.runicgateway.app.core.inbox.InboxCache
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/** Binds the at-rest stores to their EncryptedSharedPreferences impls (§4.3). */
|
/** Binds the at-rest stores to their implementations — EncryptedSharedPreferences
|
||||||
|
* for anything secret (§4.3), plain DataStore for the inbox snapshot. */
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
abstract class StorageModule {
|
abstract class StorageModule {
|
||||||
@@ -38,4 +41,9 @@ abstract class StorageModule {
|
|||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindDeviceNameProvider(impl: BuildDeviceNameProvider): DeviceNameProvider
|
abstract fun bindDeviceNameProvider(impl: BuildDeviceNameProvider): DeviceNameProvider
|
||||||
|
|
||||||
|
/** The inbox's offline snapshot — plain DataStore, not encrypted (ENGAGEMENT.md phase 8). */
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindInboxCache(impl: DataStoreInboxCache): InboxCache
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import com.runicgateway.app.core.result.ApiResult
|
|||||||
import com.runicgateway.app.data.appearance.SiteAppearance
|
import com.runicgateway.app.data.appearance.SiteAppearance
|
||||||
import com.runicgateway.app.data.repository.ConnectionRepository
|
import com.runicgateway.app.data.repository.ConnectionRepository
|
||||||
import com.runicgateway.app.data.repository.SettingsRepository
|
import com.runicgateway.app.data.repository.SettingsRepository
|
||||||
|
import com.runicgateway.app.data.repository.SiteCapabilitiesRepository
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -29,6 +30,7 @@ class AppViewModel @Inject constructor(
|
|||||||
private val settingsRepository: SettingsRepository,
|
private val settingsRepository: SettingsRepository,
|
||||||
private val baseUrlHolder: BaseUrlHolder,
|
private val baseUrlHolder: BaseUrlHolder,
|
||||||
private val pushManager: PushManager,
|
private val pushManager: PushManager,
|
||||||
|
private val siteCapabilitiesRepository: SiteCapabilitiesRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
sealed interface AppState {
|
sealed interface AppState {
|
||||||
@@ -76,6 +78,12 @@ class AppViewModel @Inject constructor(
|
|||||||
fun refreshAppearance() {
|
fun refreshAppearance() {
|
||||||
if (_state.value !is AppState.Ready) return
|
if (_state.value !is AppState.Ready) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
// What the backend SERVES is a per-host fact and refreshes on the same
|
||||||
|
// clock as the appearance: an operator who installs a module while the
|
||||||
|
// app is backgrounded should see its rows on the next resume. Done
|
||||||
|
// before the early return below, because a failed settings read is no
|
||||||
|
// reason to skip it — they are separate calls to separate routes.
|
||||||
|
siteCapabilitiesRepository.refresh()
|
||||||
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data ?: return@launch
|
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data ?: return@launch
|
||||||
pushManager.setNtfyUrl(settings.push.ntfyUrl)
|
pushManager.setNtfyUrl(settings.push.ntfyUrl)
|
||||||
// changeServer() may have raced us back to the connect screen while the
|
// changeServer() may have raced us back to the connect screen while the
|
||||||
@@ -100,6 +108,7 @@ class AppViewModel @Inject constructor(
|
|||||||
* or sign-in. Returns [SiteAppearance.NONE] if settings couldn't be loaded.
|
* or sign-in. Returns [SiteAppearance.NONE] if settings couldn't be loaded.
|
||||||
*/
|
*/
|
||||||
private suspend fun loadAppearance(): SiteAppearance {
|
private suspend fun loadAppearance(): SiteAppearance {
|
||||||
|
siteCapabilitiesRepository.refresh()
|
||||||
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data
|
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data
|
||||||
pushManager.setNtfyUrl(settings?.push?.ntfyUrl)
|
pushManager.setNtfyUrl(settings?.push?.ntfyUrl)
|
||||||
return SiteAppearance.from(settings)
|
return SiteAppearance.from(settings)
|
||||||
|
|||||||
110
app/src/main/java/com/runicgateway/app/ui/Polling.kt
Normal file
110
app/src/main/java/com/runicgateway/app/ui/Polling.kt
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repeated reads of a surface that changes while somebody is looking at it
|
||||||
|
* (`docs/modules/rust/PLAN.md` D14, and D17 for this leg).
|
||||||
|
*
|
||||||
|
* ## Why a refresh is not a load
|
||||||
|
*
|
||||||
|
* The app has had exactly one shape for a read until now: set [UiState.Loading],
|
||||||
|
* ask, replace. That is right for opening a screen and wrong for a poll — a
|
||||||
|
* twenty-second refresh built on it would clear the killfeed, put a spinner where
|
||||||
|
* it was and re-fill it, three times a minute, for ever. The website hit the same
|
||||||
|
* wall one tier along: core's `useAsync` blanks its data on every dependency
|
||||||
|
* change, so `module-rust` bundles its own `usePolled`. This is that hook's other
|
||||||
|
* half.
|
||||||
|
*
|
||||||
|
* The rule both ends keep: **a refresh is invisible when it succeeds, and keeps
|
||||||
|
* the rows when it fails.** A site whose whole premise is "it renders while the
|
||||||
|
* game is off" must not blank itself the first time a request does.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** How often a live surface re-reads itself while somebody is looking at it (D17). */
|
||||||
|
const val POLL_INTERVAL_MS = 20_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a poll produced: the state to render, and whether the last attempt failed.
|
||||||
|
*
|
||||||
|
* Two fields rather than a wider [UiState] because they are two facts and a
|
||||||
|
* screen renders them in different places — the rows in the list, the failure as
|
||||||
|
* a quiet line above it. Collapsing them would force the choice this exists to
|
||||||
|
* avoid: show the error and lose the rows, or keep the rows and say nothing.
|
||||||
|
*/
|
||||||
|
data class Polled<out T>(
|
||||||
|
val state: UiState<T> = UiState.Loading,
|
||||||
|
/** True when the most recent refresh failed **and there were rows to keep**. */
|
||||||
|
val refreshFailed: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold a refresh into what is already on screen.
|
||||||
|
*
|
||||||
|
* Three cases, and the middle one is the whole point:
|
||||||
|
*
|
||||||
|
* - **It answered.** The new data replaces the old and any previous failure
|
||||||
|
* clears. This is the ordinary path and it is silent.
|
||||||
|
* - **It failed, and there are rows.** The rows stay exactly as they are and the
|
||||||
|
* failure is reported beside them. Nothing is blanked and nothing is retried
|
||||||
|
* on the reader's behalf — the next tick is twenty seconds away.
|
||||||
|
* - **It failed, and there is nothing yet.** There is nothing to protect, so it
|
||||||
|
* becomes an ordinary error with a retry — which is what the first load
|
||||||
|
* failing means.
|
||||||
|
*
|
||||||
|
* Pure, and takes the current state rather than reading one, so the rule is
|
||||||
|
* tested without a dispatcher, a view model or Compose.
|
||||||
|
*/
|
||||||
|
fun <T> refreshInto(current: UiState<T>, result: ApiResult<T>): Polled<T> = when {
|
||||||
|
result is ApiResult.Ok -> Polled(UiState.Success(result.data), refreshFailed = false)
|
||||||
|
current is UiState.Success -> Polled(current, refreshFailed = true)
|
||||||
|
else -> Polled(result.toUiState(), refreshFailed = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run [block] now and every [intervalMs] for as long as this screen is resumed.
|
||||||
|
*
|
||||||
|
* `repeatOnLifecycle` is what makes this the phone's version of D14's Page
|
||||||
|
* Visibility gate, and it gets three behaviours from one line:
|
||||||
|
*
|
||||||
|
* - **Nothing runs while the app is away.** The coroutine is cancelled at
|
||||||
|
* `onPause`, so a backgrounded app makes no requests at all — not a slower
|
||||||
|
* poll, none.
|
||||||
|
* - **Coming back refreshes immediately.** The block is restarted from the top
|
||||||
|
* at `onResume`, which calls [block] before the first [delay] — so the first
|
||||||
|
* thing a returning reader sees is current, not up to twenty seconds old.
|
||||||
|
* - **A dialog or the recents switcher pauses it**, because that is what RESUMED
|
||||||
|
* means. The alternative, STARTED, keeps polling behind a partially
|
||||||
|
* obscured screen, which is precisely the reader who is not reading.
|
||||||
|
*
|
||||||
|
* **Keyed on the lifecycle owner alone, and [block] is held through
|
||||||
|
* `rememberUpdatedState`.** Keying on the block would restart the loop on every
|
||||||
|
* recomposition, because a lambda is a new object each time; capturing it without
|
||||||
|
* `rememberUpdatedState` would freeze the *first* one, so a tab change or a
|
||||||
|
* newly chosen wipe would keep refreshing the question the reader has stopped
|
||||||
|
* asking. The loop is stable and what it calls is current.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun PollWhileResumed(intervalMs: Long = POLL_INTERVAL_MS, block: suspend () -> Unit) {
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
val current by rememberUpdatedState(block)
|
||||||
|
LaunchedEffect(lifecycleOwner) {
|
||||||
|
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||||
|
while (true) {
|
||||||
|
current()
|
||||||
|
delay(intervalMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,10 +7,12 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ExitToApp
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -21,6 +23,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.ModalDrawerSheet
|
import androidx.compose.material3.ModalDrawerSheet
|
||||||
import androidx.compose.material3.ModalNavigationDrawer
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
import androidx.compose.material3.NavigationDrawerItem
|
import androidx.compose.material3.NavigationDrawerItem
|
||||||
|
import androidx.compose.material3.NavigationDrawerItemColors
|
||||||
import androidx.compose.material3.NavigationDrawerItemDefaults
|
import androidx.compose.material3.NavigationDrawerItemDefaults
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -32,7 +35,10 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
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 androidx.compose.ui.unit.sp
|
||||||
@@ -48,24 +54,37 @@ import androidx.navigation.compose.rememberNavController
|
|||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import com.runicgateway.app.R
|
import com.runicgateway.app.R
|
||||||
import com.runicgateway.app.core.auth.Session
|
import com.runicgateway.app.core.auth.Session
|
||||||
|
import com.runicgateway.app.core.web.WebHandoff
|
||||||
import com.runicgateway.app.data.api.dto.BrandDto
|
import com.runicgateway.app.data.api.dto.BrandDto
|
||||||
|
import com.runicgateway.app.data.appearance.SiteAppearance
|
||||||
|
import com.runicgateway.app.data.repository.Capability
|
||||||
import com.runicgateway.app.ui.auth.AccountScreen
|
import com.runicgateway.app.ui.auth.AccountScreen
|
||||||
import com.runicgateway.app.ui.auth.LoginScreen
|
import com.runicgateway.app.ui.auth.LoginScreen
|
||||||
import com.runicgateway.app.ui.auth.RecoveryCodesScreen
|
import com.runicgateway.app.ui.auth.RecoveryCodesScreen
|
||||||
import com.runicgateway.app.ui.auth.TrustedDevicesScreen
|
import com.runicgateway.app.ui.auth.TrustedDevicesScreen
|
||||||
import com.runicgateway.app.ui.auth.roleLabelRes
|
import com.runicgateway.app.ui.auth.roleLabelRes
|
||||||
|
import com.runicgateway.app.ui.components.BrandLogo
|
||||||
import com.runicgateway.app.ui.contact.ContactScreen
|
import com.runicgateway.app.ui.contact.ContactScreen
|
||||||
|
import com.runicgateway.app.ui.events.EventScreen
|
||||||
|
import com.runicgateway.app.ui.events.EventSeriesScreen
|
||||||
|
import com.runicgateway.app.ui.events.EventsScreen
|
||||||
|
import com.runicgateway.app.ui.events.MyEventsScreen
|
||||||
import com.runicgateway.app.ui.home.HomeScreen
|
import com.runicgateway.app.ui.home.HomeScreen
|
||||||
import com.runicgateway.app.ui.navigation.APP_MENU
|
import com.runicgateway.app.ui.navigation.APP_MENU
|
||||||
|
import com.runicgateway.app.ui.navigation.NavNode
|
||||||
import com.runicgateway.app.ui.navigation.Routes
|
import com.runicgateway.app.ui.navigation.Routes
|
||||||
import com.runicgateway.app.ui.navigation.visibleEntries
|
import com.runicgateway.app.ui.navigation.buildNavTree
|
||||||
|
import com.runicgateway.app.ui.navigation.isEntryVisible
|
||||||
|
import com.runicgateway.app.ui.navigation.pruneNav
|
||||||
import com.runicgateway.app.ui.news.NewsScreen
|
import com.runicgateway.app.ui.news.NewsScreen
|
||||||
import com.runicgateway.app.ui.news.PostScreen
|
import com.runicgateway.app.ui.news.PostScreen
|
||||||
import com.runicgateway.app.ui.admin.AdminContentScreen
|
import com.runicgateway.app.ui.admin.AdminContentScreen
|
||||||
import com.runicgateway.app.ui.admin.AdminDashboardScreen
|
import com.runicgateway.app.ui.admin.AdminDashboardScreen
|
||||||
import com.runicgateway.app.ui.admin.AdminModerationScreen
|
import com.runicgateway.app.ui.admin.AdminModerationScreen
|
||||||
import com.runicgateway.app.ui.admin.AdminSupportScreen
|
import com.runicgateway.app.ui.admin.AdminSupportScreen
|
||||||
import com.runicgateway.app.ui.notifications.NotificationsScreen
|
import com.runicgateway.app.ui.notifications.InboxBadgeViewModel
|
||||||
|
import com.runicgateway.app.ui.notifications.InboxScreen
|
||||||
|
import com.runicgateway.app.ui.notifications.NotificationSettingsScreen
|
||||||
import com.runicgateway.app.ui.page.PageScreen
|
import com.runicgateway.app.ui.page.PageScreen
|
||||||
import com.runicgateway.app.ui.player.CharacterSheetScreen
|
import com.runicgateway.app.ui.player.CharacterSheetScreen
|
||||||
import com.runicgateway.app.ui.player.CharactersScreen
|
import com.runicgateway.app.ui.player.CharactersScreen
|
||||||
@@ -83,7 +102,12 @@ import com.runicgateway.app.ui.shard.MarketScreen
|
|||||||
import com.runicgateway.app.ui.shard.MarketVendorScreen
|
import com.runicgateway.app.ui.shard.MarketVendorScreen
|
||||||
import com.runicgateway.app.ui.shard.RulesScreen
|
import com.runicgateway.app.ui.shard.RulesScreen
|
||||||
import com.runicgateway.app.ui.shard.ShardBoard
|
import com.runicgateway.app.ui.shard.ShardBoard
|
||||||
|
import com.runicgateway.app.ui.rust.RustBadgeViewModel
|
||||||
|
import com.runicgateway.app.ui.rust.RustAccountScreen
|
||||||
|
import com.runicgateway.app.ui.rust.RustServerScreen
|
||||||
|
import com.runicgateway.app.ui.rust.RustServersScreen
|
||||||
import com.runicgateway.app.ui.shard.ShardScreen
|
import com.runicgateway.app.ui.shard.ShardScreen
|
||||||
|
import com.runicgateway.app.ui.theme.LocalShardStructure
|
||||||
import com.runicgateway.app.ui.wiki.WikiPageScreen
|
import com.runicgateway.app.ui.wiki.WikiPageScreen
|
||||||
import com.runicgateway.app.ui.wiki.WikiScreen
|
import com.runicgateway.app.ui.wiki.WikiScreen
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -95,7 +119,16 @@ private val TOP_LEVEL_ROUTES = setOf(
|
|||||||
// on them too (M11).
|
// on them too (M11).
|
||||||
Routes.SHARD_RULES, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.ATLAS,
|
Routes.SHARD_RULES, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.ATLAS,
|
||||||
Routes.NOTIFICATIONS,
|
Routes.NOTIFICATIONS,
|
||||||
|
// Events (M13): the calendar and the history are drawer rows, so the drawer
|
||||||
|
// gesture works on them. The event page and an arc are detail screens and are
|
||||||
|
// deliberately absent — a back gesture there means "back", not "open the menu".
|
||||||
|
Routes.EVENTS, Routes.MY_EVENTS,
|
||||||
|
// The Rust server list is a drawer row (M14); one server's page is a detail
|
||||||
|
// screen and is deliberately absent — a back gesture there means "back".
|
||||||
|
Routes.RUST,
|
||||||
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
|
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
|
||||||
|
// The player's own Rust account (M15) — a drawer row like the three above it.
|
||||||
|
Routes.PLAYER_RUST,
|
||||||
Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT,
|
Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -109,13 +142,17 @@ private val TOP_LEVEL_ROUTES = setOf(
|
|||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RunicApp(
|
fun RunicApp(
|
||||||
brand: BrandDto?,
|
appearance: SiteAppearance,
|
||||||
onChangeServer: () -> Unit,
|
onChangeServer: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
deepLinkStream: String? = null,
|
deepLinkStream: String? = null,
|
||||||
|
deepLinkRef: String? = null,
|
||||||
onDeepLinkConsumed: () -> Unit = {},
|
onDeepLinkConsumed: () -> Unit = {},
|
||||||
sessionViewModel: SessionViewModel = hiltViewModel(),
|
sessionViewModel: SessionViewModel = hiltViewModel(),
|
||||||
|
inboxBadgeViewModel: InboxBadgeViewModel = hiltViewModel(),
|
||||||
|
rustBadgeViewModel: RustBadgeViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
|
val brand = appearance.brand
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -123,17 +160,44 @@ fun RunicApp(
|
|||||||
val session by sessionViewModel.session.collectAsStateWithLifecycle()
|
val session by sessionViewModel.session.collectAsStateWithLifecycle()
|
||||||
// What this shard publishes, independently of who the caller is (§5, M11).
|
// What this shard publishes, independently of who the caller is (§5, M11).
|
||||||
val shardFeatures by sessionViewModel.shardFeatures.collectAsStateWithLifecycle()
|
val shardFeatures by sessionViewModel.shardFeatures.collectAsStateWithLifecycle()
|
||||||
|
// What this BACKEND serves at all, independently of both (§5, M13). A different
|
||||||
|
// question from the line above and gated separately — see `isEntryVisible`.
|
||||||
|
val capabilities by sessionViewModel.capabilities.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// Re-validate the cached role each time the app returns to the foreground (§4.3).
|
// Re-validate the cached role each time the app returns to the foreground (§4.3),
|
||||||
LifecycleResumeEffect(Unit) {
|
// and re-read the two drawer counts with it: a tickle that arrived while the app
|
||||||
|
// was away is exactly what brings someone back to it, and a live player count is
|
||||||
|
// only live if it is re-read when somebody looks.
|
||||||
|
LifecycleResumeEffect(capabilities) {
|
||||||
sessionViewModel.revalidate()
|
sessionViewModel.revalidate()
|
||||||
|
inboxBadgeViewModel.refresh()
|
||||||
|
// The Rust count is a LIVE number, so it is re-read on the same clock the
|
||||||
|
// unread badge is: coming back to the app is exactly when a stale one
|
||||||
|
// would be noticed. Keyed on the capability answer as well as on resume,
|
||||||
|
// because the very first resume happens before this host has said whether
|
||||||
|
// the module is there — and asking then would either make a request on a
|
||||||
|
// site that has no Rust, or never make one at all.
|
||||||
|
capabilities?.let { rustBadgeViewModel.refresh(Capability.RUST in it) }
|
||||||
onPauseOrDispose { }
|
onPauseOrDispose { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val unread by inboxBadgeViewModel.unread.collectAsStateWithLifecycle()
|
||||||
|
// How many people are on the Rust fleet, for the drawer row's badge — the
|
||||||
|
// phone's answer to D15's footer count (M14). Refreshed on resume, never on a
|
||||||
|
// timer: a badge is a glance, not a feed. Gated here rather than inside the
|
||||||
|
// view model because this is the only place that knows whether the module is
|
||||||
|
// installed at all, and a host that has not answered yet asks nothing.
|
||||||
|
val rustOnline by rustBadgeViewModel.online.collectAsStateWithLifecycle()
|
||||||
|
// The badge follows the session, so signing out clears it rather than leaving
|
||||||
|
// the previous account's count on the drawer.
|
||||||
|
LaunchedEffect(session) { inboxBadgeViewModel.refresh() }
|
||||||
|
|
||||||
// A tapped push notification deep-links to its stream's screen (§11, item 7).
|
// A tapped push notification deep-links to its stream's screen (§11, item 7).
|
||||||
LaunchedEffect(deepLinkStream) {
|
LaunchedEffect(deepLinkStream, deepLinkRef) {
|
||||||
val stream = deepLinkStream ?: return@LaunchedEffect
|
val stream = deepLinkStream ?: return@LaunchedEffect
|
||||||
navController.navigate(Routes.forStream(stream)) {
|
// Both halves of the tickle: a `notification:` ref means there is an inbox
|
||||||
|
// row waiting, and that is where the tap goes (ENGAGEMENT.md phase 8).
|
||||||
|
navController.navigate(Routes.forTickle(stream, deepLinkRef)) {
|
||||||
popUpTo(Routes.HOME) { saveState = true }
|
popUpTo(Routes.HOME) { saveState = true }
|
||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
}
|
}
|
||||||
@@ -141,9 +205,37 @@ fun RunicApp(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val backStackEntry by navController.currentBackStackEntryAsState()
|
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||||
val currentRoute = backStackEntry?.destination?.route
|
// A destination's route is its NavHost *pattern*, so News reports
|
||||||
|
// "news?category={category}" (§6.2). Compare on the part before the query.
|
||||||
|
val currentRoute = backStackEntry?.destination?.route?.substringBefore('?')
|
||||||
val isTopLevel = currentRoute in TOP_LEVEL_ROUTES
|
val isTopLevel = currentRoute in TOP_LEVEL_ROUTES
|
||||||
val entries = visibleEntries(APP_MENU, session, shardFeatures)
|
// The admin's nav overrides, then the gates — never the other way round. An
|
||||||
|
// override is presentation only: it may relabel, reorder, group and hide, so
|
||||||
|
// `pruneNav` still decides what this caller may see and remains the boundary
|
||||||
|
// (§6.1, AC-3). With no stored row the merge returns APP_MENU itself.
|
||||||
|
val nav = pruneNav(buildNavTree(APP_MENU, appearance.navPublic)) {
|
||||||
|
isEntryVisible(it, session, shardFeatures, capabilities)
|
||||||
|
}
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
// An added link's path is site-relative; a hand-off needs it absolute against
|
||||||
|
// the configured base URL, which is exactly what the asset resolver does (§6.3).
|
||||||
|
val resolveUrl = LocalAssetResolver.current
|
||||||
|
val openNode: (NavNode) -> Unit = { node ->
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
when (node) {
|
||||||
|
is NavNode.Item -> navController.navigateTopLevel(node.entry.route)
|
||||||
|
// A link the app resolved opens like any other drawer row, detail screen
|
||||||
|
// or not: one rule, and back-press lands on Home as it does from every
|
||||||
|
// row. One it could not resolve goes to the browser, absolute against
|
||||||
|
// the site's base URL (§6.3).
|
||||||
|
is NavNode.Link -> node.route
|
||||||
|
?.let { navController.navigateTopLevel(it) }
|
||||||
|
?: resolveUrl(node.path)?.let { WebHandoff.open(context, it) }
|
||||||
|
// Section headers aren't clickable — the group is always open (§6.3).
|
||||||
|
is NavNode.Section -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ModalNavigationDrawer(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
@@ -161,6 +253,14 @@ fun RunicApp(
|
|||||||
// unreachable. See RunicGateway M10.
|
// unreachable. See RunicGateway M10.
|
||||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
// The instance's logo above its name (§5.6). Decorative — the name
|
||||||
|
// is the very next line — and absent on an instance that uploaded
|
||||||
|
// none, in which case the header is exactly what it was before M12.
|
||||||
|
BrandLogo(
|
||||||
|
logo = brand?.logo,
|
||||||
|
height = 32.dp,
|
||||||
|
modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 4.dp),
|
||||||
|
)
|
||||||
Text(
|
Text(
|
||||||
text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name),
|
text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
@@ -169,17 +269,41 @@ fun RunicApp(
|
|||||||
)
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
entries.forEach { entry ->
|
nav.forEach { node ->
|
||||||
NavigationDrawerItem(
|
if (node is NavNode.Section) {
|
||||||
label = { Text(stringResource(entry.labelRes)) },
|
// A group the admin created: its label as a header, its rows
|
||||||
selected = currentRoute == entry.route,
|
// beneath it. Always open — a drawer is already a vertical
|
||||||
onClick = {
|
// list, so the website's dropdown does not translate (§6.3).
|
||||||
scope.launch { drawerState.close() }
|
Text(
|
||||||
navController.navigateTopLevel(entry.route)
|
text = node.label,
|
||||||
},
|
style = MaterialTheme.typography.labelLarge,
|
||||||
colors = drawerItemColors,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
modifier = Modifier.padding(
|
||||||
)
|
start = 28.dp,
|
||||||
|
end = 28.dp,
|
||||||
|
top = 12.dp,
|
||||||
|
bottom = 4.dp,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
node.items.forEach { child ->
|
||||||
|
NavRow(
|
||||||
|
node = child,
|
||||||
|
currentRoute = currentRoute,
|
||||||
|
colors = drawerItemColors,
|
||||||
|
indented = true,
|
||||||
|
unread = unread,
|
||||||
|
rustOnline = rustOnline,
|
||||||
|
) { openNode(child) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
NavRow(
|
||||||
|
node = node,
|
||||||
|
currentRoute = currentRoute,
|
||||||
|
colors = drawerItemColors,
|
||||||
|
unread = unread,
|
||||||
|
rustOnline = rustOnline,
|
||||||
|
) { openNode(node) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||||
@@ -203,6 +327,7 @@ fun RunicApp(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors = drawerItemColors,
|
colors = drawerItemColors,
|
||||||
|
shape = LocalShardStructure.current.pill,
|
||||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
||||||
)
|
)
|
||||||
NavigationDrawerItem(
|
NavigationDrawerItem(
|
||||||
@@ -213,6 +338,7 @@ fun RunicApp(
|
|||||||
onChangeServer()
|
onChangeServer()
|
||||||
},
|
},
|
||||||
colors = drawerItemColors,
|
colors = drawerItemColors,
|
||||||
|
shape = LocalShardStructure.current.pill,
|
||||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -230,13 +356,24 @@ fun RunicApp(
|
|||||||
actionIconContentColor = MaterialTheme.colorScheme.onSurface,
|
actionIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
title = {
|
title = {
|
||||||
Text(
|
val name = brand?.name?.takeIf { it.isNotBlank() }
|
||||||
text = (brand?.name?.takeIf { it.isNotBlank() }
|
?: stringResource(R.string.app_name)
|
||||||
?: stringResource(R.string.app_name)).uppercase(),
|
// The logo stands in for the title here, so unlike the drawer's
|
||||||
style = MaterialTheme.typography.titleSmall.copy(letterSpacing = 1.2.sp),
|
// it is named for a screen reader — and it falls back to the
|
||||||
maxLines = 1,
|
// text when the instance has no logo or the load fails (§5.6).
|
||||||
overflow = TextOverflow.Ellipsis,
|
BrandLogo(
|
||||||
)
|
logo = brand?.logo,
|
||||||
|
height = 24.dp,
|
||||||
|
contentDescription = name,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = name.uppercase(),
|
||||||
|
style = MaterialTheme.typography.titleSmall
|
||||||
|
.copy(letterSpacing = 1.2.sp),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
if (isTopLevel) {
|
if (isTopLevel) {
|
||||||
@@ -267,6 +404,103 @@ fun RunicApp(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One drawer row: a coded entry, or an admin's added link (§6.3).
|
||||||
|
*
|
||||||
|
* A link that the app can open natively is deliberately indistinguishable from a
|
||||||
|
* coded row — that is the point of resolving it. One that hands off to the browser
|
||||||
|
* carries a trailing icon, so leaving the app is never a surprise.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun NavRow(
|
||||||
|
node: NavNode,
|
||||||
|
currentRoute: String?,
|
||||||
|
colors: NavigationDrawerItemColors,
|
||||||
|
indented: Boolean = false,
|
||||||
|
unread: Int = 0,
|
||||||
|
rustOnline: Int = 0,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val route = when (node) {
|
||||||
|
is NavNode.Item -> node.entry.route
|
||||||
|
is NavNode.Link -> node.route
|
||||||
|
is NavNode.Section -> null
|
||||||
|
}
|
||||||
|
val label = when (node) {
|
||||||
|
// An admin's label wins over the bundled one, and is the same string in
|
||||||
|
// every locale — see MenuEntry.label.
|
||||||
|
is NavNode.Item -> node.entry.label ?: stringResource(node.entry.labelRes)
|
||||||
|
is NavNode.Link -> node.label
|
||||||
|
is NavNode.Section -> return
|
||||||
|
}
|
||||||
|
val handsOff = node is NavNode.Link && node.route == null
|
||||||
|
// The unread count rides on whichever row leads to the inbox — including an
|
||||||
|
// admin's own nav override pointing at it, since the badge belongs to the
|
||||||
|
// destination, not to the bundled entry.
|
||||||
|
val showsUnread = !handsOff && unread > 0 && route == Routes.NOTIFICATIONS
|
||||||
|
// The live player count rides on whichever row leads to the Rust list, for the
|
||||||
|
// same reason the unread count rides on whichever leads to the inbox — the
|
||||||
|
// number belongs to the destination, not to the bundled entry, so an admin's
|
||||||
|
// own nav override pointing there carries it too.
|
||||||
|
//
|
||||||
|
// **Zero renders nothing**, rather than a `0`: an empty fleet is not a
|
||||||
|
// notification, and a badge that read `0` on a site whose servers are simply
|
||||||
|
// quiet would be worse than no badge at all.
|
||||||
|
val showsRustOnline = !handsOff && rustOnline > 0 && route == Routes.RUST
|
||||||
|
|
||||||
|
NavigationDrawerItem(
|
||||||
|
label = { Text(label) },
|
||||||
|
selected = route != null && currentRoute == route.substringBefore('?'),
|
||||||
|
onClick = onClick,
|
||||||
|
badge = when {
|
||||||
|
handsOff -> {
|
||||||
|
{
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ExitToApp,
|
||||||
|
contentDescription = stringResource(R.string.nav_opens_in_browser),
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showsUnread -> {
|
||||||
|
{
|
||||||
|
// Named for a screen reader: "7" beside "Notifications" reads as
|
||||||
|
// a count to a sighted user and as a bare number to everyone else.
|
||||||
|
val spoken = stringResource(R.string.inbox_unread_count, unread)
|
||||||
|
Text(
|
||||||
|
text = unread.toString(),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
modifier = Modifier.semantics { contentDescription = spoken },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showsRustOnline -> {
|
||||||
|
{
|
||||||
|
// Named for a screen reader: "42" beside "Rust servers" reads
|
||||||
|
// as a count to a sighted user and as a bare number to
|
||||||
|
// everyone else.
|
||||||
|
val spoken = stringResource(R.string.rust_online_badge, rustOnline)
|
||||||
|
Text(
|
||||||
|
text = rustOnline.toString(),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
modifier = Modifier.semantics { contentDescription = spoken },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
|
colors = colors,
|
||||||
|
// Like Card's elevation, NavigationDrawerItem takes its shape as a default
|
||||||
|
// argument (CircleShape) rather than from the theme, so --radius-pill has to
|
||||||
|
// be handed to it at every call site or the selected row stays fully round
|
||||||
|
// while every other radius follows the shard (phase 8's AC-5 walk).
|
||||||
|
shape = LocalShardStructure.current.pill,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(NavigationDrawerItemDefaults.ItemPadding)
|
||||||
|
.padding(start = if (indented) 16.dp else 0.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RunicNavHost(
|
private fun RunicNavHost(
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
@@ -284,7 +518,19 @@ private fun RunicNavHost(
|
|||||||
composable(Routes.HOME) {
|
composable(Routes.HOME) {
|
||||||
HomeScreen(brand = brand)
|
HomeScreen(brand = brand)
|
||||||
}
|
}
|
||||||
composable(Routes.NEWS) {
|
// The category is optional: navigating to plain Routes.NEWS matches this
|
||||||
|
// pattern with no argument and opens the default tab, which is every route
|
||||||
|
// into the screen except an admin's nav override or added link (§6.2).
|
||||||
|
composable(
|
||||||
|
route = Routes.NEWS_ROUTE,
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument(Routes.Args.CATEGORY) {
|
||||||
|
type = NavType.StringType
|
||||||
|
nullable = true
|
||||||
|
defaultValue = null
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) {
|
||||||
NewsScreen(onOpenPost = { category, idOrSlug ->
|
NewsScreen(onOpenPost = { category, idOrSlug ->
|
||||||
navController.navigate(Routes.post(category, idOrSlug))
|
navController.navigate(Routes.post(category, idOrSlug))
|
||||||
})
|
})
|
||||||
@@ -298,6 +544,50 @@ private fun RunicNavHost(
|
|||||||
) {
|
) {
|
||||||
PostScreen()
|
PostScreen()
|
||||||
}
|
}
|
||||||
|
// Events (M13). CORE's routes, so these screens are reachable on a backend
|
||||||
|
// with no game module at all — which is why they sit above the shard block
|
||||||
|
// rather than inside it.
|
||||||
|
composable(Routes.EVENTS) {
|
||||||
|
EventsScreen(onOpenEvent = { slug -> navController.navigate(Routes.event(slug)) })
|
||||||
|
}
|
||||||
|
// The app's one route with a query argument. `run` is optional and nullable:
|
||||||
|
// navigating to Routes.event(slug) with no run matches this pattern with no
|
||||||
|
// argument, which is every route in except an announcement's link.
|
||||||
|
composable(
|
||||||
|
route = Routes.EVENT_ROUTE,
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument(Routes.Args.SLUG) { type = NavType.StringType },
|
||||||
|
navArgument(Routes.Args.RUN) {
|
||||||
|
type = NavType.StringType
|
||||||
|
nullable = true
|
||||||
|
defaultValue = null
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
EventScreen(
|
||||||
|
onOpenSeries = { slug -> navController.navigate(Routes.eventSeries(slug)) },
|
||||||
|
onOpenRun = { slug, runId ->
|
||||||
|
navController.navigate(Routes.event(slug, runId.toString()))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
route = Routes.EVENT_SERIES,
|
||||||
|
arguments = listOf(navArgument(Routes.Args.SLUG) { type = NavType.StringType }),
|
||||||
|
) {
|
||||||
|
EventSeriesScreen(onOpenEvent = { slug -> navController.navigate(Routes.event(slug)) })
|
||||||
|
}
|
||||||
|
composable(Routes.MY_EVENTS) {
|
||||||
|
// Signed out, this route is not in the drawer — but a saved back-stack
|
||||||
|
// entry can still be restored onto it, so the shell says where to go
|
||||||
|
// rather than letting the screen ask the server and render a 401.
|
||||||
|
when (session) {
|
||||||
|
is Session.SignedIn -> MyEventsScreen(onOpenRun = { slug, runId ->
|
||||||
|
navController.navigate(Routes.event(slug, runId.toString()))
|
||||||
|
})
|
||||||
|
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
|
||||||
|
}
|
||||||
|
}
|
||||||
composable(Routes.SHARD) {
|
composable(Routes.SHARD) {
|
||||||
ShardScreen(onOpenBoard = { board ->
|
ShardScreen(onOpenBoard = { board ->
|
||||||
navController.navigate(
|
navController.navigate(
|
||||||
@@ -338,6 +628,28 @@ private fun RunicNavHost(
|
|||||||
) { entry ->
|
) { entry ->
|
||||||
AtlasCreatureScreen(slug = entry.arguments?.getString(Routes.Args.SLUG).orEmpty())
|
AtlasCreatureScreen(slug = entry.arguments?.getString(Routes.Args.SLUG).orEmpty())
|
||||||
}
|
}
|
||||||
|
// The Rust module's two screens (M14). Not under `shard/`: a different game,
|
||||||
|
// a different shape — a fleet with a list above it rather than one place.
|
||||||
|
composable(Routes.RUST) {
|
||||||
|
RustServersScreen(onOpenServer = { id -> navController.navigate(Routes.rustServer(id)) })
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
route = Routes.RUST_SERVER,
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument(Routes.Args.SERVER_ID) { type = NavType.StringType },
|
||||||
|
navArgument(Routes.Args.TAB) {
|
||||||
|
type = NavType.StringType
|
||||||
|
nullable = true
|
||||||
|
defaultValue = null
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
RustServerScreen(
|
||||||
|
onBack = { navController.navigateTopLevel(Routes.RUST) },
|
||||||
|
// D123: a site-event marker opens the app's own event page on its run.
|
||||||
|
onOpenEvent = { slug, runId -> navController.navigate(Routes.event(slug, runId)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
composable(Routes.WIKI) {
|
composable(Routes.WIKI) {
|
||||||
WikiScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) })
|
WikiScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) })
|
||||||
}
|
}
|
||||||
@@ -399,9 +711,24 @@ private fun RunicNavHost(
|
|||||||
}
|
}
|
||||||
composable(Routes.NOTIFICATIONS) {
|
composable(Routes.NOTIFICATIONS) {
|
||||||
// Signed-in only; a sign-out (or demotion) sends the user home rather than
|
// Signed-in only; a sign-out (or demotion) sends the user home rather than
|
||||||
// leaving stale settings up. The backend gates every call regardless (§5).
|
// leaving another account's items up. The backend gates every call
|
||||||
|
// regardless, and the inbox routes are role-agnostic (§5) — staff have an
|
||||||
|
// inbox for the same reason players do, which on the web took a second
|
||||||
|
// mount to be true.
|
||||||
when (session) {
|
when (session) {
|
||||||
is Session.SignedIn -> NotificationsScreen()
|
is Session.SignedIn -> InboxScreen(
|
||||||
|
onOpenSettings = { navController.navigate(Routes.NOTIFICATIONS_SETTINGS) },
|
||||||
|
// A notification whose link the app can render opens in the app.
|
||||||
|
// `navigate`, not `navigateTopLevel`: the inbox is where the
|
||||||
|
// reader came from and back should return there.
|
||||||
|
onOpenRoute = { route -> navController.navigate(route) },
|
||||||
|
)
|
||||||
|
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composable(Routes.NOTIFICATIONS_SETTINGS) {
|
||||||
|
when (session) {
|
||||||
|
is Session.SignedIn -> NotificationSettingsScreen()
|
||||||
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
|
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -426,6 +753,12 @@ private fun RunicNavHost(
|
|||||||
composable(Routes.PLAYER_HOUSES) {
|
composable(Routes.PLAYER_HOUSES) {
|
||||||
PlayerGate(session, navController) { MyHousesScreen() }
|
PlayerGate(session, navController) { MyHousesScreen() }
|
||||||
}
|
}
|
||||||
|
// The player's own Rust account (M15). Behind the same gate as the three
|
||||||
|
// above: the route is `requireAuth` server-side, and the gate is here so a
|
||||||
|
// signed-out reader is sent home rather than left on a screen that 401s.
|
||||||
|
composable(Routes.PLAYER_RUST) {
|
||||||
|
PlayerGate(session, navController) { RustAccountScreen() }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Staff operations (§1, §6.4, M10) — reached from the staff menu section.
|
// ── Staff operations (§1, §6.4, M10) — reached from the staff menu section.
|
||||||
// The backend re-checks role on every /admin/… call; these gates only mirror
|
// The backend re-checks role on every /admin/… call; these gates only mirror
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
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.draw.clip
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.runicgateway.app.ui.LocalAssetResolver
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two brand assets an instance can upload — the logo and the hero
|
||||||
|
* (THEMING_AND_NAV.md §5.6, M12 phase 4). Both have ridden in `BrandDto` since
|
||||||
|
* M1 and neither has ever been drawn; the app has always spelled the instance
|
||||||
|
* out in text wherever the website shows a mark.
|
||||||
|
*
|
||||||
|
* **The rule that governs this whole file: an empty slot renders nothing.** Not
|
||||||
|
* a placeholder, not a reserved gap, not the app's own emblem — an instance
|
||||||
|
* that has uploaded no logo must lay out exactly as it did before this phase
|
||||||
|
* existed, which is §2 applied to assets. The website's `BrandLogo.jsx` opens
|
||||||
|
* with the same `if (!brand.logo) return null`.
|
||||||
|
*
|
||||||
|
* **A failed load is an empty slot.** No broken-image icon and no retry: an
|
||||||
|
* asset that 404s, or that can't be reached because the shard is down, must
|
||||||
|
* degrade to the same layout as an instance that never uploaded one. That is
|
||||||
|
* why nothing here reserves its space up front — every size modifier hangs off
|
||||||
|
* the image itself, so when the image isn't composed neither is its padding.
|
||||||
|
* A caller that wants space *below* a hero passes it as `Modifier.padding`
|
||||||
|
* rather than a sibling `Spacer`, and gets both cases right for free.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widest a logo may draw, as a multiple of its height. Mirrors the website's
|
||||||
|
* `maxWidth: height * 6` — an operator who uploads a long wordmark gets it
|
||||||
|
* scaled down rather than pushing the drawer header or the top bar's title out
|
||||||
|
* of shape.
|
||||||
|
*/
|
||||||
|
private const val LOGO_MAX_ASPECT = 6f
|
||||||
|
|
||||||
|
/** The Home hero's band height (§5.6, phase 4). See [BrandHero] for why it's fixed. */
|
||||||
|
private val HERO_HEIGHT = 180.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The instance's uploaded logo at [height], or [fallback] when there is none.
|
||||||
|
*
|
||||||
|
* [fallback] defaults to drawing nothing, which is what the drawer header wants:
|
||||||
|
* the instance name sits directly below it, so an instance with no logo simply
|
||||||
|
* has the name where it has always been. The top bar passes the name itself,
|
||||||
|
* because there the logo *replaces* the title — leaving that blank on a failed
|
||||||
|
* load would strand the app in an unnamed shell until the next resume refresh,
|
||||||
|
* and "a failed load is an empty slot" means the slot falls back to whatever
|
||||||
|
* empty would have shown, which for the top bar is the text.
|
||||||
|
*
|
||||||
|
* There is deliberately no fallback while the load is still in flight. Drawing
|
||||||
|
* the text first would flash text → logo on every navigation for the sake of
|
||||||
|
* one frame, since Coil serves the second and later reads from its memory cache.
|
||||||
|
*
|
||||||
|
* Pass [contentDescription] only where the logo stands alone. Beside or above
|
||||||
|
* the name in text it is decorative, and describing it would have a screen
|
||||||
|
* reader say the instance's name twice — the same call the website's `alt=''`
|
||||||
|
* makes.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BrandLogo(
|
||||||
|
logo: String?,
|
||||||
|
height: Dp,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
contentDescription: String? = null,
|
||||||
|
fallback: @Composable () -> Unit = {},
|
||||||
|
) {
|
||||||
|
val url = brandAssetUrl(logo, LocalAssetResolver.current)
|
||||||
|
// Keyed on the url so a refreshed appearance that swaps the logo (§5.5) gets
|
||||||
|
// a fresh attempt rather than inheriting the old one's failure.
|
||||||
|
var failed by remember(url) { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (url == null || failed) {
|
||||||
|
fallback()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
AsyncImage(
|
||||||
|
model = url,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
onError = { failed = true },
|
||||||
|
modifier = modifier
|
||||||
|
.height(height)
|
||||||
|
.widthIn(max = height * LOGO_MAX_ASPECT),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The instance's hero image as a full-width band above Home's title block, or
|
||||||
|
* nothing when there is none.
|
||||||
|
*
|
||||||
|
* **Fixed height and cropped**, rather than the intrinsic aspect ratio the app's
|
||||||
|
* other images (`PostScreen`, `BlockRenderer`) draw at. The website's hero is a
|
||||||
|
* CSS background driven by `hero_layout`, which the app does not port, so the
|
||||||
|
* app needs its own rule — and the website's *default* hero is a square emblem,
|
||||||
|
* so an uploaded square is a case to expect rather than an edge one. At the
|
||||||
|
* intrinsic aspect that square would be a ~360dp block that pushes the status
|
||||||
|
* card off the first screenful; cropped to a band, a wide banner and a square
|
||||||
|
* both give the same frame above the title.
|
||||||
|
*
|
||||||
|
* Clipped to `shapes.medium`, so the hero follows the shard's `--radius-card`
|
||||||
|
* like every other surface the admin can round off (§5.2).
|
||||||
|
*
|
||||||
|
* Decorative: Home spells the instance's name and tagline out in text directly
|
||||||
|
* below, so the hero carries no content description.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BrandHero(hero: String?, modifier: Modifier = Modifier) {
|
||||||
|
val url = brandAssetUrl(hero, LocalAssetResolver.current)
|
||||||
|
var failed by remember(url) { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (url == null || failed) return
|
||||||
|
AsyncImage(
|
||||||
|
model = url,
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
onError = { failed = true },
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(HERO_HEIGHT)
|
||||||
|
.clip(MaterialTheme.shapes.medium),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a brand asset slot to a loadable URL, or null when the slot is empty.
|
||||||
|
*
|
||||||
|
* The blank check has to happen on **both** sides of [resolve]: `BrandDto`
|
||||||
|
* defaults every asset field to `""` rather than null (the server publishes the
|
||||||
|
* empty string for "not set"), and a resolver given a path it cannot make
|
||||||
|
* absolute may hand one straight back. Null out of here is the signal for "draw
|
||||||
|
* nothing", so a blank slipping through would put a zero-size image request in
|
||||||
|
* the layout instead of no image at all.
|
||||||
|
*
|
||||||
|
* Pulled out of the composables purely so it can be tested: the app has no
|
||||||
|
* Robolectric, so a composable body cannot run in a JVM unit test, but this rule
|
||||||
|
* is the whole of §5.6's "renders nothing when unset" and it is worth pinning.
|
||||||
|
*/
|
||||||
|
internal fun brandAssetUrl(path: String?, resolve: (String?) -> String?): String? =
|
||||||
|
path?.takeIf { it.isNotBlank() }
|
||||||
|
?.let(resolve)
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
310
app/src/main/java/com/runicgateway/app/ui/events/EventScreen.kt
Normal file
310
app/src/main/java/com/runicgateway/app/ui/events/EventScreen.kt
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.events
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.data.api.dto.EventOccurrenceDto
|
||||||
|
import com.runicgateway.app.data.api.dto.EventParticipantDto
|
||||||
|
import com.runicgateway.app.data.api.dto.PublicEventDto
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
|
import com.runicgateway.app.ui.components.HtmlText
|
||||||
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
import com.runicgateway.app.ui.components.PillTone
|
||||||
|
import com.runicgateway.app.ui.components.ShardCard
|
||||||
|
import com.runicgateway.app.ui.components.StatusPill
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One event's public page (EVENTS.md § API surface, M13).
|
||||||
|
*
|
||||||
|
* The storyline, its arc, what is live, what is next, what happened recently, and
|
||||||
|
* a results table once an occurrence has published one.
|
||||||
|
*
|
||||||
|
* **The plan behind the event is never shown**, because the server never sends
|
||||||
|
* it: a live run carries the LABEL of the phase it is in — resolved from the
|
||||||
|
* version that run pinned, so an edit since does not relabel it — and nothing
|
||||||
|
* else. Phases, steps and actions are the operator's.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun EventScreen(
|
||||||
|
onOpenSeries: (String) -> Unit,
|
||||||
|
onOpenRun: (String, Long) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: EventViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// Error before content. Phase 13 found the inverse of this one tier along: a
|
||||||
|
// `if (loading || !form)` spinner above the error branch left a failed load
|
||||||
|
// spinning for ever with nothing on screen naming the problem.
|
||||||
|
when (val s = state) {
|
||||||
|
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
|
||||||
|
is UiState.Loading -> LoadingView(modifier)
|
||||||
|
is UiState.Success -> EventBody(s.data, onOpenSeries, onOpenRun, modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EventBody(
|
||||||
|
event: PublicEventDto,
|
||||||
|
onOpenSeries: (String) -> Unit,
|
||||||
|
onOpenRun: (String, Long) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
item(key = "head") {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Text(
|
||||||
|
text = event.title,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
event.summary?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
event.series?.let { series ->
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.events_part_of, series.name),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.clickable { onOpenSeries(series.slug) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one fact a visitor came for, above the storyline rather than below
|
||||||
|
// it: whether it is happening now, and if not, when it next is.
|
||||||
|
item(key = "headline") { Headline(event) }
|
||||||
|
|
||||||
|
event.body?.takeIf { it.isNotBlank() }?.let { body ->
|
||||||
|
item(key = "body") {
|
||||||
|
ShardCard(Modifier.fillMaxWidth()) {
|
||||||
|
// Sanitized on write, the treatment a wiki page and a forum
|
||||||
|
// post already get.
|
||||||
|
HtmlText(body, Modifier.padding(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
event.results?.let { results ->
|
||||||
|
item(key = "results-head") {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.events_results),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = eventDateTime(results.scheduledFor, event.timezone),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (results.participants.isEmpty()) {
|
||||||
|
item(key = "results-empty") {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.events_results_nobody),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items(results.participants.size, key = { "p$it" }) { index ->
|
||||||
|
ParticipantRow(results.participants[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
occurrenceSection(
|
||||||
|
key = "upcoming",
|
||||||
|
titleRes = R.string.events_coming_up,
|
||||||
|
list = event.upcoming,
|
||||||
|
timezone = event.timezone,
|
||||||
|
slug = event.slug,
|
||||||
|
onOpenRun = onOpenRun,
|
||||||
|
linkResults = false,
|
||||||
|
)
|
||||||
|
occurrenceSection(
|
||||||
|
key = "past",
|
||||||
|
titleRes = R.string.events_previously,
|
||||||
|
list = event.past,
|
||||||
|
timezone = event.timezone,
|
||||||
|
slug = event.slug,
|
||||||
|
onOpenRun = onOpenRun,
|
||||||
|
linkResults = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (event.current == null && event.next == null && event.past.isEmpty()) {
|
||||||
|
item(key = "unscheduled") {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.events_never_scheduled),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Headline(event: PublicEventDto) {
|
||||||
|
ShardCard(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
val current = event.current
|
||||||
|
when {
|
||||||
|
event.live && current != null -> {
|
||||||
|
StatusPill(
|
||||||
|
text = stringResource(R.string.events_status_live),
|
||||||
|
tone = PillTone.Success,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
// The phase LABEL, and only while it is live.
|
||||||
|
text = current.phase?.takeIf { it.isNotBlank() }
|
||||||
|
?: stringResource(R.string.events_under_way),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
event.next != null -> {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.events_next),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = eventDateTime(event.next.scheduledFor, event.next.timezone ?: event.timezone),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> Text(
|
||||||
|
text = stringResource(R.string.events_nothing_scheduled),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A titled list of occurrences, or nothing at all when there are none.
|
||||||
|
*
|
||||||
|
* `linkResults` is what separates the two calls: only a PAST occurrence that
|
||||||
|
* actually published results gets its own tap target, because on any other one
|
||||||
|
* `?run=` would change nothing a reader could see.
|
||||||
|
*/
|
||||||
|
private fun androidx.compose.foundation.lazy.LazyListScope.occurrenceSection(
|
||||||
|
key: String,
|
||||||
|
titleRes: Int,
|
||||||
|
list: List<EventOccurrenceDto>,
|
||||||
|
timezone: String?,
|
||||||
|
slug: String,
|
||||||
|
onOpenRun: (String, Long) -> Unit,
|
||||||
|
linkResults: Boolean,
|
||||||
|
) {
|
||||||
|
if (list.isEmpty()) return
|
||||||
|
item(key = "$key-title") {
|
||||||
|
Text(
|
||||||
|
text = stringResource(titleRes),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(list.size, key = { "$key-${list[it].runId}" }) { index ->
|
||||||
|
val occurrence = list[index]
|
||||||
|
val tappable = linkResults && occurrence.resultsPublishedAt != null
|
||||||
|
ShardCard(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(
|
||||||
|
if (tappable) Modifier.clickable { onOpenRun(slug, occurrence.runId) }
|
||||||
|
else Modifier,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = eventDateTime(occurrence.scheduledFor, occurrence.timezone ?: timezone),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
statusWordRes(occurrence.status, occurrence.scheduledFor),
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ParticipantRow(participant: EventParticipantDto) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = participant.rank?.toString() ?: "—",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.End,
|
||||||
|
modifier = Modifier.width(32.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
// A module supplies a display name in its participation meta or it does
|
||||||
|
// not; the member key is never published, so there is genuinely nothing
|
||||||
|
// else to render.
|
||||||
|
text = participant.name?.takeIf { it.isNotBlank() }
|
||||||
|
?: stringResource(R.string.events_participant_unnamed),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = scoreText(participant.score),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.events
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
import com.runicgateway.app.ui.components.ShardCard
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One arc (EVENTS.md §I, M13).
|
||||||
|
*
|
||||||
|
* **The arc is the thing the tooling this replaces could not express at all.** A
|
||||||
|
* calendar plugin has no series field, so "Royal Spy Mission → Risky Partner →
|
||||||
|
* Message From the Void" existed only in a GM's head and in whatever the forum
|
||||||
|
* post said. This screen is that continuity, in the order an editor arranged it —
|
||||||
|
* which is why the events are numbered rather than dated: an arc has an order, and
|
||||||
|
* its parts may be months apart or run out of sequence.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun EventSeriesScreen(
|
||||||
|
onOpenEvent: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: EventSeriesViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// Error first, then loading — the order Phase 13 had to fix one tier along.
|
||||||
|
when (val s = state) {
|
||||||
|
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
|
||||||
|
is UiState.Loading -> LoadingView(modifier)
|
||||||
|
is UiState.Success -> {
|
||||||
|
val series = s.data
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
item(key = "head") {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Text(
|
||||||
|
text = series.name,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
series.description?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(series.events.size, key = { series.events[it].slug }) { index ->
|
||||||
|
val entry = series.events[index]
|
||||||
|
ShardCard(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onOpenEvent(entry.slug) },
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = (index + 1).toString(),
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
textAlign = TextAlign.End,
|
||||||
|
modifier = Modifier.width(32.dp),
|
||||||
|
)
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text(
|
||||||
|
text = entry.title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
entry.summary?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.events
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.data.api.dto.EventSeriesDto
|
||||||
|
import com.runicgateway.app.data.repository.EventsRepository
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.navigation.Routes
|
||||||
|
import com.runicgateway.app.ui.toUiState
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One arc (PLAN.md §9 M13).
|
||||||
|
*
|
||||||
|
* A series with nothing listed in it answers 404 rather than an empty page, so
|
||||||
|
* there is no "empty arc" state to render: the error branch is the whole of it,
|
||||||
|
* and that is the server's decision rather than this screen's — an empty page
|
||||||
|
* would publish that an operator has named something they have not announced.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class EventSeriesViewModel @Inject constructor(
|
||||||
|
private val repository: EventsRepository,
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val slug: String = savedStateHandle.get<String>(Routes.Args.SLUG).orEmpty()
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<UiState<EventSeriesDto>>(UiState.Loading)
|
||||||
|
val state: StateFlow<UiState<EventSeriesDto>> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
_state.value = UiState.Loading
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = repository.series(slug).toUiState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
166
app/src/main/java/com/runicgateway/app/ui/events/EventTimes.kt
Normal file
166
app/src/main/java/com/runicgateway/app/ui/events/EventTimes.kt
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.events
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.core.time.parseWireInstant
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendering an event's instant and its status word (EVENTS.md §I).
|
||||||
|
*
|
||||||
|
* Everything here is pure and takes its clock, zone and locale as parameters, so
|
||||||
|
* the rules below are unit-tested off-device rather than eyeballed on one.
|
||||||
|
*
|
||||||
|
* ## The split, which is the one thing about event times that is easy to get wrong
|
||||||
|
*
|
||||||
|
* The server returns UTC instants and never guesses the reader's zone. The client
|
||||||
|
* places them, and it places the two halves differently:
|
||||||
|
*
|
||||||
|
* - the **day** an entry is filed under is the READER's own — "what is on this
|
||||||
|
* month" is a question about the month the person holding the phone is living
|
||||||
|
* in;
|
||||||
|
* - the **time** beside it is always the EVENT's zone, carried on the entry —
|
||||||
|
* because every listing this feature replaces is written in the shard's local
|
||||||
|
* zone, and "8pm" means the shard's evening to everyone reading it.
|
||||||
|
*
|
||||||
|
* Rendering the time in the reader's zone instead is defensible and wrong here: a
|
||||||
|
* player in Berlin told an American shard's event is at 02:00 has been told
|
||||||
|
* something true and useless, and told it in a way that makes the shard's own
|
||||||
|
* announcement look like a mistake.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A participation score, as a reader should see it.
|
||||||
|
*
|
||||||
|
* Scores are `DECIMAL(18,4)` on the wire because a module may score by distance,
|
||||||
|
* time or a weighted tally — but most score by counting, and rendering a plain
|
||||||
|
* tally of kills as `12.0` reads as a rounding artefact. So a whole number prints
|
||||||
|
* whole and a fraction keeps its digits, with trailing zeros trimmed: `1420`,
|
||||||
|
* `318.5`, `0.25`.
|
||||||
|
*/
|
||||||
|
fun scoreText(score: Double, locale: Locale = Locale.getDefault()): String {
|
||||||
|
if (!score.isFinite()) return "0"
|
||||||
|
if (score == Math.floor(score) && Math.abs(score) < 1e15) {
|
||||||
|
return String.format(locale, "%d", score.toLong())
|
||||||
|
}
|
||||||
|
return String.format(locale, "%.4f", score).trimEnd('0').trimEnd('.', ',')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The event's own wall clock, with the zone named so it misreads as nothing. */
|
||||||
|
fun eventTime(
|
||||||
|
instant: String?,
|
||||||
|
timezone: String?,
|
||||||
|
locale: Locale = Locale.getDefault(),
|
||||||
|
): String {
|
||||||
|
val at = parseWireInstant(instant) ?: return ""
|
||||||
|
val zone = eventZone(timezone)
|
||||||
|
val time = DateTimeFormatter.ofPattern("HH:mm", locale).withZone(zone).format(at)
|
||||||
|
return "$time ${shortZone(timezone)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event's own day and time together, for a screen showing one occurrence.
|
||||||
|
*
|
||||||
|
* Localized rather than patterned, because a full date's field order is the
|
||||||
|
* locale's business; only the zone stays the event's.
|
||||||
|
*/
|
||||||
|
fun eventDateTime(
|
||||||
|
instant: String?,
|
||||||
|
timezone: String?,
|
||||||
|
locale: Locale = Locale.getDefault(),
|
||||||
|
): String {
|
||||||
|
val at = parseWireInstant(instant) ?: return ""
|
||||||
|
val zone = eventZone(timezone)
|
||||||
|
val text = DateTimeFormatter
|
||||||
|
.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)
|
||||||
|
.withLocale(locale)
|
||||||
|
.withZone(zone)
|
||||||
|
.format(at)
|
||||||
|
return "$text ${shortZone(timezone)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The reader's own day, for the heading an entry is filed under. */
|
||||||
|
fun readerDayLabel(
|
||||||
|
instant: String?,
|
||||||
|
zone: ZoneId = ZoneId.systemDefault(),
|
||||||
|
locale: Locale = Locale.getDefault(),
|
||||||
|
): String {
|
||||||
|
val at = parseWireInstant(instant) ?: return ""
|
||||||
|
return DateTimeFormatter
|
||||||
|
.ofLocalizedDate(FormatStyle.FULL)
|
||||||
|
.withLocale(locale)
|
||||||
|
.withZone(zone)
|
||||||
|
.format(at)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The zone as a reader recognises it: `America/New_York` → `New York`.
|
||||||
|
*
|
||||||
|
* Not the abbreviation (`EDT`), which is unstable across the year and unknown to
|
||||||
|
* most readers of a shard in another country.
|
||||||
|
*/
|
||||||
|
fun shortZone(timezone: String?): String {
|
||||||
|
if (timezone.isNullOrBlank()) return "UTC"
|
||||||
|
return timezone.substringAfterLast('/').replace('_', ' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event's zone, or UTC when its column holds something `java.time` will not
|
||||||
|
* read.
|
||||||
|
*
|
||||||
|
* A typo in a definition's timezone must still render: UTC off the instant is the
|
||||||
|
* honest answer when the zone cannot be honoured, and it is what the web client
|
||||||
|
* falls back to for the same reason.
|
||||||
|
*/
|
||||||
|
private fun eventZone(timezone: String?): ZoneId = try {
|
||||||
|
if (timezone.isNullOrBlank()) ZoneId.of("UTC") else ZoneId.of(timezone)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
ZoneId.of("UTC")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The word beside an occurrence, for the four statuses the server publishes.
|
||||||
|
*
|
||||||
|
* **`cancelled` needs the instant, and that is the whole reason this takes one.**
|
||||||
|
* The server publishes `failed` and `missed` as `cancelled` too — to a visitor the
|
||||||
|
* three are one event, and the difference between them is about the deployment —
|
||||||
|
* but the three do not share one English sentence. *Did not happen* is right for a
|
||||||
|
* past occurrence and a plain falsehood for a future one, and a run four days out
|
||||||
|
* that an operator has called off is exactly the common case: this is the defect
|
||||||
|
* Phase 14a's own calendar shipped and the live walk caught, which is why it is
|
||||||
|
* restated here rather than ported.
|
||||||
|
*
|
||||||
|
* So **the tense follows the clock, not the status**. A future call-off reads
|
||||||
|
* *Cancelled*; a past one reads *Did not happen*, which is also the honest word
|
||||||
|
* for the failed and missed runs folded in with it.
|
||||||
|
*
|
||||||
|
* An unrecognised status reads *Scheduled*, mirroring the server's own fallback:
|
||||||
|
* `publicStatus()` folds anything it does not know to `scheduled`, so a word the
|
||||||
|
* app has never seen is a contract break rather than a state, and rendering a raw
|
||||||
|
* enum at a reader is not an improvement on it.
|
||||||
|
*/
|
||||||
|
@StringRes
|
||||||
|
fun statusWordRes(status: String?, scheduledFor: String?, now: Instant = Instant.now()): Int =
|
||||||
|
when (status) {
|
||||||
|
"live" -> R.string.events_status_live
|
||||||
|
"completed" -> R.string.events_status_completed
|
||||||
|
"cancelled" -> {
|
||||||
|
val at = parseWireInstant(scheduledFor)
|
||||||
|
// An unreadable instant is treated as past, which is the safer of the
|
||||||
|
// two: "did not happen" about something unplaceable in time is vague,
|
||||||
|
// while "cancelled" about a past run implies it is still coming.
|
||||||
|
if (at != null && at.isAfter(now)) {
|
||||||
|
R.string.events_status_cancelled
|
||||||
|
} else {
|
||||||
|
R.string.events_status_did_not_happen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> R.string.events_status_scheduled
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.events
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.data.api.dto.PublicEventDto
|
||||||
|
import com.runicgateway.app.data.repository.EventsRepository
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.navigation.Routes
|
||||||
|
import com.runicgateway.app.ui.toUiState
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One event's page (PLAN.md §9 M13, EVENTS.md § API surface).
|
||||||
|
*
|
||||||
|
* **`run` is read from the route and passed through untouched**, because that is
|
||||||
|
* what an announcement's link carries. The page lives at the definition's slug —
|
||||||
|
* one stable address, so a link posted in Discord survives a retitle — and the
|
||||||
|
* occurrence has to be in the query or a mail about last Friday's invasion would
|
||||||
|
* open next Friday's.
|
||||||
|
*
|
||||||
|
* A run that belongs to some other event is **not** filtered here. The server
|
||||||
|
* ignores it and answers with this event anyway, which turns a stale link in a
|
||||||
|
* months-old mail into the page it was about rather than a dead end; second-
|
||||||
|
* guessing that would undo it.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class EventViewModel @Inject constructor(
|
||||||
|
private val repository: EventsRepository,
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val slug: String = savedStateHandle.get<String>(Routes.Args.SLUG).orEmpty()
|
||||||
|
|
||||||
|
/** Null unless the route carried one; never an empty string forwarded to the server. */
|
||||||
|
private val run: String? = savedStateHandle.get<String>(Routes.Args.RUN)?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<UiState<PublicEventDto>>(UiState.Loading)
|
||||||
|
val state: StateFlow<UiState<PublicEventDto>> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
_state.value = UiState.Loading
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = repository.event(slug, run).toUiState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
191
app/src/main/java/com/runicgateway/app/ui/events/EventsScreen.kt
Normal file
191
app/src/main/java/com/runicgateway/app/ui/events/EventsScreen.kt
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.events
|
||||||
|
|
||||||
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.data.api.dto.EventCalendarEntryDto
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.components.EmptyView
|
||||||
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
import com.runicgateway.app.ui.components.PillTone
|
||||||
|
import com.runicgateway.app.ui.components.ShardCard
|
||||||
|
import com.runicgateway.app.ui.components.StatusPill
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public event calendar (EVENTS.md §I, M13).
|
||||||
|
*
|
||||||
|
* **A list, not a month grid**, which is the same call the web client makes and
|
||||||
|
* for the same reason: an operator's question is "what does this month look
|
||||||
|
* like" — coverage, clashes, the gap on the third weekend — and a grid answers
|
||||||
|
* it. A visitor's question is "what is on, and when is the next one", which a
|
||||||
|
* chronological list answers in one glance and a grid answers by making them
|
||||||
|
* count squares. On a phone the grid is not even a close second.
|
||||||
|
*
|
||||||
|
* **A projection is drawn differently from a run**, one tier along from the
|
||||||
|
* operator's own reason for the distinction: past the materialisation horizon
|
||||||
|
* there is no row, nothing is committed to, and nothing can be cancelled. Drawing
|
||||||
|
* a forecast identically to a booking would be the screen promising something the
|
||||||
|
* server has not.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun EventsScreen(
|
||||||
|
onOpenEvent: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: EventsViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
when (val s = state) {
|
||||||
|
is UiState.Loading -> LoadingView(modifier)
|
||||||
|
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
|
||||||
|
is UiState.Success -> {
|
||||||
|
val entries = s.data.entries
|
||||||
|
if (entries.isEmpty()) {
|
||||||
|
EmptyView(stringResource(R.string.events_empty), modifier)
|
||||||
|
} else {
|
||||||
|
Calendar(entries, s.data.truncated, onOpenEvent, modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group by the READER's day, preserving the server's order rather than re-sorting.
|
||||||
|
*
|
||||||
|
* Internal + pure so the grouping — and the fact that it never reorders — is
|
||||||
|
* unit-tested without Compose.
|
||||||
|
*/
|
||||||
|
internal fun groupByReaderDay(entries: List<EventCalendarEntryDto>): List<CalendarDayGroup> {
|
||||||
|
val days = mutableListOf<CalendarDayGroup>()
|
||||||
|
for (entry in entries) {
|
||||||
|
val label = readerDayLabel(entry.scheduledFor)
|
||||||
|
val last = days.lastOrNull()
|
||||||
|
if (last != null && last.label == label) {
|
||||||
|
last.entries.add(entry)
|
||||||
|
} else {
|
||||||
|
days.add(CalendarDayGroup(label, mutableListOf(entry)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A mutable builder shape for [groupByReaderDay]; the screen only reads it. */
|
||||||
|
internal data class CalendarDayGroup(
|
||||||
|
val label: String,
|
||||||
|
val entries: MutableList<EventCalendarEntryDto>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Calendar(
|
||||||
|
entries: List<EventCalendarEntryDto>,
|
||||||
|
truncated: Boolean,
|
||||||
|
onOpenEvent: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val days = groupByReaderDay(entries)
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
days.forEach { day ->
|
||||||
|
item(key = "day-${day.label}") {
|
||||||
|
Text(
|
||||||
|
text = day.label,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(
|
||||||
|
items = day.entries,
|
||||||
|
key = { "${it.slug}-${it.scheduledFor}-${it.kind}" },
|
||||||
|
) { entry ->
|
||||||
|
EntryCard(entry, onOpenEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (truncated) {
|
||||||
|
item(key = "truncated") {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.events_truncated),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EntryCard(entry: EventCalendarEntryDto, onOpenEvent: (String) -> Unit) {
|
||||||
|
ShardCard(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
// A projection has a page too — the definition's — so it opens like any
|
||||||
|
// other entry. What it does not have is an occurrence to link to.
|
||||||
|
.clickable { onOpenEvent(entry.slug) },
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = entry.title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
StatusPill(
|
||||||
|
text = stringResource(statusWordRes(entry.status, entry.scheduledFor)),
|
||||||
|
tone = if (entry.live) PillTone.Success else PillTone.Neutral,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = eventTime(entry.scheduledFor, entry.timezone),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
entry.seriesName?.takeIf { it.isNotBlank() }?.let { series ->
|
||||||
|
Text(
|
||||||
|
text = series,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (entry.isProjected) {
|
||||||
|
// Said in words rather than drawn as a dashed border, because a
|
||||||
|
// phone reader skimming a list will not decode a border and the
|
||||||
|
// distinction is worth more than the pixel it would cost.
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.events_projected),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontStyle = FontStyle.Italic,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.events
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.data.api.dto.EventCalendarDto
|
||||||
|
import com.runicgateway.app.data.repository.EventsRepository
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.toUiState
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public event calendar (PLAN.md §9 M13, EVENTS.md §I).
|
||||||
|
*
|
||||||
|
* **No window is asked for**, and that is the whole of this view model's design.
|
||||||
|
* The server's default is now through 31 days out, so a client that computed a
|
||||||
|
* window before it could ask anything would make every deep link carry two ISO
|
||||||
|
* instants and would have to agree with the server about what "now" is. The
|
||||||
|
* window bound and the entry cap are the server's defence on the one surface with
|
||||||
|
* no login in front of it; there is nothing for the app to add.
|
||||||
|
*
|
||||||
|
* `toUiState`, not `toShardUiState`: these are CORE routes. A 404 here means the
|
||||||
|
* backend has no events at all, not that an admin switched a shard surface off,
|
||||||
|
* and offering "not published here" for it would name the wrong cause.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class EventsViewModel @Inject constructor(
|
||||||
|
private val repository: EventsRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<UiState<EventCalendarDto>>(UiState.Loading)
|
||||||
|
val state: StateFlow<UiState<EventCalendarDto>> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
_state.value = UiState.Loading
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = repository.calendar().toUiState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.events
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.data.api.dto.EventHistoryEntryDto
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.components.EmptyView
|
||||||
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
import com.runicgateway.app.ui.components.ShardCard
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This account's event participation (EVENTS.md §J, M13).
|
||||||
|
*
|
||||||
|
* **The screen's one real design decision is what an unranked row says.** A run
|
||||||
|
* whose participants were collected but whose results have not been published has
|
||||||
|
* a score and no rank, and that is a real state rather than an error — it is the
|
||||||
|
* same state the admin run console has shown since events Phase 10. Rendering a
|
||||||
|
* dash with nothing explaining it would read as a bug; the row says the results
|
||||||
|
* are not published, which is a fact about the event rather than about the reader.
|
||||||
|
*
|
||||||
|
* Reached by **one drawer row for every signed-in account**, players and staff
|
||||||
|
* alike. The website mounts this twice only because its `RequirePlayer` guard sits
|
||||||
|
* over `/account` and the route behind it is role-agnostic; the app has no such
|
||||||
|
* wall, so it needs no second mount.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MyEventsScreen(
|
||||||
|
onOpenRun: (String, Long) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: MyEventsViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
when (val items = state.items) {
|
||||||
|
is UiState.Loading -> LoadingView(modifier)
|
||||||
|
is UiState.Error -> ErrorView(items.kind, onRetry = viewModel::load, modifier = modifier)
|
||||||
|
is UiState.Success -> if (items.data.isEmpty()) {
|
||||||
|
EmptyView(stringResource(R.string.events_history_empty), modifier)
|
||||||
|
} else {
|
||||||
|
androidx.compose.foundation.lazy.LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
items(items.data.size, key = { items.data[it].id }) { index ->
|
||||||
|
HistoryRow(items.data[index], onOpenRun)
|
||||||
|
}
|
||||||
|
if (state.hasMore) {
|
||||||
|
item(key = "more") {
|
||||||
|
TextButton(
|
||||||
|
onClick = viewModel::loadMore,
|
||||||
|
enabled = !state.loadingMore,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
if (state.loadingMore) R.string.events_loading
|
||||||
|
else R.string.events_show_more,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HistoryRow(entry: EventHistoryEntryDto, onOpenRun: (String, Long) -> Unit) {
|
||||||
|
ShardCard(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
// Straight to the occurrence the reader took part in, not to whatever
|
||||||
|
// is next: `?run=` is what makes the event page answer about this one.
|
||||||
|
.clickable { onOpenRun(entry.slug, entry.runId) },
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text(
|
||||||
|
text = entry.title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = eventDateTime(entry.scheduledFor, entry.timezone),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
entry.seriesName?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(horizontalAlignment = Alignment.End) {
|
||||||
|
Text(
|
||||||
|
text = entry.rank
|
||||||
|
?.let { stringResource(R.string.events_rank, it) }
|
||||||
|
?: stringResource(R.string.events_results_unpublished),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.End,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.events_score, scoreText(entry.score)),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.events
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.core.auth.Session
|
||||||
|
import com.runicgateway.app.core.auth.SessionManager
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.data.api.dto.EventHistoryEntryDto
|
||||||
|
import com.runicgateway.app.data.repository.EventsRepository
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.toUiState
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This account's event participation (PLAN.md §9 M13, EVENTS.md §J).
|
||||||
|
*
|
||||||
|
* **Self-scoped by the session and nothing else.** There is no id parameter on
|
||||||
|
* the route and deliberately none here: one account never reads another's, and
|
||||||
|
* there is no argument that could later grow into one.
|
||||||
|
*
|
||||||
|
* **Keyset-paged on the participation row's own id, never an offset** — the list
|
||||||
|
* gains a row every time the reader attends something, so an offset page would
|
||||||
|
* skip and repeat rows around the seam.
|
||||||
|
*
|
||||||
|
* ## Why this watches the session, when no other screen here does
|
||||||
|
*
|
||||||
|
* **A drawer route's view model outlives a sign-out.** `navigateTopLevel` saves
|
||||||
|
* and restores back-stack state, so the `NavBackStackEntry` for this route keeps
|
||||||
|
* its `ViewModelStore` across a sign-out and a sign-in as somebody else — and a
|
||||||
|
* view model that loads only in `init` never runs again. The live walk found the
|
||||||
|
* consequence: signing out of an admin account and back in as a player showed the
|
||||||
|
* PLAYER the admin's participation history, with no request made at all.
|
||||||
|
*
|
||||||
|
* The public event screens have the same lifetime and do not care, because a
|
||||||
|
* calendar is the same for everybody. This one is per-account, so the account is
|
||||||
|
* what it keys on: the flow emits the current session immediately, which is also
|
||||||
|
* the first load, and re-emits only when the signed-in id actually changes — a
|
||||||
|
* resume revalidation returning the same user does not refetch.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class MyEventsViewModel @Inject constructor(
|
||||||
|
private val repository: EventsRepository,
|
||||||
|
sessionManager: SessionManager,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
data class State(
|
||||||
|
val items: UiState<List<EventHistoryEntryDto>> = UiState.Loading,
|
||||||
|
val hasMore: Boolean = false,
|
||||||
|
val loadingMore: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(State())
|
||||||
|
val state: StateFlow<State> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
sessionManager.state
|
||||||
|
.map { (it as? Session.SignedIn)?.user?.id }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect { userId ->
|
||||||
|
// Signed out: drop the rows rather than leave the last
|
||||||
|
// account's on screen behind a shell that is about to
|
||||||
|
// navigate away.
|
||||||
|
if (userId == null) _state.value = State(items = UiState.Success(emptyList()))
|
||||||
|
else load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
_state.value = State()
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = repository.history(PAGE)
|
||||||
|
_state.value = State(
|
||||||
|
items = result.toUiState(),
|
||||||
|
// A full page means there is probably another; a short one is the
|
||||||
|
// end. One request rather than a count the server does not send.
|
||||||
|
hasMore = (result as? ApiResult.Ok)?.data?.size == PAGE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadMore() {
|
||||||
|
val current = _state.value
|
||||||
|
val shown = (current.items as? UiState.Success)?.data ?: return
|
||||||
|
val last = shown.lastOrNull() ?: return
|
||||||
|
if (current.loadingMore || !current.hasMore) return
|
||||||
|
|
||||||
|
_state.value = current.copy(loadingMore = true)
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = repository.history(PAGE, before = last.id)) {
|
||||||
|
is ApiResult.Ok -> _state.value = State(
|
||||||
|
items = UiState.Success(shown + result.data),
|
||||||
|
hasMore = result.data.size == PAGE,
|
||||||
|
)
|
||||||
|
// A failed NEXT page keeps the pages already read rather than
|
||||||
|
// replacing a screenful of history with an error: the reader can
|
||||||
|
// still see what loaded, and tapping again retries.
|
||||||
|
else -> _state.value = current.copy(loadingMore = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val PAGE = 25
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import com.runicgateway.app.R
|
|||||||
import com.runicgateway.app.data.api.dto.BrandDto
|
import com.runicgateway.app.data.api.dto.BrandDto
|
||||||
import com.runicgateway.app.data.api.dto.StatusDto
|
import com.runicgateway.app.data.api.dto.StatusDto
|
||||||
import com.runicgateway.app.ui.UiState
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.components.BrandHero
|
||||||
import com.runicgateway.app.ui.components.ErrorView
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
import com.runicgateway.app.ui.components.FeatureCard
|
import com.runicgateway.app.ui.components.FeatureCard
|
||||||
import com.runicgateway.app.ui.components.LoadingView
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
@@ -59,6 +60,12 @@ private fun HomeContent(brand: BrandDto?, status: StatusDto, modifier: Modifier
|
|||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(20.dp),
|
.padding(20.dp),
|
||||||
) {
|
) {
|
||||||
|
// The instance's hero above the title block (§5.6) — Home is the one screen
|
||||||
|
// with a hero-shaped space. Its bottom gap rides on the image's own modifier
|
||||||
|
// rather than a Spacer, so an instance with no hero (or one whose hero fails
|
||||||
|
// to load) opens on the title exactly where it has always been.
|
||||||
|
BrandHero(hero = brand?.hero, modifier = Modifier.padding(bottom = 16.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name),
|
text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name),
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ package com.runicgateway.app.ui.navigation
|
|||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import com.runicgateway.app.R
|
import com.runicgateway.app.R
|
||||||
import com.runicgateway.app.core.auth.Session
|
import com.runicgateway.app.core.auth.Session
|
||||||
|
import com.runicgateway.app.data.repository.Capability
|
||||||
import com.runicgateway.app.data.repository.ShardFeature
|
import com.runicgateway.app.data.repository.ShardFeature
|
||||||
import com.runicgateway.app.data.repository.ShardFeatures
|
import com.runicgateway.app.data.repository.ShardFeatures
|
||||||
|
import com.runicgateway.app.data.repository.SiteCapabilities
|
||||||
import com.runicgateway.app.data.repository.canSee
|
import com.runicgateway.app.data.repository.canSee
|
||||||
|
import com.runicgateway.app.data.repository.canUse
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One shared, declarative, access-level navigation definition (PLAN.md §5): a
|
* One shared, declarative, access-level navigation definition (PLAN.md §5): a
|
||||||
@@ -52,6 +55,29 @@ data class MenuEntry(
|
|||||||
* isn't shard-derived and only [access] applies.
|
* isn't shard-derived and only [access] applies.
|
||||||
*/
|
*/
|
||||||
val feature: String? = null,
|
val feature: String? = null,
|
||||||
|
/**
|
||||||
|
* The backend capability this row needs, or null when it needs none (M13).
|
||||||
|
*
|
||||||
|
* **A different question from [feature], which is why it is a second field
|
||||||
|
* and not a wider one.** This asks whether the code behind the row is
|
||||||
|
* *installed at all* — a per-HOST fact, from `GET /public/modules` and core's
|
||||||
|
* own list — while [feature] asks whether this shard publishes that surface
|
||||||
|
* to *this viewer*, which is per-viewer and admin-configurable. A site with no
|
||||||
|
* game module has no `shard` capability and no shard rows, whoever is looking;
|
||||||
|
* a site with one may still hide its market from anonymous visitors.
|
||||||
|
*
|
||||||
|
* The two also fail differently, and [canUse] is where that lives.
|
||||||
|
*/
|
||||||
|
val capability: String? = null,
|
||||||
|
/**
|
||||||
|
* An admin's own label for this row, from the shard's `nav_public` override
|
||||||
|
* (THEMING_AND_NAV.md §6). Null — always, as coded — means [labelRes] stands.
|
||||||
|
*
|
||||||
|
* A label set this way is **not localized**: it is one string for every locale,
|
||||||
|
* which is what an admin typing a label means, and it matches the website. It
|
||||||
|
* only ever arrives via [applyNavOverrides]; nothing in [APP_MENU] sets it.
|
||||||
|
*/
|
||||||
|
val label: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -62,21 +88,111 @@ data class MenuEntry(
|
|||||||
val APP_MENU: List<MenuEntry> = listOf(
|
val APP_MENU: List<MenuEntry> = listOf(
|
||||||
MenuEntry(Routes.HOME, R.string.menu_home),
|
MenuEntry(Routes.HOME, R.string.menu_home),
|
||||||
MenuEntry(Routes.NEWS, R.string.menu_news),
|
MenuEntry(Routes.NEWS, R.string.menu_news),
|
||||||
|
// Events are CORE's, so this row is gated on core's own capability rather than
|
||||||
|
// a module's: a site with no game module still has a calendar. Placed here to
|
||||||
|
// match the website's own nav, where Events is the row after News.
|
||||||
|
MenuEntry(Routes.EVENTS, R.string.menu_events, capability = Capability.EVENTS),
|
||||||
MenuEntry(Routes.WIKI, R.string.menu_wiki),
|
MenuEntry(Routes.WIKI, R.string.menu_wiki),
|
||||||
MenuEntry(Routes.SHARD, R.string.menu_shard, feature = ShardFeature.STATUS),
|
// The shard group. Every row needs the game module INSTALLED (one capability,
|
||||||
|
// because that is the only question a capability can answer) and its own
|
||||||
|
// feature published to this viewer (M11) — both, independently.
|
||||||
|
MenuEntry(
|
||||||
|
Routes.SHARD,
|
||||||
|
R.string.menu_shard,
|
||||||
|
feature = ShardFeature.STATUS,
|
||||||
|
capability = Capability.SHARD,
|
||||||
|
),
|
||||||
// Protocol 3.0 shard content (M11). Each hides when the shard doesn't publish it,
|
// Protocol 3.0 shard content (M11). Each hides when the shard doesn't publish it,
|
||||||
// which for a brand-new install is every one of them until the plugin has swept.
|
// which for a brand-new install is every one of them until the plugin has swept.
|
||||||
MenuEntry(Routes.SHARD_RULES, R.string.menu_rules, feature = ShardFeature.RULESET),
|
MenuEntry(
|
||||||
MenuEntry(Routes.ATLAS, R.string.menu_atlas, feature = ShardFeature.ATLAS),
|
Routes.SHARD_RULES,
|
||||||
MenuEntry(Routes.SHARD_LEADERBOARDS, R.string.menu_leaderboards, feature = ShardFeature.LEADERBOARDS),
|
R.string.menu_rules,
|
||||||
MenuEntry(Routes.SHARD_MARKET, R.string.menu_market, feature = ShardFeature.MARKET),
|
feature = ShardFeature.RULESET,
|
||||||
|
capability = Capability.SHARD,
|
||||||
|
),
|
||||||
|
MenuEntry(
|
||||||
|
Routes.ATLAS,
|
||||||
|
R.string.menu_atlas,
|
||||||
|
feature = ShardFeature.ATLAS,
|
||||||
|
capability = Capability.SHARD,
|
||||||
|
),
|
||||||
|
MenuEntry(
|
||||||
|
Routes.SHARD_LEADERBOARDS,
|
||||||
|
R.string.menu_leaderboards,
|
||||||
|
feature = ShardFeature.LEADERBOARDS,
|
||||||
|
capability = Capability.SHARD,
|
||||||
|
),
|
||||||
|
MenuEntry(
|
||||||
|
Routes.SHARD_MARKET,
|
||||||
|
R.string.menu_market,
|
||||||
|
feature = ShardFeature.MARKET,
|
||||||
|
capability = Capability.SHARD,
|
||||||
|
),
|
||||||
|
// The Rust module (M14). ONE row, because the module's whole public surface is
|
||||||
|
// one list and one page beneath it — `/rust` IS the server list, not a hub
|
||||||
|
// above one.
|
||||||
|
//
|
||||||
|
// **No `feature`, and that is not an omission.** The visibility framework is
|
||||||
|
// `module-uo`'s own (`shardVisibility`, six files under `module-uo/server/`
|
||||||
|
// and none under core's), and §2.7 forbids a module importing another's — so
|
||||||
|
// `module-rust` has no per-viewer visibility layer yet. Its phase 14 builds
|
||||||
|
// one; until then these routes are public to everyone the site is public to,
|
||||||
|
// and a `feature` here would be gating on a flag nothing publishes.
|
||||||
|
MenuEntry(Routes.RUST, R.string.menu_rust, capability = Capability.RUST),
|
||||||
MenuEntry(Routes.page("about"), R.string.menu_about),
|
MenuEntry(Routes.page("about"), R.string.menu_about),
|
||||||
MenuEntry(Routes.CONTACT, R.string.menu_contact),
|
MenuEntry(Routes.CONTACT, R.string.menu_contact),
|
||||||
MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN),
|
MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN),
|
||||||
MenuEntry(Routes.NOTIFICATIONS, R.string.menu_notifications, MenuAccess.SIGNED_IN),
|
MenuEntry(Routes.NOTIFICATIONS, R.string.menu_notifications, MenuAccess.SIGNED_IN),
|
||||||
MenuEntry(Routes.PLAYER_CHARACTERS, R.string.menu_my_characters, MenuAccess.PLAYER),
|
// Participation history: SIGNED_IN, not PLAYER. The route is `requireAuth`
|
||||||
MenuEntry(Routes.PLAYER_VENDORS, R.string.menu_my_vendors, MenuAccess.PLAYER),
|
// alone and self-scoped on the caller's own id, and the website needed two
|
||||||
MenuEntry(Routes.PLAYER_HOUSES, R.string.menu_my_houses, MenuAccess.PLAYER),
|
// mounts for it only because `RequirePlayer` guards `/account` there. Staff
|
||||||
|
// attend events too, and event history is not game-linked data.
|
||||||
|
MenuEntry(
|
||||||
|
Routes.MY_EVENTS,
|
||||||
|
R.string.menu_my_events,
|
||||||
|
MenuAccess.SIGNED_IN,
|
||||||
|
capability = Capability.EVENTS,
|
||||||
|
),
|
||||||
|
// These three read `/player/shard/*`, which is the SAME module's player mount —
|
||||||
|
// so they need the capability for the same reason the public rows do. They
|
||||||
|
// carry no `feature`, because the visibility framework covers the public
|
||||||
|
// surfaces and these are self-service, gated by role and ownership instead.
|
||||||
|
// That asymmetry is exactly why the live walk found them and the suite did
|
||||||
|
// not: "a shard row" had been defined as "a row with a feature".
|
||||||
|
MenuEntry(
|
||||||
|
Routes.PLAYER_CHARACTERS,
|
||||||
|
R.string.menu_my_characters,
|
||||||
|
MenuAccess.PLAYER,
|
||||||
|
capability = Capability.SHARD,
|
||||||
|
),
|
||||||
|
MenuEntry(
|
||||||
|
Routes.PLAYER_VENDORS,
|
||||||
|
R.string.menu_my_vendors,
|
||||||
|
MenuAccess.PLAYER,
|
||||||
|
capability = Capability.SHARD,
|
||||||
|
),
|
||||||
|
MenuEntry(
|
||||||
|
Routes.PLAYER_HOUSES,
|
||||||
|
R.string.menu_my_houses,
|
||||||
|
MenuAccess.PLAYER,
|
||||||
|
capability = Capability.SHARD,
|
||||||
|
),
|
||||||
|
// The player's own Rust account (M15). Beside the three UO rows above and
|
||||||
|
// gated exactly as they are: the module's `rust` capability (a row needs the
|
||||||
|
// code behind it INSTALLED) and `PLAYER` access, which is `isPlayer ||
|
||||||
|
// isStaff` — staff play too, and `/player/rust/*` is `requireAuth` alone.
|
||||||
|
//
|
||||||
|
// **`rust`, not `identity`.** The module declares a surface word for each of
|
||||||
|
// its features, and D16's rule is that a capability answers one question —
|
||||||
|
// *is the module there* — so a surface word is not what a row hangs on. The
|
||||||
|
// paths these screens call are knowledge the app has from reading the
|
||||||
|
// module's router, exactly as §2.9 requires.
|
||||||
|
MenuEntry(
|
||||||
|
Routes.PLAYER_RUST,
|
||||||
|
R.string.menu_rust_account,
|
||||||
|
MenuAccess.PLAYER,
|
||||||
|
capability = Capability.RUST,
|
||||||
|
),
|
||||||
// Staff operations (§1, M10) — revealed for staff roles; the backend re-checks every call.
|
// Staff operations (§1, M10) — revealed for staff roles; the backend re-checks every call.
|
||||||
MenuEntry(Routes.ADMIN_DASHBOARD, R.string.menu_admin_dashboard, MenuAccess.STAFF),
|
MenuEntry(Routes.ADMIN_DASHBOARD, R.string.menu_admin_dashboard, MenuAccess.STAFF),
|
||||||
MenuEntry(Routes.ADMIN_CONTENT, R.string.menu_admin_content, MenuAccess.STAFF),
|
MenuEntry(Routes.ADMIN_CONTENT, R.string.menu_admin_content, MenuAccess.STAFF),
|
||||||
@@ -85,28 +201,52 @@ val APP_MENU: List<MenuEntry> = listOf(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The entries the given [session] may see, given the shard [features] it may reach.
|
* The entries the given [session] may see, on a backend with these [capabilities]
|
||||||
* Pure + side-effect-free so the gating is unit-tested without Compose.
|
* and this shard's [features]. Pure + side-effect-free so the gating is unit-tested
|
||||||
|
* without Compose.
|
||||||
*
|
*
|
||||||
* Two independent filters, and both must pass:
|
* Three independent filters, and all three must pass:
|
||||||
*
|
*
|
||||||
* - [MenuEntry.access] against the session — who the caller is.
|
* - [MenuEntry.access] against the session — who the caller is.
|
||||||
|
* - [MenuEntry.capability] against what this backend serves — whether the code
|
||||||
|
* behind the row is installed at all (M13). Per host.
|
||||||
* - [MenuEntry.feature] against the shard's live visibility config — what this shard
|
* - [MenuEntry.feature] against the shard's live visibility config — what this shard
|
||||||
* publishes at all (M11). `null` [features] means the answer isn't known yet and
|
* publishes to this viewer (M11). Per viewer.
|
||||||
* every shard entry shows; see [canSee] for why that direction is deliberate.
|
*
|
||||||
|
* **The last two both fail open on an unknown answer, but "unknown" means
|
||||||
|
* different things to them.** A `null` [features] is unknown; so is a `null`
|
||||||
|
* [capabilities] — but a *non-null* [capabilities] that does not name the string
|
||||||
|
* is an ANSWER, and it hides. Without that, a site with no game module renders
|
||||||
|
* five shard rows that each 404. See [canUse].
|
||||||
*/
|
*/
|
||||||
fun visibleEntries(
|
fun visibleEntries(
|
||||||
entries: List<MenuEntry>,
|
entries: List<MenuEntry>,
|
||||||
session: Session,
|
session: Session,
|
||||||
features: ShardFeatures? = null,
|
features: ShardFeatures? = null,
|
||||||
): List<MenuEntry> =
|
capabilities: SiteCapabilities? = null,
|
||||||
entries.filter { entry ->
|
): List<MenuEntry> = entries.filter { isEntryVisible(it, session, features, capabilities) }
|
||||||
val allowedByRole = when (entry.access) {
|
|
||||||
MenuAccess.PUBLIC -> true
|
/**
|
||||||
MenuAccess.SIGNED_IN -> session is Session.SignedIn
|
* [visibleEntries] for a single entry — the same three filters, and the same
|
||||||
MenuAccess.PLAYER -> session is Session.SignedIn && (session.user.isPlayer || session.user.isStaff)
|
* boundary. Split out because the drawer is a tree once an admin groups rows into
|
||||||
MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff
|
* sections (§6.3): [pruneNav] applies this predicate inside a section as well, and
|
||||||
MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator
|
* both callers must ask exactly one question or a sectioned row could be gated by
|
||||||
}
|
* a rule its top-level twin is not.
|
||||||
allowedByRole && (entry.feature == null || canSee(features, entry.feature))
|
*/
|
||||||
|
fun isEntryVisible(
|
||||||
|
entry: MenuEntry,
|
||||||
|
session: Session,
|
||||||
|
features: ShardFeatures? = null,
|
||||||
|
capabilities: SiteCapabilities? = null,
|
||||||
|
): Boolean {
|
||||||
|
val allowedByRole = when (entry.access) {
|
||||||
|
MenuAccess.PUBLIC -> true
|
||||||
|
MenuAccess.SIGNED_IN -> session is Session.SignedIn
|
||||||
|
MenuAccess.PLAYER -> session is Session.SignedIn && (session.user.isPlayer || session.user.isStaff)
|
||||||
|
MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff
|
||||||
|
MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator
|
||||||
}
|
}
|
||||||
|
return allowedByRole &&
|
||||||
|
canUse(capabilities, entry.capability) &&
|
||||||
|
(entry.feature == null || canSee(features, entry.feature))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.navigation
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.booleanOrNull
|
||||||
|
import kotlinx.serialization.json.doubleOrNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the admin's stored public-nav overrides to the app's coded menu
|
||||||
|
* (THEMING_AND_NAV.md §6). The Kotlin counterpart of the website's
|
||||||
|
* `client/src/lib/navOverrides.js`, narrowed to what a drawer can express.
|
||||||
|
*
|
||||||
|
* **This is presentation, never authorization.** An override carries `label`,
|
||||||
|
* `order` and `hidden` and nothing else: it cannot introduce a route, cannot
|
||||||
|
* touch [MenuEntry.access] or [MenuEntry.feature], and cannot un-hide anything —
|
||||||
|
* `hidden: false` is simply the absence of hiding. [visibleEntries] therefore runs
|
||||||
|
* **after** this merge, unchanged, and remains the actual boundary (§6.1, AC-3).
|
||||||
|
*
|
||||||
|
* Fail-safe throughout, matching the web: anything unrecognized — an unknown path,
|
||||||
|
* a non-string label, a path the app doesn't surface in its menu — is ignored
|
||||||
|
* rather than rejected, so a stale or hand-edited settings row degrades to the
|
||||||
|
* coded menu instead of rendering a broken drawer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A usable override for one menu row. Absent fields mean "as coded". */
|
||||||
|
internal data class NavOverride(
|
||||||
|
val label: String? = null,
|
||||||
|
val order: Double? = null,
|
||||||
|
val hidden: Boolean = false,
|
||||||
|
/**
|
||||||
|
* The id of the section this row was dropped into, or null for a top-level
|
||||||
|
* row. Read here but honored only by the tree build (`NavTree.kt`) — the flat
|
||||||
|
* [applyNavOverrides] has nowhere to put it. Not validated against the stored
|
||||||
|
* sections here; that is the tree's job, since only it knows them.
|
||||||
|
*/
|
||||||
|
val section: String? = null,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Nothing a **flat** list can express. [section] is deliberately not part of
|
||||||
|
* this: to [applyNavOverrides] a section-only override says nothing, so an
|
||||||
|
* instance that only ever grouped rows still gets its coded list back by
|
||||||
|
* identity. The tree build adds its own check.
|
||||||
|
*/
|
||||||
|
val isEmpty: Boolean get() = label == null && order == null && !hidden
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `items` map out of a stored `nav_public` value.
|
||||||
|
*
|
||||||
|
* Two shapes exist, because website phase 10 added sections and links without
|
||||||
|
* migrating what phases 6-8 had already stored: `{items, sections, links}` and a
|
||||||
|
* bare map of path → override. A bare map is unambiguous — every key is a path,
|
||||||
|
* so a key can never be the string `items`.
|
||||||
|
*
|
||||||
|
* `sections` and `links` come out of the same wrapper, and only ever out of the
|
||||||
|
* wrapped shape — see [sectionsOf] and [linksOf].
|
||||||
|
*/
|
||||||
|
internal fun itemsOf(navPublic: JsonObject?): Map<String, JsonObject> {
|
||||||
|
if (navPublic == null) return emptyMap()
|
||||||
|
val items = wrapperOf(navPublic)?.get("items") as? JsonObject ?: navPublic
|
||||||
|
return items.entries
|
||||||
|
.mapNotNull { (key, value) -> (value as? JsonObject)?.let { key to it } }
|
||||||
|
.toMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stored value as the wrapped `{items, sections, links}` shape, or null when
|
||||||
|
* it is the bare items map phases 6-8 wrote. The discriminator is the web's: an
|
||||||
|
* `items` **object**, which a bare map can never carry because every key in one is
|
||||||
|
* a path.
|
||||||
|
*/
|
||||||
|
private fun wrapperOf(navPublic: JsonObject?): JsonObject? =
|
||||||
|
navPublic?.takeIf { it["items"] is JsonObject }
|
||||||
|
|
||||||
|
internal fun sectionsOf(navPublic: JsonObject?): List<JsonObject> = jsonObjectsAt(navPublic,"sections")
|
||||||
|
|
||||||
|
internal fun linksOf(navPublic: JsonObject?): List<JsonObject> = jsonObjectsAt(navPublic,"links")
|
||||||
|
|
||||||
|
private fun jsonObjectsAt(navPublic: JsonObject?, key: String): List<JsonObject> =
|
||||||
|
(wrapperOf(navPublic)?.get(key) as? JsonArray)
|
||||||
|
?.mapNotNull { it as? JsonObject }
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
|
// Field by field, like every other read in M12: a bad `label` must not discard a
|
||||||
|
// good `order` beside it.
|
||||||
|
//
|
||||||
|
// `group` is ignored — it names a section of the *admin sidebar*, a nav the app
|
||||||
|
// never renders, and a value it cannot honor is better dropped than half-applied.
|
||||||
|
internal fun cleanOverride(raw: JsonObject): NavOverride {
|
||||||
|
val label = (raw["label"] as? JsonPrimitive)
|
||||||
|
?.takeIf { it.isString }
|
||||||
|
?.content
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotEmpty() }
|
||||||
|
val order = (raw["order"] as? JsonPrimitive)
|
||||||
|
?.takeIf { !it.isString }
|
||||||
|
?.doubleOrNull
|
||||||
|
?.takeIf { it.isFinite() }
|
||||||
|
val hidden = (raw["hidden"] as? JsonPrimitive)
|
||||||
|
?.takeIf { !it.isString }
|
||||||
|
?.booleanOrNull == true
|
||||||
|
val section = (raw["section"] as? JsonPrimitive)
|
||||||
|
?.takeIf { it.isString }
|
||||||
|
?.content
|
||||||
|
?.takeIf { it.isNotEmpty() }
|
||||||
|
return NavOverride(label = label, order = order, hidden = hidden, section = section)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [base] with the admin's overrides applied: rows relabeled, reordered and
|
||||||
|
* dropped as the stored row asks.
|
||||||
|
*
|
||||||
|
* @param base the coded menu — the only source of `route`, `access` and `feature`
|
||||||
|
* @param navPublic the parsed `nav_public` row, or null when the admin never
|
||||||
|
* edited the nav. Null, malformed, and "nothing usable in it" all return [base]
|
||||||
|
* itself, which is what makes an untouched instance's drawer provably today's
|
||||||
|
* (§2, AC-1).
|
||||||
|
*/
|
||||||
|
fun applyNavOverrides(base: List<MenuEntry>, navPublic: JsonObject?): List<MenuEntry> {
|
||||||
|
val items = itemsOf(navPublic)
|
||||||
|
if (items.isEmpty()) return base
|
||||||
|
|
||||||
|
val coded = base.map { it.route }.toSet()
|
||||||
|
// Keyed by app route, and only for a route the coded menu actually declares.
|
||||||
|
// This is where an override for a path the app doesn't surface in its drawer —
|
||||||
|
// a news category tab, a Shard hub board — is dropped (§6.2). The web does the
|
||||||
|
// same with an unknown `to`.
|
||||||
|
val overrides = buildMap {
|
||||||
|
for ((path, raw) in items) {
|
||||||
|
val route = appRouteForWebPath(path) ?: continue
|
||||||
|
if (route !in coded) continue
|
||||||
|
val override = cleanOverride(raw)
|
||||||
|
if (!override.isEmpty) put(route, override)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (overrides.isEmpty()) return base
|
||||||
|
|
||||||
|
// Rows the website's nav knows about are the ones an override can move; the
|
||||||
|
// app's own surfaces (Contact, Account, the player groups, the staff rows)
|
||||||
|
// have no counterpart to be reordered against and keep their coded order,
|
||||||
|
// appended after the public block — which is exactly where they sit today, so
|
||||||
|
// this partition is the current layout rather than a new one (§6.2).
|
||||||
|
val (mapped, appOnly) = base.partition { it.route in WEB_ROUTE_ORDER }
|
||||||
|
|
||||||
|
val sorted = mapped
|
||||||
|
// An untouched row's sort key is its index in the WEBSITE's nav, not the
|
||||||
|
// app's: a stored `order` is a position in that list, so both keys have to
|
||||||
|
// sit on one number line to be comparable at all.
|
||||||
|
//
|
||||||
|
// Two tie-breaks, the web's: an explicit order beats a coincidental index
|
||||||
|
// (the admin said "first", so first), and two explicit orders keep code
|
||||||
|
// order, because the sort is stable.
|
||||||
|
.sortedWith(
|
||||||
|
compareBy<MenuEntry> { entry ->
|
||||||
|
overrides[entry.route]?.order ?: WEB_ROUTE_ORDER.getValue(entry.route).toDouble()
|
||||||
|
}.thenByDescending { overrides[it.route]?.order != null },
|
||||||
|
)
|
||||||
|
|
||||||
|
return (sorted + appOnly).mapNotNull { entry ->
|
||||||
|
val override = overrides[entry.route] ?: return@mapNotNull entry
|
||||||
|
when {
|
||||||
|
override.hidden -> null
|
||||||
|
override.label != null -> entry.copy(label = override.label)
|
||||||
|
else -> entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
385
app/src/main/java/com/runicgateway/app/ui/navigation/NavPaths.kt
Normal file
385
app/src/main/java/com/runicgateway/app/ui/navigation/NavPaths.kt
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.navigation
|
||||||
|
|
||||||
|
import com.runicgateway.app.data.repository.ContentRepository.PostCategory
|
||||||
|
import com.runicgateway.app.ui.rust.RustTab
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The website path → app route table (THEMING_AND_NAV.md §6.2).
|
||||||
|
*
|
||||||
|
* The public nav an admin edits is keyed by **website** paths, so honoring it in
|
||||||
|
* the app needs a translation. This is the one new piece of cross-repo coupling
|
||||||
|
* the milestone introduces, which is why it lives in a single file with the
|
||||||
|
* website's own arrays quoted right beside it — the coupling is visible and
|
||||||
|
* reviewable in one place rather than spread across the drawer's call sites.
|
||||||
|
*
|
||||||
|
* ## The nav is TWO arrays now, and that is what M13 had to correct
|
||||||
|
*
|
||||||
|
* This file was written when the website's public nav was one sixteen-row array.
|
||||||
|
* Since the module-system cutover on 2026-08-12 it is **core's eight rows plus
|
||||||
|
* every installed module's**, interleaved at render time by `withModuleNav`, and
|
||||||
|
* a module's pages are mounted by core at `/<module id>/<path>` — so the nine
|
||||||
|
* shard rows moved from `/site/champs` to `/uo/champs` and this table stopped
|
||||||
|
* resolving any of them. Three things followed, all of them true of the shipped
|
||||||
|
* app until M13: a nav override on a shard row was ignored, an added link to a
|
||||||
|
* shard page handed off to a browser instead of opening natively, and the sort
|
||||||
|
* key line below was a sixteen-row line against a nav numbered differently.
|
||||||
|
*
|
||||||
|
* **The nine `/uo/` paths are hardcoded, and they are ONE module's.** The alternative
|
||||||
|
* — reading the installed module's id from `GET /public/modules` and building
|
||||||
|
* `/<id>/shard` — is forbidden by `MODULE_API.md` §2.9 (*"a client must not infer
|
||||||
|
* a route from a capability"*) and would hardcode the same path shape less
|
||||||
|
* visibly. A site running a different game module matches none of these nine, its
|
||||||
|
* links hand off to a Custom Tab, and that is the correct answer rather than a
|
||||||
|
* gap: core cannot tell the app what another module calls its pages.
|
||||||
|
*
|
||||||
|
* Verbatim from `website/client/src/components/SiteHeader.jsx`, which is the
|
||||||
|
* exported owner of core's list (`export const NAV`, and Admin → Navigation edits
|
||||||
|
* exactly it):
|
||||||
|
*
|
||||||
|
* ```js
|
||||||
|
* export const NAV = [
|
||||||
|
* { label: 'Home', to: '/', end: true },
|
||||||
|
* { label: 'News', to: '/site/news' },
|
||||||
|
* { label: 'Events', to: '/site/events' },
|
||||||
|
* { label: 'Screenshots', to: '/site/screenshots' },
|
||||||
|
* { label: 'Five on Friday', to: '/site/five-on-friday' },
|
||||||
|
* { label: 'Newsletter', to: '/site/newsletter' },
|
||||||
|
* { label: 'Wiki', to: '/wiki' },
|
||||||
|
* { label: 'About', to: '/site/about' },
|
||||||
|
* ]
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* and from `module-uo/client/src/entry.jsx`, which registers the rest:
|
||||||
|
*
|
||||||
|
* ```jsx
|
||||||
|
* registry.registerNav(ID, {
|
||||||
|
* area: 'public',
|
||||||
|
* items: [
|
||||||
|
* { label: 'Shard', to: '/uo/shard', feature: 'status' },
|
||||||
|
* { label: 'Champions', to: '/uo/champs', feature: 'champs' },
|
||||||
|
* { label: 'Guilds', to: '/uo/guilds', feature: 'guilds' },
|
||||||
|
* { label: 'Governors', to: '/uo/governors', feature: 'governors' },
|
||||||
|
* { label: 'Houses', to: '/uo/houses', feature: 'houses' },
|
||||||
|
* { label: 'Rules', to: '/uo/rules', feature: 'ruleset' },
|
||||||
|
* { label: 'Atlas', to: '/uo/atlas', feature: 'atlas' },
|
||||||
|
* { label: 'Leaderboards', to: '/uo/leaderboards', feature: 'leaderboards' },
|
||||||
|
* { label: 'Market', to: '/uo/market', feature: 'market' },
|
||||||
|
* ],
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* and from `module-rust/client/src/entry.jsx`, which registers one (M14):
|
||||||
|
*
|
||||||
|
* ```jsx
|
||||||
|
* registry.registerNav(ID, {
|
||||||
|
* area: 'public',
|
||||||
|
* items: [{ label: 'Servers', to: '/rust' }],
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* None of those nine declares an `order`, so `mergeFlat` appends them after core's
|
||||||
|
* rows in registration order — which is the order they are listed in below.
|
||||||
|
*
|
||||||
|
* The `feature` values are **not** mirrored here on purpose. [APP_MENU] is the
|
||||||
|
* app's own source of truth for gating, and a second copy of a security-relevant
|
||||||
|
* value that drifts silently is worth more than it costs. This table carries the
|
||||||
|
* mapping and nothing else.
|
||||||
|
*
|
||||||
|
* Not every row maps to something the app shows in its drawer, and that is the
|
||||||
|
* design rather than an omission — see [WEB_PATH_TO_ROUTE].
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** One row of the website's public nav: its path, and the app route it opens. */
|
||||||
|
data class WebNavPath(val path: String, val route: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The website's public nav in **its** order, mapped to app routes.
|
||||||
|
*
|
||||||
|
* The order is load-bearing, not decorative: a stored `order` is an index into
|
||||||
|
* *this* list (the admin's editor writes the position a row holds on the web), so
|
||||||
|
* a row the admin never moved has to take its key from the same number line or
|
||||||
|
* explicit and implicit keys would be incomparable. See `NavOverrides.kt`.
|
||||||
|
*
|
||||||
|
* Core's eight first, then the module's nine, because that is what `withModuleNav`
|
||||||
|
* renders and therefore what the admin's editor numbered.
|
||||||
|
*/
|
||||||
|
val WEBSITE_PUBLIC_NAV: List<WebNavPath> = listOf(
|
||||||
|
WebNavPath("/", Routes.HOME),
|
||||||
|
WebNavPath("/site/news", Routes.NEWS),
|
||||||
|
WebNavPath("/site/events", Routes.EVENTS),
|
||||||
|
// The app's News screen carries all four categories as tabs, so these three
|
||||||
|
// have a route but no drawer row of their own — see the note below.
|
||||||
|
WebNavPath("/site/screenshots", Routes.news(PostCategory.SCREENSHOTS)),
|
||||||
|
WebNavPath("/site/five-on-friday", Routes.news(PostCategory.FIVE_ON_FRIDAY)),
|
||||||
|
WebNavPath("/site/newsletter", Routes.news(PostCategory.NEWSLETTER)),
|
||||||
|
WebNavPath("/wiki", Routes.WIKI),
|
||||||
|
WebNavPath("/site/about", Routes.page("about")),
|
||||||
|
// module-uo's rows. Mounted by core at `/<module id>/<path>`, which is why
|
||||||
|
// every one of these is `/uo/` and not `/site/`.
|
||||||
|
WebNavPath("/uo/shard", Routes.SHARD),
|
||||||
|
// Behind the Shard hub in the app, deliberately — no drawer row either.
|
||||||
|
WebNavPath("/uo/champs", Routes.SHARD_CHAMPS),
|
||||||
|
WebNavPath("/uo/guilds", Routes.SHARD_GUILDS),
|
||||||
|
WebNavPath("/uo/governors", Routes.SHARD_GOVERNORS),
|
||||||
|
WebNavPath("/uo/houses", Routes.SHARD_HOUSES),
|
||||||
|
WebNavPath("/uo/rules", Routes.SHARD_RULES),
|
||||||
|
WebNavPath("/uo/atlas", Routes.ATLAS),
|
||||||
|
WebNavPath("/uo/leaderboards", Routes.SHARD_LEADERBOARDS),
|
||||||
|
WebNavPath("/uo/market", Routes.SHARD_MARKET),
|
||||||
|
// module-rust's one row (M14). It registers `{ label: 'Servers', to: '/rust' }`
|
||||||
|
// and nothing else — `/rust` IS the server list, because core strips the
|
||||||
|
// trailing separator from a module route registered with `path: ''`.
|
||||||
|
//
|
||||||
|
// **Both modules can be installed on one backend**, and then the nav is core's
|
||||||
|
// eight plus ten. This table is a superset by design: a path here for a module
|
||||||
|
// an operator has NOT installed never appears in that backend's nav and so is
|
||||||
|
// never looked up, while a path missing from it makes a link that exists hand
|
||||||
|
// off to a browser.
|
||||||
|
WebNavPath("/rust", Routes.RUST),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same table as a lookup.
|
||||||
|
*
|
||||||
|
* **A mapped route is not the same thing as a drawer row.** Seven of these paths
|
||||||
|
* resolve to a screen the app reaches some other way: the three news categories
|
||||||
|
* are tabs on one News screen, and champs / guilds / governors / houses sit behind
|
||||||
|
* the Shard hub because that is the better shape on a phone. An override for one
|
||||||
|
* of them is **ignored** — §6.1's rule is that a nav override may never introduce
|
||||||
|
* navigation, and the hub is a design decision, not an accident to correct. The
|
||||||
|
* merge enforces that by intersecting with [APP_MENU]; nothing here needs to know
|
||||||
|
* which rows those are.
|
||||||
|
*
|
||||||
|
* The mapping still exists for all sixteen because phase 6's added links resolve
|
||||||
|
* an admin-authored path against the same table, and *there* a category tab or a
|
||||||
|
* hub board is a perfectly good destination — the admin asked for it by path.
|
||||||
|
*/
|
||||||
|
val WEB_PATH_TO_ROUTE: Map<String, String> =
|
||||||
|
WEBSITE_PUBLIC_NAV.associate { it.path to it.route }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Each app route's index in the website's own nav order — the sort key a row the
|
||||||
|
* admin never moved takes, so it lands on the same number line as a stored
|
||||||
|
* `order`. All sixteen routes are distinct, so this loses nothing.
|
||||||
|
*/
|
||||||
|
internal val WEB_ROUTE_ORDER: Map<String, Int> =
|
||||||
|
WEBSITE_PUBLIC_NAV.withIndex().associate { (index, row) -> row.route to index }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app route a website nav path opens, or null when the app has no screen for
|
||||||
|
* it. A trailing slash is tolerated (`/wiki/` is `/wiki`) since a hand-edited
|
||||||
|
* settings row may carry one; the root path is left alone.
|
||||||
|
*/
|
||||||
|
fun appRouteForWebPath(path: String?): String? = WEB_PATH_TO_ROUTE[normalizeWebPath(path)]
|
||||||
|
|
||||||
|
/** `/wiki/` → `/wiki`, blank → null, and `/` left alone. */
|
||||||
|
private fun normalizeWebPath(path: String?): String? {
|
||||||
|
val trimmed = path?.trim().orEmpty()
|
||||||
|
if (trimmed.isEmpty()) return null
|
||||||
|
val normalized = if (trimmed.length > 1) trimmed.trimEnd('/') else trimmed
|
||||||
|
return normalized.ifEmpty { "/" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The website's top-level paths that are **not** CMS pages.
|
||||||
|
*
|
||||||
|
* The site serves its CMS pages from a top-level `/<slug>` (React Router ranks its
|
||||||
|
* static routes above that dynamic one), which is what lets [resolveWebPath]'s
|
||||||
|
* last rule open an admin-authored page natively. These are the segments that rule
|
||||||
|
* must not swallow: the SPA's own sections, and the two server mounts. A link to
|
||||||
|
* one of them hands off to the browser, which is where they actually live.
|
||||||
|
*/
|
||||||
|
private val RESERVED_TOP_LEVEL = setOf(
|
||||||
|
"admin", "account", "player", "site", "wiki", "invite", "preview", "api", "uploads",
|
||||||
|
// An installed module's pages are mounted at `/<id>/…` and are not CMS pages.
|
||||||
|
// Only ids the app knows about need listing: an unknown module's `/<id>` would
|
||||||
|
// resolve to a CMS page that 404s, which is the same answer the browser gives
|
||||||
|
// it, and core cannot enumerate them for us here anyway.
|
||||||
|
//
|
||||||
|
// `rust` is here for the opposite reason to the rest: `/rust` DOES resolve, to
|
||||||
|
// the server list, and it does so through the nav table above — this set only
|
||||||
|
// stops the CMS-page fallback claiming it. Without the entry a site with the
|
||||||
|
// module absent would open a page-not-found screen instead of the browser.
|
||||||
|
"uo", "rust",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app route an **arbitrary** website path opens, or null when the app has no
|
||||||
|
* screen for it and the link must hand off to a Custom Tab (§6.3).
|
||||||
|
*
|
||||||
|
* [appRouteForWebPath] answers for the sixteen paths the *nav* is built from; this
|
||||||
|
* answers for a path an admin typed into an added link, which may name any page on
|
||||||
|
* the site. It is the app's read of the site's own route table, and like the table
|
||||||
|
* above it is cross-repo coupling kept in one file — quoted here for the same
|
||||||
|
* reason, from `website/client/src/App.jsx`:
|
||||||
|
*
|
||||||
|
* ```jsx
|
||||||
|
* <Route path="/" element={<Portal />} />
|
||||||
|
* <Route path="/site/news" element={<News />} />
|
||||||
|
* <Route path="/site/screenshots" element={<Screenshots />} />
|
||||||
|
* <Route path="/site/five-on-friday" element={<FiveOnFriday />} />
|
||||||
|
* <Route path="/site/newsletter" element={<Newsletter />} />
|
||||||
|
* <Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
|
||||||
|
* <Route path="/site/events" element={<Events />} />
|
||||||
|
* <Route path="/site/events/series/:slug" element={<EventSeries />} />
|
||||||
|
* <Route path="/site/events/:slug" element={<EventPage />} />
|
||||||
|
* <Route path="/site/about" element={<About />} />
|
||||||
|
* <Route path="/site/status" element={<Status />} />
|
||||||
|
* <Route path="/wiki" element={<Wiki />} />
|
||||||
|
* <Route path="/wiki/:slug" element={<WikiArticle />} />
|
||||||
|
* // Installed modules' pages, mounted at `/<module id>/<path>`:
|
||||||
|
* // /uo/shard, /uo/shard/activity, /uo/champs, /uo/guilds, /uo/guilds/:id,
|
||||||
|
* // /uo/governors, /uo/houses, /uo/rules, /uo/leaderboards, /uo/market,
|
||||||
|
* // /uo/market/vendors/:serial, /uo/atlas, /uo/atlas/:slug
|
||||||
|
* // /rust, /rust/servers/:id
|
||||||
|
* // CMS pages: top-level /:slug, matched only after the named routes above
|
||||||
|
* <Route path="/:slug" element={<CmsPage />} />
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Note what is *not* in it: no `/site/news/<id>` (a news item renders on its
|
||||||
|
* category page; the newsletter's is the site's one post-detail route), no
|
||||||
|
* `/page/<slug>`, and no `/contact` — the app's contact form is app-only (§6.2).
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* / → HOME
|
||||||
|
* /site/news → NEWS
|
||||||
|
* /site/{screenshots,five-on-friday,newsletter}
|
||||||
|
* → NEWS, that category's tab
|
||||||
|
* /site/newsletter/<id> → POST (the site's one post-detail route)
|
||||||
|
* /site/events → EVENTS
|
||||||
|
* /site/events/series/<slug> → EVENT_SERIES
|
||||||
|
* /site/events/<slug>[?run=<id>] → EVENT (the one route that takes a query)
|
||||||
|
* /wiki → WIKI
|
||||||
|
* /wiki/<slug> → WIKI_PAGE
|
||||||
|
* /uo/<shard surface> → the mapped shard route (§6.2)
|
||||||
|
* /uo/atlas/<slug> → ATLAS_CREATURE
|
||||||
|
* /uo/market/vendors/<serial> → SHARD_MARKET_VENDOR
|
||||||
|
* /rust → RUST (module-rust's server list)
|
||||||
|
* /rust/servers/<id>[?tab=<tab>] → RUST_SERVER (a tab the app has; Rust D71)
|
||||||
|
* /player/rust → PLAYER_RUST (Rust D71)
|
||||||
|
* /site/about → PAGE("about")
|
||||||
|
* /<slug> → PAGE(slug), unless <slug> is reserved
|
||||||
|
* anything else → null, i.e. the Custom Tab
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* **A path carrying a query or a fragment hands off — with exactly one
|
||||||
|
* exception.** The rule exists because no app route took either, so a native
|
||||||
|
* match would quietly drop what the admin wrote while the browser honors it. The
|
||||||
|
* event page (M13) is the first route that takes a query, and it takes one key:
|
||||||
|
* `run`, which is what every `event.` announcement's `eventUrl` carries. So a
|
||||||
|
* `?run=` on an event path resolves natively and **anything else in a query
|
||||||
|
* string, any second parameter, and any fragment still hand off** — the carve-out
|
||||||
|
* is one key on one path, not a general "parse the query".
|
||||||
|
*
|
||||||
|
* **Rust D71 adds the second, on the same terms:** `tab` on a Rust server's path,
|
||||||
|
* which is what the *new leader* notice links to. It resolves only when the value
|
||||||
|
* is a tab the app has. `?tab=clans` still hands off, because the website has a
|
||||||
|
* Clans tab and the app does not, and opening the feed instead would be the quiet
|
||||||
|
* drop this rule exists to prevent.
|
||||||
|
*
|
||||||
|
* That narrowness is the point: an admin who writes `/site/events/x?utm=mail` gets
|
||||||
|
* the browser, which honors `utm`, rather than an app screen that silently ignored
|
||||||
|
* it.
|
||||||
|
*
|
||||||
|
* Resolving a path is not the same as being allowed to see the screen behind it.
|
||||||
|
* A link to `/site/market` on a shard that does not publish the market lands on
|
||||||
|
* the Market screen's honest "not published here" state, which is what typing the
|
||||||
|
* URL on the web does too (§6.3).
|
||||||
|
*/
|
||||||
|
fun resolveWebPath(path: String?): String? {
|
||||||
|
val raw = path?.trim().orEmpty()
|
||||||
|
// A fragment is never honored natively: no app route has one to put it in.
|
||||||
|
if (raw.isEmpty() || '#' in raw) return null
|
||||||
|
|
||||||
|
val queryAt = raw.indexOf('?')
|
||||||
|
val query = if (queryAt >= 0) raw.substring(queryAt + 1) else ""
|
||||||
|
val normalized = normalizeWebPath(if (queryAt >= 0) raw.substring(0, queryAt) else raw)
|
||||||
|
?: return null
|
||||||
|
|
||||||
|
if (query.isEmpty()) WEB_PATH_TO_ROUTE[normalized]?.let { return it }
|
||||||
|
if (!normalized.startsWith("/")) return null
|
||||||
|
|
||||||
|
// Blank segments ("/site//news") mean a malformed path, not a slug.
|
||||||
|
val segments = normalized.removePrefix("/").split('/')
|
||||||
|
if (segments.any { it.isBlank() }) return null
|
||||||
|
|
||||||
|
// The one path that may carry a query, and the one key it may carry. Checked
|
||||||
|
// before the general "a query hands off" rule below, and nowhere else.
|
||||||
|
if (segments.size == 3 && segments[0] == "site" && segments[1] == "events" &&
|
||||||
|
segments[2] != "series"
|
||||||
|
) {
|
||||||
|
// No query is the ordinary case — a link to the event rather than to one
|
||||||
|
// of its occurrences. A query is honored only when it is exactly the run.
|
||||||
|
if (query.isEmpty()) return Routes.event(segments[2])
|
||||||
|
val run = runParam(query) ?: return null
|
||||||
|
return Routes.event(segments[2], run)
|
||||||
|
}
|
||||||
|
if (segments.size == 3 && segments[0] == MODULE_RUST && segments[1] == "servers" && query.isNotEmpty()) {
|
||||||
|
val tab = rustTabParam(query) ?: return null
|
||||||
|
return Routes.rustServer(segments[2], tab)
|
||||||
|
}
|
||||||
|
if (query.isNotEmpty()) return null
|
||||||
|
|
||||||
|
return when {
|
||||||
|
segments.size == 1 -> segments[0].takeIf { it !in RESERVED_TOP_LEVEL }?.let(Routes::page)
|
||||||
|
segments[0] == "wiki" && segments.size == 2 -> Routes.wikiPage(segments[1])
|
||||||
|
segments[0] == "site" && segments.size == 3 && segments[1] == "newsletter" ->
|
||||||
|
Routes.post(PostCategory.NEWSLETTER.urlSlug, segments[2])
|
||||||
|
segments[0] == "site" && segments.size == 4 && segments[1] == "events" &&
|
||||||
|
segments[2] == "series" -> Routes.eventSeries(segments[3])
|
||||||
|
segments[0] == MODULE_UO && segments.size == 3 && segments[1] == "atlas" ->
|
||||||
|
Routes.atlasCreature(segments[2])
|
||||||
|
segments[0] == MODULE_UO && segments.size == 4 && segments[1] == "market" &&
|
||||||
|
segments[2] == "vendors" -> Routes.marketVendor(segments[3])
|
||||||
|
// module-rust's one page below the list. `/rust` itself is already
|
||||||
|
// answered by the nav table above, before this fallback is reached.
|
||||||
|
segments[0] == MODULE_RUST && segments.size == 3 && segments[1] == "servers" ->
|
||||||
|
Routes.rustServer(segments[2])
|
||||||
|
// The player's own Rust account, which the *account linked* notice links
|
||||||
|
// to (D71). Not in the nav table above, which is built from the PUBLIC
|
||||||
|
// nav; a signed-out tap is caught by the player gate like any other.
|
||||||
|
segments.size == 2 && segments[0] == "player" && segments[1] == MODULE_RUST -> Routes.PLAYER_RUST
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `run` value of a query that consists of **exactly** `run=<something>`, or
|
||||||
|
* null for every other query — including one that merely contains a `run` among
|
||||||
|
* others.
|
||||||
|
*
|
||||||
|
* Deliberately not a query parser. A second parameter means the writer meant
|
||||||
|
* something the app cannot honor, and the honest answer to that is the browser.
|
||||||
|
* An empty value (`?run=`) is null too: it would reach the screen as a blank
|
||||||
|
* string and be forwarded to the server as one.
|
||||||
|
*/
|
||||||
|
private fun runParam(query: String): String? {
|
||||||
|
val value = query.removePrefix("run=")
|
||||||
|
if (value.length == query.length || value.isEmpty()) return null
|
||||||
|
return value.takeIf { '&' !in it && '=' !in it }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tab a query of **exactly** `tab=<something>` names, when the app has that
|
||||||
|
* tab (Rust D71), or null, which hands the link to the browser. Same shape as
|
||||||
|
* [runParam], and for the same reasons.
|
||||||
|
*/
|
||||||
|
private fun rustTabParam(query: String): RustTab? {
|
||||||
|
val value = query.removePrefix("tab=")
|
||||||
|
if (value.length == query.length || value.isEmpty() || '&' in value || '=' in value) return null
|
||||||
|
return RustTab.fromWire(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The module id whose public pages this table maps.
|
||||||
|
*
|
||||||
|
* Named once rather than spelled into four branches, so what is coupled to one
|
||||||
|
* module is countable. It is a literal on purpose — see the file header.
|
||||||
|
*/
|
||||||
|
private const val MODULE_UO = "uo"
|
||||||
|
|
||||||
|
/** The second game module's id (M14). A literal for the same reason. */
|
||||||
|
private const val MODULE_RUST = "rust"
|
||||||
239
app/src/main/java/com/runicgateway/app/ui/navigation/NavTree.kt
Normal file
239
app/src/main/java/com/runicgateway/app/ui/navigation/NavTree.kt
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.navigation
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.doubleOrNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The drawer as a one-level tree: the coded menu, plus the **sections** an admin
|
||||||
|
* grouped rows into and the **links** they added of their own (THEMING_AND_NAV.md
|
||||||
|
* §6.3). The Kotlin counterpart of the website's `buildPublicNav` + `pruneNav`.
|
||||||
|
*
|
||||||
|
* The public nav is the one nav an admin can restructure rather than only reorder,
|
||||||
|
* and §6.1's invariant survives that structurally rather than by vigilance: a
|
||||||
|
* coded row is still keyed by a website path the app's own table declares, so an
|
||||||
|
* override still cannot invent a destination or touch a gate, while everything
|
||||||
|
* that *can* name an arbitrary path lives in [NavNode.Link], where the path rule
|
||||||
|
* is applied and the result is resolved through [resolveWebPath].
|
||||||
|
*
|
||||||
|
* An added link carries no gate and needs none — the screen behind it enforces its
|
||||||
|
* own access, so a link to somewhere this caller cannot reach lands on that
|
||||||
|
* screen's own honest state, exactly as typing the URL on the web does.
|
||||||
|
*/
|
||||||
|
sealed interface NavNode {
|
||||||
|
|
||||||
|
/** A coded [MenuEntry], relabeled/reordered by the merge but never re-gated. */
|
||||||
|
data class Item(val entry: MenuEntry) : NavNode
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An admin-authored link to a page on this site.
|
||||||
|
*
|
||||||
|
* @param path the stored website path, already validated — this is what a
|
||||||
|
* Custom Tab opens, resolved against the site's base URL
|
||||||
|
* @param route the app route [path] maps to, or null when the app has no
|
||||||
|
* screen for it and the link must hand off (§6.3)
|
||||||
|
*/
|
||||||
|
data class Link(
|
||||||
|
val id: String,
|
||||||
|
val label: String,
|
||||||
|
val path: String,
|
||||||
|
val route: String?,
|
||||||
|
) : NavNode
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A drawer group: its [label] as a header, its [items] beneath it.
|
||||||
|
*
|
||||||
|
* The website renders these as click-to-open dropdowns; a drawer is already a
|
||||||
|
* vertical list, so the app renders the group open (§6.3). Never empty — see
|
||||||
|
* [pruneNav].
|
||||||
|
*/
|
||||||
|
data class Section(
|
||||||
|
val id: String,
|
||||||
|
val label: String,
|
||||||
|
val items: List<NavNode>,
|
||||||
|
) : NavNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A usable `sections` entry. */
|
||||||
|
private data class SectionSpec(val id: String, val label: String, val order: Double?)
|
||||||
|
|
||||||
|
/** A usable `links` entry, with its section already checked against the stored ones. */
|
||||||
|
private data class LinkSpec(
|
||||||
|
val id: String,
|
||||||
|
val label: String,
|
||||||
|
val to: String,
|
||||||
|
val order: Double?,
|
||||||
|
val section: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One node waiting to be placed: its sort key, and whether that key was stored. */
|
||||||
|
private data class Placed(val node: NavNode, val section: String?, val key: Double, val explicit: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Characters that must never appear in a stored link path. The same rule the
|
||||||
|
* website applies on read: a value that would leave the origin, or carry markup
|
||||||
|
* into a link, is dropped rather than rendered.
|
||||||
|
*/
|
||||||
|
private val FORBIDDEN_IN_PATH = Regex("""[\s<>"'\\]""")
|
||||||
|
|
||||||
|
// Forgiving, like every other read in M12: an entry that is not usable is dropped
|
||||||
|
// and its neighbours kept. A repeated id is dropped too — the first wins, since
|
||||||
|
// the id is what a link's identity in the drawer is.
|
||||||
|
private fun readSections(raw: List<JsonObject>): List<SectionSpec> {
|
||||||
|
val seen = mutableSetOf<String>()
|
||||||
|
return raw.mapNotNull { section ->
|
||||||
|
val id = section.stringOrNull("id") ?: return@mapNotNull null
|
||||||
|
val label = section.stringOrNull("label")?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
|
||||||
|
if (!seen.add(id)) return@mapNotNull null
|
||||||
|
SectionSpec(id = id, label = label, order = section.orderOrNull())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readLinks(raw: List<JsonObject>, knownSections: Set<String>): List<LinkSpec> {
|
||||||
|
val seen = mutableSetOf<String>()
|
||||||
|
return raw.mapNotNull { link ->
|
||||||
|
val id = link.stringOrNull("id") ?: return@mapNotNull null
|
||||||
|
val label = link.stringOrNull("label")?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
|
||||||
|
val to = link.stringOrNull("to") ?: return@mapNotNull null
|
||||||
|
if (!to.startsWith("/") || to.startsWith("//") || FORBIDDEN_IN_PATH.containsMatchIn(to)) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
if (!seen.add(id)) return@mapNotNull null
|
||||||
|
LinkSpec(
|
||||||
|
id = id,
|
||||||
|
label = label,
|
||||||
|
to = to,
|
||||||
|
order = link.orderOrNull(),
|
||||||
|
// A link naming a section that does not exist is a top-level link, not
|
||||||
|
// a dropped one: the admin's destination is still good.
|
||||||
|
section = link.stringOrNull("section")?.takeIf { it in knownSections },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject.stringOrNull(key: String): String? =
|
||||||
|
(this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||||
|
|
||||||
|
private fun JsonObject.orderOrNull(): Double? =
|
||||||
|
(this["order"] as? JsonPrimitive)?.takeIf { !it.isString }?.doubleOrNull?.takeIf { it.isFinite() }
|
||||||
|
|
||||||
|
// Two tie-breaks, the web's and phase 5's: an explicit order beats a coincidental
|
||||||
|
// index (the admin said "first", so first), and two explicit orders keep
|
||||||
|
// declaration order, because the sort is stable.
|
||||||
|
private fun List<Placed>.place(): List<NavNode> =
|
||||||
|
sortedWith(compareBy<Placed> { it.key }.thenByDescending { it.explicit }).map { it.node }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The coded menu with the admin's `nav_public` applied in full: relabeled,
|
||||||
|
* reordered and hidden as phase 5 already did, plus grouped into sections and
|
||||||
|
* joined by added links.
|
||||||
|
*
|
||||||
|
* With no sections and no links this **is** phase 5 — [applyNavOverrides] answers,
|
||||||
|
* so an untouched instance still gets [APP_MENU] back by identity and AC-1's proof
|
||||||
|
* is unchanged (§2). The tree build only runs when the admin actually created
|
||||||
|
* structure.
|
||||||
|
*
|
||||||
|
* @param base the coded menu — the only source of `route`, `access` and `feature`
|
||||||
|
* @param navPublic the parsed `nav_public` row, or null when the admin never
|
||||||
|
* edited the nav
|
||||||
|
*/
|
||||||
|
fun buildNavTree(base: List<MenuEntry>, navPublic: JsonObject?): List<NavNode> {
|
||||||
|
val sections = readSections(sectionsOf(navPublic))
|
||||||
|
val links = readLinks(linksOf(navPublic), sections.map { it.id }.toSet())
|
||||||
|
if (sections.isEmpty() && links.isEmpty()) {
|
||||||
|
return applyNavOverrides(base, navPublic).map { NavNode.Item(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val knownSections = sections.map { it.id }.toSet()
|
||||||
|
val coded = base.map { it.route }.toSet()
|
||||||
|
// Keyed by app route, and only for a route the coded menu declares — the same
|
||||||
|
// narrowing as the flat merge, so an override for a path the app maps but does
|
||||||
|
// not surface (a news category tab, a Shard hub board) is dropped here too.
|
||||||
|
val overrides = buildMap {
|
||||||
|
for ((path, raw) in itemsOf(navPublic)) {
|
||||||
|
val route = appRouteForWebPath(path) ?: continue
|
||||||
|
if (route !in coded) continue
|
||||||
|
val override = cleanOverride(raw)
|
||||||
|
// A section the stored value never declares is no section at all.
|
||||||
|
val section = override.section?.takeIf { it in knownSections }
|
||||||
|
if (!override.isEmpty || section != null) put(route, override.copy(section = section))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The app's own surfaces (Contact, Account, the player groups, the staff rows)
|
||||||
|
// have no website counterpart to be reordered against or grouped under, so they
|
||||||
|
// keep their coded order after the public block — where they already sit (§6.2).
|
||||||
|
val (mapped, appOnly) = base.partition { it.route in WEB_ROUTE_ORDER }
|
||||||
|
|
||||||
|
val placed = mutableListOf<Placed>()
|
||||||
|
for (entry in mapped) {
|
||||||
|
val override = overrides[entry.route]
|
||||||
|
if (override?.hidden == true) continue
|
||||||
|
placed += Placed(
|
||||||
|
node = NavNode.Item(override?.label?.let { entry.copy(label = it) } ?: entry),
|
||||||
|
section = override?.section,
|
||||||
|
// An untouched row's key is its index in the WEBSITE's nav, so stored
|
||||||
|
// and implicit keys sit on one number line (phase 5).
|
||||||
|
key = override?.order ?: WEB_ROUTE_ORDER.getValue(entry.route).toDouble(),
|
||||||
|
explicit = override?.order != null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// An admin-created entity with no stored order appends after the coded rows, in
|
||||||
|
// creation order, rather than jumping to the front on a 0 default.
|
||||||
|
var next = WEBSITE_PUBLIC_NAV.size
|
||||||
|
for (section in sections) {
|
||||||
|
placed += Placed(
|
||||||
|
node = NavNode.Section(section.id, section.label, emptyList()),
|
||||||
|
section = null,
|
||||||
|
key = section.order ?: (next++).toDouble(),
|
||||||
|
explicit = section.order != null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for (link in links) {
|
||||||
|
placed += Placed(
|
||||||
|
node = NavNode.Link(link.id, link.label, link.to, resolveWebPath(link.to)),
|
||||||
|
section = link.section,
|
||||||
|
key = link.order ?: (next++).toDouble(),
|
||||||
|
explicit = link.order != null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val top = placed.filter { it.node is NavNode.Section || it.section == null }.place()
|
||||||
|
return top.map { node ->
|
||||||
|
if (node !is NavNode.Section) {
|
||||||
|
node
|
||||||
|
} else {
|
||||||
|
node.copy(items = placed.filter { it.section == node.id }.place())
|
||||||
|
}
|
||||||
|
} + appOnly.map { NavNode.Item(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tree with this caller's gates applied — and a section they empty dropped.
|
||||||
|
*
|
||||||
|
* This is the boundary, and it runs **after** [buildNavTree], never before: an
|
||||||
|
* override is presentation, so a row it relabels, moves or marks `hidden: false`
|
||||||
|
* is still shown only if [isVisible] says so (§6.1, AC-3).
|
||||||
|
*
|
||||||
|
* The empty-section case is the one with real correctness risk and the reason the
|
||||||
|
* rule is ported rather than left to the drawer: a group whose every member is
|
||||||
|
* withheld by the caller's role or by the shard's visibility config must not draw
|
||||||
|
* as a header with nothing under it.
|
||||||
|
*
|
||||||
|
* Links are not gated — see [NavNode].
|
||||||
|
*
|
||||||
|
* @param isVisible the caller's own predicate, applied to coded items only, so
|
||||||
|
* this file stays ignorant of sessions and shard features
|
||||||
|
*/
|
||||||
|
fun pruneNav(tree: List<NavNode>, isVisible: (MenuEntry) -> Boolean): List<NavNode> {
|
||||||
|
fun keep(node: NavNode) = node !is NavNode.Item || isVisible(node.entry)
|
||||||
|
return tree.mapNotNull { node ->
|
||||||
|
when (node) {
|
||||||
|
is NavNode.Section -> node.copy(items = node.items.filter(::keep)).takeIf { it.items.isNotEmpty() }
|
||||||
|
else -> node.takeIf { keep(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
*/
|
*/
|
||||||
package com.runicgateway.app.ui.navigation
|
package com.runicgateway.app.ui.navigation
|
||||||
|
|
||||||
|
import com.runicgateway.app.data.repository.ContentRepository
|
||||||
|
import com.runicgateway.app.ui.rust.RustTab
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigation destinations for the M1 public surface (PLAN.md §5). Routes are
|
* Navigation destinations for the M1 public surface (PLAN.md §5). Routes are
|
||||||
* plain strings for Navigation-Compose; argument-bearing routes expose a
|
* plain strings for Navigation-Compose; argument-bearing routes expose a
|
||||||
@@ -14,6 +17,19 @@ object Routes {
|
|||||||
const val WIKI = "wiki"
|
const val WIKI = "wiki"
|
||||||
const val CONTACT = "contact"
|
const val CONTACT = "contact"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The News hub's NavHost pattern: [NEWS] plus an optional category, so a link
|
||||||
|
* to one of the website's three category pages can land on the matching tab
|
||||||
|
* (THEMING_AND_NAV.md §6.2). Navigating to plain [NEWS] matches this pattern
|
||||||
|
* with no argument and opens the default tab, so every existing call site —
|
||||||
|
* the drawer, [forStream] — is unaffected.
|
||||||
|
*
|
||||||
|
* Declared beside [NEWS] rather than replacing it because the two are used for
|
||||||
|
* different things: this is what `composable()` and `destination.route` speak,
|
||||||
|
* [NEWS] is what callers navigate to.
|
||||||
|
*/
|
||||||
|
const val NEWS_ROUTE = "news?category={category}"
|
||||||
|
|
||||||
/** Native login (§4.1) and the signed-in account surface (§5). */
|
/** Native login (§4.1) and the signed-in account surface (§5). */
|
||||||
const val LOGIN = "login"
|
const val LOGIN = "login"
|
||||||
const val ACCOUNT = "account"
|
const val ACCOUNT = "account"
|
||||||
@@ -22,8 +38,65 @@ object Routes {
|
|||||||
const val ACCOUNT_TRUSTED_DEVICES = "account/trusted-devices"
|
const val ACCOUNT_TRUSTED_DEVICES = "account/trusted-devices"
|
||||||
const val ACCOUNT_RECOVERY_CODES = "account/recovery-codes"
|
const val ACCOUNT_RECOVERY_CODES = "account/recovery-codes"
|
||||||
|
|
||||||
/** Opt-in push notification settings (§11, signed-in). */
|
/**
|
||||||
|
* The in-app inbox (ENGAGEMENT.md phase 8, signed-in) and its settings.
|
||||||
|
*
|
||||||
|
* The bare route is the CONTENT and the named sub-route the preferences, which
|
||||||
|
* is exactly how the web surface is laid out (`/account/notifications` and
|
||||||
|
* `…/settings`) — and what a person means when they tap "Notifications".
|
||||||
|
*/
|
||||||
const val NOTIFICATIONS = "notifications"
|
const val NOTIFICATIONS = "notifications"
|
||||||
|
const val NOTIFICATIONS_SETTINGS = "notifications/settings"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events (§9 M13) — CORE's, not a module's: these screens exist on a backend
|
||||||
|
* with no game module at all, which is why they are not under `shard/`.
|
||||||
|
*
|
||||||
|
* **[EVENT_ROUTE] is the app's first route that takes a query**, and it takes
|
||||||
|
* exactly one: `run`, naming which occurrence a results table is about. The
|
||||||
|
* page lives at the definition's slug so a weekly event has one address that
|
||||||
|
* survives a retitle, and the occurrence has to live somewhere else. See
|
||||||
|
* [resolveWebPath], whose "a query hands off" rule this is the one exception
|
||||||
|
* to.
|
||||||
|
*
|
||||||
|
* **[MY_EVENTS] is `account/events` and not `events/mine`**, which is not
|
||||||
|
* cosmetic: `events/mine` and `events/{slug}` are both two segments, and a
|
||||||
|
* static-versus-argument race between two NavHost patterns is exactly the bug
|
||||||
|
* events Phase 13 shipped one tier along, where a static `events/new` outranked
|
||||||
|
* `events/:id` in React Router and made creating an event impossible for seven
|
||||||
|
* phases. Under `account/` there is no dynamic sibling and no race to lose.
|
||||||
|
*/
|
||||||
|
const val EVENTS = "events"
|
||||||
|
const val EVENT_ROUTE = "events/{slug}?run={run}"
|
||||||
|
const val EVENT_SERIES = "events/series/{slug}"
|
||||||
|
const val MY_EVENTS = "account/events"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Rust module's surface (§9 M14, `docs/modules/rust/PLAN.md` D12, D13).
|
||||||
|
*
|
||||||
|
* **[RUST] is the server list, not a hub above one.** The module registers its
|
||||||
|
* pages with `path: ''` and core strips the trailing separator, so `/rust` on
|
||||||
|
* the website *is* the list — there is no landing page between the drawer row
|
||||||
|
* and the servers, and adding one here would invent a screen the website does
|
||||||
|
* not have.
|
||||||
|
*
|
||||||
|
* **A different game, so a different route tree.** These are deliberately not
|
||||||
|
* folded into [SHARD]: one shard is a place, and a Rust site is a fleet. The
|
||||||
|
* two can be installed on the same backend, and then both trees exist at once.
|
||||||
|
*/
|
||||||
|
const val RUST = "rust"
|
||||||
|
const val RUST_SERVER = "rust/servers/{serverId}?tab={tab}"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player's own Rust account (§9 M15) — the Steam accounts they hold and
|
||||||
|
* what the site has given them in game.
|
||||||
|
*
|
||||||
|
* Under `player/` with the three UO rows rather than under `rust/` with the
|
||||||
|
* two public ones, because the grouping that matters to a reader is *whose
|
||||||
|
* data is this*: these are the signed-in, self-scoped screens. The website
|
||||||
|
* agrees — it serves this from the player tier, at `/player/rust`.
|
||||||
|
*/
|
||||||
|
const val PLAYER_RUST = "player/rust"
|
||||||
|
|
||||||
/** Public shard hub (§6.2). */
|
/** Public shard hub (§6.2). */
|
||||||
const val SHARD = "shard"
|
const val SHARD = "shard"
|
||||||
@@ -75,10 +148,20 @@ object Routes {
|
|||||||
const val CATEGORY = "category"
|
const val CATEGORY = "category"
|
||||||
const val ID_OR_SLUG = "idOrSlug"
|
const val ID_OR_SLUG = "idOrSlug"
|
||||||
const val SERIAL = "serial"
|
const val SERIAL = "serial"
|
||||||
|
const val RUN = "run"
|
||||||
|
const val SERVER_ID = "serverId"
|
||||||
|
const val TAB = "tab"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun page(slug: String) = "page/$slug"
|
fun page(slug: String) = "page/$slug"
|
||||||
fun post(categoryUrlSlug: String, idOrSlug: String) = "news/$categoryUrlSlug/$idOrSlug"
|
fun post(categoryUrlSlug: String, idOrSlug: String) = "news/$categoryUrlSlug/$idOrSlug"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The News hub with [category] preselected. Takes the enum rather than a slug
|
||||||
|
* so an unmapped category cannot reach the NavHost — the screen's tabs are the
|
||||||
|
* enum's entries, and a slug it doesn't know would select nothing.
|
||||||
|
*/
|
||||||
|
fun news(category: ContentRepository.PostCategory) = "news?category=${category.urlSlug}"
|
||||||
fun wikiPage(slug: String) = "wiki/$slug"
|
fun wikiPage(slug: String) = "wiki/$slug"
|
||||||
|
|
||||||
/** The character-sheet route for an in-game serial (e.g. "0x24C"). */
|
/** The character-sheet route for an in-game serial (e.g. "0x24C"). */
|
||||||
@@ -87,9 +170,66 @@ object Routes {
|
|||||||
/** One player vendor's shop, by in-game (hex) serial. */
|
/** One player vendor's shop, by in-game (hex) serial. */
|
||||||
fun marketVendor(serial: String) = "shard/market/$serial"
|
fun marketVendor(serial: String) = "shard/market/$serial"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One Rust server's page.
|
||||||
|
*
|
||||||
|
* The id is a slug an operator chose, so it is encoded: nothing stops one
|
||||||
|
* carrying a character a path would otherwise eat, and a server nobody can
|
||||||
|
* open is a worse failure than a name nobody can read.
|
||||||
|
*
|
||||||
|
* **Encoded here rather than with `android.net.Uri`**, which is a stub in a
|
||||||
|
* JVM unit test and throws "not mocked" — this object is pure and every test
|
||||||
|
* that builds a route would have to become an instrumented one to keep it
|
||||||
|
* that way.
|
||||||
|
*
|
||||||
|
* [tab] is what the *new leader* notice's link carries (Rust D71), and it is
|
||||||
|
* dropped when absent, as `run` is on [event].
|
||||||
|
*/
|
||||||
|
fun rustServer(id: String, tab: RustTab? = null): String {
|
||||||
|
val base = "rust/servers/${encodePathSegment(id)}"
|
||||||
|
return if (tab == null) base else "$base?tab=${tab.wire}"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Percent-encode one path segment, allowing only the unreserved set.
|
||||||
|
*
|
||||||
|
* Deliberately stricter than it needs to be: encoding a character that did
|
||||||
|
* not need it still round-trips, where missing one that did produces a route
|
||||||
|
* NavHost matches differently from the one that was built. UTF-8 first, so a
|
||||||
|
* non-ASCII name is encoded per byte rather than per character.
|
||||||
|
*/
|
||||||
|
private fun encodePathSegment(value: String): String = buildString {
|
||||||
|
for (byte in value.toByteArray(Charsets.UTF_8)) {
|
||||||
|
val code = byte.toInt() and 0xFF
|
||||||
|
val char = code.toChar()
|
||||||
|
if (code < 128 && (char.isLetterOrDigit() || char in UNRESERVED)) {
|
||||||
|
append(char)
|
||||||
|
} else {
|
||||||
|
append('%').append(code.toString(16).uppercase().padStart(2, '0'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val UNRESERVED = "-._~"
|
||||||
|
|
||||||
/** One creature's atlas page, by slug. */
|
/** One creature's atlas page, by slug. */
|
||||||
fun atlasCreature(slug: String) = "atlas/$slug"
|
fun atlasCreature(slug: String) = "atlas/$slug"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One event's page, optionally about one occurrence.
|
||||||
|
*
|
||||||
|
* [runId] is what an announcement's link carries, and it is dropped when
|
||||||
|
* absent rather than sent as an empty argument — `events/x?run=` would reach
|
||||||
|
* the screen as a blank string and be forwarded to the server as one.
|
||||||
|
*/
|
||||||
|
fun event(slug: String, runId: String? = null): String {
|
||||||
|
val base = "events/$slug"
|
||||||
|
return if (runId.isNullOrBlank()) base else "$base?run=$runId"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One arc, by slug. */
|
||||||
|
fun eventSeries(slug: String) = "events/series/$slug"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The in-app destination a tapped push notification deep-links to (§11, M7
|
* The in-app destination a tapped push notification deep-links to (§11, M7
|
||||||
* Part 2 work item 7). Maps a stream id to the screen that shows its content;
|
* Part 2 work item 7). Maps a stream id to the screen that shows its content;
|
||||||
@@ -106,6 +246,35 @@ object Routes {
|
|||||||
com.runicgateway.app.core.push.PushStreams.VENDOR_SALE -> PLAYER_VENDORS
|
com.runicgateway.app.core.push.PushStreams.VENDOR_SALE -> PLAYER_VENDORS
|
||||||
com.runicgateway.app.core.push.PushStreams.HOUSE_IDOC -> PLAYER_HOUSES
|
com.runicgateway.app.core.push.PushStreams.HOUSE_IDOC -> PLAYER_HOUSES
|
||||||
com.runicgateway.app.core.push.PushStreams.ACCOUNT_LOGIN -> ACCOUNT
|
com.runicgateway.app.core.push.PushStreams.ACCOUNT_LOGIN -> ACCOUNT
|
||||||
else -> HOME
|
// An engagement rule's tickle carries the TRIGGER id as its stream
|
||||||
|
// (ENGAGEMENT.md §7.2's one namespace), and `event.run.started` is the only
|
||||||
|
// event trigger that is also a push stream. The calendar is the honest
|
||||||
|
// destination when there is no inbox row to send it to — the tickle names
|
||||||
|
// no occurrence, so there is no page to open. A row, when there is one,
|
||||||
|
// wins via [forTickle] and carries the link that does.
|
||||||
|
else -> if (streamId.startsWith(EVENT_STREAM_PREFIX)) EVENTS else HOME
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What every core `event.` trigger id begins with (EVENTS.md §J). */
|
||||||
|
private const val EVENT_STREAM_PREFIX = "event."
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a tapped tickle lands, given both halves of `{ stream, ref }`.
|
||||||
|
*
|
||||||
|
* **A `notification:<id>` ref means the engine wrote this user an inbox row**
|
||||||
|
* (`pushChannel.js` builds it), so the tap goes to the inbox whatever the
|
||||||
|
* stream is — an engagement rule's stream id is a TRIGGER id in §7.2's one
|
||||||
|
* namespace, and [forStream]'s fixed map would send most of them to Home.
|
||||||
|
* Every other tickle keeps the route it has always had, so no shipped stream
|
||||||
|
* changes where it lands.
|
||||||
|
*
|
||||||
|
* The ref is not decoded beyond that prefix and is never rendered: it is a
|
||||||
|
* hint that a row exists, and the app's contract is wake-and-pull.
|
||||||
|
*/
|
||||||
|
fun forTickle(streamId: String, ref: String?): String =
|
||||||
|
if (ref != null && ref.startsWith(INBOX_REF_PREFIX)) NOTIFICATIONS else forStream(streamId)
|
||||||
|
|
||||||
|
/** What `pushChannel.js` prefixes an inbox row's id with. */
|
||||||
|
const val INBOX_REF_PREFIX = "notification:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,14 @@
|
|||||||
*/
|
*/
|
||||||
package com.runicgateway.app.ui.news
|
package com.runicgateway.app.ui.news
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.runicgateway.app.data.api.dto.PostDto
|
import com.runicgateway.app.data.api.dto.PostDto
|
||||||
import com.runicgateway.app.data.repository.ContentRepository
|
import com.runicgateway.app.data.repository.ContentRepository
|
||||||
import com.runicgateway.app.data.repository.ContentRepository.PostCategory
|
import com.runicgateway.app.data.repository.ContentRepository.PostCategory
|
||||||
import com.runicgateway.app.ui.UiState
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.navigation.Routes
|
||||||
import com.runicgateway.app.ui.toUiState
|
import com.runicgateway.app.ui.toUiState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -21,9 +23,15 @@ import javax.inject.Inject
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class NewsViewModel @Inject constructor(
|
class NewsViewModel @Inject constructor(
|
||||||
private val contentRepository: ContentRepository,
|
private val contentRepository: ContentRepository,
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _category = MutableStateFlow(PostCategory.NEWS)
|
// Which tab to open on. Absent — every route into this screen except an
|
||||||
|
// admin's nav override or added link (THEMING_AND_NAV.md §6.2) — is the
|
||||||
|
// default feed, and so is a slug the app doesn't know.
|
||||||
|
private val _category = MutableStateFlow(
|
||||||
|
PostCategory.fromUrlSlug(savedStateHandle[Routes.Args.CATEGORY]) ?: PostCategory.NEWS,
|
||||||
|
)
|
||||||
val category: StateFlow<PostCategory> = _category.asStateFlow()
|
val category: StateFlow<PostCategory> = _category.asStateFlow()
|
||||||
|
|
||||||
private val _state = MutableStateFlow<UiState<List<PostDto>>>(UiState.Loading)
|
private val _state = MutableStateFlow<UiState<List<PostDto>>>(UiState.Loading)
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.notifications
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.core.auth.Session
|
||||||
|
import com.runicgateway.app.core.auth.SessionManager
|
||||||
|
import com.runicgateway.app.core.inbox.InboxCache
|
||||||
|
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.data.repository.NotificationsRepository
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The drawer's unread badge (ENGAGEMENT.md phase 8).
|
||||||
|
*
|
||||||
|
* Its own view model, and its own endpoint: `/notifications/unread-count` exists
|
||||||
|
* precisely because this is the question asked most often and it should not make
|
||||||
|
* the server assemble a page of bodies to answer with one integer. Refreshed when
|
||||||
|
* the app resumes rather than on a timer — the tickle is what says "something
|
||||||
|
* happened", so polling would be a second, worse copy of push.
|
||||||
|
*
|
||||||
|
* Falls back to the cached count while offline, for the same reason the inbox
|
||||||
|
* does: a badge that dropped to zero because the train went into a tunnel would
|
||||||
|
* be telling the user they have read something they have not.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class InboxBadgeViewModel @Inject constructor(
|
||||||
|
private val notifications: NotificationsRepository,
|
||||||
|
private val cache: InboxCache,
|
||||||
|
private val sessionManager: SessionManager,
|
||||||
|
private val baseUrlHolder: BaseUrlHolder,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _unread = MutableStateFlow(0)
|
||||||
|
val unread: StateFlow<Int> = _unread.asStateFlow()
|
||||||
|
|
||||||
|
/** Ask the server, falling back to the snapshot. A signed-out session is zero. */
|
||||||
|
fun refresh() = viewModelScope.launch {
|
||||||
|
val user = (sessionManager.state.value as? Session.SignedIn)?.user
|
||||||
|
if (user == null) {
|
||||||
|
_unread.value = 0
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
when (val result = notifications.unreadCount()) {
|
||||||
|
is ApiResult.Ok -> _unread.value = result.data.unread
|
||||||
|
else -> {
|
||||||
|
val owner = InboxCache.ownerKey(baseUrlHolder.current?.toString(), user.id)
|
||||||
|
cache.read(owner)?.let { _unread.value = it.unread }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.notifications
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.time.parseWireInstant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render an inbox item's `createdAt` for display, in the device's own zone and
|
||||||
|
* locale (ENGAGEMENT.md phase 8). Pure, so it is unit-testable off-device.
|
||||||
|
*
|
||||||
|
* **Two shapes have to be accepted, and which one arrives is not the app's to
|
||||||
|
* decide** — see [parseWireInstant], which owns that trap for every screen that
|
||||||
|
* reads a timestamp, this one and the event screens (M13).
|
||||||
|
*
|
||||||
|
* Anything unparseable returns null and the row simply shows no stamp: a
|
||||||
|
* notification with an odd date is still worth reading.
|
||||||
|
*/
|
||||||
|
fun inboxTimestamp(
|
||||||
|
raw: String,
|
||||||
|
zone: ZoneId = ZoneId.systemDefault(),
|
||||||
|
formatter: DateTimeFormatter =
|
||||||
|
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT),
|
||||||
|
): String? {
|
||||||
|
val instant = parseWireInstant(raw) ?: return null
|
||||||
|
return try {
|
||||||
|
formatter.withZone(zone).format(instant)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.notifications
|
||||||
|
|
||||||
|
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.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.core.web.WebHandoff
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationItemDto
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.components.EmptyView
|
||||||
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
import com.runicgateway.app.ui.components.ShardCard
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The in-app inbox (ENGAGEMENT.md phase 8): what the engine's `inapp` channel
|
||||||
|
* wrote for this user, newest first.
|
||||||
|
*
|
||||||
|
* This is the drawer's "Notifications" — the settings that used to live there are
|
||||||
|
* one tap away behind the gear, mirroring exactly what phase 7 shipped on the web
|
||||||
|
* (the bare path is the inbox, `…/settings` is the preferences). It is what a
|
||||||
|
* tapped push tickle deep-links to, and the pull that follows the wake.
|
||||||
|
*
|
||||||
|
* **The list carries content, so it is deliberately plain text.** An item's body
|
||||||
|
* is the server's `toText` render, never the email HTML — that markup is table
|
||||||
|
* rows and inline hex with a light-only `color-scheme`, which in a themed app
|
||||||
|
* would be a pale card in a dark one. It also means there is no operator markup
|
||||||
|
* on this surface to sanitize.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun InboxScreen(
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
|
onOpenRoute: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: InboxViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
Column(modifier.fillMaxSize()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (state.unread > 0) {
|
||||||
|
stringResource(R.string.inbox_unread_count, state.unread)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.inbox_all_read)
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
if (state.unread > 0) {
|
||||||
|
TextButton(onClick = viewModel::markAllRead) {
|
||||||
|
Text(stringResource(R.string.inbox_mark_all_read))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconButton(onClick = onOpenSettings) {
|
||||||
|
Icon(Icons.Filled.Settings, stringResource(R.string.inbox_open_settings))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Showing the snapshot rather than the server's answer is said out loud: a
|
||||||
|
// notification surface that quietly showed a stale list would be lying
|
||||||
|
// about the one thing it exists to be — current.
|
||||||
|
if (state.fromCache) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.inbox_offline_cached),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val items = state.items) {
|
||||||
|
is UiState.Loading -> LoadingView()
|
||||||
|
is UiState.Error -> ErrorView(items.kind, onRetry = viewModel::load)
|
||||||
|
is UiState.Success -> if (items.data.isEmpty()) {
|
||||||
|
EmptyView(stringResource(R.string.inbox_empty))
|
||||||
|
} else {
|
||||||
|
InboxList(
|
||||||
|
items = items.data,
|
||||||
|
hasMore = state.hasMore && !state.fromCache,
|
||||||
|
onEndReached = viewModel::loadMore,
|
||||||
|
onOpen = { item ->
|
||||||
|
viewModel.markRead(item.id)
|
||||||
|
// Most items have no url at all — an inbox row is complete on
|
||||||
|
// its own — and the ones that do carry a SITE-RELATIVE path.
|
||||||
|
//
|
||||||
|
// A path the app has a screen for opens natively (M13): an
|
||||||
|
// event announcement's link is the case that made this worth
|
||||||
|
// doing. Everything else resolves against the configured
|
||||||
|
// shard and goes to the browser, exactly as before.
|
||||||
|
val route = viewModel.routeFor(item)
|
||||||
|
if (route != null) {
|
||||||
|
onOpenRoute(route)
|
||||||
|
} else {
|
||||||
|
viewModel.linkFor(item)?.let { WebHandoff.open(context, it) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InboxList(
|
||||||
|
items: List<NotificationItemDto>,
|
||||||
|
hasMore: Boolean,
|
||||||
|
onEndReached: () -> Unit,
|
||||||
|
onOpen: (NotificationItemDto) -> Unit,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
contentPadding = PaddingValues(vertical = 12.dp),
|
||||||
|
) {
|
||||||
|
items(items, key = { it.id }) { item -> InboxCard(item, onOpen) }
|
||||||
|
if (hasMore) {
|
||||||
|
item {
|
||||||
|
// Paging by "the last row came into view" rather than a button: the
|
||||||
|
// cursor is the last id on screen, so reaching the end IS the request.
|
||||||
|
LaunchedEffect(items.lastOrNull()?.id) { onEndReached() }
|
||||||
|
Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.inbox_loading_more),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InboxCard(item: NotificationItemDto, onOpen: (NotificationItemDto) -> Unit) {
|
||||||
|
ShardCard(Modifier.fillMaxWidth().clickable { onOpen(item) }) {
|
||||||
|
Column(Modifier.padding(16.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (!item.read) {
|
||||||
|
// The unread mark is a dot beside the title AND a heavier weight
|
||||||
|
// on it: colour alone would carry the whole signal, which is not
|
||||||
|
// a distinction everyone can see.
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.padding(end = 8.dp)
|
||||||
|
.size(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.primary),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = item.title,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = if (item.read) FontWeight.Normal else FontWeight.Bold,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item.body?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val stamp = item.createdAt?.let { inboxTimestamp(it) }
|
||||||
|
if (stamp != null) {
|
||||||
|
Text(
|
||||||
|
text = stamp,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.notifications
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.core.auth.Session
|
||||||
|
import com.runicgateway.app.core.auth.SessionManager
|
||||||
|
import com.runicgateway.app.core.inbox.InboxCache
|
||||||
|
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.core.result.map
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationItemDto
|
||||||
|
import com.runicgateway.app.data.repository.NotificationsRepository
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.navigation.resolveWebPath
|
||||||
|
import com.runicgateway.app.ui.toUiState
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the in-app inbox (ENGAGEMENT.md phase 8): the items the engine's `inapp`
|
||||||
|
* channel wrote for this user, newest first, with the unread badge and the two
|
||||||
|
* mark-read writes.
|
||||||
|
*
|
||||||
|
* **The tickle contract is wake-and-pull, and this is the pull.** A push tickle
|
||||||
|
* carries `{ stream, ref }` and nothing else by design; `ref` is a HINT that an
|
||||||
|
* inbox row exists, never content, and `pushChannel.js` says so in as many words —
|
||||||
|
* the two rows are independent and either can be retried, so a client that
|
||||||
|
* rendered the ref would show nothing the first time a retry reordered them. So a
|
||||||
|
* tap deep-links here and this refreshes; the ref is not read.
|
||||||
|
*
|
||||||
|
* **Paging is keyset, not offset.** The next page is `before = the last id on
|
||||||
|
* screen`, because the list gains rows at the top while it is being read and an
|
||||||
|
* offset would show the same item twice or skip one.
|
||||||
|
*
|
||||||
|
* **It reloads when the ACCOUNT changes, not merely when it is created.** A
|
||||||
|
* drawer route's view model outlives a sign-out: `navigateTopLevel` saves and
|
||||||
|
* restores back-stack state, so the `NavBackStackEntry` keeps its
|
||||||
|
* `ViewModelStore` and a view model that loaded only in `init` never runs again.
|
||||||
|
* Signing out and back in as somebody else showed the second account the FIRST
|
||||||
|
* account's inbox — titles and body text written for another person — with no
|
||||||
|
* request made at all, while the badge beside it showed the new account's real
|
||||||
|
* count, because the shell refreshes that one on every session change.
|
||||||
|
*
|
||||||
|
* [InboxCache] was never the hole: it is keyed by `(base URL, user id)` and a
|
||||||
|
* snapshot has never crossed an account. The hole was the in-memory state, which
|
||||||
|
* nothing invalidated.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class InboxViewModel @Inject constructor(
|
||||||
|
private val notifications: NotificationsRepository,
|
||||||
|
private val cache: InboxCache,
|
||||||
|
private val sessionManager: SessionManager,
|
||||||
|
private val baseUrlHolder: BaseUrlHolder,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
data class State(
|
||||||
|
val items: UiState<List<NotificationItemDto>> = UiState.Loading,
|
||||||
|
val unread: Int = 0,
|
||||||
|
val hasMore: Boolean = false,
|
||||||
|
val loadingMore: Boolean = false,
|
||||||
|
val refreshing: Boolean = false,
|
||||||
|
/**
|
||||||
|
* True while what is on screen came from [InboxCache] rather than the
|
||||||
|
* server. The screen says so — an inbox that quietly showed a stale list
|
||||||
|
* would be a notification surface that lies about being current.
|
||||||
|
*/
|
||||||
|
val fromCache: Boolean = false,
|
||||||
|
/** When that snapshot was captured; only meaningful with [fromCache]. */
|
||||||
|
val cachedAt: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(State())
|
||||||
|
val state: StateFlow<State> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
sessionManager.state
|
||||||
|
.map { (it as? Session.SignedIn)?.user?.id }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect { userId ->
|
||||||
|
if (userId == null) {
|
||||||
|
// Signed out. The shell is already navigating away; drop the
|
||||||
|
// rows rather than leave them addressable behind it.
|
||||||
|
_state.value = State(items = UiState.Success(emptyList()))
|
||||||
|
} else {
|
||||||
|
// Reset BEFORE loading, not after: `load()` paints the cache
|
||||||
|
// only when there is no `Success` on screen, so the previous
|
||||||
|
// account's rows would otherwise stay up — and stay up for
|
||||||
|
// the whole round trip.
|
||||||
|
_state.value = State()
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the cached page immediately, then refresh from the server.
|
||||||
|
*
|
||||||
|
* The cache is painted first rather than after a failure so a cold open on a
|
||||||
|
* slow connection shows the last known inbox instead of a spinner; a
|
||||||
|
* successful pull replaces it, and a network failure leaves it up with
|
||||||
|
* [State.fromCache] set. A *server* error is a different thing from being
|
||||||
|
* offline and is not papered over with stale rows — unless there is nothing
|
||||||
|
* else to show, in which case the error is still what the screen reports.
|
||||||
|
*/
|
||||||
|
fun load() = viewModelScope.launch {
|
||||||
|
val owner = ownerKey()
|
||||||
|
if (owner != null && _state.value.items !is UiState.Success) {
|
||||||
|
cache.read(owner)?.let { snapshot ->
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
items = UiState.Success(snapshot.items),
|
||||||
|
unread = snapshot.unread,
|
||||||
|
fromCache = true,
|
||||||
|
cachedAt = snapshot.savedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull the newest page. Keeps whatever is on screen until it succeeds. */
|
||||||
|
fun refresh() = viewModelScope.launch {
|
||||||
|
_state.update { it.copy(refreshing = true) }
|
||||||
|
when (val result = notifications.inbox()) {
|
||||||
|
is ApiResult.Ok -> {
|
||||||
|
val page = result.data
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
items = UiState.Success(page.items),
|
||||||
|
unread = page.unread,
|
||||||
|
hasMore = page.hasMore,
|
||||||
|
refreshing = false,
|
||||||
|
fromCache = false,
|
||||||
|
cachedAt = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ownerKey()?.let { cache.write(it, page.items, page.unread) }
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// Nothing cached to fall back on → the error IS the screen. Something
|
||||||
|
// cached → keep it up and label it, which is the whole point of §7's
|
||||||
|
// "the app degrades, it does not fail".
|
||||||
|
val holdCache = _state.value.items is UiState.Success && _state.value.fromCache
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
items = if (holdCache) it.items else result.map { page -> page.items }.toUiState(),
|
||||||
|
refreshing = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append the next page.
|
||||||
|
*
|
||||||
|
* A no-op while one is in flight, when the server said there is no next page,
|
||||||
|
* or while the list is the cached snapshot — paging a cache we know to be one
|
||||||
|
* page long would ask the server for `before` an id it may no longer have.
|
||||||
|
*/
|
||||||
|
fun loadMore() = viewModelScope.launch {
|
||||||
|
val current = _state.value
|
||||||
|
val shown = (current.items as? UiState.Success)?.data ?: return@launch
|
||||||
|
if (current.loadingMore || !current.hasMore || current.fromCache) return@launch
|
||||||
|
val cursor = shown.lastOrNull()?.id ?: return@launch
|
||||||
|
|
||||||
|
_state.update { it.copy(loadingMore = true) }
|
||||||
|
when (val result = notifications.inbox(before = cursor)) {
|
||||||
|
is ApiResult.Ok -> {
|
||||||
|
// Guard the same id arriving twice: a keyset window can shift under
|
||||||
|
// a concurrent write, and a duplicate id in a LazyColumn key crashes.
|
||||||
|
val seen = shown.mapTo(mutableSetOf()) { it.id }
|
||||||
|
val appended = result.data.items.filterNot { it.id in seen }
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
items = UiState.Success(shown + appended),
|
||||||
|
unread = result.data.unread,
|
||||||
|
hasMore = result.data.hasMore,
|
||||||
|
loadingMore = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A failed "more" leaves the pages already read alone — losing them
|
||||||
|
// because the fourth page timed out would be worse than stopping.
|
||||||
|
else -> _state.update { it.copy(loadingMore = false, hasMore = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark one item read, optimistically.
|
||||||
|
*
|
||||||
|
* The row flips locally before the call so the tap feels immediate, and the
|
||||||
|
* server's post-write `unread` replaces the local guess when it lands. A
|
||||||
|
* failure is not rolled back: read-ness is the least consequential thing in
|
||||||
|
* the app to get briefly wrong, and un-reading a row under the user's finger
|
||||||
|
* looks like a bug. The next refresh corrects it.
|
||||||
|
*/
|
||||||
|
fun markRead(id: Long) = viewModelScope.launch {
|
||||||
|
val shown = (_state.value.items as? UiState.Success)?.data ?: return@launch
|
||||||
|
if (shown.firstOrNull { it.id == id }?.read != false) return@launch
|
||||||
|
|
||||||
|
_state.update { current ->
|
||||||
|
current.copy(
|
||||||
|
items = UiState.Success(shown.map { if (it.id == id) it.copy(read = true) else it }),
|
||||||
|
unread = (current.unread - 1).coerceAtLeast(0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
when (val result = notifications.markRead(id)) {
|
||||||
|
is ApiResult.Ok -> _state.update { it.copy(unread = result.data.unread) }
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
cacheCurrent()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark the whole inbox read. Same optimism, and the same reason for it. */
|
||||||
|
fun markAllRead() = viewModelScope.launch {
|
||||||
|
val shown = (_state.value.items as? UiState.Success)?.data ?: return@launch
|
||||||
|
_state.update {
|
||||||
|
it.copy(items = UiState.Success(shown.map { item -> item.copy(read = true) }), unread = 0)
|
||||||
|
}
|
||||||
|
notifications.markAllRead()
|
||||||
|
cacheCurrent()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The absolute link for an item, or null when it has none this app can open.
|
||||||
|
*
|
||||||
|
* **An item's `url` is SITE-RELATIVE** — `/guilds/the-silver-anvil/forum/403`
|
||||||
|
* is what the server writes, because it is rendered from the template's button
|
||||||
|
* block for a browser that is already on the site. A phone is not, so it has to
|
||||||
|
* be resolved against the configured base or every link in the inbox is dead;
|
||||||
|
* the live rig is what caught that.
|
||||||
|
*
|
||||||
|
* `HttpUrl.resolve` does both jobs: it absolutises a relative path and it
|
||||||
|
* returns null for anything that would not end up as http(s) — a `javascript:`
|
||||||
|
* or `intent:` url in a notification body opens nothing at all.
|
||||||
|
*/
|
||||||
|
fun linkFor(item: NotificationItemDto): String? {
|
||||||
|
val raw = item.url?.trim().orEmpty()
|
||||||
|
if (raw.isEmpty()) return null
|
||||||
|
return baseUrlHolder.current?.resolve(raw)?.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app route this item opens natively, or null when it has none and
|
||||||
|
* [linkFor] should hand it to a browser (M13).
|
||||||
|
*
|
||||||
|
* **Why this exists at all:** events Phase 14a gave the six public `event.`
|
||||||
|
* triggers an `eventUrl` of the form `/site/events/<slug>?run=<id>`, so an
|
||||||
|
* inbox row about an event now has a native destination — and opening a
|
||||||
|
* Custom Tab onto a page the app itself renders is a worse answer than it was
|
||||||
|
* when there was no such page.
|
||||||
|
*
|
||||||
|
* **It reuses `resolveWebPath` rather than adding a second link-routing
|
||||||
|
* mechanism.** That function is already the app's read of the site's own route
|
||||||
|
* table, it already answers null for everything it does not recognise, and
|
||||||
|
* every path it does not recognise still hands off exactly as before. Adding a
|
||||||
|
* parser here would put the decision in two places.
|
||||||
|
*
|
||||||
|
* The item's url is site-relative by contract, but an absolute one on this
|
||||||
|
* host is accepted too: the shape is the server's to change, and a link that
|
||||||
|
* opened the browser only because it arrived fully qualified would be a
|
||||||
|
* puzzle. An absolute url on ANOTHER host is not ours to route — the app has
|
||||||
|
* no screen for somebody else's site — so it falls through to the browser.
|
||||||
|
*/
|
||||||
|
fun routeFor(item: NotificationItemDto): String? {
|
||||||
|
val raw = item.url?.trim().orEmpty()
|
||||||
|
if (raw.isEmpty()) return null
|
||||||
|
val base = baseUrlHolder.current ?: return null
|
||||||
|
val resolved = base.resolve(raw) ?: return null
|
||||||
|
if (resolved.host != base.host) return null
|
||||||
|
val query = resolved.query
|
||||||
|
return resolveWebPath(resolved.encodedPath + if (query.isNullOrEmpty()) "" else "?$query")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the snapshot in step with a local read.
|
||||||
|
*
|
||||||
|
* Without this, going offline right after reading everything would bring the
|
||||||
|
* badge back on the next cold open. Only ever written for the account that
|
||||||
|
* owns it — [InboxCache] scopes by (base URL, user id).
|
||||||
|
*/
|
||||||
|
private suspend fun cacheCurrent() {
|
||||||
|
val owner = ownerKey() ?: return
|
||||||
|
val current = _state.value
|
||||||
|
val shown = (current.items as? UiState.Success)?.data ?: return
|
||||||
|
cache.write(owner, shown, current.unread)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ownerKey(): String? {
|
||||||
|
val user = (sessionManager.state.value as? Session.SignedIn)?.user ?: return null
|
||||||
|
return InboxCache.ownerKey(baseUrlHolder.current?.toString(), user.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.notifications
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
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.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationChannelDto
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationChannelItemDto
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.components.EmptyView
|
||||||
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
import com.runicgateway.app.ui.components.SectionLabel
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The notification **settings** screen (PLAN.md §11, ENGAGEMENT.md phase 8): every
|
||||||
|
* subscribable id with a control per channel that applies to it.
|
||||||
|
*
|
||||||
|
* It used to be the drawer's "Notifications"; that entry is the inbox now and this
|
||||||
|
* is behind its gear, which is the arrangement phase 7 shipped on the web. What
|
||||||
|
* changed underneath is bigger than the move: the screen asks
|
||||||
|
* `/notifications/channels` and so can express email and on-site preferences, not
|
||||||
|
* just whether a stream pushes.
|
||||||
|
*
|
||||||
|
* **A channel with two modes gets a switch and one with three gets chips**, and
|
||||||
|
* which is which comes off the wire — `email` is the one that supports `digest`
|
||||||
|
* today, and a fourth channel with its own modes would render correctly here
|
||||||
|
* without an app release.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun NotificationSettingsScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: NotificationSettingsViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// Ask once for POST_NOTIFICATIONS when the user first switches a push mode on
|
||||||
|
// (API 33+). Email and in-app need no permission — only push posts anything.
|
||||||
|
val permissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission(),
|
||||||
|
) { /* granted or not, the preference is already saved server-side */ }
|
||||||
|
|
||||||
|
fun ensureNotificationPermission() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.notifications_title),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.notifications_subtitle),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
state.feedback?.let { fb ->
|
||||||
|
Text(
|
||||||
|
text = stringResource(fb.messageRes),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (fb.ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(bottom = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A shard with no push relay still has email and on-site preferences worth
|
||||||
|
// setting, so this is a note beside the list now rather than the whole
|
||||||
|
// screen — which is what it had to be when push was all there was.
|
||||||
|
if (!state.supported) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.notifications_unsupported),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontStyle = FontStyle.Italic,
|
||||||
|
modifier = Modifier.padding(bottom = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val prefs = state.prefs) {
|
||||||
|
is UiState.Loading -> LoadingView()
|
||||||
|
is UiState.Error -> ErrorView(kind = prefs.kind, onRetry = viewModel::load)
|
||||||
|
is UiState.Success -> ChannelPrefsList(
|
||||||
|
prefs = prefs.data,
|
||||||
|
hasLinkedAccount = state.hasLinkedAccount,
|
||||||
|
pushSupported = state.supported,
|
||||||
|
busy = state.busy,
|
||||||
|
onSetMode = { item, channel, mode ->
|
||||||
|
if (channel == CHANNEL_PUSH && mode != MODE_OFF) ensureNotificationPermission()
|
||||||
|
viewModel.setMode(item, channel, mode)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChannelPrefsList(
|
||||||
|
prefs: NotificationChannelPrefsDto,
|
||||||
|
hasLinkedAccount: Boolean,
|
||||||
|
pushSupported: Boolean,
|
||||||
|
busy: Boolean,
|
||||||
|
onSetMode: (NotificationChannelItemDto, String, String) -> Unit,
|
||||||
|
) {
|
||||||
|
if (prefs.items.isEmpty()) {
|
||||||
|
EmptyView(message = stringResource(R.string.notifications_empty))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val channelsById = prefs.channels.associateBy { it.id }
|
||||||
|
val (personal, general) = prefs.items.partition { it.personal }
|
||||||
|
|
||||||
|
if (general.isNotEmpty()) {
|
||||||
|
SectionLabel(stringResource(R.string.notifications_section_general))
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
general.forEach { item ->
|
||||||
|
ItemRow(item, channelsById, hasLinkedAccount, busy, pushSupported, onSetMode)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (personal.isNotEmpty()) {
|
||||||
|
SectionLabel(stringResource(R.string.notifications_section_personal))
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
personal.forEach { item ->
|
||||||
|
ItemRow(item, channelsById, hasLinkedAccount, busy, pushSupported, onSetMode)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ItemRow(
|
||||||
|
item: NotificationChannelItemDto,
|
||||||
|
channelsById: Map<String, NotificationChannelDto>,
|
||||||
|
hasLinkedAccount: Boolean,
|
||||||
|
busy: Boolean,
|
||||||
|
pushSupported: Boolean,
|
||||||
|
onSetMode: (NotificationChannelItemDto, String, String) -> Unit,
|
||||||
|
) {
|
||||||
|
// The link holds back one control, push switching on, so the row stays live
|
||||||
|
// and the hint names that one control (Rust D69). Where this device has no
|
||||||
|
// push at all, there is no control to hold back and nothing to explain.
|
||||||
|
val pushHeld = pushSupported && pushNeedsLink(item, hasLinkedAccount)
|
||||||
|
Column(Modifier.fillMaxWidth().padding(vertical = 12.dp)) {
|
||||||
|
Text(
|
||||||
|
text = item.label,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (pushHeld) stringResource(R.string.notifications_requires_link) else item.description,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontStyle = if (pushHeld) FontStyle.Italic else FontStyle.Normal,
|
||||||
|
)
|
||||||
|
// The item's OWN channel list, in the registry's order. An id nothing can
|
||||||
|
// push carries no push control at all, rather than a dead switch.
|
||||||
|
item.channels.forEach { channelId ->
|
||||||
|
val channel = channelsById[channelId] ?: return@forEach
|
||||||
|
if (channelId == CHANNEL_PUSH && !pushSupported) return@forEach
|
||||||
|
val mode = item.modes[channelId] ?: channel.defaultMode
|
||||||
|
ChannelControl(
|
||||||
|
channel = channel,
|
||||||
|
mode = mode,
|
||||||
|
// A held push switch that is already ON stays enabled, because
|
||||||
|
// switching it off is never refused ([canSetMode]).
|
||||||
|
enabled = !busy && !(channelId == CHANNEL_PUSH && pushHeld && mode == MODE_OFF),
|
||||||
|
onSetMode = { next -> onSetMode(item, channelId, next) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun ChannelControl(
|
||||||
|
channel: NotificationChannelDto,
|
||||||
|
mode: String,
|
||||||
|
enabled: Boolean,
|
||||||
|
onSetMode: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
val modes = channel.modes.ifEmpty { listOf(MODE_OFF) }
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = channel.label,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f).padding(end = 12.dp),
|
||||||
|
)
|
||||||
|
// Two modes is a yes/no question and reads best as a switch; three is a
|
||||||
|
// choice and needs its options named — `digest` means nothing as an
|
||||||
|
// unlabelled third state.
|
||||||
|
if (modes.size == 2 && modes.contains(MODE_OFF)) {
|
||||||
|
val on = modes.first { it != MODE_OFF }
|
||||||
|
Switch(
|
||||||
|
checked = mode != MODE_OFF,
|
||||||
|
onCheckedChange = { checked -> onSetMode(if (checked) on else MODE_OFF) },
|
||||||
|
enabled = enabled,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
modes.forEach { candidate ->
|
||||||
|
FilterChip(
|
||||||
|
selected = candidate == mode,
|
||||||
|
onClick = { if (candidate != mode) onSetMode(candidate) },
|
||||||
|
enabled = enabled,
|
||||||
|
label = { Text(modeLabel(candidate)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy for a delivery mode. A mode this build has never heard of is labelled with
|
||||||
|
* its own wire name rather than hidden — the server accepts it, so a chip reading
|
||||||
|
* `weekly` is more use to the person in front of it than a control that vanished.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun modeLabel(mode: String): String = when (mode) {
|
||||||
|
MODE_OFF -> stringResource(R.string.notifications_mode_off)
|
||||||
|
"instant" -> stringResource(R.string.notifications_mode_instant)
|
||||||
|
"digest" -> stringResource(R.string.notifications_mode_digest)
|
||||||
|
else -> mode
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.notifications
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.core.auth.Session
|
||||||
|
import com.runicgateway.app.core.auth.SessionManager
|
||||||
|
import com.runicgateway.app.core.push.PushManager
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationChannelItemDto
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto
|
||||||
|
import com.runicgateway.app.data.repository.NotificationsRepository
|
||||||
|
import com.runicgateway.app.data.repository.LinkedAccountRepository
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.toUiState
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/** The push channel's id — the one channel that also drives a device registration. */
|
||||||
|
const val CHANNEL_PUSH = "push"
|
||||||
|
|
||||||
|
/** The mode every channel accepts, and the one that means "do not deliver". */
|
||||||
|
const val MODE_OFF = "off"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the notification **settings** screen (PLAN.md §11, ENGAGEMENT.md phase 8).
|
||||||
|
*
|
||||||
|
* **This screen moved off `/notifications/subscriptions` onto
|
||||||
|
* `/notifications/channels`.** The old endpoint asked one question — is push on
|
||||||
|
* for this stream — and there are now three channels to ask it of. The server
|
||||||
|
* keeps `notification_subscriptions` as the push projection of the new table and
|
||||||
|
* fans every write to either into the other, so the shipped APK's screen went on
|
||||||
|
* working the whole time and this one is not a migration anybody has to run.
|
||||||
|
*
|
||||||
|
* **The controls are rendered from the wire, never from a hardcoded three.** Each
|
||||||
|
* item names the channels that apply to it — a trigger-only id carries no `push`
|
||||||
|
* because nothing is registered to push it — and each channel names the modes it
|
||||||
|
* accepts, which is how `email`'s `digest` reaches the app without an app release.
|
||||||
|
* The modes the server sends are the EFFECTIVE ones (it has already substituted
|
||||||
|
* each channel's default), so this class never re-implements the defaulting.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class NotificationSettingsViewModel @Inject constructor(
|
||||||
|
private val notifications: NotificationsRepository,
|
||||||
|
private val linkedAccounts: LinkedAccountRepository,
|
||||||
|
private val pushManager: PushManager,
|
||||||
|
sessionManager: SessionManager,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
|
||||||
|
|
||||||
|
data class State(
|
||||||
|
val prefs: UiState<NotificationChannelPrefsDto> = UiState.Loading,
|
||||||
|
/**
|
||||||
|
* Whether the user has ≥1 linked game account on this site's module, asked
|
||||||
|
* of that module (Rust D69). It only gates switching push ON (see [canSetMode]).
|
||||||
|
*/
|
||||||
|
val hasLinkedAccount: Boolean = false,
|
||||||
|
/** Whether this shard advertises a push relay at all (else the screen says so). */
|
||||||
|
val supported: Boolean = true,
|
||||||
|
val busy: Boolean = false,
|
||||||
|
val feedback: Feedback? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(State())
|
||||||
|
val state: StateFlow<State> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
pushManager.supported.collect { supported -> _state.update { it.copy(supported = supported) } }
|
||||||
|
}
|
||||||
|
// Reloaded on an account change for the reason the inbox is, and one
|
||||||
|
// reason more: these controls are WRITTEN from. A screen still rendering
|
||||||
|
// the previous account's preferences would send this account's PUT built
|
||||||
|
// out of them, so a stale render here corrupts rather than merely
|
||||||
|
// discloses.
|
||||||
|
viewModelScope.launch {
|
||||||
|
sessionManager.state
|
||||||
|
.map { (it as? Session.SignedIn)?.user?.id }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect { userId -> if (userId != null) load() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
_state.update { it.copy(prefs = UiState.Loading) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(prefs = notifications.channelPrefs().toUiState()) }
|
||||||
|
_state.update { it.copy(hasLinkedAccount = linkedAccounts.hasLinkedAccount()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearFeedback() = _state.update { it.copy(feedback = null) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set one (item, channel) pair.
|
||||||
|
*
|
||||||
|
* One pair, one sparse PUT: the endpoint writes only what it is given, so a
|
||||||
|
* toggle cannot disturb a channel this screen is not showing — and the
|
||||||
|
* response is the full stored truth, which is what the screen re-renders
|
||||||
|
* from. An entry the server drops (an unknown id, an inapplicable channel)
|
||||||
|
* therefore shows up as the control springing back, not as a silent lie.
|
||||||
|
*/
|
||||||
|
fun setMode(item: NotificationChannelItemDto, channel: String, mode: String) {
|
||||||
|
val current = _state.value
|
||||||
|
if (current.busy) return
|
||||||
|
if (!canSetMode(item, channel, mode, current.hasLinkedAccount)) return
|
||||||
|
|
||||||
|
_state.update { it.copy(busy = true, feedback = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = notifications.setChannelMode(item.id, channel, mode)) {
|
||||||
|
is ApiResult.Ok -> {
|
||||||
|
_state.update { it.copy(prefs = UiState.Success(result.data)) }
|
||||||
|
if (channel == CHANNEL_PUSH) reconcilePush(result.data) else finish(true, R.string.notifications_saved)
|
||||||
|
}
|
||||||
|
is ApiResult.NetworkError -> finish(false, R.string.error_network)
|
||||||
|
is ApiResult.HttpError -> finish(false, R.string.notifications_save_error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register or unregister the device to match the stored push set (PLAN.md §11).
|
||||||
|
*
|
||||||
|
* Read from the RESPONSE rather than from what was just sent, because the
|
||||||
|
* server may have dropped the entry — and because "is any push mode on" is a
|
||||||
|
* question about the whole table, not about the row that changed.
|
||||||
|
*/
|
||||||
|
private suspend fun reconcilePush(prefs: NotificationChannelPrefsDto) {
|
||||||
|
val anyPushOn = prefs.items.any { item ->
|
||||||
|
val mode = item.modes[CHANNEL_PUSH]
|
||||||
|
mode != null && mode != MODE_OFF
|
||||||
|
}
|
||||||
|
if (!anyPushOn) {
|
||||||
|
pushManager.disable()
|
||||||
|
finish(true, R.string.notifications_all_off)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
when (val res = pushManager.enable()) {
|
||||||
|
is PushManager.PushResult.Enabled -> finish(true, R.string.notifications_saved)
|
||||||
|
is PushManager.PushResult.Unsupported -> finish(false, R.string.notifications_unsupported)
|
||||||
|
is PushManager.PushResult.NotSignedIn -> finish(false, R.string.notifications_save_error)
|
||||||
|
is PushManager.PushResult.Failed ->
|
||||||
|
finish(false, if (res.status == 400) R.string.notifications_relay_error else R.string.notifications_save_error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finish(ok: Boolean, @StringRes messageRes: Int) =
|
||||||
|
_state.update { it.copy(busy = false, feedback = Feedback(ok, messageRes)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this item's push is held back for want of a linked game account
|
||||||
|
* (PLAN.md §11, `docs/modules/rust/PLAN.md` D69).
|
||||||
|
*
|
||||||
|
* **Push only.** Until phase 11 the whole row went dead, every channel, which
|
||||||
|
* made a personal stream impossible to switch *off* on any site where the link
|
||||||
|
* check failed. That was every Rust site, because the check asked `module-uo`.
|
||||||
|
* In-app and email have nothing to do with the link, and neither does turning
|
||||||
|
* anything off. Pure so the gating is unit-tested without Compose.
|
||||||
|
*/
|
||||||
|
fun pushNeedsLink(item: NotificationChannelItemDto, hasLinkedAccount: Boolean): Boolean =
|
||||||
|
item.requiresLinkedAccount && !hasLinkedAccount && CHANNEL_PUSH in item.channels
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [mode] may be set on [channel] for [item].
|
||||||
|
*
|
||||||
|
* The one thing refused is switching push **on** for an item [pushNeedsLink] holds
|
||||||
|
* back. Switching anything off is never refused: a gate that can trap a switch in
|
||||||
|
* the on position is worse than no gate.
|
||||||
|
*/
|
||||||
|
fun canSetMode(
|
||||||
|
item: NotificationChannelItemDto,
|
||||||
|
channel: String,
|
||||||
|
mode: String,
|
||||||
|
hasLinkedAccount: Boolean,
|
||||||
|
): Boolean = !(channel == CHANNEL_PUSH && mode != MODE_OFF && pushNeedsLink(item, hasLinkedAccount))
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
package com.runicgateway.app.ui.notifications
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
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.padding
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
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.text.font.FontStyle
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import com.runicgateway.app.R
|
|
||||||
import com.runicgateway.app.data.api.dto.NotificationStreamDto
|
|
||||||
import com.runicgateway.app.ui.UiState
|
|
||||||
import com.runicgateway.app.ui.components.EmptyView
|
|
||||||
import com.runicgateway.app.ui.components.ErrorView
|
|
||||||
import com.runicgateway.app.ui.components.LoadingView
|
|
||||||
import com.runicgateway.app.ui.components.SectionLabel
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Notifications settings screen (PLAN.md §11, M7 Part 2 work item 6): the
|
|
||||||
* subscribable catalog with per-stream toggles. Personal streams are greyed until a
|
|
||||||
* game account is linked; turning a stream on requests the POST_NOTIFICATIONS
|
|
||||||
* permission (API 33+) and registers the device, turning them all off unregisters it.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun NotificationsScreen(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
viewModel: NotificationsViewModel = hiltViewModel(),
|
|
||||||
) {
|
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
// Ask once for POST_NOTIFICATIONS when the user first enables a stream (API 33+).
|
|
||||||
val permissionLauncher = rememberLauncherForActivityResult(
|
|
||||||
ActivityResultContracts.RequestPermission(),
|
|
||||||
) { /* granted or not, the subscription is already saved server-side */ }
|
|
||||||
|
|
||||||
fun ensureNotificationPermission() {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(16.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.notifications_title),
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.notifications_subtitle),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
|
|
||||||
if (!state.supported) {
|
|
||||||
EmptyView(message = stringResource(R.string.notifications_unsupported))
|
|
||||||
return@Column
|
|
||||||
}
|
|
||||||
|
|
||||||
state.feedback?.let { fb ->
|
|
||||||
Text(
|
|
||||||
text = stringResource(fb.messageRes),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = if (fb.ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
|
||||||
modifier = Modifier.padding(bottom = 12.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
when (val catalog = state.catalog) {
|
|
||||||
is UiState.Loading -> LoadingView()
|
|
||||||
is UiState.Error -> ErrorView(kind = catalog.kind, onRetry = viewModel::load)
|
|
||||||
is UiState.Success -> StreamList(
|
|
||||||
streams = catalog.data,
|
|
||||||
subscribed = state.subscribed,
|
|
||||||
hasLinkedAccount = state.hasLinkedAccount,
|
|
||||||
busy = state.busy,
|
|
||||||
onToggle = { stream, on ->
|
|
||||||
if (on) ensureNotificationPermission()
|
|
||||||
viewModel.setSubscribed(stream, on)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun StreamList(
|
|
||||||
streams: List<NotificationStreamDto>,
|
|
||||||
subscribed: Set<String>,
|
|
||||||
hasLinkedAccount: Boolean,
|
|
||||||
busy: Boolean,
|
|
||||||
onToggle: (NotificationStreamDto, Boolean) -> Unit,
|
|
||||||
) {
|
|
||||||
if (streams.isEmpty()) {
|
|
||||||
EmptyView(message = stringResource(R.string.notifications_empty))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val (personal, general) = streams.partition { it.personal }
|
|
||||||
|
|
||||||
if (general.isNotEmpty()) {
|
|
||||||
SectionLabel(stringResource(R.string.notifications_section_general))
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
general.forEach { stream ->
|
|
||||||
StreamRow(stream, subscribed.contains(stream.id), enabled = !busy, hint = null) { on ->
|
|
||||||
onToggle(stream, on)
|
|
||||||
}
|
|
||||||
HorizontalDivider()
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(20.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (personal.isNotEmpty()) {
|
|
||||||
SectionLabel(stringResource(R.string.notifications_section_personal))
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
personal.forEach { stream ->
|
|
||||||
val selectable = streamSelectable(stream, hasLinkedAccount)
|
|
||||||
val hint = if (!selectable) stringResource(R.string.notifications_requires_link) else null
|
|
||||||
StreamRow(stream, subscribed.contains(stream.id) && selectable, enabled = !busy && selectable, hint = hint) { on ->
|
|
||||||
onToggle(stream, on)
|
|
||||||
}
|
|
||||||
HorizontalDivider()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun StreamRow(
|
|
||||||
stream: NotificationStreamDto,
|
|
||||||
checked: Boolean,
|
|
||||||
enabled: Boolean,
|
|
||||||
hint: String?,
|
|
||||||
onToggle: (Boolean) -> Unit,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
|
||||||
Text(
|
|
||||||
text = stream.label,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = hint ?: stream.description,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
fontStyle = if (hint != null) FontStyle.Italic else FontStyle.Normal,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Switch(checked = checked, onCheckedChange = onToggle, enabled = enabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
package com.runicgateway.app.ui.notifications
|
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.runicgateway.app.R
|
|
||||||
import com.runicgateway.app.core.push.PushManager
|
|
||||||
import com.runicgateway.app.core.result.ApiResult
|
|
||||||
import com.runicgateway.app.data.api.dto.NotificationStreamDto
|
|
||||||
import com.runicgateway.app.data.repository.NotificationsRepository
|
|
||||||
import com.runicgateway.app.data.repository.PlayerShardRepository
|
|
||||||
import com.runicgateway.app.ui.UiState
|
|
||||||
import com.runicgateway.app.ui.toUiState
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drives the Notifications settings screen (PLAN.md §11, M7 Part 2 work item 6):
|
|
||||||
* the stream catalog with per-stream toggles bound to
|
|
||||||
* `GET/PUT /auth/me/notifications/subscriptions`. A **personal** stream is greyed
|
|
||||||
* until the user has a linked game account (§11), and turning the opt-in set
|
|
||||||
* non-empty/empty drives the [PushManager] to register/unregister the device.
|
|
||||||
*/
|
|
||||||
@HiltViewModel
|
|
||||||
class NotificationsViewModel @Inject constructor(
|
|
||||||
private val notifications: NotificationsRepository,
|
|
||||||
private val playerShard: PlayerShardRepository,
|
|
||||||
private val pushManager: PushManager,
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
|
|
||||||
|
|
||||||
data class State(
|
|
||||||
val catalog: UiState<List<NotificationStreamDto>> = UiState.Loading,
|
|
||||||
val subscribed: Set<String> = emptySet(),
|
|
||||||
/** Whether the user has ≥1 linked game account — personal streams need it. */
|
|
||||||
val hasLinkedAccount: Boolean = false,
|
|
||||||
/** Whether this shard advertises a push relay at all (else the screen says so). */
|
|
||||||
val supported: Boolean = true,
|
|
||||||
val busy: Boolean = false,
|
|
||||||
val feedback: Feedback? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
private val _state = MutableStateFlow(State())
|
|
||||||
val state: StateFlow<State> = _state.asStateFlow()
|
|
||||||
|
|
||||||
init {
|
|
||||||
viewModelScope.launch {
|
|
||||||
pushManager.supported.collect { supported -> _state.update { it.copy(supported = supported) } }
|
|
||||||
}
|
|
||||||
load()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun load() {
|
|
||||||
_state.update { it.copy(catalog = UiState.Loading) }
|
|
||||||
viewModelScope.launch {
|
|
||||||
val catalog = notifications.streams().let { result ->
|
|
||||||
when (result) {
|
|
||||||
is ApiResult.Ok -> ApiResult.Ok(result.data.streams)
|
|
||||||
is ApiResult.HttpError -> result
|
|
||||||
is ApiResult.NetworkError -> result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_state.update { it.copy(catalog = catalog.toUiState()) }
|
|
||||||
|
|
||||||
when (val subs = notifications.subscriptions()) {
|
|
||||||
is ApiResult.Ok -> _state.update { it.copy(subscribed = subs.data.streams.toSet()) }
|
|
||||||
else -> Unit
|
|
||||||
}
|
|
||||||
// A linked game account gates the personal streams; failure → treat as none.
|
|
||||||
val linked = (playerShard.accounts() as? ApiResult.Ok)?.data?.isNotEmpty() == true
|
|
||||||
_state.update { it.copy(hasLinkedAccount = linked) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearFeedback() = _state.update { it.copy(feedback = null) }
|
|
||||||
|
|
||||||
/** Toggle [stream]; refuses a personal stream with no linked account. */
|
|
||||||
fun setSubscribed(stream: NotificationStreamDto, on: Boolean) {
|
|
||||||
val s = _state.value
|
|
||||||
if (s.busy) return
|
|
||||||
if (on && !streamSelectable(stream, s.hasLinkedAccount)) return
|
|
||||||
val next = if (on) s.subscribed + stream.id else s.subscribed - stream.id
|
|
||||||
|
|
||||||
_state.update { it.copy(busy = true, feedback = null) }
|
|
||||||
viewModelScope.launch {
|
|
||||||
when (val result = notifications.setSubscriptions(next.toList())) {
|
|
||||||
is ApiResult.Ok -> {
|
|
||||||
val stored = result.data.streams.toSet()
|
|
||||||
_state.update { it.copy(subscribed = stored) }
|
|
||||||
reconcilePush(stored)
|
|
||||||
}
|
|
||||||
is ApiResult.NetworkError -> finish(false, R.string.error_network)
|
|
||||||
is ApiResult.HttpError -> finish(false, R.string.notifications_save_error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register or unregister the device to match the opted-in set (PLAN.md §11:
|
|
||||||
* register when signed-in + subscribed, unregister when the set empties).
|
|
||||||
*/
|
|
||||||
private suspend fun reconcilePush(subscribed: Set<String>) {
|
|
||||||
if (subscribed.isEmpty()) {
|
|
||||||
pushManager.disable()
|
|
||||||
finish(true, R.string.notifications_all_off)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
when (val res = pushManager.enable()) {
|
|
||||||
is PushManager.PushResult.Enabled -> finish(true, R.string.notifications_saved)
|
|
||||||
is PushManager.PushResult.Unsupported -> finish(false, R.string.notifications_unsupported)
|
|
||||||
is PushManager.PushResult.NotSignedIn -> finish(false, R.string.notifications_save_error)
|
|
||||||
is PushManager.PushResult.Failed ->
|
|
||||||
finish(false, if (res.status == 400) R.string.notifications_relay_error else R.string.notifications_save_error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun finish(ok: Boolean, @StringRes messageRes: Int) =
|
|
||||||
_state.update { it.copy(busy = false, feedback = Feedback(ok, messageRes)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether a stream's toggle is selectable for a user: a personal stream needs a
|
|
||||||
* linked game account (PLAN.md §11). Pure so the gating is unit-tested without Compose.
|
|
||||||
*/
|
|
||||||
fun streamSelectable(stream: NotificationStreamDto, hasLinkedAccount: Boolean): Boolean =
|
|
||||||
!stream.requiresLinkedAccount || hasLinkedAccount
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.rust
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.data.repository.EventsRepository
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which event a run on the map belongs to (`docs/modules/rust/PLAN.md` D123, D125).
|
||||||
|
*
|
||||||
|
* A site-event marker carries core's run id and nothing else about its event, and
|
||||||
|
* the app's event page is addressed by the event's slug. Core's public calendar
|
||||||
|
* maps one to the other: each `run` entry names its run (D125) and its slug. A
|
||||||
|
* live run is in the calendar's default window however long ago it started, so
|
||||||
|
* **one read of the default window** holds every run a marker could name that the
|
||||||
|
* public may see.
|
||||||
|
*
|
||||||
|
* **What is absent stays unlinked, and that is the gate.** Rehearsals and
|
||||||
|
* unlisted events are not on the public calendar, so a rehearsal's zone resolves
|
||||||
|
* to nothing and its card says only *site event*. There is no second rule here
|
||||||
|
* for which runs to hide.
|
||||||
|
*
|
||||||
|
* **It re-reads at most once a [refreshMs], and only for an id it does not
|
||||||
|
* know.** A map with one zone on it asks the calendar once, not every ten
|
||||||
|
* seconds; a new run appearing mid-session is found within a minute. A failed
|
||||||
|
* read keeps what the last one found and still counts as a read, so a site whose
|
||||||
|
* calendar is down is asked once a minute rather than on every poll.
|
||||||
|
*
|
||||||
|
* The calendar is the same for every viewer, so nothing here is per account.
|
||||||
|
*/
|
||||||
|
class EventRunResolver(
|
||||||
|
private val events: EventsRepository,
|
||||||
|
private val now: () -> Long = System::currentTimeMillis,
|
||||||
|
private val refreshMs: Long = REFRESH_MS,
|
||||||
|
) {
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private var slugs: Map<String, String> = emptyMap()
|
||||||
|
private var readAt: Long? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The slug for each of [runIds] the public calendar lists. Ids it does not
|
||||||
|
* list are absent from the answer.
|
||||||
|
*/
|
||||||
|
suspend fun resolve(runIds: Set<String>): Map<String, String> = mutex.withLock {
|
||||||
|
val unknown = runIds.any { it !in slugs }
|
||||||
|
val last = readAt
|
||||||
|
if (unknown && (last == null || now() - last >= refreshMs)) read()
|
||||||
|
runIds.mapNotNull { id -> slugs[id]?.let { id to it } }.toMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun read() {
|
||||||
|
readAt = now()
|
||||||
|
val result = events.calendar()
|
||||||
|
if (result is ApiResult.Ok) {
|
||||||
|
// Core sends the id as a number and the plugin as a string, so they
|
||||||
|
// meet as text.
|
||||||
|
slugs = result.data.entries
|
||||||
|
.filter { it.kind == "run" && it.runId != null && it.slug.isNotBlank() }
|
||||||
|
.associate { it.runId.toString() to it.slug }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val REFRESH_MS = 60_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
243
app/src/main/java/com/runicgateway/app/ui/rust/MapFrame.kt
Normal file
243
app/src/main/java/com/runicgateway/app/ui/rust/MapFrame.kt
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.rust
|
||||||
|
|
||||||
|
import com.runicgateway.app.data.api.dto.RustMapGeometryDto
|
||||||
|
import kotlin.math.floor
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a world position reaches a pixel (`docs/modules/rust/PLAN.md` §30.3, D121).
|
||||||
|
*
|
||||||
|
* Ported from the web's `mapGeometry.js`, with its test cases, so the phone and
|
||||||
|
* the page cannot place the same crate in two squares. Rust's world is centred on
|
||||||
|
* the origin, x east and z north. The picture is the world at a scale, with an
|
||||||
|
* ocean margin around it that is measured in **pixels** and is not scaled (the
|
||||||
|
* rig's 3 000 m map is 3 000 × 0.5 + 2 × 500 = 2 500 px). So:
|
||||||
|
*
|
||||||
|
* s = (width − 2 × margin) / worldSize
|
||||||
|
* px = (x + worldSize / 2) × s + margin
|
||||||
|
* py = (z + worldSize / 2) × s + margin measured UP from the bottom edge
|
||||||
|
*
|
||||||
|
* The web measures `py` up because Leaflet's simple frame grows north. A canvas
|
||||||
|
* grows **down**, so [toPixel] answers `height − py`, and that flip happens here
|
||||||
|
* and nowhere else.
|
||||||
|
*
|
||||||
|
* The grid is the **game's** (D119): [RustMapGeometryDto.gridCells] cells of
|
||||||
|
* [RustMapGeometryDto.gridCellSize] metres per side, lettered from the west and
|
||||||
|
* numbered from the north, `A0` at the north-west corner. Nothing here assumes a
|
||||||
|
* cell size, and nothing here assumes the rig's world size either.
|
||||||
|
*/
|
||||||
|
class MapFrame(val geometry: RustMapGeometryDto) {
|
||||||
|
|
||||||
|
/** Picture pixels per world metre, or 0 for a geometry that cannot place anything. */
|
||||||
|
val scale: Double = if (geometry.worldSize > 0 && geometry.width > 0) {
|
||||||
|
(geometry.width - 2 * geometry.oceanMargin) / geometry.worldSize
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when positions can be placed at all. */
|
||||||
|
val canPlace: Boolean get() = scale > 0
|
||||||
|
|
||||||
|
val width: Double get() = geometry.width
|
||||||
|
val height: Double get() = geometry.height
|
||||||
|
|
||||||
|
private val half: Double get() = geometry.worldSize / 2
|
||||||
|
|
||||||
|
/** A world position as a point in the picture's pixels, y measured down from the top. */
|
||||||
|
fun toPixel(x: Double, z: Double): PicturePoint = PicturePoint(
|
||||||
|
x = (x + half) * scale + geometry.oceanMargin,
|
||||||
|
y = geometry.height - ((z + half) * scale + geometry.oceanMargin),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A distance on the ground as picture pixels: a zone's radius. */
|
||||||
|
fun metres(m: Double): Double = m * scale
|
||||||
|
|
||||||
|
/** The grid label for a world position, the way the in-game map writes it; null without a grid. */
|
||||||
|
fun gridLabel(x: Double, z: Double): String? {
|
||||||
|
val g = geometry
|
||||||
|
if (g.gridCells <= 0 || g.gridCellSize <= 0) return null
|
||||||
|
val col = floor((x + half) / g.gridCellSize).toInt().coerceIn(0, g.gridCells - 1)
|
||||||
|
val row = floor((half - z) / g.gridCellSize).toInt().coerceIn(0, g.gridCells - 1)
|
||||||
|
return "${column(col)}$row"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The grid in world metres: [MapGrid.lines] as pairs of ends, and a label at
|
||||||
|
* each cell's north-west corner.
|
||||||
|
*/
|
||||||
|
fun grid(): MapGrid {
|
||||||
|
val g = geometry
|
||||||
|
if (g.gridCells <= 0 || g.gridCellSize <= 0) return MapGrid(emptyList(), emptyList())
|
||||||
|
val n = g.gridCells
|
||||||
|
val c = g.gridCellSize
|
||||||
|
val lines = buildList {
|
||||||
|
for (i in 0..n) {
|
||||||
|
val at = -half + i * c
|
||||||
|
add(WorldLine(at, half, at, half - n * c))
|
||||||
|
add(WorldLine(-half, half - i * c, -half + n * c, half - i * c))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val labels = buildList {
|
||||||
|
for (col in 0 until n) {
|
||||||
|
for (row in 0 until n) {
|
||||||
|
add(GridLabel("${column(col)}$row", -half + col * c, half - row * c))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return MapGrid(lines, labels)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** A column number as Rust spells it: 0 is A, 25 is Z, 26 is AA. */
|
||||||
|
fun column(index: Int): String {
|
||||||
|
val name = StringBuilder()
|
||||||
|
var n = index + 1
|
||||||
|
while (n > 0) {
|
||||||
|
val r = (n - 1) % 26
|
||||||
|
name.insert(0, ('A' + r))
|
||||||
|
n = (n - 1) / 26
|
||||||
|
}
|
||||||
|
return name.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A point in the picture's own pixels, y down. */
|
||||||
|
data class PicturePoint(val x: Double, val y: Double)
|
||||||
|
|
||||||
|
/** A line in world metres, from (x1, z1) to (x2, z2). */
|
||||||
|
data class WorldLine(val x1: Double, val z1: Double, val x2: Double, val z2: Double)
|
||||||
|
|
||||||
|
/** A grid label and the world position of its cell's north-west corner. */
|
||||||
|
data class GridLabel(val text: String, val x: Double, val z: Double)
|
||||||
|
|
||||||
|
data class MapGrid(val lines: List<WorldLine>, val labels: List<GridLabel>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The picture's address, relative to the site's base.
|
||||||
|
*
|
||||||
|
* The module hands out `path` relative to `/api/v1`, as it does every path. It
|
||||||
|
* is joined here as **relative** (`api/v1/…`, no leading slash), the way every
|
||||||
|
* Retrofit path in this app is written, so a site served under a prefix keeps
|
||||||
|
* the prefix when [com.runicgateway.app.ui.LocalAssetResolver] resolves it.
|
||||||
|
*/
|
||||||
|
fun mapPictureUrl(path: String): String = "api/v1/" + path.trimStart('/')
|
||||||
|
|
||||||
|
/** Seconds as `m:ss`, for a locked crate's hack. */
|
||||||
|
fun countdown(seconds: Number?): String {
|
||||||
|
val s = max(0, (seconds?.toDouble() ?: 0.0).roundToInt())
|
||||||
|
return "${s / 60}:${(s % 60).toString().padStart(2, '0')}"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the picture sits on screen: screen pixels per picture pixel, and the
|
||||||
|
* screen position of the picture's top-left corner.
|
||||||
|
*
|
||||||
|
* Pure, so the gesture arithmetic is tested without Compose. **Zoom runs from
|
||||||
|
* fit-to-screen to [MAX_ZOOM] screen pixels per picture pixel** (§31.2), and a
|
||||||
|
* pan may take the view a quarter of the picture past its edges: some things sail
|
||||||
|
* off the edge of the world, and the rig's cargo ship spent phase 14's probe
|
||||||
|
* outside the picture entirely.
|
||||||
|
*/
|
||||||
|
data class MapTransform(val scale: Float, val offsetX: Float, val offsetY: Float) {
|
||||||
|
|
||||||
|
fun screenX(pictureX: Double): Float = (pictureX * scale).toFloat() + offsetX
|
||||||
|
fun screenY(pictureY: Double): Float = (pictureY * scale).toFloat() + offsetY
|
||||||
|
|
||||||
|
/** The picture pixel under a screen point. */
|
||||||
|
fun pictureX(screenX: Float): Double = ((screenX - offsetX) / scale).toDouble()
|
||||||
|
fun pictureY(screenY: Float): Double = ((screenY - offsetY) / scale).toDouble()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One step of a pinch: zoom by [zoom] about [centroidX], [centroidY] and move
|
||||||
|
* by [panX], [panY], then clamp to what the view allows.
|
||||||
|
*/
|
||||||
|
fun transformed(
|
||||||
|
view: ViewSize,
|
||||||
|
picture: ViewSize,
|
||||||
|
centroidX: Float,
|
||||||
|
centroidY: Float,
|
||||||
|
panX: Float,
|
||||||
|
panY: Float,
|
||||||
|
zoom: Float,
|
||||||
|
): MapTransform {
|
||||||
|
val next = (scale * zoom).coerceIn(minScale(view, picture), maxScale(view, picture))
|
||||||
|
val factor = next / scale
|
||||||
|
return MapTransform(
|
||||||
|
scale = next,
|
||||||
|
offsetX = centroidX - (centroidX - offsetX) * factor + panX,
|
||||||
|
offsetY = centroidY - (centroidY - offsetY) * factor + panY,
|
||||||
|
).clamped(view, picture)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same view in a box of a different size: the picture pixel that was at
|
||||||
|
* the centre stays at the centre, and the zoom is clamped to what the new box
|
||||||
|
* allows.
|
||||||
|
*
|
||||||
|
* **Not a refit.** The walk found the reader's zoom thrown away when the
|
||||||
|
* status line under the map went from two lines to one: the map's box grew by
|
||||||
|
* a line's height, and a rule that refitted on every new size could not tell
|
||||||
|
* that from a rotation. A rotation keeps its centre too, which is what a
|
||||||
|
* reader turning the phone to see more of the same place wants.
|
||||||
|
*/
|
||||||
|
fun resized(from: ViewSize, to: ViewSize, picture: ViewSize): MapTransform {
|
||||||
|
val centreX = pictureX(from.width / 2)
|
||||||
|
val centreY = pictureY(from.height / 2)
|
||||||
|
val s = scale.coerceIn(minScale(to, picture), maxScale(to, picture))
|
||||||
|
return MapTransform(
|
||||||
|
scale = s,
|
||||||
|
offsetX = to.width / 2 - (centreX * s).toFloat(),
|
||||||
|
offsetY = to.height / 2 - (centreY * s).toFloat(),
|
||||||
|
).clamped(to, picture)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the view's centre within the picture plus a quarter of it on every
|
||||||
|
* side, so a reader can follow something off the edge and cannot lose the map.
|
||||||
|
*/
|
||||||
|
fun clamped(view: ViewSize, picture: ViewSize): MapTransform {
|
||||||
|
val cx = view.width / 2
|
||||||
|
val cy = view.height / 2
|
||||||
|
val w = picture.width * scale
|
||||||
|
val h = picture.height * scale
|
||||||
|
return copy(
|
||||||
|
offsetX = offsetX.coerceIn(cx - w * (1 + OVERSCROLL), cx + w * OVERSCROLL),
|
||||||
|
offsetY = offsetY.coerceIn(cy - h * (1 + OVERSCROLL), cy + h * OVERSCROLL),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Screen pixels per picture pixel at the closest zoom. */
|
||||||
|
const val MAX_ZOOM = 4f
|
||||||
|
|
||||||
|
/** How far past its edges the view may be taken, as a share of the picture. */
|
||||||
|
const val OVERSCROLL = 0.25f
|
||||||
|
|
||||||
|
/** The whole picture, centred. */
|
||||||
|
fun fit(view: ViewSize, picture: ViewSize): MapTransform {
|
||||||
|
val s = minScale(view, picture)
|
||||||
|
return MapTransform(
|
||||||
|
scale = s,
|
||||||
|
offsetX = (view.width - picture.width * s) / 2,
|
||||||
|
offsetY = (view.height - picture.height * s) / 2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun minScale(view: ViewSize, picture: ViewSize): Float {
|
||||||
|
if (picture.width <= 0 || picture.height <= 0) return 1f
|
||||||
|
return min(view.width / picture.width, view.height / picture.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Never less than fit: a tiny picture on a large screen still fits. */
|
||||||
|
fun maxScale(view: ViewSize, picture: ViewSize): Float = max(MAX_ZOOM, minScale(view, picture))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A width and height in pixels. */
|
||||||
|
data class ViewSize(val width: Float, val height: Float)
|
||||||
154
app/src/main/java/com/runicgateway/app/ui/rust/MapMarkers.kt
Normal file
154
app/src/main/java/com/runicgateway/app/ui/rust/MapMarkers.kt
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.rust
|
||||||
|
|
||||||
|
import com.runicgateway.app.data.api.dto.RustMapDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustMapLiveDto
|
||||||
|
import kotlin.math.hypot
|
||||||
|
import kotlin.math.max
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The switches on the legend. [GRID] is the reader's own and always offered;
|
||||||
|
* [MATES] is offered only when the server sends the viewer's own position.
|
||||||
|
*/
|
||||||
|
enum class MapLayer { GRID, WORLD, EVENTS, PLAYERS, BASES, MATES }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One thing drawn on the map, in world metres, carrying only what its layer sent.
|
||||||
|
*
|
||||||
|
* One flat shape rather than one per layer so that drawing, tapping and the card
|
||||||
|
* all read the same list, and a marker can never be tappable in a place it is not
|
||||||
|
* drawn. Nothing here is looked up: a player's name is the one the players layer
|
||||||
|
* carried to a viewer entitled to it (§31.4).
|
||||||
|
*/
|
||||||
|
data class MapMarker(
|
||||||
|
val layer: MapLayer,
|
||||||
|
/** `monument`, a world kind (`cargo`, `crate`…), an event kind (`zone`, `npc`…), `tc`, `vending`, `player` or `mate`. */
|
||||||
|
val kind: String,
|
||||||
|
val x: Double,
|
||||||
|
val z: Double,
|
||||||
|
/** A monument's label, a zone's name, a player's or mate's name. */
|
||||||
|
val name: String? = null,
|
||||||
|
/** A monument's grid square as the game wrote it; others are computed from the frame. */
|
||||||
|
val grid: String? = null,
|
||||||
|
/** Core's run id, for something an event placed. */
|
||||||
|
val runId: String? = null,
|
||||||
|
/** A zone's reach, in metres on the ground. */
|
||||||
|
val radiusMetres: Double? = null,
|
||||||
|
val hackLeftSec: Int? = null,
|
||||||
|
val hacked: Boolean = false,
|
||||||
|
val online: Boolean = false,
|
||||||
|
val sleeping: Boolean = false,
|
||||||
|
val self: Boolean = false,
|
||||||
|
) {
|
||||||
|
/** A zone is ground, not a point: it is drawn and tapped as an area. */
|
||||||
|
val isZone: Boolean get() = layer == MapLayer.EVENTS && kind == "zone"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every marker to draw, in drawing order, bottom first: monuments, world events,
|
||||||
|
* site events, bases, players, and the viewer's own and their mates' on top.
|
||||||
|
*
|
||||||
|
* A layer is drawn when the server sent it **and** the reader has not switched it
|
||||||
|
* off. A layer the server did not send is null on [live] and contributes nothing,
|
||||||
|
* which is the whole gate; there is no second check here to disagree with it.
|
||||||
|
*/
|
||||||
|
fun mapMarkers(map: RustMapDto, live: RustMapLiveDto?, shown: Set<MapLayer>): List<MapMarker> = buildList {
|
||||||
|
if (MapLayer.WORLD in shown) {
|
||||||
|
map.monuments.orEmpty().forEach {
|
||||||
|
add(MapMarker(MapLayer.WORLD, "monument", it.x, it.z, name = it.label, grid = it.grid))
|
||||||
|
}
|
||||||
|
live?.world.orEmpty().forEach {
|
||||||
|
add(MapMarker(MapLayer.WORLD, it.kind, it.x, it.z, hackLeftSec = it.hackLeftSec, hacked = it.hacked))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (MapLayer.EVENTS in shown) {
|
||||||
|
live?.events.orEmpty().forEach {
|
||||||
|
add(
|
||||||
|
MapMarker(
|
||||||
|
MapLayer.EVENTS,
|
||||||
|
it.kind,
|
||||||
|
it.x,
|
||||||
|
it.z,
|
||||||
|
name = it.name,
|
||||||
|
runId = it.runId?.takeIf { id -> id.isNotBlank() },
|
||||||
|
radiusMetres = it.radius,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (MapLayer.BASES in shown) {
|
||||||
|
live?.bases.orEmpty().forEach { add(MapMarker(MapLayer.BASES, it.kind, it.x, it.z)) }
|
||||||
|
}
|
||||||
|
if (MapLayer.PLAYERS in shown) {
|
||||||
|
live?.players.orEmpty().forEach {
|
||||||
|
add(
|
||||||
|
MapMarker(
|
||||||
|
MapLayer.PLAYERS,
|
||||||
|
"player",
|
||||||
|
it.x,
|
||||||
|
it.z,
|
||||||
|
name = it.name?.takeIf { n -> n.isNotBlank() } ?: it.steamId,
|
||||||
|
online = it.online,
|
||||||
|
sleeping = it.sleeping,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (MapLayer.MATES in shown) {
|
||||||
|
live?.mates.orEmpty().forEach {
|
||||||
|
add(
|
||||||
|
MapMarker(
|
||||||
|
MapLayer.MATES,
|
||||||
|
"mate",
|
||||||
|
it.x,
|
||||||
|
it.z,
|
||||||
|
name = it.name?.takeIf { n -> n.isNotBlank() },
|
||||||
|
online = it.online,
|
||||||
|
sleeping = it.sleeping,
|
||||||
|
self = it.self,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The marker a tap at ([tapX], [tapY]) on screen means, or null.
|
||||||
|
*
|
||||||
|
* **A point wins over a zone.** The nearest point within [reachPx] is the answer;
|
||||||
|
* only when there is none does a zone answer, and then one whose ground the tap
|
||||||
|
* is on or within reach of. Otherwise a zone drawn round a monument would swallow
|
||||||
|
* every tap on the monument, and on the players standing in it. Among equals the
|
||||||
|
* one drawn last, which is the one on top, wins.
|
||||||
|
*/
|
||||||
|
fun nearestMarker(
|
||||||
|
markers: List<MapMarker>,
|
||||||
|
frame: MapFrame,
|
||||||
|
transform: MapTransform,
|
||||||
|
tapX: Float,
|
||||||
|
tapY: Float,
|
||||||
|
reachPx: Float,
|
||||||
|
): MapMarker? {
|
||||||
|
fun distance(m: MapMarker): Double {
|
||||||
|
val p = frame.toPixel(m.x, m.z)
|
||||||
|
val d = hypot(transform.screenX(p.x) - tapX.toDouble(), transform.screenY(p.y) - tapY.toDouble())
|
||||||
|
if (!m.isZone) return d
|
||||||
|
val radius = frame.metres(m.radiusMetres ?: 0.0) * transform.scale
|
||||||
|
return max(0.0, d - radius)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closest(candidates: List<MapMarker>): MapMarker? = candidates
|
||||||
|
.asReversed()
|
||||||
|
.map { it to distance(it) }
|
||||||
|
.filter { it.second <= reachPx }
|
||||||
|
.minByOrNull { it.second }
|
||||||
|
?.first
|
||||||
|
|
||||||
|
return closest(markers.filterNot { it.isZone }) ?: closest(markers.filter { it.isZone })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The distinct run ids the events layer names, for [EventRunResolver]. */
|
||||||
|
fun eventRunIds(live: RustMapLiveDto?): Set<String> =
|
||||||
|
live?.events.orEmpty().mapNotNull { it.runId?.takeIf { id -> id.isNotBlank() } }.toSet()
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.rust
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
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.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.data.api.dto.RustLinkDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustReachDto
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
import com.runicgateway.app.ui.components.PillTone
|
||||||
|
import com.runicgateway.app.ui.components.ShardCard
|
||||||
|
import com.runicgateway.app.ui.components.StatusPill
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player's own Rust account (PLAN.md §9 M15) — the app's mirror of the
|
||||||
|
* module's `/player/rust` page, and the same shape as
|
||||||
|
* [com.runicgateway.app.ui.player.CharactersScreen] one game along: the code
|
||||||
|
* card first, then what the code got them.
|
||||||
|
*
|
||||||
|
* Two independent reads. The accounts can fail with the entitlements on screen,
|
||||||
|
* and the other way round, because an entitlement is authored against the
|
||||||
|
* website account and exists before a Steam id does.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun RustAccountScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: RustAccountViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
LinkCard(state, viewModel)
|
||||||
|
|
||||||
|
when (val links = state.links) {
|
||||||
|
is UiState.Loading -> LoadingView()
|
||||||
|
is UiState.Error -> ErrorView(links.kind, onRetry = viewModel::load)
|
||||||
|
is UiState.Success -> {
|
||||||
|
if (links.data.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.rust_account_none),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
links.data.forEach { link ->
|
||||||
|
LinkRow(
|
||||||
|
link = link,
|
||||||
|
busy = state.unlinking == link.steamId,
|
||||||
|
onUnlink = { viewModel.unlink(link.steamId) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.rust_account_fleet_note),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.rust_held_title),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
when (val held = state.held) {
|
||||||
|
is UiState.Loading -> LoadingView()
|
||||||
|
is UiState.Error -> ErrorView(held.kind, onRetry = viewModel::load)
|
||||||
|
is UiState.Success -> Held(held.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The code form.
|
||||||
|
*
|
||||||
|
* The three-step instruction is not decoration: nothing else in the app tells a
|
||||||
|
* player that the code comes from the game, and a code field with no explanation
|
||||||
|
* is a code field nobody can use.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun LinkCard(state: RustAccountViewModel.State, viewModel: RustAccountViewModel) {
|
||||||
|
var code by rememberSaveable { mutableStateOf("") }
|
||||||
|
|
||||||
|
ShardCard(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(16.dp)) {
|
||||||
|
Text(stringResource(R.string.rust_link_title), style = MaterialTheme.typography.titleMedium)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.rust_link_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = code,
|
||||||
|
onValueChange = { code = it.uppercase() },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !state.busy,
|
||||||
|
label = { Text(stringResource(R.string.rust_link_code)) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 12.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.link(code); code = "" },
|
||||||
|
enabled = !state.busy && code.isNotBlank(),
|
||||||
|
modifier = Modifier.padding(top = 12.dp),
|
||||||
|
) { Text(stringResource(R.string.rust_link_action)) }
|
||||||
|
|
||||||
|
// Beside the button that caused it. A refusal at the top of a long
|
||||||
|
// scroll is a press that visibly did nothing (PLAN.md §21.5).
|
||||||
|
state.feedback?.let { feedback ->
|
||||||
|
Text(
|
||||||
|
text = stringResource(feedback.messageRes),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (feedback.ok) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(top = 10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One linked Steam account, and the button that releases it. */
|
||||||
|
@Composable
|
||||||
|
private fun LinkRow(link: RustLinkDto, busy: Boolean, onUnlink: () -> Unit) {
|
||||||
|
ShardCard(Modifier.fillMaxWidth()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = link.name?.takeIf { it.isNotBlank() } ?: link.steamId,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = link.steamId,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
// When, and which server minted the code. Not part of the
|
||||||
|
// identity — a link is fleet-wide — but it is where a support
|
||||||
|
// conversation starts, and the website's own row says it.
|
||||||
|
val when_ = rustAgo(link.linkedAt)
|
||||||
|
val where = link.serverId?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
if (when_ != null || where != null) {
|
||||||
|
Text(
|
||||||
|
text = listOfNotNull(
|
||||||
|
when_?.let { stringResource(R.string.rust_account_linked_when, it) },
|
||||||
|
where,
|
||||||
|
).joinToString(" · "),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(onClick = onUnlink, enabled = !busy) {
|
||||||
|
Text(stringResource(R.string.rust_unlink_action))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ranks and grants, drawn the same way because they read the same. */
|
||||||
|
@Composable
|
||||||
|
private fun Held(held: RustPlayerPermissionsDto) {
|
||||||
|
if (held.groups.isEmpty() && held.grants.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.rust_held_empty),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
held.groups.forEach { group ->
|
||||||
|
HeldCard(
|
||||||
|
title = group.title.ifBlank { group.name },
|
||||||
|
detail = group.permissions.joinToString(" · ").takeIf { it.isNotBlank() },
|
||||||
|
reach = group.reach,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
held.grants.forEach { grant ->
|
||||||
|
HeldCard(
|
||||||
|
title = grant.permission,
|
||||||
|
detail = grant.note?.takeIf { it.isNotBlank() },
|
||||||
|
reach = grant.reach,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zero linked accounts is WHY everything above is waiting, and the screen
|
||||||
|
// says so rather than leaving a page of hollow pills to be read as a fault.
|
||||||
|
if (held.accounts == 0) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.rust_held_unlinked),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else if (held.groups.any { it.reach.any { s -> !s.live } } ||
|
||||||
|
held.grants.any { it.reach.any { s -> !s.live } }
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.rust_held_waiting_note),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun HeldCard(title: String, detail: String?, reach: List<RustReachDto>) {
|
||||||
|
ShardCard(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(16.dp)) {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleSmall)
|
||||||
|
|
||||||
|
detail?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reach.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.rust_held_no_servers),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
modifier = Modifier.padding(top = 10.dp),
|
||||||
|
) {
|
||||||
|
reach.forEach { server ->
|
||||||
|
// The tone IS the state: a server that has it and one
|
||||||
|
// that has not are the two things this screen exists to
|
||||||
|
// tell apart, and colour alone would not say which — so
|
||||||
|
// the label carries the word as well.
|
||||||
|
StatusPill(
|
||||||
|
text = stringResource(
|
||||||
|
if (server.live) R.string.rust_reach_live else R.string.rust_reach_waiting,
|
||||||
|
server.name.ifBlank { server.id },
|
||||||
|
),
|
||||||
|
tone = if (server.live) PillTone.Success else PillTone.Neutral,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.rust
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.data.api.dto.RustLinkDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto
|
||||||
|
import com.runicgateway.app.data.repository.PlayerRustRepository
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.toUiState
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player's own Rust account (PLAN.md §9 M15; `docs/modules/rust/PLAN.md`
|
||||||
|
* §19, §20, §22): link a Steam account with the code `/link` hands out in game,
|
||||||
|
* release one, and see what the site has given them on which servers.
|
||||||
|
*
|
||||||
|
* **Two reads, one screen, and neither blocks the other.** The entitlement read
|
||||||
|
* carries its own state because an entitlement is authored against the *website*
|
||||||
|
* account: it exists before a Steam id does, and the person who has just been
|
||||||
|
* given something and has not linked yet is exactly the one who needs to see
|
||||||
|
* both halves at once. A failure on either side leaves the other on screen.
|
||||||
|
*
|
||||||
|
* **A refusal is chosen by status, not by prose** (the convention
|
||||||
|
* [com.runicgateway.app.ui.player.CharactersViewModel] set one game along). The
|
||||||
|
* four the website distinguishes are four different pieces of advice, and
|
||||||
|
* flattening them is the failure worth naming: a player told to get a new code
|
||||||
|
* when the server their code came from was merely unreachable will go and get
|
||||||
|
* another code from the same unreachable server.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class RustAccountViewModel @Inject constructor(
|
||||||
|
private val repository: PlayerRustRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
/** A one-shot banner for the code form, rendered beside the button that caused it. */
|
||||||
|
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
|
||||||
|
|
||||||
|
data class State(
|
||||||
|
val links: UiState<List<RustLinkDto>> = UiState.Loading,
|
||||||
|
val held: UiState<RustPlayerPermissionsDto> = UiState.Loading,
|
||||||
|
val busy: Boolean = false,
|
||||||
|
val feedback: Feedback? = null,
|
||||||
|
/** The Steam id currently being released, so only its own row shows it. */
|
||||||
|
val unlinking: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(State())
|
||||||
|
val state: StateFlow<State> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
_state.update { it.copy(links = UiState.Loading, held = UiState.Loading) }
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(links = repository.links().toUiState()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(held = repository.permissions().toUiState()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-read what the site holds without blanking the accounts above it. */
|
||||||
|
private fun reloadHeld() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(held = repository.permissions().toUiState()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearFeedback() = _state.update { it.copy(feedback = null) }
|
||||||
|
|
||||||
|
fun link(code: String) {
|
||||||
|
if (_state.value.busy || code.isBlank()) return
|
||||||
|
_state.update { it.copy(busy = true, feedback = null) }
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = repository.link(code.trim())) {
|
||||||
|
is ApiResult.Ok -> {
|
||||||
|
// `already` is a second press of the button, not an error:
|
||||||
|
// the code was good and that account was already theirs.
|
||||||
|
val res =
|
||||||
|
if (result.data.already) R.string.rust_link_already else R.string.rust_link_ok
|
||||||
|
|
||||||
|
_state.update { it.copy(busy = false, feedback = Feedback(true, res)) }
|
||||||
|
refreshAfterChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
is ApiResult.HttpError -> _state.update {
|
||||||
|
it.copy(busy = false, feedback = Feedback(false, linkErrorRes(result.status)))
|
||||||
|
}
|
||||||
|
|
||||||
|
is ApiResult.NetworkError -> _state.update {
|
||||||
|
it.copy(busy = false, feedback = Feedback(false, R.string.error_network))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unlink(steamId: String) {
|
||||||
|
if (_state.value.unlinking != null) return
|
||||||
|
_state.update { it.copy(unlinking = steamId, feedback = null) }
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = repository.unlink(steamId)
|
||||||
|
_state.update { it.copy(unlinking = null) }
|
||||||
|
|
||||||
|
when (result) {
|
||||||
|
is ApiResult.Ok -> refreshAfterChange()
|
||||||
|
|
||||||
|
is ApiResult.HttpError -> _state.update {
|
||||||
|
it.copy(feedback = Feedback(false, R.string.rust_unlink_error))
|
||||||
|
}
|
||||||
|
|
||||||
|
is ApiResult.NetworkError -> _state.update {
|
||||||
|
it.copy(feedback = Feedback(false, R.string.error_network))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Both halves, after the caller changed one of them.
|
||||||
|
*
|
||||||
|
* Linking an account does not change what the site has authored — but it
|
||||||
|
* changes what reaches a game, and the next sync is what makes that true. So
|
||||||
|
* the entitlement list is re-read too: its `live` marks are the only thing on
|
||||||
|
* this screen that a link can silently invalidate.
|
||||||
|
*/
|
||||||
|
private fun refreshAfterChange() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(links = repository.links().toUiState()) }
|
||||||
|
}
|
||||||
|
reloadHeld()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun linkErrorRes(status: Int): Int = when (status) {
|
||||||
|
// Unknown or expired: the code is spent, and the way out is a new one.
|
||||||
|
400 -> R.string.rust_link_bad_code
|
||||||
|
// Another website account holds that Steam id. It is never moved
|
||||||
|
// silently; `/unlink` in game is the release (D23).
|
||||||
|
409 -> R.string.rust_link_taken
|
||||||
|
429 -> R.string.rust_link_capped
|
||||||
|
// A server could not be reached. **The code is still good**, which is
|
||||||
|
// why this may not say "get a new one".
|
||||||
|
503 -> R.string.rust_link_unreachable
|
||||||
|
else -> R.string.rust_link_error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.rust
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.data.repository.RustRepository
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The live player count on the drawer's Rust row — the phone's answer to D15
|
||||||
|
* (`docs/modules/rust/PLAN.md` §17.4).
|
||||||
|
*
|
||||||
|
* ## Why the drawer and not a footer
|
||||||
|
*
|
||||||
|
* D15 put "2 servers · 42 online" in core's `site.footer.status` slot, which
|
||||||
|
* exists because every page of the website renders the same footer. The app has
|
||||||
|
* no footer and no slot; what it has is a drawer row per surface and, already, a
|
||||||
|
* precedent for a number beside one — the inbox's unread badge, in the same
|
||||||
|
* `NavigationDrawerItem` badge slot, with the same screen-reader treatment. So
|
||||||
|
* the count rides there.
|
||||||
|
*
|
||||||
|
* **The number is players, not servers.** A badge is one integer, and of the two
|
||||||
|
* halves of D15's line the live one is how many people are on: a server count
|
||||||
|
* changes when an operator edits configuration, which is not news, and is visible
|
||||||
|
* on the page the row opens anyway.
|
||||||
|
*
|
||||||
|
* ## What keeps it honest
|
||||||
|
*
|
||||||
|
* The website's version renders nothing until it has an answer, nothing at all if
|
||||||
|
* the request fails, and never polls — because one request per page view is a
|
||||||
|
* cost and a timer in a footer on every page is a different kind of thing. All
|
||||||
|
* three rules hold here:
|
||||||
|
*
|
||||||
|
* - **Zero renders nothing.** No badge, rather than a `0` — an empty server is
|
||||||
|
* not a notification.
|
||||||
|
* - **A failure leaves the last count** rather than dropping to zero. A moment
|
||||||
|
* with no connectivity is not everybody logging off.
|
||||||
|
* - **It refreshes on resume, with the unread badge**, and never on a timer. The
|
||||||
|
* count is a glance, not a feed.
|
||||||
|
*
|
||||||
|
* It is asked for **only when the module is installed** — the caller gates on the
|
||||||
|
* `rust` capability — so a site running a different game makes no request at all.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class RustBadgeViewModel @Inject constructor(
|
||||||
|
private val repository: RustRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _online = MutableStateFlow(0)
|
||||||
|
|
||||||
|
/** How many people are on across every server, or 0 when there is nothing to say. */
|
||||||
|
val online: StateFlow<Int> = _online.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask, if the Rust module is there.
|
||||||
|
*
|
||||||
|
* [installed] is passed in rather than read here so this holds no opinion
|
||||||
|
* about capabilities: the drawer already knows, and a view model that
|
||||||
|
* re-derived it would be a second copy of a rule that lives in one place.
|
||||||
|
* Absent — the host has not answered yet — makes no request and keeps
|
||||||
|
* whatever is showing.
|
||||||
|
*/
|
||||||
|
fun refresh(installed: Boolean) {
|
||||||
|
if (!installed) {
|
||||||
|
_online.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = repository.servers()) {
|
||||||
|
// A server that is stale or unreachable already answers `online:
|
||||||
|
// false` with `players: 0`, so summing the whole list needs no
|
||||||
|
// second staleness rule here.
|
||||||
|
is ApiResult.Ok -> _online.value = result.data.sumOf { it.players }
|
||||||
|
// Keep the last number. See the class doc.
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
199
app/src/main/java/com/runicgateway/app/ui/rust/RustFeed.kt
Normal file
199
app/src/main/java/com/runicgateway/app/ui/rust/RustFeed.kt
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.rust
|
||||||
|
|
||||||
|
import com.runicgateway.app.data.api.dto.RustEventDto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One stored frame as one line of a feed — the Kotlin half of `module-rust`'s
|
||||||
|
* `client/src/lib/feed.js` (PLAN.md §9 M14).
|
||||||
|
*
|
||||||
|
* `GET /public/rust/servers/{id}/events` answers rows shaped
|
||||||
|
* `{ id, kind, t, wipeId, steamId, frame }`, where `frame` is the whole frame the
|
||||||
|
* bridge plugin emitted. Everything a killfeed line needs is in there, under the
|
||||||
|
* names the plugin wrote, and **this file is the one place in the app that knows
|
||||||
|
* them**.
|
||||||
|
*
|
||||||
|
* ## It returns parts, not a sentence
|
||||||
|
*
|
||||||
|
* A row wants the names emphasised and the detail muted, and a function returning
|
||||||
|
* `"Alice killed Bob"` would force the screen to re-parse its own output to style
|
||||||
|
* it. Parts also make this testable without Compose, which is the only way this
|
||||||
|
* leg has real coverage of what a feed row says.
|
||||||
|
*
|
||||||
|
* ## The rule for an unknown kind
|
||||||
|
*
|
||||||
|
* **It renders as itself.** A later protocol adds kinds and an operator's module
|
||||||
|
* may be older than their game host, so a feed that dropped what it did not
|
||||||
|
* recognise would be a screen quietly saying less than the truth. The server's
|
||||||
|
* allowlist has already decided the row may be seen; what is left here is
|
||||||
|
* presentation, and the honest presentation of a kind we have no words for is its
|
||||||
|
* own name.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The row's category, for the small colour a screen gives it — never for meaning. */
|
||||||
|
enum class FeedTone { KILL, DEATH, JOIN, LEAVE, CHAT, SERVER, OTHER }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row, ready to render.
|
||||||
|
*
|
||||||
|
* [actor] and [subject] are names and are emphasised; [verb] and [detail] are
|
||||||
|
* prose. Any of them may be null or empty.
|
||||||
|
*
|
||||||
|
* [join] is what goes between the actor and the verb, and it exists for exactly
|
||||||
|
* one case: chat. "Brannock see you in september" is not a sentence anybody
|
||||||
|
* writes, and putting the colon in the message would put presentation inside text
|
||||||
|
* a player typed.
|
||||||
|
*/
|
||||||
|
data class FeedLine(
|
||||||
|
val tone: FeedTone,
|
||||||
|
val actor: String? = null,
|
||||||
|
val join: String = " ",
|
||||||
|
val verb: String = "",
|
||||||
|
val subject: String? = null,
|
||||||
|
val detail: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One filter the feed offers, and the kinds it asks the API for. */
|
||||||
|
data class FeedFilter(val id: String, val label: String, val kinds: List<String>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kinds this feed asks for.
|
||||||
|
*
|
||||||
|
* `player.tally` is public and deliberately **not** here: it is an aggregate the
|
||||||
|
* plugin flushes every sixty seconds per active player, so a feed including it
|
||||||
|
* would be mostly wood counts. It is the leaderboard's input, and the leaderboard
|
||||||
|
* is where it shows up.
|
||||||
|
*/
|
||||||
|
val FEED_KINDS: List<String> = listOf(
|
||||||
|
"player.death",
|
||||||
|
"player.connected",
|
||||||
|
"player.disconnected",
|
||||||
|
"player.respawned",
|
||||||
|
"player.chat",
|
||||||
|
"server.wipe",
|
||||||
|
"server.initialized",
|
||||||
|
"server.shutdown",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The filters the feed offers. The first is the default and asks for everything. */
|
||||||
|
val FEED_FILTERS: List<FeedFilter> = listOf(
|
||||||
|
FeedFilter("all", "Everything", FEED_KINDS),
|
||||||
|
FeedFilter("kills", "Kills", listOf("player.death")),
|
||||||
|
FeedFilter("chat", "Chat", listOf("player.chat")),
|
||||||
|
FeedFilter(
|
||||||
|
"sessions",
|
||||||
|
"Comings and goings",
|
||||||
|
listOf("player.connected", "player.disconnected", "player.respawned"),
|
||||||
|
),
|
||||||
|
FeedFilter("server", "Server", listOf("server.wipe", "server.initialized", "server.shutdown")),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The kinds a filter id asks for; an id nobody offers falls back to everything. */
|
||||||
|
fun kindsFor(filterId: String): List<String> =
|
||||||
|
(FEED_FILTERS.firstOrNull { it.id == filterId } ?: FEED_FILTERS.first()).kinds
|
||||||
|
|
||||||
|
/** One row as the parts a screen renders. */
|
||||||
|
fun describe(row: RustEventDto): FeedLine {
|
||||||
|
val name = row.str("name")
|
||||||
|
|
||||||
|
return when (row.kind) {
|
||||||
|
"player.death" -> death(row, name)
|
||||||
|
|
||||||
|
"player.connected" ->
|
||||||
|
FeedLine(FeedTone.JOIN, actor = name, verb = "connected")
|
||||||
|
|
||||||
|
"player.disconnected" -> FeedLine(
|
||||||
|
tone = FeedTone.LEAVE,
|
||||||
|
actor = name,
|
||||||
|
verb = "disconnected",
|
||||||
|
// Two optional halves, and the session is the interesting one. The
|
||||||
|
// plugin OMITS `sessionSec` for a player who was already on when it
|
||||||
|
// loaded, so an absent value means "unknown" and never zero — which is
|
||||||
|
// why this reads the parsed number rather than trusting a default.
|
||||||
|
detail = listOfNotNull(
|
||||||
|
row.str("reason"),
|
||||||
|
row.num("sessionSec")?.takeIf { it > 0 }?.let { "after ${playtime(it.toLong())}" },
|
||||||
|
).joinToString(" · "),
|
||||||
|
)
|
||||||
|
|
||||||
|
"player.respawned" ->
|
||||||
|
FeedLine(FeedTone.JOIN, actor = name, verb = "respawned")
|
||||||
|
|
||||||
|
"player.chat" -> FeedLine(
|
||||||
|
tone = FeedTone.CHAT,
|
||||||
|
actor = name,
|
||||||
|
join = ": ",
|
||||||
|
// The message is the row, so it goes in `verb` where a screen renders
|
||||||
|
// it unemphasised — and it is the one field on this wire whose bytes a
|
||||||
|
// player chooses. Compose renders it as text and never as markup;
|
||||||
|
// nothing here may ever stop doing that.
|
||||||
|
verb = row.str("message").orEmpty(),
|
||||||
|
detail = row.str("channel")?.takeIf { it != "Global" }.orEmpty(),
|
||||||
|
)
|
||||||
|
|
||||||
|
"server.wipe" -> FeedLine(
|
||||||
|
tone = FeedTone.SERVER,
|
||||||
|
verb = "The map was wiped",
|
||||||
|
detail = row.str("wipeId")?.let { "new wipe $it" }.orEmpty(),
|
||||||
|
)
|
||||||
|
|
||||||
|
"server.initialized" -> FeedLine(FeedTone.SERVER, verb = "The server came up")
|
||||||
|
|
||||||
|
"server.shutdown" -> FeedLine(FeedTone.SERVER, verb = "The server went down")
|
||||||
|
|
||||||
|
else -> FeedLine(
|
||||||
|
tone = FeedTone.OTHER,
|
||||||
|
actor = name,
|
||||||
|
verb = row.kind.takeIf { it.isNotBlank() } ?: "unknown",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A death, which is four different sentences.
|
||||||
|
*
|
||||||
|
* The plugin distinguishes `player`, `self`, `npc` and `environment` precisely so
|
||||||
|
* a reader does not have to guess from an absent field, and collapsing any two of
|
||||||
|
* them loses something. A killfeed reporting a fall as a kill by nobody is the
|
||||||
|
* failure this avoids.
|
||||||
|
*/
|
||||||
|
private fun death(row: RustEventDto, name: String?): FeedLine {
|
||||||
|
val where = listOfNotNull(
|
||||||
|
row.str("weapon")?.let { "with ${prefabName(it)}" },
|
||||||
|
row.num("distance")?.let { "${Math.round(it)}m" },
|
||||||
|
row.str("grid"),
|
||||||
|
if (row.flag("sleeping")) "while sleeping" else null,
|
||||||
|
).joinToString(" · ")
|
||||||
|
|
||||||
|
return when (row.str("attackerType")) {
|
||||||
|
"player" -> FeedLine(
|
||||||
|
tone = FeedTone.KILL,
|
||||||
|
actor = row.str("attackerName"),
|
||||||
|
verb = "killed",
|
||||||
|
subject = name,
|
||||||
|
detail = where,
|
||||||
|
)
|
||||||
|
|
||||||
|
"self" -> FeedLine(
|
||||||
|
tone = FeedTone.DEATH,
|
||||||
|
actor = name,
|
||||||
|
verb = "died by their own hand",
|
||||||
|
detail = where,
|
||||||
|
)
|
||||||
|
|
||||||
|
"npc" -> FeedLine(
|
||||||
|
tone = FeedTone.DEATH,
|
||||||
|
actor = prefabName(row.str("attackerName")).ifBlank { "Something" },
|
||||||
|
verb = "killed",
|
||||||
|
subject = name,
|
||||||
|
detail = where,
|
||||||
|
)
|
||||||
|
|
||||||
|
// `environment` and anything else: falling, drowning, the world. The
|
||||||
|
// plugin legitimately has no attacker on this path, so an ABSENT type is
|
||||||
|
// this case rather than a missing field to complain about.
|
||||||
|
else -> FeedLine(tone = FeedTone.DEATH, actor = name, verb = "died", detail = where)
|
||||||
|
}
|
||||||
|
}
|
||||||
205
app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt
Normal file
205
app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.rust
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.time.parseWireInstant
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatting for the Rust screens — pure, no Compose, no Android (PLAN.md §9
|
||||||
|
* M14).
|
||||||
|
*
|
||||||
|
* The Kotlin half of `module-rust`'s `client/src/lib/format.js`, and it is a
|
||||||
|
* deliberate second implementation rather than something shared: the two clients
|
||||||
|
* have different formatting libraries under them (`Intl` there, `java.time`
|
||||||
|
* here), and the thing worth keeping identical is the **rules**, not the code.
|
||||||
|
* Those rules are restated here beside each function so a reader can check them
|
||||||
|
* against the website without opening it.
|
||||||
|
*
|
||||||
|
* Everything takes `now` as a parameter, so a boundary is testable rather than a
|
||||||
|
* property of the machine the test runs on.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stamp on a feed row.
|
||||||
|
*
|
||||||
|
* **Today's rows get a time; everything older gets a date as well.** The feed can
|
||||||
|
* be filtered to a past wipe, and a row from six weeks ago rendered as `14:03`
|
||||||
|
* reads as this afternoon — which is exactly what the website's own page walk
|
||||||
|
* found, three events from August all apparently a few minutes old. The boundary
|
||||||
|
* is the **calendar day**, not a duration, because that is what a reader means by
|
||||||
|
* "what time was that".
|
||||||
|
*/
|
||||||
|
fun feedClock(
|
||||||
|
value: String?,
|
||||||
|
epochMillis: Long? = null,
|
||||||
|
now: Instant = Instant.now(),
|
||||||
|
zone: ZoneId = ZoneId.systemDefault(),
|
||||||
|
locale: Locale = Locale.getDefault(),
|
||||||
|
): String {
|
||||||
|
val at = epochMillis?.takeIf { it > 0 }?.let(Instant::ofEpochMilli) ?: parseWireInstant(value) ?: return ""
|
||||||
|
|
||||||
|
val time = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||||
|
.withLocale(locale)
|
||||||
|
.format(at.atZone(zone))
|
||||||
|
|
||||||
|
val sameDay = at.atZone(zone).toLocalDate() == now.atZone(zone).toLocalDate()
|
||||||
|
if (sameDay) return time
|
||||||
|
|
||||||
|
val date = DateTimeFormatter.ofPattern("d MMM", locale).format(at.atZone(zone))
|
||||||
|
return "$date $time"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A date, for a wipe: the thing people actually compare wipes by. */
|
||||||
|
fun wipeDay(
|
||||||
|
value: String?,
|
||||||
|
zone: ZoneId = ZoneId.systemDefault(),
|
||||||
|
locale: Locale = Locale.getDefault(),
|
||||||
|
): String? {
|
||||||
|
val at = parseWireInstant(value) ?: return null
|
||||||
|
return DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||||
|
.withLocale(locale)
|
||||||
|
.format(at.atZone(zone))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The next wipe (module-rust phase 16), as `Thu 1 Oct, 19:00 · in 6 days` in
|
||||||
|
* the PHONE's zone — or null when there is no instant to show.
|
||||||
|
*
|
||||||
|
* The module computes the instant from the operator's rule in the operator's
|
||||||
|
* zone; the phone only says it the reader's way, which is the same split the
|
||||||
|
* website's `nextWipe()` makes. The `(rescheduled)` mark for a one-off date is
|
||||||
|
* the caller's, from a string resource.
|
||||||
|
*/
|
||||||
|
fun nextWipeWhen(
|
||||||
|
value: String?,
|
||||||
|
now: Instant = Instant.now(),
|
||||||
|
zone: ZoneId = ZoneId.systemDefault(),
|
||||||
|
locale: Locale = Locale.getDefault(),
|
||||||
|
): String? {
|
||||||
|
val at = parseWireInstant(value) ?: return null
|
||||||
|
val local = at.atZone(zone)
|
||||||
|
val date = DateTimeFormatter.ofPattern("EEE d MMM", locale).format(local)
|
||||||
|
val time = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale).format(local)
|
||||||
|
val distance = rustAgo(value, now) ?: return "$date, $time"
|
||||||
|
return "$date, $time · $distance"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "3 minutes ago", for a "last reported" line.
|
||||||
|
*
|
||||||
|
* Returns null rather than a word for an absent stamp, so the caller decides what
|
||||||
|
* "never" looks like in its own layout — on this surface a server that has never
|
||||||
|
* reported is a real and ordinary state, not a missing value to apologise for.
|
||||||
|
*/
|
||||||
|
fun rustAgo(value: String?, now: Instant = Instant.now()): String? {
|
||||||
|
val at = parseWireInstant(value) ?: return null
|
||||||
|
val seconds = java.time.Duration.between(at, now).seconds
|
||||||
|
|
||||||
|
// Under a minute in either direction, say the thing rather than "in 0 seconds".
|
||||||
|
if (kotlin.math.abs(seconds) < 45) return "just now"
|
||||||
|
|
||||||
|
val future = seconds < 0
|
||||||
|
val magnitude = kotlin.math.abs(seconds)
|
||||||
|
val (unit, size) = AGO_UNITS.first { magnitude >= it.second }
|
||||||
|
val amount = Math.round(magnitude.toDouble() / size)
|
||||||
|
val plural = if (amount == 1L) unit else "${unit}s"
|
||||||
|
|
||||||
|
return if (future) "in $amount $plural" else "$amount $plural ago"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val AGO_UNITS = listOf(
|
||||||
|
"year" to 31_536_000L,
|
||||||
|
"month" to 2_592_000L,
|
||||||
|
"week" to 604_800L,
|
||||||
|
"day" to 86_400L,
|
||||||
|
"hour" to 3_600L,
|
||||||
|
"minute" to 60L,
|
||||||
|
"second" to 1L,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A session or a playtime, as `4h 12m`.
|
||||||
|
*
|
||||||
|
* Seconds are dropped above a minute and kept below it: a two-hour session
|
||||||
|
* reported to the second is noise, and a forty-second one reported as "0m" is
|
||||||
|
* wrong.
|
||||||
|
*/
|
||||||
|
fun playtime(seconds: Long?): String {
|
||||||
|
val total = seconds ?: return "—"
|
||||||
|
if (total <= 0) return "—"
|
||||||
|
if (total < 60) return "${total}s"
|
||||||
|
|
||||||
|
val hours = total / 3600
|
||||||
|
val minutes = Math.round((total % 3600) / 60.0)
|
||||||
|
|
||||||
|
return when {
|
||||||
|
hours == 0L -> "${minutes}m"
|
||||||
|
minutes == 0L -> "${hours}h"
|
||||||
|
else -> "${hours}h ${minutes}m"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A prefab short name as something readable — `patrolhelicopter` stays itself,
|
||||||
|
* `rifle.ak` becomes `rifle ak`.
|
||||||
|
*
|
||||||
|
* Deliberately a light touch rather than a lookup table: a table mapping every
|
||||||
|
* Rust prefab to a pretty name is a second copy of the game's item list that goes
|
||||||
|
* stale every wipe, and the short name is what a Rust player reads on their own
|
||||||
|
* server console anyway.
|
||||||
|
*/
|
||||||
|
fun prefabName(name: String?): String {
|
||||||
|
if (name.isNullOrBlank()) return ""
|
||||||
|
return name.replace(Regex("[_.]+"), " ").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A steam id, shortened for a table cell, without pretending it is a name. */
|
||||||
|
fun shortSteamId(steamId: String?): String {
|
||||||
|
val id = steamId.orEmpty()
|
||||||
|
return if (id.length > 10) "…${id.takeLast(6)}" else id
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What to call a player who has no name yet.
|
||||||
|
*
|
||||||
|
* The presence board and the leaderboard both carry a nullable `name`: the plugin
|
||||||
|
* knows a steam id before it knows anything else. Showing a shortened id is
|
||||||
|
* honest — it is not a name and does not look like one — where "Unknown" would
|
||||||
|
* lose the only identifier there is.
|
||||||
|
*/
|
||||||
|
fun playerLabel(name: String?, steamId: String?): String =
|
||||||
|
name?.takeIf { it.isNotBlank() } ?: shortSteamId(steamId)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A chat title's `#rrggbb` colour as opaque ARGB, or null for anything else —
|
||||||
|
* a title with a colour this build cannot read is drawn in the theme's own chip
|
||||||
|
* colours rather than guessed at (M19).
|
||||||
|
*/
|
||||||
|
fun titleArgb(hex: String?): Long? {
|
||||||
|
val m = Regex("^#([0-9a-fA-F]{6})$").find(hex.orEmpty()) ?: return null
|
||||||
|
return 0xFF000000L or m.groupValues[1].toLong(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a title chip of this colour needs DARK ink — the same rule as the
|
||||||
|
* website's `contrastInk`: black or white, whichever has the higher WCAG
|
||||||
|
* contrast. The colour is the operator's, chosen for a dark game chat, and the
|
||||||
|
* app draws in the reader's theme, so the colour becomes the chip and the ink is
|
||||||
|
* picked for it.
|
||||||
|
*/
|
||||||
|
fun titleInkIsDark(argb: Long): Boolean {
|
||||||
|
fun linear(channel: Long): Double {
|
||||||
|
val v = channel / 255.0
|
||||||
|
return if (v <= 0.03928) v / 12.92 else Math.pow((v + 0.055) / 1.055, 2.4)
|
||||||
|
}
|
||||||
|
val l = 0.2126 * linear((argb shr 16) and 0xFF) +
|
||||||
|
0.7152 * linear((argb shr 8) and 0xFF) +
|
||||||
|
0.0722 * linear(argb and 0xFF)
|
||||||
|
return (l + 0.05) / 0.05 >= 1.05 / (l + 0.05)
|
||||||
|
}
|
||||||
680
app/src/main/java/com/runicgateway/app/ui/rust/RustMapPanel.kt
Normal file
680
app/src/main/java/com/runicgateway/app/ui/rust/RustMapPanel.kt
Normal file
@@ -0,0 +1,680 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.rust
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.drawable.BitmapDrawable
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||||
|
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.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
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.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.saveable.Saver
|
||||||
|
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.clipToBounds
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
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.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.TextLayoutResult
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.drawText
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import coil.imageLoader
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import coil.request.SuccessResult
|
||||||
|
import coil.size.Size
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.data.api.dto.RustMapDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustMapLayerDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustMapLiveDto
|
||||||
|
import com.runicgateway.app.ui.LocalAssetResolver
|
||||||
|
import com.runicgateway.app.ui.PollWhileResumed
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.components.EmptyView
|
||||||
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
import com.runicgateway.app.ui.components.ShardCard
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Map tab (`docs/modules/rust/PLAN.md` §31, D121–D124).
|
||||||
|
*
|
||||||
|
* **The phone draws the map itself** (D121): Coil fetches the picture once per
|
||||||
|
* map into its disk cache, since the URL carries the hash, and a [Canvas] draws
|
||||||
|
* the picture, the grid and the markers, with pinch, pan and double tap.
|
||||||
|
*
|
||||||
|
* **Nothing here decides who may see what.** The server sends only the layers
|
||||||
|
* this viewer may see. The switches on the legend are the reader's convenience
|
||||||
|
* and never a boundary, and a layer the viewer was not sent is still listed,
|
||||||
|
* disabled, with who can see it: "staff only" explains an empty map where
|
||||||
|
* silence would imply an empty server (§23.3's shape).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun RustMapPanel(
|
||||||
|
serverOnline: Boolean,
|
||||||
|
onOpenEvent: (slug: String, runId: String) -> Unit,
|
||||||
|
viewModel: RustMapViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val ui by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
when (val s = ui.map) {
|
||||||
|
is UiState.Loading -> LoadingView()
|
||||||
|
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load)
|
||||||
|
is UiState.Success -> {
|
||||||
|
val map = s.data
|
||||||
|
val geometry = map.geometry
|
||||||
|
if (geometry == null) {
|
||||||
|
EmptyView(stringResource(R.string.rust_map_none))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// D124: while RESUMED and on this tab, which is exactly while this
|
||||||
|
// composable is on screen. Nothing is asked for a viewer who is sent
|
||||||
|
// nothing that moves.
|
||||||
|
if (map.anyLive) {
|
||||||
|
val interval = map.pollMs?.takeIf { it > 0 } ?: DEFAULT_POLL_MS
|
||||||
|
key(interval) { PollWhileResumed(intervalMs = interval) { viewModel.poll() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
MapContent(
|
||||||
|
map = map,
|
||||||
|
frame = remember(geometry) { MapFrame(geometry) },
|
||||||
|
ui = ui,
|
||||||
|
serverOnline = serverOnline,
|
||||||
|
onToggle = viewModel::toggle,
|
||||||
|
onSelect = viewModel::select,
|
||||||
|
onOpenEvent = onOpenEvent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val DEFAULT_POLL_MS = 10_000L
|
||||||
|
|
||||||
|
/** What became of the picture. */
|
||||||
|
private sealed interface Picture {
|
||||||
|
data object None : Picture
|
||||||
|
data object Loading : Picture
|
||||||
|
data object Failed : Picture
|
||||||
|
data class Ready(val bitmap: ImageBitmap) : Picture
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MapContent(
|
||||||
|
map: RustMapDto,
|
||||||
|
frame: MapFrame,
|
||||||
|
ui: RustMapUi,
|
||||||
|
serverOnline: Boolean,
|
||||||
|
onToggle: (MapLayer) -> Unit,
|
||||||
|
onSelect: (MapMarker?) -> Unit,
|
||||||
|
onOpenEvent: (String, String) -> Unit,
|
||||||
|
) {
|
||||||
|
val picture = rememberPicture(map.picture?.path)
|
||||||
|
val markers = remember(map, ui.live, ui.shown) { mapMarkers(map, ui.live, ui.shown) }
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
when (picture) {
|
||||||
|
Picture.None -> Note(stringResource(R.string.rust_map_no_picture))
|
||||||
|
Picture.Failed -> Note(stringResource(R.string.rust_map_picture_failed))
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxWidth().weight(1f)) {
|
||||||
|
MapCanvas(
|
||||||
|
frame = frame,
|
||||||
|
background = parseColour(frame.geometry.background) ?: OCEAN,
|
||||||
|
picture = (picture as? Picture.Ready)?.bitmap,
|
||||||
|
showGrid = MapLayer.GRID in ui.shown,
|
||||||
|
markers = markers,
|
||||||
|
selected = ui.selected,
|
||||||
|
// A new map is a new frame; the reader's zoom on the old one means nothing.
|
||||||
|
resetKey = map.mapKey,
|
||||||
|
onSelect = onSelect,
|
||||||
|
)
|
||||||
|
ui.selected?.let { marker ->
|
||||||
|
MarkerCard(
|
||||||
|
marker = marker,
|
||||||
|
grid = marker.grid ?: frame.gridLabel(marker.x, marker.z),
|
||||||
|
eventSlug = marker.runId?.let { ui.eventSlugs[it] },
|
||||||
|
onOpenEvent = onOpenEvent,
|
||||||
|
onClose = { onSelect(null) },
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter).padding(12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Note(liveStatus(map, ui, serverOnline))
|
||||||
|
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = LEGEND_MAX_HEIGHT)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
Legend(map, ui.live, ui.shown, onToggle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val LEGEND_MAX_HEIGHT = 240.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The picture, decoded once at its own size as `RGB_565` with hardware bitmaps
|
||||||
|
* off: half the memory of ARGB, 12.5 MB at the rig's 2 500 px (§31.2).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun rememberPicture(path: String?): Picture {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val resolve = LocalAssetResolver.current
|
||||||
|
val url = path?.takeIf { it.isNotBlank() }?.let { resolve(mapPictureUrl(it)) }
|
||||||
|
|
||||||
|
// Keyed on the URL, which carries the picture's hash: a new map is a new
|
||||||
|
// load, and the old picture stays drawn until the new one has arrived.
|
||||||
|
var picture by remember { mutableStateOf(if (url == null) Picture.None else Picture.Loading) }
|
||||||
|
LaunchedEffect(url) {
|
||||||
|
picture = if (url == null) Picture.None else loadPicture(context, url)
|
||||||
|
}
|
||||||
|
return picture
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadPicture(context: Context, url: String): Picture {
|
||||||
|
val request = ImageRequest.Builder(context)
|
||||||
|
.data(url)
|
||||||
|
.size(Size.ORIGINAL)
|
||||||
|
.bitmapConfig(Bitmap.Config.RGB_565)
|
||||||
|
.allowHardware(false)
|
||||||
|
.build()
|
||||||
|
val result = context.imageLoader.execute(request) as? SuccessResult
|
||||||
|
val bitmap = (result?.drawable as? BitmapDrawable)?.bitmap ?: return Picture.Failed
|
||||||
|
return Picture.Ready(bitmap.asImageBitmap())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MapCanvas(
|
||||||
|
frame: MapFrame,
|
||||||
|
background: Color,
|
||||||
|
picture: ImageBitmap?,
|
||||||
|
showGrid: Boolean,
|
||||||
|
markers: List<MapMarker>,
|
||||||
|
selected: MapMarker?,
|
||||||
|
resetKey: String?,
|
||||||
|
onSelect: (MapMarker?) -> Unit,
|
||||||
|
) {
|
||||||
|
val pictureSize = ViewSize(frame.width.toFloat(), frame.height.toFloat())
|
||||||
|
var view by remember { mutableStateOf<ViewSize?>(null) }
|
||||||
|
// Null until the reader moves the map: until then, and after a new map
|
||||||
|
// resets it, the map is drawn fitted to the view. **Saveable**, because
|
||||||
|
// Open event leaves this screen and Back returns to it: the walk found the
|
||||||
|
// reader's zoom gone on the way back, the card still up over a whole-world
|
||||||
|
// view of the marker it described.
|
||||||
|
var moved by rememberSaveable(resetKey, pictureSize, stateSaver = TransformSaver) {
|
||||||
|
mutableStateOf<MapTransform?>(null)
|
||||||
|
}
|
||||||
|
val transform = moved ?: view?.let { MapTransform.fit(it, pictureSize) }
|
||||||
|
|
||||||
|
// Read through updated state so the gesture handlers, which are installed
|
||||||
|
// once, act on what is drawn now rather than on what was drawn when they were.
|
||||||
|
val currentMarkers by rememberUpdatedState(markers)
|
||||||
|
val currentTransform by rememberUpdatedState(transform)
|
||||||
|
val currentFrame by rememberUpdatedState(frame)
|
||||||
|
|
||||||
|
val textMeasurer = rememberTextMeasurer()
|
||||||
|
val labelStyle = TextStyle(fontSize = 10.sp, fontWeight = FontWeight.SemiBold, color = GRID_LABEL)
|
||||||
|
val grid = remember(frame) { frame.grid() }
|
||||||
|
val labels: List<Pair<GridLabel, TextLayoutResult>> = remember(grid, textMeasurer) {
|
||||||
|
grid.labels.map { it to textMeasurer.measure(it.text, labelStyle) }
|
||||||
|
}
|
||||||
|
val description = stringResource(R.string.rust_map_description)
|
||||||
|
|
||||||
|
Canvas(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.clipToBounds()
|
||||||
|
.background(SURROUND)
|
||||||
|
.semantics { contentDescription = description }
|
||||||
|
.onSizeChanged {
|
||||||
|
val next = ViewSize(it.width.toFloat(), it.height.toFloat())
|
||||||
|
// A new size keeps the reader's view rather than refitting: the
|
||||||
|
// box grows and shrinks with the status line under it, and a
|
||||||
|
// rotation is still the same place. The first size after a
|
||||||
|
// return is not a new one, and keeps the restored zoom.
|
||||||
|
val previous = view
|
||||||
|
if (next != previous) {
|
||||||
|
val m = moved
|
||||||
|
if (previous != null && m != null) moved = m.resized(previous, next, pictureSize)
|
||||||
|
view = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pointerInput(pictureSize) {
|
||||||
|
detectTransformGestures { centroid, pan, zoom, _ ->
|
||||||
|
val v = view ?: return@detectTransformGestures
|
||||||
|
val t = currentTransform ?: return@detectTransformGestures
|
||||||
|
moved = t.transformed(v, pictureSize, centroid.x, centroid.y, pan.x, pan.y, zoom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pointerInput(pictureSize) {
|
||||||
|
detectTapGestures(
|
||||||
|
onTap = { at ->
|
||||||
|
val t = currentTransform ?: return@detectTapGestures
|
||||||
|
onSelect(nearestMarker(currentMarkers, currentFrame, t, at.x, at.y, TAP_REACH.toPx()))
|
||||||
|
},
|
||||||
|
onDoubleTap = { at ->
|
||||||
|
val v = view ?: return@detectTapGestures
|
||||||
|
val t = currentTransform ?: return@detectTapGestures
|
||||||
|
moved = t.transformed(v, pictureSize, at.x, at.y, 0f, 0f, 2f)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
val t = transform ?: return@Canvas
|
||||||
|
val left = t.offsetX
|
||||||
|
val top = t.offsetY
|
||||||
|
val w = pictureSize.width * t.scale
|
||||||
|
val h = pictureSize.height * t.scale
|
||||||
|
|
||||||
|
// Without a picture the geometry is filled with the game's own colour.
|
||||||
|
drawRect(background, topLeft = Offset(left, top), size = androidx.compose.ui.geometry.Size(w, h))
|
||||||
|
picture?.let {
|
||||||
|
drawImage(
|
||||||
|
image = it,
|
||||||
|
dstOffset = IntOffset(left.roundToInt(), top.roundToInt()),
|
||||||
|
dstSize = IntSize(w.roundToInt(), h.roundToInt()),
|
||||||
|
filterQuality = FilterQuality.Low,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showGrid) drawGrid(frame, t, grid, labels)
|
||||||
|
markers.forEach { drawMarker(frame, t, it, highlighted = it == selected) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.drawGrid(
|
||||||
|
frame: MapFrame,
|
||||||
|
t: MapTransform,
|
||||||
|
grid: MapGrid,
|
||||||
|
labels: List<Pair<GridLabel, TextLayoutResult>>,
|
||||||
|
) {
|
||||||
|
val stroke = 1.dp.toPx()
|
||||||
|
fun at(x: Double, z: Double): Offset {
|
||||||
|
val p = frame.toPixel(x, z)
|
||||||
|
return Offset(t.screenX(p.x), t.screenY(p.y))
|
||||||
|
}
|
||||||
|
grid.lines.forEach { drawLine(GRID_LINE, at(it.x1, it.z1), at(it.x2, it.z2), strokeWidth = stroke) }
|
||||||
|
|
||||||
|
// Labels only once a cell is wide enough on screen to hold one: at the
|
||||||
|
// fitted zoom a 20-cell map's labels overlap into a wall of text (found on
|
||||||
|
// the phase 14 walk, and the web's rule).
|
||||||
|
val cellPx = frame.metres(frame.geometry.gridCellSize) * t.scale
|
||||||
|
if (cellPx < LABEL_MIN_CELL.toPx()) return
|
||||||
|
val pad = Offset(3.dp.toPx(), 2.dp.toPx())
|
||||||
|
labels.forEach { (label, layout) ->
|
||||||
|
val corner = at(label.x, label.z)
|
||||||
|
// Only the ones on screen: a zoomed-in 20-cell map has 400 and shows a few.
|
||||||
|
if (corner.x > size.width || corner.y > size.height) return@forEach
|
||||||
|
if (corner.x + cellPx < 0 || corner.y + cellPx < 0) return@forEach
|
||||||
|
drawText(layout, topLeft = corner + pad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One marker. **Points are drawn at a fixed size on screen** whatever the zoom, so
|
||||||
|
* a dot does not grow over the monument it sits on at 4×; **a zone is drawn on
|
||||||
|
* the ground**, in metres, because how far it reaches is what it says (§31.4).
|
||||||
|
*/
|
||||||
|
private fun DrawScope.drawMarker(frame: MapFrame, t: MapTransform, m: MapMarker, highlighted: Boolean) {
|
||||||
|
val p = frame.toPixel(m.x, m.z)
|
||||||
|
val centre = Offset(t.screenX(p.x), t.screenY(p.y))
|
||||||
|
|
||||||
|
if (m.isZone) {
|
||||||
|
val radius = (frame.metres(m.radiusMetres ?: 0.0) * t.scale).toFloat()
|
||||||
|
drawCircle(EVENT.copy(alpha = 0.12f), radius, centre)
|
||||||
|
drawCircle(EVENT, radius, centre, style = Stroke(width = (if (highlighted) 3 else 2).dp.toPx()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val style = markerStyle(m)
|
||||||
|
val radius = style.radius.dp.toPx()
|
||||||
|
drawCircle(style.colour.copy(alpha = 0.95f), radius, centre)
|
||||||
|
drawCircle(Color.Black, radius, centre, style = Stroke(width = style.outline.dp.toPx()))
|
||||||
|
if (highlighted) drawCircle(Color.White, radius + 4.dp.toPx(), centre, style = Stroke(width = 2.dp.toPx()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The zoom and pan as three floats, so they survive leaving the screen. */
|
||||||
|
private val TransformSaver = Saver<MapTransform?, FloatArray>(
|
||||||
|
save = { t -> t?.let { floatArrayOf(it.scale, it.offsetX, it.offsetY) } },
|
||||||
|
restore = { MapTransform(it[0], it[1], it[2]) },
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class MarkerStyle(val colour: Color, val radius: Int, val outline: Int = 1)
|
||||||
|
|
||||||
|
/** The web's colours and sizes (`MapView.jsx`), so the two maps read alike. */
|
||||||
|
private fun markerStyle(m: MapMarker): MarkerStyle = when (m.layer) {
|
||||||
|
MapLayer.WORLD -> when (m.kind) {
|
||||||
|
"monument" -> MarkerStyle(MONUMENT, 4)
|
||||||
|
"cargo" -> MarkerStyle(WORLD_COLOURS.getValue("cargo"), 7)
|
||||||
|
else -> MarkerStyle(WORLD_COLOURS[m.kind] ?: WORLD_COLOURS.getValue("crate"), 5)
|
||||||
|
}
|
||||||
|
MapLayer.EVENTS -> MarkerStyle(EVENT, 5)
|
||||||
|
MapLayer.PLAYERS -> if (m.online) MarkerStyle(ONLINE, 5) else MarkerStyle(SLEEPING, 4)
|
||||||
|
MapLayer.BASES -> MarkerStyle(if (m.kind == "vending") VENDING else TC, 4)
|
||||||
|
MapLayer.MATES -> if (m.self) MarkerStyle(SELF, 8, 2) else MarkerStyle(MATE, 6, 2)
|
||||||
|
MapLayer.GRID -> MarkerStyle(Color.White, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What was tapped: what it is, its grid square, and what its layer carried.
|
||||||
|
* Nothing is looked up beyond what was sent (§31.4).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun MarkerCard(
|
||||||
|
marker: MapMarker,
|
||||||
|
grid: String?,
|
||||||
|
eventSlug: String?,
|
||||||
|
onOpenEvent: (String, String) -> Unit,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
ShardCard(modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 4.dp)) {
|
||||||
|
Text(markerTitle(marker), style = MaterialTheme.typography.titleSmall)
|
||||||
|
markerDetail(marker)?.let {
|
||||||
|
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
grid?.let {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.rust_map_grid, it),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||||
|
// D123: a run core's public calendar lists opens the app's own event
|
||||||
|
// page on that run. A run it does not list (a rehearsal, an
|
||||||
|
// unlisted event) says only what it is.
|
||||||
|
val runId = marker.runId
|
||||||
|
if (marker.layer == MapLayer.EVENTS && runId != null && eventSlug != null) {
|
||||||
|
TextButton(onClick = { onOpenEvent(eventSlug, runId) }) {
|
||||||
|
Text(stringResource(R.string.rust_map_open_event))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TextButton(onClick = onClose) { Text(stringResource(R.string.rust_map_close)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun markerTitle(m: MapMarker): String = when (m.layer) {
|
||||||
|
MapLayer.WORLD -> when (m.kind) {
|
||||||
|
"monument" -> m.name ?: m.kind
|
||||||
|
"cargo" -> stringResource(R.string.rust_map_cargo)
|
||||||
|
"heli" -> stringResource(R.string.rust_map_heli)
|
||||||
|
"chinook" -> stringResource(R.string.rust_map_chinook)
|
||||||
|
"bradley" -> stringResource(R.string.rust_map_bradley)
|
||||||
|
"supply" -> stringResource(R.string.rust_map_supply)
|
||||||
|
"crate" -> stringResource(R.string.rust_map_crate)
|
||||||
|
else -> prefabName(m.kind)
|
||||||
|
}
|
||||||
|
MapLayer.EVENTS -> when (m.kind) {
|
||||||
|
"zone" -> m.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.rust_map_event_zone)
|
||||||
|
"npc" -> stringResource(R.string.rust_map_event_npc)
|
||||||
|
else -> stringResource(R.string.rust_map_event_crate)
|
||||||
|
}
|
||||||
|
MapLayer.PLAYERS -> m.name ?: ""
|
||||||
|
MapLayer.BASES -> stringResource(if (m.kind == "vending") R.string.rust_map_vending else R.string.rust_map_tc)
|
||||||
|
MapLayer.MATES -> if (m.self) stringResource(R.string.rust_map_you) else m.name ?: stringResource(R.string.rust_map_clan_mate)
|
||||||
|
MapLayer.GRID -> ""
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun markerDetail(m: MapMarker): String? = when (m.layer) {
|
||||||
|
MapLayer.WORLD -> when {
|
||||||
|
m.kind != "crate" -> null
|
||||||
|
m.hacked -> stringResource(R.string.rust_map_crate_hacked)
|
||||||
|
m.hackLeftSec != null -> stringResource(R.string.rust_map_crate_hack, countdown(m.hackLeftSec))
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
// Every site-event marker says so, linked or not (§31.2): a run the public
|
||||||
|
// calendar does not list gets this line and no button.
|
||||||
|
MapLayer.EVENTS -> stringResource(R.string.rust_map_site_event)
|
||||||
|
MapLayer.PLAYERS, MapLayer.MATES -> when {
|
||||||
|
!m.online -> stringResource(R.string.rust_map_player_offline)
|
||||||
|
m.sleeping -> stringResource(R.string.rust_map_player_sleeping)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row for each layer. A layer the viewer was sent has a switch; one they
|
||||||
|
* were not is listed disabled with who can see it, and the players layer says
|
||||||
|
* when it is narrower than its own switch because presence is (D113).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun Legend(map: RustMapDto, live: RustMapLiveDto?, shown: Set<MapLayer>, onToggle: (MapLayer) -> Unit) {
|
||||||
|
LegendRow(stringResource(R.string.rust_map_layer_grid), emptyList(), true, MapLayer.GRID in shown, null) {
|
||||||
|
onToggle(MapLayer.GRID)
|
||||||
|
}
|
||||||
|
LAYER_ROWS.forEach { (layer, labelRes) ->
|
||||||
|
val gate = map.layers.of(layer)
|
||||||
|
var note = if (gate.visible) null else hiddenNote(gate)
|
||||||
|
if (layer == MapLayer.PLAYERS && gate.cappedByPresence && !gate.visible) {
|
||||||
|
note = stringResource(R.string.rust_map_capped, note.orEmpty())
|
||||||
|
}
|
||||||
|
if (gate.visible && layer == MapLayer.PLAYERS && live?.playersTruncated == true) {
|
||||||
|
note = stringResource(R.string.rust_map_players_truncated)
|
||||||
|
}
|
||||||
|
if (gate.visible && layer == MapLayer.BASES && live?.basesTruncated == true) {
|
||||||
|
note = stringResource(R.string.rust_map_bases_truncated)
|
||||||
|
}
|
||||||
|
LegendRow(stringResource(labelRes), SWATCHES.getValue(layer), gate.visible, gate.visible && layer in shown, note) {
|
||||||
|
onToggle(layer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val mates = map.mates
|
||||||
|
if (mates.visible) {
|
||||||
|
LegendRow(
|
||||||
|
stringResource(R.string.rust_map_layer_mates),
|
||||||
|
SWATCHES.getValue(MapLayer.MATES),
|
||||||
|
true,
|
||||||
|
MapLayer.MATES in shown,
|
||||||
|
stringResource(R.string.rust_map_mates_note),
|
||||||
|
) { onToggle(MapLayer.MATES) }
|
||||||
|
} else if (mates.on && mates.signedIn && !mates.linked) {
|
||||||
|
// Offered only to a signed-in, unlinked viewer on a server with the switch
|
||||||
|
// on, as the web does: anyone else could not act on it.
|
||||||
|
LegendRow(
|
||||||
|
stringResource(R.string.rust_map_layer_mates),
|
||||||
|
SWATCHES.getValue(MapLayer.MATES),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
stringResource(R.string.rust_map_mates_link),
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun hiddenNote(gate: RustMapLayerDto): String = stringResource(
|
||||||
|
when (gate.audience) {
|
||||||
|
"signed_in" -> R.string.rust_map_hidden_signin
|
||||||
|
"public" -> R.string.rust_map_hidden_public
|
||||||
|
else -> R.string.rust_map_hidden_staff
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LegendRow(
|
||||||
|
label: String,
|
||||||
|
swatches: List<Color>,
|
||||||
|
enabled: Boolean,
|
||||||
|
checked: Boolean,
|
||||||
|
note: String?,
|
||||||
|
onToggle: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Switch(checked = checked, onCheckedChange = { onToggle() }, enabled = enabled)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (enabled) {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
swatches.forEach {
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Box(Modifier.size(10.dp).background(it, CircleShape))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
note?.let {
|
||||||
|
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ColumnScope.Note(text: String) {
|
||||||
|
if (text.isBlank()) return
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The line under the map, in the web's words (`liveStatus` in `MapView.jsx`). */
|
||||||
|
@Composable
|
||||||
|
private fun liveStatus(map: RustMapDto, ui: RustMapUi, serverOnline: Boolean): String {
|
||||||
|
if (!map.anyLive) return stringResource(R.string.rust_map_status_nothing_live)
|
||||||
|
val live = ui.live
|
||||||
|
if (live == null) {
|
||||||
|
return stringResource(if (ui.liveFailed) R.string.rust_map_status_failed else R.string.rust_map_status_asking)
|
||||||
|
}
|
||||||
|
if (ui.liveFailed) return stringResource(R.string.rust_map_status_failed_kept)
|
||||||
|
if (!live.live) {
|
||||||
|
return stringResource(if (serverOnline) R.string.rust_map_status_not_now else R.string.rust_map_status_offline)
|
||||||
|
}
|
||||||
|
val at = ui.liveAt ?: return ""
|
||||||
|
return stringResource(R.string.rust_map_status_as_of, CLOCK.format(Instant.ofEpochMilli(at)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private val CLOCK: DateTimeFormatter =
|
||||||
|
DateTimeFormatter.ofLocalizedTime(FormatStyle.MEDIUM).withZone(ZoneId.systemDefault())
|
||||||
|
|
||||||
|
private fun com.runicgateway.app.data.api.dto.RustMapLayersDto.of(layer: MapLayer): RustMapLayerDto = when (layer) {
|
||||||
|
MapLayer.WORLD -> world
|
||||||
|
MapLayer.EVENTS -> events
|
||||||
|
MapLayer.PLAYERS -> players
|
||||||
|
MapLayer.BASES -> bases
|
||||||
|
else -> RustMapLayerDto()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `#RRGGBB` as the plugin sends it, or null for anything else. */
|
||||||
|
internal fun parseColour(value: String?): Color? {
|
||||||
|
val hex = value?.trim()?.removePrefix("#") ?: return null
|
||||||
|
if (hex.length != 6) return null
|
||||||
|
val rgb = hex.toLongOrNull(16) ?: return null
|
||||||
|
return Color(0xFF000000 or rgb)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val TAP_REACH = 24.dp
|
||||||
|
private val LABEL_MIN_CELL = 30.dp
|
||||||
|
|
||||||
|
// The web's palette (`MapView.jsx` COLOURS), so a reader moving between the two
|
||||||
|
// reads the same marker as the same thing.
|
||||||
|
private val OCEAN = Color(0xFF0B3B4A)
|
||||||
|
private val SURROUND = Color(0xFF071F27)
|
||||||
|
private val GRID_LINE = Color.White.copy(alpha = 0.18f)
|
||||||
|
private val GRID_LABEL = Color.White.copy(alpha = 0.55f)
|
||||||
|
private val MONUMENT = Color(0xFFE8D9A8)
|
||||||
|
private val EVENT = Color(0xFFCE93D8)
|
||||||
|
private val ONLINE = Color(0xFFFFFFFF)
|
||||||
|
private val SLEEPING = Color(0xFF9E9E9E)
|
||||||
|
private val TC = Color(0xFFFF7043)
|
||||||
|
private val VENDING = Color(0xFF26A69A)
|
||||||
|
private val SELF = Color(0xFF00E5FF)
|
||||||
|
private val MATE = Color(0xFF7CFFB2)
|
||||||
|
private val WORLD_COLOURS = mapOf(
|
||||||
|
"cargo" to Color(0xFF4FC3F7),
|
||||||
|
"heli" to Color(0xFFEF5350),
|
||||||
|
"chinook" to Color(0xFFFFA726),
|
||||||
|
"bradley" to Color(0xFFA1887F),
|
||||||
|
"supply" to Color(0xFF66BB6A),
|
||||||
|
"crate" to Color(0xFFFFEE58),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val LAYER_ROWS = listOf(
|
||||||
|
MapLayer.WORLD to R.string.rust_map_layer_world,
|
||||||
|
MapLayer.EVENTS to R.string.rust_map_layer_events,
|
||||||
|
MapLayer.PLAYERS to R.string.rust_map_layer_players,
|
||||||
|
MapLayer.BASES to R.string.rust_map_layer_bases,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val SWATCHES = mapOf(
|
||||||
|
MapLayer.WORLD to listOf(MONUMENT, WORLD_COLOURS.getValue("cargo"), WORLD_COLOURS.getValue("heli"), WORLD_COLOURS.getValue("crate")),
|
||||||
|
MapLayer.EVENTS to listOf(EVENT),
|
||||||
|
MapLayer.PLAYERS to listOf(ONLINE, SLEEPING),
|
||||||
|
MapLayer.BASES to listOf(TC, VENDING),
|
||||||
|
MapLayer.MATES to listOf(SELF, MATE),
|
||||||
|
)
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.rust
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.core.auth.Session
|
||||||
|
import com.runicgateway.app.core.auth.SessionManager
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.data.api.dto.RustMapDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustMapLiveDto
|
||||||
|
import com.runicgateway.app.data.repository.EventsRepository
|
||||||
|
import com.runicgateway.app.data.repository.RustRepository
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.navigation.Routes
|
||||||
|
import com.runicgateway.app.ui.toUiState
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/** Everything the Map tab is showing. */
|
||||||
|
data class RustMapUi(
|
||||||
|
val map: UiState<RustMapDto> = UiState.Loading,
|
||||||
|
/** The last live answer, or null before the first. Kept when a later ask fails. */
|
||||||
|
val live: RustMapLiveDto? = null,
|
||||||
|
/** True when the most recent ask failed; [live] is then the one before it. */
|
||||||
|
val liveFailed: Boolean = false,
|
||||||
|
/** When [live] arrived, epoch ms. */
|
||||||
|
val liveAt: Long? = null,
|
||||||
|
/** The legend's switches: the reader's convenience, never a boundary. */
|
||||||
|
val shown: Set<MapLayer> = MapLayer.entries.toSet(),
|
||||||
|
val selected: MapMarker? = null,
|
||||||
|
/** Run id → event slug, for the runs core's public calendar lists. */
|
||||||
|
val eventSlugs: Map<String, String> = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One server's map (`docs/modules/rust/PLAN.md` §31, D121–D125; PLAN.md M17).
|
||||||
|
*
|
||||||
|
* ## It draws what it is sent
|
||||||
|
*
|
||||||
|
* `map` and `map/live` are projected per viewer **on the server**, from the same
|
||||||
|
* bearer token every other request carries. A layer this viewer may not see is
|
||||||
|
* absent from the answer, so nothing here decides who sees what.
|
||||||
|
*
|
||||||
|
* ## Keyed on the signed-in account
|
||||||
|
*
|
||||||
|
* Mates and the players layer are per viewer, and this view model lives as long
|
||||||
|
* as the server screen's back-stack entry, across a sign-out and a sign-in as
|
||||||
|
* somebody else. Events phase 14b found what that costs: a view model that loads
|
||||||
|
* once showed the next account the previous one's answer without asking. So the
|
||||||
|
* account is what this keys on. A change of account drops everything drawn,
|
||||||
|
* including the tapped card, and asks again, and an answer to a question asked
|
||||||
|
* for the previous account is thrown away when it lands (the [generation]).
|
||||||
|
* Signing out is a change too: the map is public, so a signed-out reader gets
|
||||||
|
* the public map rather than an empty one.
|
||||||
|
*
|
||||||
|
* ## When it asks
|
||||||
|
*
|
||||||
|
* `map` once when the tab opens, and again when a live answer names a different
|
||||||
|
* `mapKey`: a wipe or a new seed, whose picture follows without leaving the tab.
|
||||||
|
* `map/live` on the screen's poll (D124), and not at all when the server sends
|
||||||
|
* this viewer no moving layer.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class RustMapViewModel @Inject constructor(
|
||||||
|
private val repository: RustRepository,
|
||||||
|
events: EventsRepository,
|
||||||
|
sessionManager: SessionManager,
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val serverId: String = savedStateHandle[Routes.Args.SERVER_ID] ?: ""
|
||||||
|
|
||||||
|
private val resolver = EventRunResolver(events)
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(RustMapUi())
|
||||||
|
val state: StateFlow<RustMapUi> = _state.asStateFlow()
|
||||||
|
|
||||||
|
/** Bumped on every change of account; an answer from an older one is dropped. */
|
||||||
|
private var generation = 0
|
||||||
|
|
||||||
|
/** A poll still waiting for its answer: the next tick does not stack another. */
|
||||||
|
private var polling = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
sessionManager.state
|
||||||
|
.map { (it as? Session.SignedIn)?.user?.id }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect {
|
||||||
|
generation++
|
||||||
|
polling = false
|
||||||
|
_state.update { s -> RustMapUi(shown = s.shown) }
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The map's picture, frame and gates. A retry after a failure too. */
|
||||||
|
fun load() {
|
||||||
|
val asked = generation
|
||||||
|
_state.update { it.copy(map = UiState.Loading) }
|
||||||
|
viewModelScope.launch { askMap(asked) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The poll tick. Asks only once the map has a frame to place things in and
|
||||||
|
* only when this viewer is sent something that moves.
|
||||||
|
*/
|
||||||
|
fun poll() {
|
||||||
|
val map = (_state.value.map as? UiState.Success)?.data ?: return
|
||||||
|
if (map.geometry == null || !map.anyLive || polling) return
|
||||||
|
|
||||||
|
val asked = generation
|
||||||
|
polling = true
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = repository.mapLive(serverId)
|
||||||
|
if (asked != generation) return@launch
|
||||||
|
polling = false
|
||||||
|
|
||||||
|
when (result) {
|
||||||
|
is ApiResult.Ok -> {
|
||||||
|
val answer = result.data
|
||||||
|
_state.update {
|
||||||
|
it.copy(live = answer, liveFailed = false, liveAt = System.currentTimeMillis())
|
||||||
|
}
|
||||||
|
// A new map under the same server: the picture follows.
|
||||||
|
if (answer.mapKey != null && map.mapKey != null && answer.mapKey != map.mapKey) {
|
||||||
|
askMap(asked)
|
||||||
|
}
|
||||||
|
resolveRuns(answer, asked)
|
||||||
|
}
|
||||||
|
// Positions are kept on a failure, in Polling.kt's shape: the last
|
||||||
|
// answer stays drawn and the status line says the ask failed.
|
||||||
|
else -> _state.update { it.copy(liveFailed = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggle(layer: MapLayer) {
|
||||||
|
_state.update {
|
||||||
|
val shown = if (layer in it.shown) it.shown - layer else it.shown + layer
|
||||||
|
// A card for something the reader just hid would describe a marker
|
||||||
|
// that is no longer on the map.
|
||||||
|
val selected = it.selected?.takeIf { m -> m.layer in shown }
|
||||||
|
it.copy(shown = shown, selected = selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun select(marker: MapMarker?) {
|
||||||
|
_state.update { it.copy(selected = marker) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun askMap(asked: Int) {
|
||||||
|
val result = repository.map(serverId)
|
||||||
|
if (asked != generation) return
|
||||||
|
_state.update { current ->
|
||||||
|
// A failed RE-read after a new mapKey keeps the map that is drawn:
|
||||||
|
// the old picture under new positions is better than an error screen,
|
||||||
|
// and the next poll asks again.
|
||||||
|
if (result !is ApiResult.Ok && current.map is UiState.Success) current
|
||||||
|
else current.copy(map = result.toUiState())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun resolveRuns(answer: RustMapLiveDto, asked: Int) {
|
||||||
|
val ids = eventRunIds(answer)
|
||||||
|
if (ids.isEmpty()) return
|
||||||
|
val slugs = resolver.resolve(ids)
|
||||||
|
if (asked != generation) return
|
||||||
|
_state.update { it.copy(eventSlugs = slugs) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the server sends this viewer at least one layer that moves. */
|
||||||
|
val RustMapDto.anyLive: Boolean
|
||||||
|
get() = layers.world.visible || layers.events.visible || layers.players.visible ||
|
||||||
|
layers.bases.visible || mates.visible
|
||||||
@@ -0,0 +1,663 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.rust
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ScrollableTabRow
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
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.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.data.api.dto.RustEventDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustEventListDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustOnlineDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustServerDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustTitleDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustWipeDto
|
||||||
|
import com.runicgateway.app.ui.ErrorKind
|
||||||
|
import com.runicgateway.app.ui.PollWhileResumed
|
||||||
|
import com.runicgateway.app.ui.Polled
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.components.EmptyView
|
||||||
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
import com.runicgateway.app.ui.components.PillTone
|
||||||
|
import com.runicgateway.app.ui.components.SectionLabel
|
||||||
|
import com.runicgateway.app.ui.components.ShardCard
|
||||||
|
import com.runicgateway.app.ui.components.StatusPill
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One Rust server: the feed, the leaderboard, who is on, the map when the module
|
||||||
|
* has one (D122), and the wipes (D13).
|
||||||
|
*
|
||||||
|
* **One screen with tabs, not four destinations** — the same call the website
|
||||||
|
* makes, and more obviously right on a phone: the four panels are four questions
|
||||||
|
* about one thing, and a reader moving between them is not navigating.
|
||||||
|
*
|
||||||
|
* The phase criterion lives here. With the server unreachable this still renders
|
||||||
|
* its map, size, seed, wipe date, killfeed, leaderboards, last known presence
|
||||||
|
* board and wipe history, because every one of those is read from the website's
|
||||||
|
* own tables rather than from the game.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun RustServerScreen(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onOpenEvent: (slug: String, runId: String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: RustServerViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val ui by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
PollWhileResumed { viewModel.refresh() }
|
||||||
|
|
||||||
|
when (val s = ui.server.state) {
|
||||||
|
is UiState.Loading -> LoadingView(modifier)
|
||||||
|
|
||||||
|
// **A mistyped address is not a fault and must not be dressed as one.**
|
||||||
|
// The website's first version put its generic error panel under this
|
||||||
|
// heading, so an unknown id read "No such server / Something went wrong"
|
||||||
|
// and sent a reader looking for an outage. A 404 is its own answer; the
|
||||||
|
// error panel is kept for a request that failed for a reason nobody can
|
||||||
|
// see. A server an operator disabled answers the same 404 — switching one
|
||||||
|
// off is not switching it into a refusal.
|
||||||
|
is UiState.Error -> if (s.kind == ErrorKind.NOT_FOUND) {
|
||||||
|
MissingServer(onBack, modifier)
|
||||||
|
} else {
|
||||||
|
ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
is UiState.Success -> ServerDetail(s.data, ui, viewModel, onOpenEvent, modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MissingServer(onBack: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier.fillMaxSize().padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.rust_no_such_server), style = MaterialTheme.typography.titleLarge)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.rust_no_such_server_detail),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.rust_back_to_servers),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.clickable(onClick = onBack).padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ServerDetail(
|
||||||
|
server: RustServerDto,
|
||||||
|
ui: RustServerUi,
|
||||||
|
viewModel: RustServerViewModel,
|
||||||
|
onOpenEvent: (slug: String, runId: String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(modifier.fillMaxSize()) {
|
||||||
|
ServerHeader(server, ui.selectedWipe, viewModel::selectWipe, ui.wipes)
|
||||||
|
|
||||||
|
val tabs = ui.tabs
|
||||||
|
ScrollableTabRow(selectedTabIndex = tabs.indexOf(ui.tab), edgePadding = 16.dp) {
|
||||||
|
tabs.forEach { tab ->
|
||||||
|
Tab(
|
||||||
|
selected = tab == ui.tab,
|
||||||
|
onClick = { viewModel.selectTab(tab) },
|
||||||
|
text = { Text(stringResource(tabLabel(tab))) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (ui.tab) {
|
||||||
|
RustTab.FEED -> FeedPanel(ui.feed, ui.filterId, viewModel::selectFilter, viewModel::retryFeed)
|
||||||
|
RustTab.LEADERBOARD -> LeaderboardPanel(
|
||||||
|
ui.leaderboard,
|
||||||
|
ui.sort,
|
||||||
|
viewModel::selectSort,
|
||||||
|
viewModel::retryLeaderboard,
|
||||||
|
)
|
||||||
|
RustTab.ONLINE -> OnlinePanel(ui.online, server.online, viewModel::retryOnline)
|
||||||
|
RustTab.MAP -> RustMapPanel(serverOnline = server.online, onOpenEvent = onOpenEvent)
|
||||||
|
RustTab.WIPES -> WipesPanel(
|
||||||
|
ui.wipes,
|
||||||
|
server.wipeId,
|
||||||
|
ui.selectedWipe,
|
||||||
|
viewModel::openWipe,
|
||||||
|
viewModel::retryWipes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tabLabel(tab: RustTab): Int = when (tab) {
|
||||||
|
RustTab.FEED -> R.string.rust_tab_feed
|
||||||
|
RustTab.LEADERBOARD -> R.string.rust_tab_leaderboard
|
||||||
|
RustTab.ONLINE -> R.string.rust_tab_online
|
||||||
|
RustTab.MAP -> R.string.rust_tab_map
|
||||||
|
RustTab.WIPES -> R.string.rust_tab_wipes
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ServerHeader(
|
||||||
|
server: RustServerDto,
|
||||||
|
selectedWipe: String?,
|
||||||
|
onSelectWipe: (String?) -> Unit,
|
||||||
|
wipes: UiState<List<RustWipeDto>>,
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||||
|
Text(server.name.ifBlank { server.id }, style = MaterialTheme.typography.headlineSmall)
|
||||||
|
|
||||||
|
describeWorld(server)?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
nextWipeLine(server)?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (server.online) {
|
||||||
|
StatusPill(
|
||||||
|
text = stringResource(R.string.rust_online_count, server.players, server.maxPlayers),
|
||||||
|
tone = PillTone.Success,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
StatusPill(text = stringResource(R.string.rust_offline), tone = PillTone.Neutral)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = lastReported(server),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The wipe picker sits above the tabs because it filters two of them. It
|
||||||
|
// is absent until the wipe list has loaded — offering a filter with one
|
||||||
|
// option would look like a server that has only ever had one wipe.
|
||||||
|
val available = (wipes as? UiState.Success)?.data.orEmpty()
|
||||||
|
if (available.isNotEmpty()) {
|
||||||
|
WipeFilter(available, server.wipeId, selectedWipe, onSelectWipe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WipeFilter(
|
||||||
|
wipes: List<RustWipeDto>,
|
||||||
|
currentWipeId: String?,
|
||||||
|
selected: String?,
|
||||||
|
onSelect: (String?) -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(top = 10.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
// **Null is all time, and it is the default.** It is a real choice rather
|
||||||
|
// than an absent filter: all-time is the per-wipe rows summed, which is
|
||||||
|
// the answer to "who plays here", where a wipe is the answer to "who is
|
||||||
|
// winning now".
|
||||||
|
FilterChip(
|
||||||
|
selected = selected == null,
|
||||||
|
onClick = { onSelect(null) },
|
||||||
|
label = { Text(stringResource(R.string.rust_all_time)) },
|
||||||
|
)
|
||||||
|
wipes.forEach { wipe ->
|
||||||
|
val label = wipeDay(wipe.saveCreatedAt ?: wipe.firstSeen) ?: wipe.wipeId
|
||||||
|
FilterChip(
|
||||||
|
selected = selected == wipe.wipeId,
|
||||||
|
onClick = { onSelect(wipe.wipeId) },
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
if (wipe.wipeId == currentWipeId) {
|
||||||
|
stringResource(R.string.rust_wipe_current, label)
|
||||||
|
} else {
|
||||||
|
label
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Feed ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FeedPanel(
|
||||||
|
feed: Polled<RustEventListDto>,
|
||||||
|
filterId: String,
|
||||||
|
onFilter: (String) -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
FEED_FILTERS.forEach { filter ->
|
||||||
|
FilterChip(
|
||||||
|
selected = filter.id == filterId,
|
||||||
|
onClick = { onFilter(filter.id) },
|
||||||
|
label = { Text(filter.label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val s = feed.state) {
|
||||||
|
is UiState.Loading -> LoadingView()
|
||||||
|
is UiState.Error -> ErrorView(s.kind, onRetry = onRetry)
|
||||||
|
is UiState.Success -> {
|
||||||
|
// Said once, above the rows, so a thin feed reads as withheld
|
||||||
|
// rather than as a quiet server (org lead: nothing names who is
|
||||||
|
// online by default).
|
||||||
|
if (s.data.presenceHidden) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
if (s.data.presenceAudience == "signed_in") {
|
||||||
|
R.string.rust_feed_presence_hidden_signin
|
||||||
|
} else {
|
||||||
|
R.string.rust_feed_presence_hidden_staff
|
||||||
|
},
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (s.data.events.isEmpty()) {
|
||||||
|
EmptyView(stringResource(R.string.rust_feed_empty))
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
if (feed.refreshFailed) {
|
||||||
|
item { RefreshFailedLine() }
|
||||||
|
}
|
||||||
|
items(s.data.events, key = { it.id }) { FeedRow(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FeedRow(row: RustEventDto) {
|
||||||
|
val line = describe(row)
|
||||||
|
|
||||||
|
Row(verticalAlignment = Alignment.Top) {
|
||||||
|
// The stamp carries a date for anything not from today — a row from six
|
||||||
|
// weeks ago rendered as a bare time reads as this afternoon, which is
|
||||||
|
// exactly what happens the moment the feed is filtered to a past wipe.
|
||||||
|
Text(
|
||||||
|
text = feedClock(value = null, epochMillis = row.t),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(end = 10.dp, top = 2.dp),
|
||||||
|
)
|
||||||
|
Column {
|
||||||
|
Row {
|
||||||
|
line.actor?.let {
|
||||||
|
Text(it, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(line.join, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
// The chat message lands here, and it is the one field on this
|
||||||
|
// wire whose bytes a player chooses. Compose renders it as text
|
||||||
|
// and never as markup; nothing here may ever stop doing that.
|
||||||
|
Text(line.verb, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
line.subject?.let {
|
||||||
|
Text(" ", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Text(it, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (line.detail.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = line.detail,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Leaderboard ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The columns, and which of them the API can sort by.
|
||||||
|
*
|
||||||
|
* `structures` has no sort on the wire and therefore no tap here — a header that
|
||||||
|
* sorts by something other than what it says is worse than one that does not
|
||||||
|
* sort.
|
||||||
|
*/
|
||||||
|
private data class RustColumn(val labelRes: Int, val sort: String?, val value: (RustLeaderboardRowDto) -> String)
|
||||||
|
|
||||||
|
/** The name's share of the row against one numeric column's. */
|
||||||
|
private const val NAME_WEIGHT = 1.7f
|
||||||
|
|
||||||
|
/** How far a header that is not the current sort is faded. */
|
||||||
|
private const val SORTED_AWAY = 0.55f
|
||||||
|
|
||||||
|
private val RUST_COLUMNS = listOf(
|
||||||
|
RustColumn(R.string.rust_col_kills, RustSort.KILLS) { it.kills.toString() },
|
||||||
|
RustColumn(R.string.rust_col_deaths, RustSort.DEATHS) { it.deaths.toString() },
|
||||||
|
RustColumn(R.string.rust_col_npc_kills, RustSort.NPC_KILLS) { it.npcKills.toString() },
|
||||||
|
RustColumn(R.string.rust_col_structures, null) { it.structures.toString() },
|
||||||
|
RustColumn(R.string.rust_col_played, RustSort.PLAYTIME) { playtime(it.playtimeSec) },
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun LeaderboardPanel(
|
||||||
|
state: UiState<List<RustLeaderboardRowDto>>,
|
||||||
|
sort: String,
|
||||||
|
onSort: (String) -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
) {
|
||||||
|
when (state) {
|
||||||
|
is UiState.Loading -> LoadingView()
|
||||||
|
is UiState.Error -> ErrorView(state.kind, onRetry = onRetry)
|
||||||
|
is UiState.Success -> if (state.data.isEmpty()) {
|
||||||
|
// **Empty is a real answer here and is not "no data".** All-time is
|
||||||
|
// the per-wipe rows summed, so a player who appears only in an older
|
||||||
|
// wipe drops out of the current one rather than reading zero — an
|
||||||
|
// empty board for a wipe means nobody scored on that map.
|
||||||
|
EmptyView(stringResource(R.string.rust_leaderboard_empty))
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Row(Modifier.fillMaxWidth()) {
|
||||||
|
SectionLabel(
|
||||||
|
text = stringResource(R.string.rust_col_player),
|
||||||
|
modifier = Modifier.weight(NAME_WEIGHT),
|
||||||
|
)
|
||||||
|
RUST_COLUMNS.forEach { column ->
|
||||||
|
// The ACTIVE sort is marked on the header, not on the
|
||||||
|
// values: the header is the control, and tinting a
|
||||||
|
// column of numbers instead says "these are special"
|
||||||
|
// rather than "this is what the table is ordered by".
|
||||||
|
SectionLabel(
|
||||||
|
text = stringResource(column.labelRes),
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.then(
|
||||||
|
if (column.sort != null) {
|
||||||
|
Modifier.clickable { onSort(column.sort) }
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
if (column.sort == sort) {
|
||||||
|
Modifier.alpha(1f)
|
||||||
|
} else {
|
||||||
|
Modifier.alpha(SORTED_AWAY)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(state.data, key = { it.steamId }) { row ->
|
||||||
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
// A wider share for the name, and one line with an ellipsis.
|
||||||
|
// Five numeric columns beside an equal-weight name column
|
||||||
|
// left "Brannock" touching its own kill count, which the
|
||||||
|
// walk read as one field.
|
||||||
|
Column(Modifier.weight(NAME_WEIGHT).padding(end = 8.dp)) {
|
||||||
|
Text(
|
||||||
|
text = playerLabel(row.name, row.steamId),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
// M19: the chat titles this player holds now, under the
|
||||||
|
// name — the column is too narrow to put them beside it.
|
||||||
|
if (row.titles.isNotEmpty()) {
|
||||||
|
FlowRow(
|
||||||
|
Modifier.padding(top = 2.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
) {
|
||||||
|
row.titles.forEach { title -> TitleChip(title) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RUST_COLUMNS.forEach { column ->
|
||||||
|
Text(
|
||||||
|
text = column.value(row),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One chat title (M19): the operator's colour as the chip, and black or white
|
||||||
|
* ink chosen for it, as the website draws it. A colour this build cannot read
|
||||||
|
* falls back to the theme's own chip colours.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun TitleChip(title: RustTitleDto) {
|
||||||
|
val argb = titleArgb(title.color)
|
||||||
|
val background = argb?.let { Color(it) } ?: MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
val ink = when {
|
||||||
|
argb == null -> MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
titleInkIsDark(argb) -> Color.Black
|
||||||
|
else -> Color.White
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = title.text,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = ink,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(50))
|
||||||
|
.background(background)
|
||||||
|
.padding(horizontal = 6.dp, vertical = 1.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Online ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OnlinePanel(
|
||||||
|
online: Polled<RustOnlineDto>,
|
||||||
|
serverOnline: Boolean,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
) {
|
||||||
|
when (val s = online.state) {
|
||||||
|
is UiState.Loading -> LoadingView()
|
||||||
|
is UiState.Error -> ErrorView(s.kind, onRetry = onRetry)
|
||||||
|
// Withheld is not empty. Below the operator's audience the server sends
|
||||||
|
// the count and no names, and an empty list rendered as "nobody is on"
|
||||||
|
// would be a false statement about a full server.
|
||||||
|
is UiState.Success -> if (s.data.hidden) {
|
||||||
|
val count = s.data.count
|
||||||
|
EmptyView(
|
||||||
|
pluralStringResource(R.plurals.rust_online_hidden_count, count, count) + "\n" +
|
||||||
|
stringResource(
|
||||||
|
when (s.data.audience) {
|
||||||
|
"signed_in" -> R.string.rust_online_hidden_signin
|
||||||
|
"public" -> R.string.rust_online_hidden_public
|
||||||
|
else -> R.string.rust_online_hidden_staff
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else if (s.data.players.isEmpty()) {
|
||||||
|
EmptyView(
|
||||||
|
stringResource(
|
||||||
|
if (serverOnline) R.string.rust_nobody_on else R.string.rust_presence_offline,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
// **The board is the last one that ARRIVED, and an unreachable
|
||||||
|
// server does not clear it** — deliberately, because these rows
|
||||||
|
// are still the best answer anybody has. Presented bare they read
|
||||||
|
// as "these people are on right now", which is the one thing an
|
||||||
|
// offline server cannot be saying. So the panel says which it is.
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
if (serverOnline) R.string.rust_presence_live else R.string.rust_presence_last_known,
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (online.refreshFailed) {
|
||||||
|
item { RefreshFailedLine() }
|
||||||
|
}
|
||||||
|
items(s.data.players, key = { it.steamId }) { player ->
|
||||||
|
ShardCard(Modifier.fillMaxWidth()) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = playerLabel(player.name, player.steamId),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
if (player.sleeping) {
|
||||||
|
StatusPill(
|
||||||
|
text = stringResource(R.string.rust_sleeping),
|
||||||
|
tone = PillTone.Neutral,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wipes ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WipesPanel(
|
||||||
|
state: UiState<List<RustWipeDto>>,
|
||||||
|
currentWipeId: String?,
|
||||||
|
selected: String?,
|
||||||
|
onOpenWipe: (String) -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
) {
|
||||||
|
when (state) {
|
||||||
|
is UiState.Loading -> LoadingView()
|
||||||
|
is UiState.Error -> ErrorView(state.kind, onRetry = onRetry)
|
||||||
|
is UiState.Success -> if (state.data.isEmpty()) {
|
||||||
|
EmptyView(stringResource(R.string.rust_wipes_empty))
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
items(state.data, key = { it.wipeId }) { wipe ->
|
||||||
|
ShardCard(
|
||||||
|
Modifier.fillMaxWidth().clickable { onOpenWipe(wipe.wipeId) },
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = wipeDay(wipe.saveCreatedAt ?: wipe.firstSeen) ?: wipe.wipeId,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
if (wipe.wipeId == currentWipeId) {
|
||||||
|
StatusPill(
|
||||||
|
text = stringResource(R.string.rust_wipe_this_one),
|
||||||
|
tone = PillTone.Success,
|
||||||
|
)
|
||||||
|
} else if (wipe.wipeId == selected) {
|
||||||
|
StatusPill(
|
||||||
|
text = stringResource(R.string.rust_wipe_selected),
|
||||||
|
tone = PillTone.Info,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RefreshFailedLine() {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.rust_refresh_failed),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.rust
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.data.api.dto.RustEventListDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustOnlineDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustServerDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustWipeDto
|
||||||
|
import com.runicgateway.app.data.repository.Capability
|
||||||
|
import com.runicgateway.app.data.repository.RustRepository
|
||||||
|
import com.runicgateway.app.data.repository.SiteCapabilitiesRepository
|
||||||
|
import com.runicgateway.app.data.repository.canUse
|
||||||
|
import com.runicgateway.app.ui.Polled
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.navigation.Routes
|
||||||
|
import com.runicgateway.app.ui.refreshInto
|
||||||
|
import com.runicgateway.app.ui.toUiState
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sections of a server's page (D13), in the website's order. [MAP] sits
|
||||||
|
* between Online and Wipes where the web has it (D122), and is shown only when
|
||||||
|
* the site's module declares `map`.
|
||||||
|
*/
|
||||||
|
enum class RustTab {
|
||||||
|
FEED, LEADERBOARD, ONLINE, MAP, WIPES;
|
||||||
|
|
||||||
|
/** The website's name for this tab, as `?tab=` carries it (`ServerDetail.jsx`). */
|
||||||
|
val wire: String get() = name.lowercase()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* The tab the website calls [wire], or null for one the app does not have.
|
||||||
|
* The website also has `clans`, and a link to it is not a link to the feed.
|
||||||
|
*/
|
||||||
|
fun fromWire(wire: String?): RustTab? = entries.firstOrNull { it.wire == wire }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a leaderboard column sorts by — the API's own vocabulary, not the app's. */
|
||||||
|
object RustSort {
|
||||||
|
const val KILLS = "kills"
|
||||||
|
const val DEATHS = "deaths"
|
||||||
|
const val NPC_KILLS = "npcKills"
|
||||||
|
const val PLAYTIME = "playtime"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything one server's page is showing.
|
||||||
|
*
|
||||||
|
* One state object rather than eight flows: every panel on the page is about the
|
||||||
|
* same server and the same selected wipe, and a screen that collected them
|
||||||
|
* separately could render a leaderboard for one wipe beside a feed for another
|
||||||
|
* for a frame.
|
||||||
|
*/
|
||||||
|
data class RustServerUi(
|
||||||
|
val serverId: String = "",
|
||||||
|
val server: Polled<RustServerDto> = Polled(),
|
||||||
|
val tab: RustTab = RustTab.FEED,
|
||||||
|
val filterId: String = "all",
|
||||||
|
val sort: String = RustSort.KILLS,
|
||||||
|
/** The wipe every panel is filtered to. **Null is all time**, not "unknown". */
|
||||||
|
val selectedWipe: String? = null,
|
||||||
|
/** The whole answer, not its rows: `presenceHidden` is part of what it says. */
|
||||||
|
val feed: Polled<RustEventListDto> = Polled(),
|
||||||
|
/** Likewise — `hidden` and `count` are what tell "withheld" from "nobody". */
|
||||||
|
val online: Polled<RustOnlineDto> = Polled(),
|
||||||
|
val leaderboard: UiState<List<RustLeaderboardRowDto>> = UiState.Loading,
|
||||||
|
val wipes: UiState<List<RustWipeDto>> = UiState.Loading,
|
||||||
|
/** Whether the Map tab is offered, under the app's one capability rule (D122). */
|
||||||
|
val mapAvailable: Boolean = true,
|
||||||
|
) {
|
||||||
|
/** The tabs this site offers, in order. */
|
||||||
|
val tabs: List<RustTab> get() = RustTab.entries.filter { it != RustTab.MAP || mapAvailable }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One Rust server (PLAN.md §9 M14; `docs/modules/rust/PLAN.md` D13, D14).
|
||||||
|
*
|
||||||
|
* ## What polls and what does not
|
||||||
|
*
|
||||||
|
* D14, and it is a statement about the questions rather than about cost: the
|
||||||
|
* **feed**, **who is on** and the **server's own line** change while somebody is
|
||||||
|
* looking at the page, and the **leaderboard** and the **wipe list** do not in
|
||||||
|
* any way a reader would want to watch. A leaderboard that re-sorted itself under
|
||||||
|
* a finger every twenty seconds would be worse than a stale one.
|
||||||
|
*
|
||||||
|
* Only the **visible** live panel is polled. The website can afford to mount the
|
||||||
|
* one tab it is showing; here the tabs are one screen, so the refresh asks what
|
||||||
|
* the reader is actually looking at.
|
||||||
|
*
|
||||||
|
* ## Changing the question versus asking it again
|
||||||
|
*
|
||||||
|
* A poll is the same question asked again, so it keeps what is on screen
|
||||||
|
* ([refreshInto]). Changing the filter, the sort or the wipe is a **different
|
||||||
|
* question**, so the panel blanks and loads — what is there is an answer to
|
||||||
|
* something the reader has stopped asking, and leaving it up while the new one
|
||||||
|
* arrives would show a killfeed for last wipe under a heading naming this one.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class RustServerViewModel @Inject constructor(
|
||||||
|
private val repository: RustRepository,
|
||||||
|
capabilities: SiteCapabilitiesRepository,
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val serverId: String = savedStateHandle[Routes.Args.SERVER_ID] ?: ""
|
||||||
|
|
||||||
|
/** The tab a link asked for (D71), or null for the feed every page opens on. */
|
||||||
|
private val initialTab: RustTab? = RustTab.fromWire(savedStateHandle[Routes.Args.TAB])
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(RustServerUi(serverId = serverId))
|
||||||
|
val state: StateFlow<RustServerUi> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
// Before the tab a link asked for, so `?tab=map` on a site without a map
|
||||||
|
// is refused by [selectTab] and opens the feed.
|
||||||
|
viewModelScope.launch {
|
||||||
|
capabilities.capabilities.collect { caps ->
|
||||||
|
val available = canUse(caps, Capability.MAP)
|
||||||
|
_state.update {
|
||||||
|
// The tab going away under the reader lands them on the feed,
|
||||||
|
// which every page has and has already loaded.
|
||||||
|
val tab = if (!available && it.tab == RustTab.MAP) RustTab.FEED else it.tab
|
||||||
|
it.copy(mapAvailable = available, tab = tab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
initialTab?.takeIf { it != RustTab.FEED }?.let(::selectTab)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A first load or a retry of the whole page. */
|
||||||
|
fun load() {
|
||||||
|
_state.update { it.copy(server = Polled(UiState.Loading), feed = Polled(UiState.Loading)) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
askServer()
|
||||||
|
askFeed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The poll tick.
|
||||||
|
*
|
||||||
|
* The server line always, and then whichever live panel is on screen. A tab
|
||||||
|
* showing the leaderboard or the wipes does no extra work — the reader is
|
||||||
|
* looking at something that does not move.
|
||||||
|
*/
|
||||||
|
fun refresh() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
askServer()
|
||||||
|
when (_state.value.tab) {
|
||||||
|
RustTab.FEED -> askFeed()
|
||||||
|
RustTab.ONLINE -> askOnline()
|
||||||
|
// The map keeps its own cadence (D124) in [RustMapViewModel].
|
||||||
|
RustTab.LEADERBOARD, RustTab.WIPES, RustTab.MAP -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a tab, loading its panel the first time it is opened.
|
||||||
|
*
|
||||||
|
* The two that do not poll are loaded exactly once per question: re-asking on
|
||||||
|
* every tab switch would put a spinner over a leaderboard the reader has
|
||||||
|
* already read, for an answer that cannot have changed while they were three
|
||||||
|
* taps away.
|
||||||
|
*/
|
||||||
|
fun selectTab(tab: RustTab) {
|
||||||
|
val already = _state.value
|
||||||
|
if (tab !in already.tabs) return
|
||||||
|
_state.update { it.copy(tab = tab) }
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (tab) {
|
||||||
|
RustTab.FEED -> if (already.feed.state !is UiState.Success) askFeed()
|
||||||
|
RustTab.ONLINE -> if (already.online.state !is UiState.Success) askOnline()
|
||||||
|
RustTab.LEADERBOARD -> if (already.leaderboard !is UiState.Success) askLeaderboard()
|
||||||
|
RustTab.WIPES -> if (already.wipes !is UiState.Success) askWipes()
|
||||||
|
// Loaded by its own view model when the tab composes.
|
||||||
|
RustTab.MAP -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A different question for the feed: blank it and ask. */
|
||||||
|
fun selectFilter(filterId: String) {
|
||||||
|
if (filterId == _state.value.filterId) return
|
||||||
|
_state.update { it.copy(filterId = filterId, feed = Polled(UiState.Loading)) }
|
||||||
|
viewModelScope.launch { askFeed() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A different question for the leaderboard: blank it and ask. */
|
||||||
|
fun selectSort(sort: String) {
|
||||||
|
if (sort == _state.value.sort) return
|
||||||
|
_state.update { it.copy(sort = sort, leaderboard = UiState.Loading) }
|
||||||
|
viewModelScope.launch { askLeaderboard() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick a wipe, or all time with null.
|
||||||
|
*
|
||||||
|
* It is the one selection that changes **two** panels, so both are blanked —
|
||||||
|
* and only the loaded ones are re-asked, so choosing a wipe from the Wipes tab
|
||||||
|
* does not fetch a leaderboard nobody has opened.
|
||||||
|
*/
|
||||||
|
fun selectWipe(wipeId: String?) {
|
||||||
|
if (wipeId == _state.value.selectedWipe) return
|
||||||
|
|
||||||
|
val hadLeaderboard = _state.value.leaderboard is UiState.Success
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
selectedWipe = wipeId,
|
||||||
|
feed = Polled(UiState.Loading),
|
||||||
|
leaderboard = if (hadLeaderboard) UiState.Loading else it.leaderboard,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
askFeed()
|
||||||
|
if (hadLeaderboard) askLeaderboard()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick a wipe from the Wipes tab, which is a navigation as much as a filter.
|
||||||
|
*
|
||||||
|
* The question it asks is "what happened during that map", and the answer is
|
||||||
|
* the feed — so it lands there rather than leaving the reader on a list of
|
||||||
|
* dates with nothing visibly changed.
|
||||||
|
*/
|
||||||
|
fun openWipe(wipeId: String) {
|
||||||
|
selectWipe(wipeId)
|
||||||
|
selectTab(RustTab.FEED)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry one panel after its own load failed.
|
||||||
|
*
|
||||||
|
* Four entry points rather than one, because a failed leaderboard is not a
|
||||||
|
* reason to re-read the feed the reader can already see — and [load] is the
|
||||||
|
* whole page, which is right for a failed *server* read and heavy-handed for
|
||||||
|
* anything else.
|
||||||
|
*/
|
||||||
|
fun retryFeed() {
|
||||||
|
_state.update { it.copy(feed = Polled(UiState.Loading)) }
|
||||||
|
viewModelScope.launch { askFeed() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun retryLeaderboard() {
|
||||||
|
_state.update { it.copy(leaderboard = UiState.Loading) }
|
||||||
|
viewModelScope.launch { askLeaderboard() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun retryOnline() {
|
||||||
|
_state.update { it.copy(online = Polled(UiState.Loading)) }
|
||||||
|
viewModelScope.launch { askOnline() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun retryWipes() {
|
||||||
|
_state.update { it.copy(wipes = UiState.Loading) }
|
||||||
|
viewModelScope.launch { askWipes() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun askServer() {
|
||||||
|
val result = repository.server(serverId)
|
||||||
|
_state.update { it.copy(server = refreshInto(it.server.state, result)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun askFeed() {
|
||||||
|
val current = _state.value
|
||||||
|
val result = repository.events(
|
||||||
|
id = serverId,
|
||||||
|
kinds = kindsFor(current.filterId),
|
||||||
|
wipe = current.selectedWipe,
|
||||||
|
limit = FEED_LIMIT,
|
||||||
|
)
|
||||||
|
_state.update { it.copy(feed = refreshInto(it.feed.state, result)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun askOnline() {
|
||||||
|
val result = repository.online(serverId)
|
||||||
|
_state.update { it.copy(online = refreshInto(it.online.state, result)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun askLeaderboard() {
|
||||||
|
val current = _state.value
|
||||||
|
val result = repository.leaderboard(
|
||||||
|
id = serverId,
|
||||||
|
wipe = current.selectedWipe,
|
||||||
|
sort = current.sort,
|
||||||
|
limit = LEADERBOARD_LIMIT,
|
||||||
|
)
|
||||||
|
_state.update { it.copy(leaderboard = result.toUiState()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun askWipes() {
|
||||||
|
_state.update { it.copy(wipes = repository.wipes(serverId).toUiState()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** Matches the website's feed page size; the server caps at 200 regardless. */
|
||||||
|
const val FEED_LIMIT = 100
|
||||||
|
const val LEADERBOARD_LIMIT = 50
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.rust
|
||||||
|
|
||||||
|
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.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.data.api.dto.RustServerDto
|
||||||
|
import com.runicgateway.app.ui.PollWhileResumed
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.components.EmptyView
|
||||||
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
import com.runicgateway.app.ui.components.PillTone
|
||||||
|
import com.runicgateway.app.ui.components.ShardCard
|
||||||
|
import com.runicgateway.app.ui.components.StatusPill
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every Rust server this site follows — the module's landing page (D8, D12).
|
||||||
|
*
|
||||||
|
* **The phase criterion is this screen with every server off.** Nothing here is a
|
||||||
|
* live call to a game host: the website answers from its own tables, so a fleet
|
||||||
|
* that has been down for a week renders a week of last-known state rather than an
|
||||||
|
* error. The one thing that can fail is the website itself.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun RustServersScreen(
|
||||||
|
onOpenServer: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: RustServersViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val polled by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
PollWhileResumed { viewModel.refresh() }
|
||||||
|
|
||||||
|
when (val s = polled.state) {
|
||||||
|
is UiState.Loading -> LoadingView(modifier)
|
||||||
|
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
|
||||||
|
is UiState.Success -> {
|
||||||
|
if (s.data.isEmpty()) {
|
||||||
|
EmptyView(stringResource(R.string.rust_servers_empty), modifier)
|
||||||
|
} else {
|
||||||
|
ServerList(s.data, polled.refreshFailed, onOpenServer, modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ServerList(
|
||||||
|
servers: List<RustServerDto>,
|
||||||
|
refreshFailed: Boolean,
|
||||||
|
onOpenServer: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
// A failed refresh says so and changes nothing else. The rows below it are
|
||||||
|
// the last good answer and stay exactly as they were — blanking them is
|
||||||
|
// the one thing a site whose premise is "it renders while the game is off"
|
||||||
|
// must not do when a request fails.
|
||||||
|
if (refreshFailed) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.rust_refresh_failed),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items(servers, key = { it.id }) { server ->
|
||||||
|
ServerRow(server) { onOpenServer(server.id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ServerRow(server: RustServerDto, onOpen: () -> Unit) {
|
||||||
|
ShardCard(modifier = Modifier.fillMaxWidth().clickable(onClick = onOpen)) {
|
||||||
|
// `ShardCard` is the themed Card and nothing more — it carries no padding
|
||||||
|
// of its own, so every caller pads its own content. Without this the text
|
||||||
|
// sits flush against the card's edge and the first glyph of each line
|
||||||
|
// reads as clipped, which is what the walk saw.
|
||||||
|
Column(Modifier.padding(16.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = server.name.ifBlank { server.id },
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
// `online` already has staleness folded into it server-side — a row
|
||||||
|
// nobody has written recently cannot claim a server is up — so this
|
||||||
|
// renders the field rather than second-guessing it.
|
||||||
|
if (server.online) {
|
||||||
|
StatusPill(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.rust_online_count,
|
||||||
|
server.players,
|
||||||
|
server.maxPlayers,
|
||||||
|
),
|
||||||
|
tone = PillTone.Success,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
StatusPill(text = stringResource(R.string.rust_offline), tone = PillTone.Neutral)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val world = describeWorld(server)
|
||||||
|
if (world != null) {
|
||||||
|
Text(
|
||||||
|
text = world,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
nextWipeLine(server)?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = lastReported(server),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The world line — the things a Rust player asks first.
|
||||||
|
*
|
||||||
|
* Null when the server has never described itself, so the caller leaves the line
|
||||||
|
* out rather than printing an empty one. A server configured this morning that
|
||||||
|
* has not connected yet is in exactly that state, and it is not an error.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun describeWorld(server: RustServerDto): String? {
|
||||||
|
val parts = listOfNotNull(
|
||||||
|
server.level,
|
||||||
|
server.worldSize?.let { stringResource(R.string.rust_world_size, it) },
|
||||||
|
server.seed?.let { stringResource(R.string.rust_world_seed, it) },
|
||||||
|
wipeDay(server.wipedAt)?.let { stringResource(R.string.rust_wiped_on, it) },
|
||||||
|
)
|
||||||
|
return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Next wipe Thu 1 Oct, 19:00 · in 6 days", with "(rescheduled)" for a one-off
|
||||||
|
* date — or null, and the caller draws no line, when the server has no schedule
|
||||||
|
* or the module predates phase 16 and sends no field.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun nextWipeLine(server: RustServerDto): String? {
|
||||||
|
val next = server.nextWipe ?: return null
|
||||||
|
val whenText = nextWipeWhen(next.at) ?: return null
|
||||||
|
return if (next.source == "once") {
|
||||||
|
stringResource(R.string.rust_next_wipe_rescheduled, whenText)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.rust_next_wipe, whenText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "last reported 3 minutes ago".
|
||||||
|
*
|
||||||
|
* **Reads `lastSeenAt` and never `updatedAt`.** The module shipped that exact
|
||||||
|
* confusion and fixed it in phase 4: `updatedAt` moves on every poll including a
|
||||||
|
* failed one, so an offline server claimed it had just checked in, every thirty
|
||||||
|
* seconds, for as long as it stayed down. Only a frame moves `lastSeenAt`.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun lastReported(server: RustServerDto): String {
|
||||||
|
val ago = rustAgo(server.lastSeenAt)
|
||||||
|
?: return stringResource(R.string.rust_never_reported)
|
||||||
|
|
||||||
|
return if (server.stale) {
|
||||||
|
stringResource(R.string.rust_last_reported_stale, ago)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.rust_last_reported, ago)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.rust
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.data.api.dto.RustServerDto
|
||||||
|
import com.runicgateway.app.data.repository.RustRepository
|
||||||
|
import com.runicgateway.app.ui.Polled
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.refreshInto
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Rust server list — the module's landing page, one tier along (PLAN.md §9
|
||||||
|
* M14; `docs/modules/rust/PLAN.md` D12).
|
||||||
|
*
|
||||||
|
* **`toUiState`, not `toShardUiState`.** These routes carry no `requireFeature`
|
||||||
|
* gate, so a `404` here is a genuinely missing thing and never an admin's
|
||||||
|
* visibility switch. Offering "this shard doesn't publish it" for one would name
|
||||||
|
* a cause that does not exist on this surface.
|
||||||
|
*
|
||||||
|
* [refresh] is what the screen's poll calls and [load] is what a retry calls, and
|
||||||
|
* the difference is the whole of [refreshInto]: a refresh keeps the rows when it
|
||||||
|
* fails, a load is allowed to blank them because there is nothing on screen to
|
||||||
|
* protect.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class RustServersViewModel @Inject constructor(
|
||||||
|
private val repository: RustRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(Polled<List<RustServerDto>>())
|
||||||
|
val state: StateFlow<Polled<List<RustServerDto>>> = _state.asStateFlow()
|
||||||
|
|
||||||
|
/** A first load or a retry: show the spinner, then replace whatever comes back. */
|
||||||
|
fun load() {
|
||||||
|
_state.value = Polled(UiState.Loading)
|
||||||
|
viewModelScope.launch { ask() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A poll: silent on success, and it keeps the rows on failure. */
|
||||||
|
fun refresh() {
|
||||||
|
viewModelScope.launch { ask() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ask() {
|
||||||
|
_state.value = refreshInto(_state.value.state, repository.servers())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ import com.runicgateway.app.core.auth.SessionManager
|
|||||||
import com.runicgateway.app.data.repository.AuthRepository
|
import com.runicgateway.app.data.repository.AuthRepository
|
||||||
import com.runicgateway.app.data.repository.ShardFeatures
|
import com.runicgateway.app.data.repository.ShardFeatures
|
||||||
import com.runicgateway.app.data.repository.ShardFeaturesRepository
|
import com.runicgateway.app.data.repository.ShardFeaturesRepository
|
||||||
|
import com.runicgateway.app.data.repository.SiteCapabilities
|
||||||
|
import com.runicgateway.app.data.repository.SiteCapabilitiesRepository
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -26,6 +28,7 @@ class SessionViewModel @Inject constructor(
|
|||||||
sessionManager: SessionManager,
|
sessionManager: SessionManager,
|
||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
shardFeaturesRepository: ShardFeaturesRepository,
|
shardFeaturesRepository: ShardFeaturesRepository,
|
||||||
|
siteCapabilitiesRepository: SiteCapabilitiesRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val session: StateFlow<Session> = sessionManager.state
|
val session: StateFlow<Session> = sessionManager.state
|
||||||
@@ -38,6 +41,19 @@ class SessionViewModel @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
val shardFeatures: StateFlow<ShardFeatures?> = shardFeaturesRepository.features
|
val shardFeatures: StateFlow<ShardFeatures?> = shardFeaturesRepository.features
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What this BACKEND serves — core's capabilities and every installed module's
|
||||||
|
* (M13). Exposed here for the reason [shardFeatures] is: the shared menu is
|
||||||
|
* the consumer, and a row is filtered by both.
|
||||||
|
*
|
||||||
|
* **Read-only here, and deliberately not refreshed here.** This answer is per
|
||||||
|
* HOST, not per viewer: signing in does not install a module. It is resolved
|
||||||
|
* beside the appearance in [com.runicgateway.app.ui.AppViewModel], which is
|
||||||
|
* what owns the host's lifecycle — first load, resume, and the Settings →
|
||||||
|
* Server switch that invalidates it.
|
||||||
|
*/
|
||||||
|
val capabilities: StateFlow<SiteCapabilities?> = siteCapabilitiesRepository.capabilities
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// The answer is per-viewer, so it is re-resolved on every session change.
|
// The answer is per-viewer, so it is re-resolved on every session change.
|
||||||
// A StateFlow conflates equal values, so a resume revalidation that returns
|
// A StateFlow conflates equal values, so a resume revalidation that returns
|
||||||
|
|||||||
@@ -6,35 +6,117 @@ package com.runicgateway.app.ui.theme
|
|||||||
import androidx.compose.ui.text.ExperimentalTextApi
|
import androidx.compose.ui.text.ExperimentalTextApi
|
||||||
import androidx.compose.ui.text.font.Font
|
import androidx.compose.ui.text.font.Font
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.font.FontVariation
|
import androidx.compose.ui.text.font.FontVariation
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import com.runicgateway.app.R
|
import com.runicgateway.app.R
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type families for the M5 shard-website design pass (docs/android/PLAN.md §M5).
|
* Every type family the app can draw with — the eight the admin's Appearance page
|
||||||
|
* can select between (THEMING_AND_NAV.md §5.3) plus the two system stacks its
|
||||||
|
* "shipped default" options resolve to.
|
||||||
*
|
*
|
||||||
* - [Cinzel] — the engraved serif display face used for headings, screen titles,
|
* All eight webfonts are **bundled**, not downloadable: the Play Store font
|
||||||
* and the top-bar title. Shipped as a single weight-axis **variable** font
|
* provider is the only downloadable-font source Compose ships with, so a
|
||||||
* (`res/font/cinzel_variable.ttf`, SIL OFL — see `app/licenses/Cinzel-OFL.txt`);
|
* de-Googled device would silently fall back and every text style would gain an
|
||||||
* the 500/600/700 instances the design uses are pinned via [FontVariation]
|
* async loading state. The binaries are taken verbatim from `google/fonts`, which
|
||||||
* (supported on API 26+, and our minSdk is 29).
|
* is how [Cinzel] arrived in M5; each carries its SIL OFL licence under
|
||||||
* - [AppSerif] — the parchment body face. Android's platform serif is Noto Serif,
|
* `app/licenses/`, **never** under `res/font/` (aapt rejects a `.txt` there).
|
||||||
* which reads as the design's Georgia body copy without bundling another binary.
|
*
|
||||||
* - [AppSans] — the label/meta/button face (the design's "Helvetica Neue" runs).
|
* A family reaches a text style through [ShardTypeface], which is what maps a
|
||||||
|
* shard's `--display` / `--serif` / `--sans` stacks onto these. The three
|
||||||
|
* declared *shipped* roles are [Cinzel] for the engraved display/headline/title
|
||||||
|
* block, [AppSerif] for parchment body copy, and [AppSans] for the letter-spaced
|
||||||
|
* label/meta/button block.
|
||||||
|
*
|
||||||
|
* ## Weights
|
||||||
|
*
|
||||||
|
* The type scale asks for four: 400 (body), 500 and 700 (labels), 600 (display).
|
||||||
|
* Every family here must supply all four, because a family is **not** confined to
|
||||||
|
* the role its dropdown lives in — the `modern` preset puts Work Sans in the
|
||||||
|
* display slot and the `fantasy` preset puts EB Garamond in the sans slot, both
|
||||||
|
* bypassing the server's per-role option list (see [ShardTypeface]). The variable
|
||||||
|
* families pin the four instances through [FontVariation] (API 26+; minSdk is 29).
|
||||||
|
*
|
||||||
|
* Two families are exceptions, both upstream facts rather than choices:
|
||||||
|
* - **[IMFellEnglish] has a single weight.** Its one 400 face answers all four
|
||||||
|
* requests and Android synthesises the bold. The website's dropdown labels it
|
||||||
|
* "(no bold weight)" for the same reason.
|
||||||
|
* - **[Cinzel] is left at the 500/600/700 it shipped with in M5.** It is the only
|
||||||
|
* family the server offers in the display role alone, so nothing can ask it for
|
||||||
|
* 400; adding an instance would have edited M5's type for no reachable case.
|
||||||
|
*
|
||||||
|
* ## Italics
|
||||||
|
*
|
||||||
|
* Four families carry a true italic — the same four `client/index.html` requests
|
||||||
|
* one for. The rest are upright-only and Compose skews them, which is what the
|
||||||
|
* app already did for every family before this milestone and what the website
|
||||||
|
* does for its own upright-only faces. The app draws italic in two places.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalTextApi::class)
|
@OptIn(ExperimentalTextApi::class)
|
||||||
private fun cinzel(weight: FontWeight) =
|
private fun variable(resId: Int, weight: FontWeight, style: FontStyle = FontStyle.Normal) =
|
||||||
Font(
|
Font(
|
||||||
R.font.cinzel_variable,
|
resId,
|
||||||
weight = weight,
|
weight = weight,
|
||||||
|
style = style,
|
||||||
variationSettings = FontVariation.Settings(FontVariation.weight(weight.weight)),
|
variationSettings = FontVariation.Settings(FontVariation.weight(weight.weight)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The four weights the type scale asks for, in the order Compose prefers to match.
|
||||||
|
private val ScaleWeights = listOf(
|
||||||
|
FontWeight.Normal, // 400 — body
|
||||||
|
FontWeight.Medium, // 500 — labelMedium / labelSmall
|
||||||
|
FontWeight.SemiBold, // 600 — display / headline / title
|
||||||
|
FontWeight.Bold, // 700 — labelLarge
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A variable family pinned at the four scale weights, upright only. */
|
||||||
|
private fun variableFamily(resId: Int) =
|
||||||
|
FontFamily(ScaleWeights.map { variable(resId, it) })
|
||||||
|
|
||||||
|
/** A variable family pinned at the four scale weights, upright and italic. */
|
||||||
|
private fun variableFamily(uprightResId: Int, italicResId: Int) =
|
||||||
|
FontFamily(
|
||||||
|
ScaleWeights.map { variable(uprightResId, it) } +
|
||||||
|
ScaleWeights.map { variable(italicResId, it, FontStyle.Italic) },
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun cinzel(weight: FontWeight) = variable(R.font.cinzel_variable, weight)
|
||||||
|
|
||||||
val Cinzel = FontFamily(
|
val Cinzel = FontFamily(
|
||||||
cinzel(FontWeight.Medium), // 500
|
cinzel(FontWeight.Medium), // 500
|
||||||
cinzel(FontWeight.SemiBold), // 600
|
cinzel(FontWeight.SemiBold), // 600
|
||||||
cinzel(FontWeight.Bold), // 700
|
cinzel(FontWeight.Bold), // 700
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val EBGaramond = variableFamily(R.font.eb_garamond_variable, R.font.eb_garamond_italic)
|
||||||
|
val Merriweather = variableFamily(R.font.merriweather_variable, R.font.merriweather_italic)
|
||||||
|
val PlayfairDisplay =
|
||||||
|
variableFamily(R.font.playfair_display_variable, R.font.playfair_display_italic)
|
||||||
|
val Inter = variableFamily(R.font.inter_variable)
|
||||||
|
val WorkSans = variableFamily(R.font.work_sans_variable)
|
||||||
|
val SourceSans3 = variableFamily(R.font.source_sans_3_variable)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IM Fell English, whose upstream release is a single 400 face per style — there
|
||||||
|
* is no weight axis and no bold cut to pin. Declared once per style so a request
|
||||||
|
* at 500/600/700 lands on it rather than falling out of the family.
|
||||||
|
*/
|
||||||
|
val IMFellEnglish = FontFamily(
|
||||||
|
Font(R.font.im_fell_english_regular, weight = FontWeight.Normal),
|
||||||
|
Font(R.font.im_fell_english_italic, weight = FontWeight.Normal, style = FontStyle.Italic),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The platform serif (Noto Serif), which is what the website's
|
||||||
|
* `Georgia, "Times New Roman", serif` stack resolves to on Android — and the
|
||||||
|
* app's shipped body face since M5.
|
||||||
|
*/
|
||||||
val AppSerif = FontFamily.Serif
|
val AppSerif = FontFamily.Serif
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The platform sans (Roboto), which the website's
|
||||||
|
* `"Helvetica Neue", Arial, sans-serif` stack resolves to on Android — and the
|
||||||
|
* app's shipped label face since M5.
|
||||||
|
*/
|
||||||
val AppSans = FontFamily.SansSerif
|
val AppSans = FontFamily.SansSerif
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shard's resolved type families — the `fonts` third of the admin's
|
||||||
|
* Appearance page (THEMING_AND_NAV.md §5.3), alongside [ShardPalette] and
|
||||||
|
* [ShardStructure].
|
||||||
|
*
|
||||||
|
* The three roles map onto the M5 type scale's three groups verbatim: `--display`
|
||||||
|
* carries the Cinzel display/headline/title block, `--serif` the body block, and
|
||||||
|
* `--sans` the label/meta/button block. Sizes, weights and tracking do not move —
|
||||||
|
* only the family, which is why [shardTypography] is a one-field substitution and
|
||||||
|
* an unthemed shard is a provable no-op (§2, AC-1).
|
||||||
|
*
|
||||||
|
* Resolution is pure, so the acceptance tests need no Compose rule.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
data class ShardTypeface(
|
||||||
|
/** `--display`. */
|
||||||
|
val display: FontFamily = Cinzel,
|
||||||
|
/** `--serif`. */
|
||||||
|
val serif: FontFamily = AppSerif,
|
||||||
|
/** `--sans`. */
|
||||||
|
val sans: FontFamily = AppSans,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
/** The shipped app: the three M5 families. */
|
||||||
|
val Shipped = ShardTypeface()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a `theme` token map into three families, **field by field** (§2):
|
||||||
|
* an unreadable `--sans` must not cost the `--serif` beside it, exactly as
|
||||||
|
* in [ShardPalette.resolve] and [ShardStructure.resolve].
|
||||||
|
*/
|
||||||
|
fun resolve(theme: Map<String, String>): ShardTypeface {
|
||||||
|
if (theme.isEmpty()) return Shipped
|
||||||
|
return ShardTypeface(
|
||||||
|
display = family(theme["--display"]) ?: Shipped.display,
|
||||||
|
serif = family(theme["--serif"]) ?: Shipped.serif,
|
||||||
|
sans = family(theme["--sans"]) ?: Shipped.sans,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every family name the server can publish, keyed by the lowercased first family
|
||||||
|
* of the stack.
|
||||||
|
*
|
||||||
|
* **This map is deliberately global rather than per-role**, and that is not a
|
||||||
|
* simplification. The server validates an *admin-entered* font against
|
||||||
|
* `FONT_OPTIONS[role]`, but a preset's tokens are copied verbatim by
|
||||||
|
* `resolveThemeTokens` and never pass through that list — `modern` publishes
|
||||||
|
* `--display: 'Work Sans', Arial, sans-serif`, which the display dropdown does
|
||||||
|
* not offer, and `fantasy` publishes `--sans: 'EB Garamond', Georgia, serif`,
|
||||||
|
* which the sans dropdown does not either. A per-role lookup would have missed
|
||||||
|
* the display face of one preset and the label face of the other. It is the same
|
||||||
|
* trap phase 2 hit with `--shadow-card`, in a different token group.
|
||||||
|
*
|
||||||
|
* The two system entries are the "shipped default" options: neither pulls in a
|
||||||
|
* webfont on the web, and on Android both resolve to the platform family the app
|
||||||
|
* has drawn with since M5.
|
||||||
|
*/
|
||||||
|
private val FamiliesByFirstName: Map<String, FontFamily> = mapOf(
|
||||||
|
"cinzel" to Cinzel,
|
||||||
|
"eb garamond" to EBGaramond,
|
||||||
|
"merriweather" to Merriweather,
|
||||||
|
"playfair display" to PlayfairDisplay,
|
||||||
|
"im fell english" to IMFellEnglish,
|
||||||
|
"inter" to Inter,
|
||||||
|
"work sans" to WorkSans,
|
||||||
|
"source sans 3" to SourceSans3,
|
||||||
|
"georgia" to AppSerif,
|
||||||
|
"helvetica neue" to AppSans,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a CSS font stack to a family by **its first name**, which is how the
|
||||||
|
* value is constructed server-side and the only part of it that carries the
|
||||||
|
* admin's choice — everything after the first comma is the web's fallback chain,
|
||||||
|
* which Android has no use for.
|
||||||
|
*
|
||||||
|
* Returns `null` for a stack this app cannot draw, so the caller falls back to
|
||||||
|
* the role's shipped family rather than to some other role's.
|
||||||
|
*/
|
||||||
|
private fun family(stack: String?): FontFamily? {
|
||||||
|
val first = stack?.substringBefore(',')?.trim()?.trim('\'', '"')?.trim()
|
||||||
|
if (first.isNullOrEmpty()) return null
|
||||||
|
return FamiliesByFirstName[first.lowercase()]
|
||||||
|
}
|
||||||
@@ -36,7 +36,14 @@ internal fun shardColorScheme(palette: ShardPalette): ColorScheme = darkColorSch
|
|||||||
onSurfaceVariant = palette.muted,
|
onSurfaceVariant = palette.muted,
|
||||||
surfaceContainer = palette.elevated,
|
surfaceContainer = palette.elevated,
|
||||||
surfaceContainerHigh = palette.elevated,
|
surfaceContainerHigh = palette.elevated,
|
||||||
|
// Material's filled Card takes its container from surfaceContainerHighest —
|
||||||
|
// FilledCardTokens.ContainerColor, checked in the 1.3.0 artifact's bytecode.
|
||||||
|
// Leaving it unmapped is what made every ShardCard draw in darkColorScheme()'s
|
||||||
|
// default grey instead of --panel-flat, on themed AND untouched instances alike
|
||||||
|
// (found on device in phase 8's AC-5 walk; see "Phase 8 as landed").
|
||||||
|
surfaceContainerHighest = palette.elevated,
|
||||||
surfaceContainerLow = palette.surface,
|
surfaceContainerLow = palette.surface,
|
||||||
|
surfaceContainerLowest = palette.surface,
|
||||||
outline = palette.outline,
|
outline = palette.outline,
|
||||||
outlineVariant = palette.divider,
|
outlineVariant = palette.divider,
|
||||||
secondaryContainer = palette.pillBg, // neutral chips / selected drawer item
|
secondaryContainer = palette.pillBg, // neutral chips / selected drawer item
|
||||||
@@ -60,7 +67,10 @@ internal fun shardColorScheme(palette: ShardPalette): ColorScheme = darkColorSch
|
|||||||
* for the ten tokens with a Material role, and through [LocalShardPalette] for
|
* for the ten tokens with a Material role, and through [LocalShardPalette] for
|
||||||
* the five without one. The radii split the same way — [MaterialTheme]'s shape
|
* the five without one. The radii split the same way — [MaterialTheme]'s shape
|
||||||
* scale for everything Material draws, [LocalShardStructure] for the pill and
|
* scale for everything Material draws, [LocalShardStructure] for the pill and
|
||||||
* the card depth, which it cannot carry.
|
* the card depth, which it cannot carry. The type families need no split and so
|
||||||
|
* no composition local: every text style in the app comes from
|
||||||
|
* [MaterialTheme.typography], and the two that override anything override the
|
||||||
|
* style rather than the family.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun RunicGatewayTheme(
|
fun RunicGatewayTheme(
|
||||||
@@ -75,6 +85,8 @@ fun RunicGatewayTheme(
|
|||||||
}
|
}
|
||||||
val colorScheme = remember(palette) { shardColorScheme(palette) }
|
val colorScheme = remember(palette) { shardColorScheme(palette) }
|
||||||
val structure = remember(appearance) { ShardStructure.resolve(appearance.theme) }
|
val structure = remember(appearance) { ShardStructure.resolve(appearance.theme) }
|
||||||
|
val typeface = remember(appearance) { ShardTypeface.resolve(appearance.theme) }
|
||||||
|
val typography = remember(typeface) { shardTypography(typeface) }
|
||||||
|
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalShardPalette provides palette,
|
LocalShardPalette provides palette,
|
||||||
@@ -82,7 +94,7 @@ fun RunicGatewayTheme(
|
|||||||
) {
|
) {
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = colorScheme,
|
colorScheme = colorScheme,
|
||||||
typography = Typography,
|
typography = typography,
|
||||||
shapes = structure.shapes,
|
shapes = structure.shapes,
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,73 +9,82 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The M5 type scale. Three families carry the design (see [ui/theme/Font.kt]):
|
* The M5 type scale, drawn in whichever three families the shard resolved to
|
||||||
* [Cinzel] for the engraved display/headline/title roles, [AppSerif] (Noto Serif)
|
* (THEMING_AND_NAV.md §5.3).
|
||||||
* for parchment body copy, and [AppSans] for the letter-spaced label/meta/button
|
*
|
||||||
* roles. Sizes and tracking mirror the "Runic Gateway Screens" mockup.
|
* Only the family moves. Every size, weight, line height and tracking below is
|
||||||
|
* the M5 value from the "Runic Gateway Screens" mockup, so `ShardTypeface.Shipped`
|
||||||
|
* — an unthemed instance, a backend that predates the feature, a settings call
|
||||||
|
* that failed — reproduces the pre-M12 scale exactly. [androidx.compose.material3.Typography]
|
||||||
|
* implements `equals`, so AC-1 asserts that in one comparison.
|
||||||
|
*
|
||||||
|
* The three groups map onto the three roles verbatim: [ShardTypeface.display]
|
||||||
|
* carries the engraved display/headline/title block, [ShardTypeface.serif] the
|
||||||
|
* parchment body copy, and [ShardTypeface.sans] the letter-spaced
|
||||||
|
* label/meta/button roles.
|
||||||
*/
|
*/
|
||||||
val Typography = Typography(
|
internal fun shardTypography(faces: ShardTypeface) = Typography(
|
||||||
// Display / headline / title — Cinzel engraved serif
|
// Display / headline / title — the engraved serif role
|
||||||
displayLarge = TextStyle(
|
displayLarge = TextStyle(
|
||||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
|
||||||
fontSize = 40.sp, lineHeight = 46.sp, letterSpacing = 0.4.sp,
|
fontSize = 40.sp, lineHeight = 46.sp, letterSpacing = 0.4.sp,
|
||||||
),
|
),
|
||||||
displayMedium = TextStyle(
|
displayMedium = TextStyle(
|
||||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
|
||||||
fontSize = 32.sp, lineHeight = 40.sp, letterSpacing = 0.3.sp,
|
fontSize = 32.sp, lineHeight = 40.sp, letterSpacing = 0.3.sp,
|
||||||
),
|
),
|
||||||
displaySmall = TextStyle(
|
displaySmall = TextStyle(
|
||||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
|
||||||
fontSize = 28.sp, lineHeight = 36.sp, letterSpacing = 0.2.sp,
|
fontSize = 28.sp, lineHeight = 36.sp, letterSpacing = 0.2.sp,
|
||||||
),
|
),
|
||||||
headlineLarge = TextStyle(
|
headlineLarge = TextStyle(
|
||||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
|
||||||
fontSize = 26.sp, lineHeight = 34.sp, letterSpacing = 0.2.sp,
|
fontSize = 26.sp, lineHeight = 34.sp, letterSpacing = 0.2.sp,
|
||||||
),
|
),
|
||||||
headlineMedium = TextStyle(
|
headlineMedium = TextStyle(
|
||||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
|
||||||
fontSize = 24.sp, lineHeight = 32.sp, letterSpacing = 0.2.sp,
|
fontSize = 24.sp, lineHeight = 32.sp, letterSpacing = 0.2.sp,
|
||||||
),
|
),
|
||||||
headlineSmall = TextStyle(
|
headlineSmall = TextStyle(
|
||||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
|
||||||
fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = 0.2.sp,
|
fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = 0.2.sp,
|
||||||
),
|
),
|
||||||
titleLarge = TextStyle(
|
titleLarge = TextStyle(
|
||||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
|
||||||
fontSize = 20.sp, lineHeight = 26.sp, letterSpacing = 0.2.sp,
|
fontSize = 20.sp, lineHeight = 26.sp, letterSpacing = 0.2.sp,
|
||||||
),
|
),
|
||||||
titleMedium = TextStyle(
|
titleMedium = TextStyle(
|
||||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
|
||||||
fontSize = 17.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp,
|
fontSize = 17.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp,
|
||||||
),
|
),
|
||||||
titleSmall = TextStyle(
|
titleSmall = TextStyle(
|
||||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
|
||||||
fontSize = 15.sp, lineHeight = 22.sp, letterSpacing = 0.1.sp,
|
fontSize = 15.sp, lineHeight = 22.sp, letterSpacing = 0.1.sp,
|
||||||
),
|
),
|
||||||
// Body — parchment serif
|
// Body — the parchment serif role
|
||||||
bodyLarge = TextStyle(
|
bodyLarge = TextStyle(
|
||||||
fontFamily = AppSerif, fontWeight = FontWeight.Normal,
|
fontFamily = faces.serif, fontWeight = FontWeight.Normal,
|
||||||
fontSize = 16.sp, lineHeight = 26.sp, letterSpacing = 0.15.sp,
|
fontSize = 16.sp, lineHeight = 26.sp, letterSpacing = 0.15.sp,
|
||||||
),
|
),
|
||||||
bodyMedium = TextStyle(
|
bodyMedium = TextStyle(
|
||||||
fontFamily = AppSerif, fontWeight = FontWeight.Normal,
|
fontFamily = faces.serif, fontWeight = FontWeight.Normal,
|
||||||
fontSize = 15.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp,
|
fontSize = 15.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp,
|
||||||
),
|
),
|
||||||
bodySmall = TextStyle(
|
bodySmall = TextStyle(
|
||||||
fontFamily = AppSerif, fontWeight = FontWeight.Normal,
|
fontFamily = faces.serif, fontWeight = FontWeight.Normal,
|
||||||
fontSize = 13.sp, lineHeight = 20.sp, letterSpacing = 0.2.sp,
|
fontSize = 13.sp, lineHeight = 20.sp, letterSpacing = 0.2.sp,
|
||||||
),
|
),
|
||||||
// Labels / meta / buttons — sans, letter-spaced
|
// Labels / meta / buttons — the sans role, letter-spaced
|
||||||
labelLarge = TextStyle(
|
labelLarge = TextStyle(
|
||||||
fontFamily = AppSans, fontWeight = FontWeight.Bold,
|
fontFamily = faces.sans, fontWeight = FontWeight.Bold,
|
||||||
fontSize = 14.sp, lineHeight = 18.sp, letterSpacing = 0.45.sp,
|
fontSize = 14.sp, lineHeight = 18.sp, letterSpacing = 0.45.sp,
|
||||||
),
|
),
|
||||||
labelMedium = TextStyle(
|
labelMedium = TextStyle(
|
||||||
fontFamily = AppSans, fontWeight = FontWeight.Medium,
|
fontFamily = faces.sans, fontWeight = FontWeight.Medium,
|
||||||
fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.4.sp,
|
fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.4.sp,
|
||||||
),
|
),
|
||||||
labelSmall = TextStyle(
|
labelSmall = TextStyle(
|
||||||
fontFamily = AppSans, fontWeight = FontWeight.Medium,
|
fontFamily = faces.sans, fontWeight = FontWeight.Medium,
|
||||||
fontSize = 11.sp, lineHeight = 15.sp, letterSpacing = 0.5.sp,
|
fontSize = 11.sp, lineHeight = 15.sp, letterSpacing = 0.5.sp,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
BIN
app/src/main/res/font/eb_garamond_italic.ttf
Normal file
BIN
app/src/main/res/font/eb_garamond_italic.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/eb_garamond_variable.ttf
Normal file
BIN
app/src/main/res/font/eb_garamond_variable.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/im_fell_english_italic.ttf
Normal file
BIN
app/src/main/res/font/im_fell_english_italic.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/im_fell_english_regular.ttf
Normal file
BIN
app/src/main/res/font/im_fell_english_regular.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/inter_variable.ttf
Normal file
BIN
app/src/main/res/font/inter_variable.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/merriweather_italic.ttf
Normal file
BIN
app/src/main/res/font/merriweather_italic.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/merriweather_variable.ttf
Normal file
BIN
app/src/main/res/font/merriweather_variable.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/playfair_display_italic.ttf
Normal file
BIN
app/src/main/res/font/playfair_display_italic.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/playfair_display_variable.ttf
Normal file
BIN
app/src/main/res/font/playfair_display_variable.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/source_sans_3_variable.ttf
Normal file
BIN
app/src/main/res/font/source_sans_3_variable.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/work_sans_variable.ttf
Normal file
BIN
app/src/main/res/font/work_sans_variable.ttf
Normal file
Binary file not shown.
@@ -37,10 +37,14 @@
|
|||||||
|
|
||||||
<!-- ── Navigation menu (§5) ────────────────────────────────────────── -->
|
<!-- ── Navigation menu (§5) ────────────────────────────────────────── -->
|
||||||
<string name="nav_open_menu">Open navigation menu</string>
|
<string name="nav_open_menu">Open navigation menu</string>
|
||||||
|
<!-- On an admin-added link the app has no screen for; it opens in a browser (§6.3). -->
|
||||||
|
<string name="nav_opens_in_browser">Opens in your browser</string>
|
||||||
<string name="menu_home">Home</string>
|
<string name="menu_home">Home</string>
|
||||||
<string name="menu_news">News</string>
|
<string name="menu_news">News</string>
|
||||||
|
<string name="menu_events">Events</string>
|
||||||
<string name="menu_wiki">Wiki</string>
|
<string name="menu_wiki">Wiki</string>
|
||||||
<string name="menu_shard">Shard</string>
|
<string name="menu_shard">Shard</string>
|
||||||
|
<string name="menu_rust">Rust servers</string>
|
||||||
<string name="menu_rules">Rules</string>
|
<string name="menu_rules">Rules</string>
|
||||||
<string name="menu_atlas">Atlas</string>
|
<string name="menu_atlas">Atlas</string>
|
||||||
<string name="menu_leaderboards">Leaderboards</string>
|
<string name="menu_leaderboards">Leaderboards</string>
|
||||||
@@ -48,6 +52,7 @@
|
|||||||
<string name="menu_about">About</string>
|
<string name="menu_about">About</string>
|
||||||
<string name="menu_contact">Contact</string>
|
<string name="menu_contact">Contact</string>
|
||||||
<string name="menu_account">My account</string>
|
<string name="menu_account">My account</string>
|
||||||
|
<string name="menu_my_events">My events</string>
|
||||||
<string name="menu_my_characters">My characters</string>
|
<string name="menu_my_characters">My characters</string>
|
||||||
<string name="menu_my_vendors">My vendors</string>
|
<string name="menu_my_vendors">My vendors</string>
|
||||||
<string name="menu_my_houses">My houses</string>
|
<string name="menu_my_houses">My houses</string>
|
||||||
@@ -482,10 +487,10 @@
|
|||||||
<!-- ── Push notifications (§11, M7 Part 2) ─────────────────────────── -->
|
<!-- ── Push notifications (§11, M7 Part 2) ─────────────────────────── -->
|
||||||
<string name="menu_notifications">Notifications</string>
|
<string name="menu_notifications">Notifications</string>
|
||||||
<string name="notifications_title">Notifications</string>
|
<string name="notifications_title">Notifications</string>
|
||||||
<string name="notifications_subtitle">Choose what this shard notifies you about. Nothing is sent unless you turn it on.</string>
|
<string name="notifications_subtitle">Choose what this shard notifies you about, and how it reaches you. Nothing is sent unless you turn it on.</string>
|
||||||
<string name="notifications_section_general">General</string>
|
<string name="notifications_section_general">General</string>
|
||||||
<string name="notifications_section_personal">Your game account</string>
|
<string name="notifications_section_personal">Your game account</string>
|
||||||
<string name="notifications_requires_link">Link a game account to enable this.</string>
|
<string name="notifications_requires_link">Link a game account to get this as a push notification.</string>
|
||||||
<string name="notifications_empty">This shard offers no notification streams yet.</string>
|
<string name="notifications_empty">This shard offers no notification streams yet.</string>
|
||||||
<string name="notifications_unsupported">This shard hasn\'t set up push notifications yet.</string>
|
<string name="notifications_unsupported">This shard hasn\'t set up push notifications yet.</string>
|
||||||
<string name="notifications_saved">Notification settings saved.</string>
|
<string name="notifications_saved">Notification settings saved.</string>
|
||||||
@@ -493,6 +498,18 @@
|
|||||||
<string name="notifications_save_error">Couldn\'t save your notification settings. Try again.</string>
|
<string name="notifications_save_error">Couldn\'t save your notification settings. Try again.</string>
|
||||||
<string name="notifications_relay_error">This shard\'s push relay isn\'t reachable right now.</string>
|
<string name="notifications_relay_error">This shard\'s push relay isn\'t reachable right now.</string>
|
||||||
|
|
||||||
|
<!-- The in-app inbox and the per-channel settings (ENGAGEMENT.md phase 8). -->
|
||||||
|
<string name="notifications_mode_off">Off</string>
|
||||||
|
<string name="notifications_mode_instant">As it happens</string>
|
||||||
|
<string name="notifications_mode_digest">Daily summary</string>
|
||||||
|
<string name="inbox_empty">Nothing here yet. Notifications you\'re sent will show up here.</string>
|
||||||
|
<string name="inbox_all_read">All caught up</string>
|
||||||
|
<string name="inbox_unread_count">%1$d unread</string>
|
||||||
|
<string name="inbox_mark_all_read">Mark all read</string>
|
||||||
|
<string name="inbox_open_settings">Notification settings</string>
|
||||||
|
<string name="inbox_loading_more">Loading more…</string>
|
||||||
|
<string name="inbox_offline_cached">Offline — showing what was saved on this device.</string>
|
||||||
|
|
||||||
<!-- Notification channels + the ongoing foreground-service notification. -->
|
<!-- Notification channels + the ongoing foreground-service notification. -->
|
||||||
<string name="push_channel_messages">Shard notifications</string>
|
<string name="push_channel_messages">Shard notifications</string>
|
||||||
<string name="push_channel_messages_desc">Alerts you opted into from this shard.</string>
|
<string name="push_channel_messages_desc">Alerts you opted into from this shard.</string>
|
||||||
@@ -511,4 +528,169 @@
|
|||||||
<string name="push_stream_house_idoc">Your house entered IDOC</string>
|
<string name="push_stream_house_idoc">Your house entered IDOC</string>
|
||||||
<string name="push_stream_account_login">Login to your account</string>
|
<string name="push_stream_account_login">Login to your account</string>
|
||||||
<string name="push_stream_generic">New notification</string>
|
<string name="push_stream_generic">New notification</string>
|
||||||
|
|
||||||
|
<!-- ── Events (§9 M13, EVENTS.md §I) ─────────────────────────── -->
|
||||||
|
<!--
|
||||||
|
The four status words. `cancelled` has TWO, chosen by the clock rather than
|
||||||
|
the status: "did not happen" is right for a past occurrence and false for a
|
||||||
|
future one, and a run four days out that an operator called off is the common
|
||||||
|
case. See EventTimes.statusWordRes.
|
||||||
|
-->
|
||||||
|
<string name="events_status_live">Happening now</string>
|
||||||
|
<string name="events_status_scheduled">Scheduled</string>
|
||||||
|
<string name="events_status_completed">Finished</string>
|
||||||
|
<string name="events_status_cancelled">Cancelled</string>
|
||||||
|
<string name="events_status_did_not_happen">Did not happen</string>
|
||||||
|
|
||||||
|
<string name="events_empty">Nothing on the calendar just yet — check back soon.</string>
|
||||||
|
<!-- A forecast past the materialisation horizon: nothing is committed to it. -->
|
||||||
|
<string name="events_projected">Expected — not yet confirmed</string>
|
||||||
|
<string name="events_truncated">Showing the first part of a busy calendar.</string>
|
||||||
|
|
||||||
|
<string name="events_part_of">Part of %1$s</string>
|
||||||
|
<string name="events_next">Next</string>
|
||||||
|
<string name="events_under_way">Under way</string>
|
||||||
|
<string name="events_nothing_scheduled">Nothing scheduled at the moment.</string>
|
||||||
|
<string name="events_never_scheduled">This event has not been scheduled yet.</string>
|
||||||
|
<string name="events_coming_up">Coming up</string>
|
||||||
|
<string name="events_previously">Previously</string>
|
||||||
|
<string name="events_results">Results</string>
|
||||||
|
<string name="events_results_nobody">Results were published with nobody recorded.</string>
|
||||||
|
<!-- A module puts a display name in its participation meta or it does not; the
|
||||||
|
member key is never published, so there is nothing else to render. -->
|
||||||
|
<string name="events_participant_unnamed">Unnamed</string>
|
||||||
|
|
||||||
|
<string name="events_history_empty">You have not taken part in an event yet.</string>
|
||||||
|
<string name="events_rank">Rank %1$d</string>
|
||||||
|
<!-- Not a dash: an unranked row is a real state, not a missing value. -->
|
||||||
|
<string name="events_results_unpublished">Results not published</string>
|
||||||
|
<string name="events_score">Score %1$s</string>
|
||||||
|
<string name="events_show_more">Show more</string>
|
||||||
|
<string name="events_loading">Loading…</string>
|
||||||
|
|
||||||
|
<!-- Rust module (M14, docs/modules/rust/PLAN.md §18) -->
|
||||||
|
<string name="rust_servers_empty">No Rust servers are configured on this site yet.</string>
|
||||||
|
<string name="rust_offline">Offline</string>
|
||||||
|
<string name="rust_online_count">%1$d / %2$d online</string>
|
||||||
|
<string name="rust_never_reported">has never reported</string>
|
||||||
|
<string name="rust_last_reported">last reported %1$s</string>
|
||||||
|
<string name="rust_last_reported_stale">last reported %1$s — out of date, so it is shown as offline</string>
|
||||||
|
<string name="rust_world_size">size %1$d</string>
|
||||||
|
<string name="rust_world_seed">seed %1$d</string>
|
||||||
|
<string name="rust_wiped_on">wiped %1$s</string>
|
||||||
|
<string name="rust_next_wipe">Next wipe %1$s</string>
|
||||||
|
<string name="rust_next_wipe_rescheduled">Next wipe %1$s (rescheduled)</string>
|
||||||
|
<string name="rust_refresh_failed">Could not refresh just now. This is the last thing the site heard.</string>
|
||||||
|
<string name="rust_no_such_server">No such server</string>
|
||||||
|
<string name="rust_no_such_server_detail">This address does not name a server this site follows.</string>
|
||||||
|
<string name="rust_back_to_servers">Back to the server list</string>
|
||||||
|
<string name="rust_tab_feed">Feed</string>
|
||||||
|
<string name="rust_tab_leaderboard">Leaderboard</string>
|
||||||
|
<string name="rust_tab_online">Online</string>
|
||||||
|
<string name="rust_tab_wipes">Wipes</string>
|
||||||
|
<string name="rust_tab_map">Map</string>
|
||||||
|
<!-- The Map tab (Rust phase 15, D121–D125). The wording follows the web's Map
|
||||||
|
tab, so a reader moving between the two reads the same sentences. -->
|
||||||
|
<string name="rust_map_none">No map yet. This server has not told the site which map it is on. The map will appear once the server is up.</string>
|
||||||
|
<string name="rust_map_no_picture">This server has no picture of its map, so the layers are drawn on a plain background.</string>
|
||||||
|
<string name="rust_map_picture_failed">The picture of the map could not be loaded, so the layers are drawn on a plain background.</string>
|
||||||
|
<string name="rust_map_description">Map of the server</string>
|
||||||
|
<string name="rust_map_status_nothing_live">No moving layers are shown to you on this server.</string>
|
||||||
|
<string name="rust_map_status_asking">Asking the server where things are…</string>
|
||||||
|
<string name="rust_map_status_not_now">The server did not say where things are just now. The site asks again every ten seconds.</string>
|
||||||
|
<string name="rust_map_status_offline">The server is offline, so nothing is moving on its map.</string>
|
||||||
|
<string name="rust_map_status_failed">Positions could not be loaded.</string>
|
||||||
|
<string name="rust_map_status_failed_kept">Could not refresh just now. These are the last positions the site heard.</string>
|
||||||
|
<string name="rust_map_status_as_of">Positions as of %1$s. They refresh every ten seconds while this tab is open.</string>
|
||||||
|
<string name="rust_map_layer_grid">Grid</string>
|
||||||
|
<string name="rust_map_layer_world">Monuments & world events</string>
|
||||||
|
<string name="rust_map_layer_events">Site events</string>
|
||||||
|
<string name="rust_map_layer_players">Players</string>
|
||||||
|
<string name="rust_map_layer_bases">Bases</string>
|
||||||
|
<string name="rust_map_layer_mates">You and your clan</string>
|
||||||
|
<string name="rust_map_hidden_signin">Sign in to see this layer.</string>
|
||||||
|
<string name="rust_map_hidden_public">Shown to everyone.</string>
|
||||||
|
<string name="rust_map_hidden_staff">Shown to staff only.</string>
|
||||||
|
<string name="rust_map_capped">%1$s Limited by who may see who is online.</string>
|
||||||
|
<string name="rust_map_players_truncated">Not every sleeper is shown.</string>
|
||||||
|
<string name="rust_map_bases_truncated">Not every base is shown.</string>
|
||||||
|
<string name="rust_map_mates_note">Your own position, and clan mates who are online.</string>
|
||||||
|
<string name="rust_map_mates_link">Link your Steam account to see yourself and your clan here.</string>
|
||||||
|
<string name="rust_map_cargo">Cargo ship</string>
|
||||||
|
<string name="rust_map_heli">Patrol helicopter</string>
|
||||||
|
<string name="rust_map_chinook">Chinook</string>
|
||||||
|
<string name="rust_map_bradley">Bradley APC</string>
|
||||||
|
<string name="rust_map_supply">Supply drop</string>
|
||||||
|
<string name="rust_map_crate">Locked crate</string>
|
||||||
|
<string name="rust_map_crate_hack">%1$s left on the hack</string>
|
||||||
|
<string name="rust_map_crate_hacked">Hacked</string>
|
||||||
|
<string name="rust_map_event_zone">Event zone</string>
|
||||||
|
<string name="rust_map_event_crate">Event crate</string>
|
||||||
|
<string name="rust_map_event_npc">Event NPC</string>
|
||||||
|
<string name="rust_map_site_event">Site event</string>
|
||||||
|
<string name="rust_map_open_event">Open event</string>
|
||||||
|
<string name="rust_map_tc">Tool cupboard</string>
|
||||||
|
<string name="rust_map_vending">Vending machine</string>
|
||||||
|
<string name="rust_map_player_sleeping">Sleeping</string>
|
||||||
|
<string name="rust_map_player_offline">Asleep, offline</string>
|
||||||
|
<string name="rust_map_you">You</string>
|
||||||
|
<string name="rust_map_clan_mate">Clan mate</string>
|
||||||
|
<string name="rust_map_grid">Grid %1$s</string>
|
||||||
|
<string name="rust_map_close">Close</string>
|
||||||
|
<string name="rust_all_time">All time</string>
|
||||||
|
<string name="rust_wipe_current">%1$s (this wipe)</string>
|
||||||
|
<string name="rust_wipe_this_one">Current</string>
|
||||||
|
<string name="rust_wipe_selected">Showing</string>
|
||||||
|
<string name="rust_feed_empty">Nothing has happened on this server yet — or not during the wipe you are looking at.</string>
|
||||||
|
<string name="rust_leaderboard_empty">Nobody has scored here yet.</string>
|
||||||
|
<string name="rust_wipes_empty">This server has not reported a wipe yet.</string>
|
||||||
|
<!-- Nothing names who is online by default (org lead, 2026-09-22). Below the
|
||||||
|
operator's audience the server sends a count and no names. -->
|
||||||
|
<plurals name="rust_online_hidden_count">
|
||||||
|
<item quantity="one">%1$d player online</item>
|
||||||
|
<item quantity="other">%1$d players online</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="rust_online_hidden_staff">Only this site’s staff can see who they are.</string>
|
||||||
|
<string name="rust_online_hidden_signin">Sign in to see who they are.</string>
|
||||||
|
<string name="rust_online_hidden_public">This site is not showing who they are right now.</string>
|
||||||
|
<string name="rust_feed_presence_hidden_staff">Joins, deaths and chat are not shown. Only this site’s staff can see what players did.</string>
|
||||||
|
<string name="rust_feed_presence_hidden_signin">Joins, deaths and chat are not shown. Sign in to see what players did.</string>
|
||||||
|
<string name="rust_nobody_on">The server is up and the island is empty. Somebody has to be first.</string>
|
||||||
|
<string name="rust_presence_offline">Presence is the one thing on this page that cannot be answered from the record — it is who is connected now, and nothing is.</string>
|
||||||
|
<string name="rust_presence_live">On the server right now.</string>
|
||||||
|
<string name="rust_presence_last_known">The last board this server sent. It is offline, so this is who was on then — not who is on now.</string>
|
||||||
|
<string name="rust_sleeping">Sleeping</string>
|
||||||
|
<string name="rust_col_player">Player</string>
|
||||||
|
<string name="rust_col_kills">Kills</string>
|
||||||
|
<string name="rust_col_deaths">Deaths</string>
|
||||||
|
<string name="rust_col_npc_kills">NPC</string>
|
||||||
|
<string name="rust_col_structures">Built</string>
|
||||||
|
<string name="rust_col_played">Played</string>
|
||||||
|
<string name="rust_online_badge">%1$d players online</string>
|
||||||
|
|
||||||
|
<!-- The player's own Rust account (M15 — module-rust phase 8) -->
|
||||||
|
<string name="menu_rust_account">My Rust account</string>
|
||||||
|
<string name="rust_link_title">Link your Steam account</string>
|
||||||
|
<string name="rust_link_hint">Join any of our Rust servers and type /link in chat. The server replies with a six-character code only you can see, good for five minutes. Enter it here — it works once.</string>
|
||||||
|
<string name="rust_link_code">Link code</string>
|
||||||
|
<string name="rust_link_action">Link account</string>
|
||||||
|
<string name="rust_link_ok">Linked. Your play on our servers now appears under your name here.</string>
|
||||||
|
<string name="rust_link_already">That account was already linked to you.</string>
|
||||||
|
<string name="rust_link_bad_code">That code is unknown or has expired. Type /link in game for a new one.</string>
|
||||||
|
<string name="rust_link_taken">That Steam account is linked to another website account. Run /unlink in game to release it.</string>
|
||||||
|
<string name="rust_link_capped">Too many attempts just now. Try again in a few minutes.</string>
|
||||||
|
<string name="rust_link_unreachable">A server could not be reached, so that code could not be checked. Your code is still good — try again in a minute.</string>
|
||||||
|
<string name="rust_link_error">Could not link that code.</string>
|
||||||
|
<string name="rust_unlink_action">Unlink</string>
|
||||||
|
<string name="rust_unlink_error">Could not unlink that account.</string>
|
||||||
|
<string name="rust_account_linked_when">linked %1$s</string>
|
||||||
|
<string name="rust_account_none">No Steam account is linked to this profile yet.</string>
|
||||||
|
<string name="rust_account_fleet_note">A link covers every server this community runs — a Steam account is one person wherever they play. You can also type /unlink in game.</string>
|
||||||
|
<string name="rust_held_title">What you can do in game</string>
|
||||||
|
<string name="rust_held_empty">Nothing yet. Ranks and rewards this site hands out show up here, and reach you in game on the servers they cover.</string>
|
||||||
|
<string name="rust_held_unlinked">None of this reaches the game yet — link a Steam account above and the site pushes it across on its next sync.</string>
|
||||||
|
<string name="rust_held_waiting_note">A server marked waiting has not confirmed it yet. One that is offline catches up when it comes back.</string>
|
||||||
|
<string name="rust_held_no_servers">No servers are configured yet.</string>
|
||||||
|
<string name="rust_reach_live">%1$s · has it</string>
|
||||||
|
<string name="rust_reach_waiting">%1$s · waiting</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.push
|
||||||
|
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationInboxDto
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationItemDto
|
||||||
|
import com.runicgateway.app.data.api.fake.FakeNotificationsApi
|
||||||
|
import com.runicgateway.app.data.repository.NotificationsRepository
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tickle titled from the inbox row it points at (`docs/modules/rust/PLAN.md`
|
||||||
|
* D70), and every way that falls back to the per-stream title.
|
||||||
|
*/
|
||||||
|
class PushContentTest {
|
||||||
|
|
||||||
|
private val raid = NotificationItemDto(
|
||||||
|
id = 42,
|
||||||
|
triggerId = "rust.base.destroyed",
|
||||||
|
title = "Your base is being raided",
|
||||||
|
body = "A door was destroyed in S16 on Oxide rig.",
|
||||||
|
)
|
||||||
|
private val raidTickle = PushTickle(stream = "rust.base.destroyed", ref = "notification:42")
|
||||||
|
|
||||||
|
// ── The ref ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test fun aRefNamesAnInboxIdOnlyWhenItIsExactlyOne() {
|
||||||
|
assertEquals(42L, inboxIdFromRef("notification:42"))
|
||||||
|
assertNull(inboxIdFromRef(null))
|
||||||
|
assertNull(inboxIdFromRef("notification:"))
|
||||||
|
assertNull(inboxIdFromRef("notification:0"))
|
||||||
|
assertNull(inboxIdFromRef("notification:-4"))
|
||||||
|
assertNull(inboxIdFromRef("notification:4a"))
|
||||||
|
assertNull(inboxIdFromRef("notification: 4"))
|
||||||
|
assertNull(inboxIdFromRef("xnotification:4"))
|
||||||
|
assertNull(inboxIdFromRef("0x24C"))
|
||||||
|
assertNull(inboxIdFromRef("notification:" + "9".repeat(40)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Which row ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test fun theRowMustBeTheNamedOneFromTheNamedTrigger() {
|
||||||
|
val other = raid.copy(id = 41, title = "An older raid")
|
||||||
|
assertEquals(raid, itemForTickle(raidTickle, listOf(other, raid)))
|
||||||
|
// Same id, different trigger: the tickle disagrees with the row.
|
||||||
|
assertNull(itemForTickle(raidTickle.copy(stream = "rust.server.online"), listOf(raid)))
|
||||||
|
assertNull(itemForTickle(raidTickle.copy(ref = "notification:7"), listOf(raid)))
|
||||||
|
assertNull(itemForTickle(raidTickle.copy(ref = null), listOf(raid)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── What the notification says ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test fun theRowsTitleAndBodyAreUsed() {
|
||||||
|
val text = notificationText(raid, generic = "New notification")
|
||||||
|
assertEquals("Your base is being raided", text.title)
|
||||||
|
assertEquals("A door was destroyed in S16 on Oxide rig.", text.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun noRowOrABlankTitleFallsBackToTheStreamTitle() {
|
||||||
|
assertEquals(NotificationText("New notification", null), notificationText(null, "New notification"))
|
||||||
|
assertEquals(
|
||||||
|
NotificationText("New notification", null),
|
||||||
|
notificationText(raid.copy(title = " "), "New notification"),
|
||||||
|
)
|
||||||
|
assertEquals(null, notificationText(raid.copy(body = " "), "x").body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The pull ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private val api = FakeNotificationsApi()
|
||||||
|
private val resolver = PushContentResolver(NotificationsRepository(api))
|
||||||
|
|
||||||
|
@Test fun theResolverPullsTheFirstPage() = runTest {
|
||||||
|
api.pages = mapOf(null to NotificationInboxDto(items = listOf(raid), unread = 1))
|
||||||
|
|
||||||
|
assertEquals(raid, resolver.itemFor(raidTickle))
|
||||||
|
assertEquals(listOf<Long?>(null), api.inboxCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aTickleWithNoInboxRefIsNeverPulled() = runTest {
|
||||||
|
// A classic M7 stream, or a push-only rule with no inbox row.
|
||||||
|
assertNull(resolver.itemFor(PushTickle(stream = "news.post", ref = "0x24C")))
|
||||||
|
assertTrue(api.inboxCalls.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aFailedPullIsNullNotAnError() = runTest {
|
||||||
|
api.error = IOException("offline")
|
||||||
|
assertNull(resolver.itemFor(raidTickle))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aRowNotOnThePageIsNull() = runTest {
|
||||||
|
api.pages = mapOf(null to NotificationInboxDto(items = listOf(raid.copy(id = 99))))
|
||||||
|
assertNull(resolver.itemFor(raidTickle))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,4 +82,71 @@ class NotificationsDtoTest {
|
|||||||
)
|
)
|
||||||
assertNull(dto.push.ntfyUrl)
|
assertNull(dto.push.ntfyUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── The inbox + per-channel prefs (ENGAGEMENT.md phases 3, 7/8) ────────
|
||||||
|
|
||||||
|
@Test fun inboxPageDecodesWithItsUnreadCount() {
|
||||||
|
val dto = json.decodeFromString<NotificationInboxDto>(
|
||||||
|
"""{"items":[{"id":42,"triggerId":"team.post.created","title":"New post",
|
||||||
|
"body":"Someone posted in your team.","url":"https://shard.example/teams/1",
|
||||||
|
"read":false,"readAt":null,"createdAt":"2026-08-31T12:30:00.000Z"}],
|
||||||
|
"hasMore":true,"unread":3}""",
|
||||||
|
)
|
||||||
|
assertEquals(1, dto.items.size)
|
||||||
|
assertEquals(42L, dto.items.first().id)
|
||||||
|
assertEquals("team.post.created", dto.items.first().triggerId)
|
||||||
|
assertFalse(dto.items.first().read)
|
||||||
|
assertTrue(dto.hasMore)
|
||||||
|
// The whole inbox, not the page — the badge and the list come from one response.
|
||||||
|
assertEquals(3, dto.unread)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun anItemWithNoBodyOrUrlDecodes() {
|
||||||
|
// Most items have neither: an inbox row is complete on its own.
|
||||||
|
val dto = json.decodeFromString<NotificationItemDto>(
|
||||||
|
"""{"id":7,"triggerId":"news.post","title":"Patch notes","body":null,"url":null,
|
||||||
|
"read":true,"readAt":"2026-08-31T13:00:00.000Z","createdAt":"2026-08-31T12:30:00.000Z"}""",
|
||||||
|
)
|
||||||
|
assertNull(dto.body)
|
||||||
|
assertNull(dto.url)
|
||||||
|
assertTrue(dto.read)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun channelPrefsDecodeTheirModesAndPerItemChannels() {
|
||||||
|
val dto = json.decodeFromString<NotificationChannelPrefsDto>(
|
||||||
|
"""{"channels":[
|
||||||
|
{"id":"push","label":"Push","carriesContent":false,"defaultMode":"off",
|
||||||
|
"supportsDigest":false,"modes":["off","instant"]},
|
||||||
|
{"id":"email","label":"Email","carriesContent":true,"defaultMode":"off",
|
||||||
|
"supportsDigest":true,"modes":["off","instant","digest"]}],
|
||||||
|
"items":[
|
||||||
|
{"id":"uo.house.idoc_warning","label":"House in danger","description":"",
|
||||||
|
"personal":true,"requiresLinkedAccount":true,"ceiling":"authenticated",
|
||||||
|
"channels":["email","inapp"],"modes":{"email":"digest","inapp":"instant"}}]}""",
|
||||||
|
)
|
||||||
|
assertFalse(dto.channels.first { it.id == "push" }.carriesContent)
|
||||||
|
assertTrue(dto.channels.first { it.id == "email" }.supportsDigest)
|
||||||
|
val item = dto.items.single()
|
||||||
|
// A trigger-only id carries no push facet at all — the UI renders controls
|
||||||
|
// from THIS list, never from a hardcoded three.
|
||||||
|
assertFalse(item.channels.contains("push"))
|
||||||
|
assertEquals("digest", item.modes["email"])
|
||||||
|
assertNull(item.modes["push"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun theSparseUpdateAlwaysCarriesItsPrefsField() {
|
||||||
|
// Same reasoning as the subscriptions DTO: kotlinx omits a property equal
|
||||||
|
// to its default, and the validator requires the field.
|
||||||
|
val body = json.encodeToString(NotificationChannelPrefsUpdateDto(emptyList()))
|
||||||
|
assertEquals("""{"prefs":[]}""", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun theSparseUpdateSendsOnlyThePairItNames() {
|
||||||
|
val body = json.encodeToString(
|
||||||
|
NotificationChannelPrefsUpdateDto(
|
||||||
|
listOf(NotificationChannelPrefDto(id = "news.post", channel = "email", mode = "digest")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals("""{"prefs":[{"id":"news.post","channel":"email","mode":"digest"}]}""", body)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nothing names who is online by default (org lead, 2026-09-22). These are the
|
||||||
|
* answers module-rust's public routes give below and inside the operator's
|
||||||
|
* presence audience, copied from a live walk against the module — and the old
|
||||||
|
* shape, from a core running a module that predates the flag.
|
||||||
|
*/
|
||||||
|
class RustPresenceDtoTest {
|
||||||
|
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
explicitNulls = false
|
||||||
|
coerceInputValues = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun withheldOnlineCarriesTheCountAndNoNames() {
|
||||||
|
val dto = json.decodeFromString<RustOnlineDto>(
|
||||||
|
"""{"players":[],"hidden":true,"count":2,"audience":"staff"}""",
|
||||||
|
)
|
||||||
|
assertTrue(dto.hidden)
|
||||||
|
assertEquals(2, dto.count)
|
||||||
|
assertEquals("staff", dto.audience)
|
||||||
|
assertTrue(dto.players.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun visibleOnlineNamesThePlayers() {
|
||||||
|
val dto = json.decodeFromString<RustOnlineDto>(
|
||||||
|
"""{"players":[{"steamId":"76561198000000002","name":"Builder Bea","sleeping":false,
|
||||||
|
"connectedAt":"2026-09-23T05:02:30.000Z"}],"hidden":false,"count":1,"audience":"staff"}""",
|
||||||
|
)
|
||||||
|
assertFalse(dto.hidden)
|
||||||
|
assertEquals("Builder Bea", dto.players.single().name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun anOlderModuleWithoutTheFlagReadsAsVisible() {
|
||||||
|
val dto = json.decodeFromString<RustOnlineDto>("""{"players":[]}""")
|
||||||
|
assertFalse(dto.hidden)
|
||||||
|
assertNull(dto.audience)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aWithheldFeedSaysSo() {
|
||||||
|
val dto = json.decodeFromString<RustEventListDto>(
|
||||||
|
"""{"events":[{"id":11,"kind":"server.wipe","t":1789500000000,"wipeId":"w-20260920T000000Z",
|
||||||
|
"steamId":null,"frame":{}}],"presenceHidden":true,"presenceAudience":"signed_in"}""",
|
||||||
|
)
|
||||||
|
assertTrue(dto.presenceHidden)
|
||||||
|
assertEquals("signed_in", dto.presenceAudience)
|
||||||
|
assertEquals("server.wipe", dto.events.single().kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user